ai-helpdesk-api / MobileApp /src /screens /admin /AdminUsersScreen.js
ritesh19180's picture
Upload folder using huggingface_hub
ea21da5 verified
Raw
History Blame Contribute Delete
23.3 kB
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
StyleSheet, View, Text, FlatList, TextInput, TouchableOpacity,
ActivityIndicator, RefreshControl, StatusBar, Alert, Modal
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { supabase } from '../../lib/supabase';
import { COLORS, SHADOWS } from '../../styles/theme';
import {
UserCheck, UserX, Users, Mail, Building2, ShieldAlert,
Search, X, Eye, Trash2, Shield, Calendar, Hash, Loader2
} from 'lucide-react-native';
import * as Haptics from 'expo-haptics';
const AdminUsersScreen = () => {
const [profiles, setProfiles] = useState([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [activeTab, setActiveTab] = useState('pending'); // 'pending' | 'active'
const [searchQuery, setSearchQuery] = useState('');
const [currentAdmin, setCurrentAdmin] = useState(null);
// Modal / Detail States
const [selectedUser, setSelectedUser] = useState(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [updatingUser, setUpdatingUser] = useState(null); // ID of user currently being modified in DB
const fetchUsers = useCallback(async () => {
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
setCurrentAdmin(user);
const { data: adminProfile } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single();
if (!adminProfile?.company) return;
// Fetch all profiles belonging to the same company
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('company', adminProfile.company)
.order('full_name', { ascending: true });
if (!error && data) {
// Exclude the current logged in admin themselves from directory list
setProfiles(data.filter(p => p.id !== user.id));
}
} catch (e) {
console.error('Fetch company users error:', e);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
fetchUsers();
// Subscribe to profile changes to update real-time
const profilesChannel = supabase
.channel('company_profiles_admin')
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'profiles'
}, () => fetchUsers())
.subscribe();
return () => {
supabase.removeChannel(profilesChannel);
};
}, [fetchUsers]);
const onRefresh = () => {
setRefreshing(true);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
fetchUsers();
};
const handleApprove = async (userItem) => {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setUpdatingUser(userItem.id);
try {
// 1. Update Profile in public.profiles
const { error } = await supabase
.from('profiles')
.update({
status: 'active',
company_id: userItem.company_id || null
})
.eq('id', userItem.id);
if (error) {
Alert.alert("Error", "Could not approve user registration.");
} else {
// 2. Insert system notification to let user know they're approved
await supabase.from('notifications').insert({
user_id: userItem.id,
title: "Account Approved",
message: `Your registration for ${userItem.company} has been approved by the Administrator. Welcome!`,
type: "status_change",
is_unread: true
});
// 3. Try to trigger background edge email function if available (Non-blocking)
(async () => {
try {
await supabase.functions.invoke('send-user-approval-email', {
body: {
userId: userItem.id,
email: userItem.email,
name: userItem.full_name,
company: userItem.company
}
});
} catch (_) {}
})();
fetchUsers();
}
} catch (e) {
console.error(e);
} finally {
setUpdatingUser(null);
}
};
const handleDecline = (userItem) => {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
Alert.alert(
"Decline Registration",
`Are you sure you want to reject ${userItem.full_name}'s registration?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Decline",
style: "destructive",
onPress: async () => {
setUpdatingUser(userItem.id);
try {
const { error } = await supabase
.from('profiles')
.update({ status: 'rejected' })
.eq('id', userItem.id);
if (error) {
Alert.alert("Error", "Could not reject user.");
} else {
fetchUsers();
}
} catch (e) {
console.error(e);
} finally {
setUpdatingUser(null);
}
}
}
]
);
};
const handleUpdateRole = async (userItem, newRole) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
setUpdatingUser(userItem.id);
try {
const { error } = await supabase
.from('profiles')
.update({ role: newRole })
.eq('id', userItem.id);
if (error) {
Alert.alert("Error", "Could not update user security clearance.");
} else {
if (selectedUser?.id === userItem.id) {
setSelectedUser(prev => ({ ...prev, role: newRole }));
}
fetchUsers();
}
} catch (e) {
console.error(e);
} finally {
setUpdatingUser(null);
}
};
const handlePurgeUser = (userItem) => {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
Alert.alert(
"Purge User Record",
`CRITICAL: You are about to permanently delete @${userItem.full_name}'s profile from the system active directory. This action is irreversible.`,
[
{ text: "Abort", style: "cancel" },
{
text: "Confirm Purge",
style: "destructive",
onPress: async () => {
setUpdatingUser(userItem.id);
try {
const { error } = await supabase
.from('profiles')
.delete()
.eq('id', userItem.id);
if (error) {
Alert.alert("Error", "Could not delete user profile from DB.");
} else {
setShowDetailModal(false);
setSelectedUser(null);
fetchUsers();
}
} catch (e) {
console.error(e);
} finally {
setUpdatingUser(null);
}
}
}
]
);
};
const getFilteredUsers = useMemo(() => {
let filtered = profiles;
// Tab Filter
if (activeTab === 'pending') {
filtered = filtered.filter(p => p.status === 'pending_approval');
} else {
filtered = filtered.filter(p => p.status === 'active');
}
// Search Query Filter
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase().trim();
filtered = filtered.filter(p =>
p.full_name?.toLowerCase().includes(q) ||
p.email?.toLowerCase().includes(q) ||
p.role?.toLowerCase().includes(q) ||
String(p.id).includes(q)
);
}
return filtered;
}, [profiles, activeTab, searchQuery]);
const openProfileDetail = (userItem) => {
Haptics.selectionAsync();
setSelectedUser(userItem);
setShowDetailModal(true);
};
const renderUserItem = ({ item }) => {
const isPending = item.status === 'pending_approval';
const isUserAdmin = item.role === 'admin' || item.role === 'master_admin';
return (
<View style={styles.userCard}>
<TouchableOpacity
style={styles.avatar}
onPress={() => openProfileDetail(item)}
activeOpacity={0.7}
>
<Text style={styles.avatarText}>{item.full_name?.[0]?.toUpperCase() || 'U'}</Text>
</TouchableOpacity>
<View style={styles.userInfo}>
<TouchableOpacity onPress={() => openProfileDetail(item)} activeOpacity={0.7}>
<Text style={styles.userName}>{item.full_name}</Text>
</TouchableOpacity>
<View style={styles.metaRow}>
<Mail size={12} color={COLORS.textMuted} />
<Text style={styles.metaText} numberOfLines={1}>{item.email}</Text>
</View>
<View style={styles.metaRow}>
<Shield size={12} color={isUserAdmin ? COLORS.primary : COLORS.textMuted} />
<Text style={[styles.metaText, isUserAdmin && { color: COLORS.primary, fontWeight: '700' }]}>
{isUserAdmin ? 'Administrator' : 'Standard Employee'}
</Text>
</View>
</View>
{updatingUser === item.id ? (
<ActivityIndicator size="small" color={COLORS.primary} style={{ marginRight: 10 }} />
) : isPending ? (
<View style={styles.actions}>
<TouchableOpacity
style={[styles.actionBtn, styles.approveBtn]}
onPress={() => handleApprove(item)}
title="Approve"
>
<UserCheck size={18} color="#fff" />
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, styles.declineBtn]}
onPress={() => handleDecline(item)}
title="Decline"
>
<UserX size={18} color="#fff" />
</TouchableOpacity>
</View>
) : (
<View style={{ flexDirection: 'row', gap: 6 }}>
<TouchableOpacity
style={styles.viewDetailsBtn}
onPress={() => openProfileDetail(item)}
>
<Eye size={16} color={COLORS.textLight} />
</TouchableOpacity>
</View>
)}
</View>
);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar barStyle="dark-content" />
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Company Directory</Text>
</View>
{/* Search Input */}
<View style={styles.searchSection}>
<View style={styles.searchBar}>
<Search size={18} color={COLORS.textMuted} />
<TextInput
style={styles.input}
placeholder="Search employees by name, email, role..."
placeholderTextColor="rgba(0,0,0,0.3)"
value={searchQuery}
onChangeText={setSearchQuery}
autoCorrect={false}
/>
{searchQuery.length > 0 && (
<TouchableOpacity onPress={() => setSearchQuery('')} style={styles.clearSearchBtn}>
<X size={16} color={COLORS.textMuted} />
</TouchableOpacity>
)}
</View>
</View>
{/* Tabs */}
<View style={styles.tabContainer}>
<TouchableOpacity
style={[styles.tabButton, activeTab === 'pending' && styles.activeTabButton]}
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setActiveTab('pending');
}}
>
<ShieldAlert size={16} color={activeTab === 'pending' ? COLORS.primary : COLORS.textMuted} />
<Text style={[styles.tabText, activeTab === 'pending' && styles.activeTabText]}>
Pending Review ({profiles.filter(p => p.status === 'pending_approval').length})
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tabButton, activeTab === 'active' && styles.activeTabButton]}
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setActiveTab('active');
}}
>
<Users size={16} color={activeTab === 'active' ? COLORS.primary : COLORS.textMuted} />
<Text style={[styles.tabText, activeTab === 'active' && styles.activeTabText]}>
Active Members ({profiles.filter(p => p.status === 'active').length})
</Text>
</TouchableOpacity>
</View>
{/* List */}
{loading ? (
<View style={styles.centered}>
<ActivityIndicator size="large" color={COLORS.primary} />
</View>
) : (
<FlatList
data={getFilteredUsers}
keyExtractor={(item) => item.id}
renderItem={renderUserItem}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
RefreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={[COLORS.primary]} />}
ListEmptyComponent={
<View style={styles.empty}>
<Users size={48} color={COLORS.textMuted} strokeWidth={1} />
<Text style={styles.emptyText}>
{activeTab === 'pending' ? 'No registrations pending review' : 'No active members match your search'}
</Text>
</View>
}
/>
)}
{/* Sliding Profile Detail Modal Sheet */}
<Modal
visible={showDetailModal}
transparent
animationType="slide"
onRequestClose={() => setShowDetailModal(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalSheet}>
{/* Modal Header */}
<View style={styles.modalHeader}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<View style={styles.modalAvatar}>
<Text style={styles.modalAvatarText}>{selectedUser?.full_name?.[0]?.toUpperCase() || 'U'}</Text>
</View>
<View>
<Text style={styles.modalTitle} numberOfLines={1}>{selectedUser?.full_name}</Text>
<Text style={styles.modalSubtitle} numberOfLines={1}>{selectedUser?.email}</Text>
</View>
</View>
<TouchableOpacity onPress={() => setShowDetailModal(false)} style={styles.closeBtn}>
<X size={20} color={COLORS.textLight} />
</TouchableOpacity>
</View>
{/* Modal Body */}
{selectedUser && (
<View style={styles.modalBody}>
<Text style={styles.sectionHeader}>ENTITY METADATA</Text>
<View style={styles.metaCard}>
<View style={styles.metaCardRow}>
<Hash size={14} color={COLORS.textMuted} />
<View style={{ flex: 1 }}>
<Text style={styles.metaLabel}>SYSTEM UUID</Text>
<Text style={styles.metaValueMono}>{selectedUser.id}</Text>
</View>
</View>
<View style={[styles.metaCardRow, { borderBottomWidth: 0 }]}>
<Calendar size={14} color={COLORS.textMuted} />
<View style={{ flex: 1 }}>
<Text style={styles.metaLabel}>JOINED DATE</Text>
<Text style={styles.metaValue}>
{new Date(selectedUser.created_at).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
</Text>
</View>
</View>
</View>
<Text style={styles.sectionHeader}>SECURITY PROTOCOLS</Text>
<View style={styles.rolesRow}>
<TouchableOpacity
style={[styles.roleOptionCard, selectedUser.role === 'user' && styles.activeRoleCard]}
onPress={() => handleUpdateRole(selectedUser, 'user')}
>
<Users size={20} color={selectedUser.role === 'user' ? COLORS.primary : COLORS.textMuted} />
<Text style={[styles.roleOptionText, selectedUser.role === 'user' && styles.activeRoleText]}>Standard User</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.roleOptionCard, selectedUser.role === 'admin' && styles.activeRoleCard]}
onPress={() => handleUpdateRole(selectedUser, 'admin')}
>
<Shield size={20} color={selectedUser.role === 'admin' ? COLORS.primary : COLORS.textMuted} />
<Text style={[styles.roleOptionText, selectedUser.role === 'admin' && styles.activeRoleText]}>Administrator</Text>
</TouchableOpacity>
</View>
{/* Loading state indicator for updates */}
{updatingUser === selectedUser.id && (
<View style={{ flexDirection: 'row', justifyContent: 'center', alignItems: 'center', gap: 8, marginVertical: 8 }}>
<ActivityIndicator size="small" color={COLORS.primary} />
<Text style={{ fontSize: 11, fontWeight: '700', color: COLORS.primary }}>SYNCHRONIZING ROLES...</Text>
</View>
)}
{/* Direct Actions */}
<View style={styles.modalActions}>
<TouchableOpacity
style={styles.purgeBtn}
onPress={() => handlePurgeUser(selectedUser)}
disabled={updatingUser === selectedUser.id}
>
<Trash2 size={16} color="#fff" />
<Text style={styles.purgeBtnText}>Purge Employee Record</Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
</View>
</Modal>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.background },
header: { paddingHorizontal: 24, paddingTop: 16, paddingBottom: 16 },
title: { fontSize: 28, fontWeight: '900', color: COLORS.text, letterSpacing: -0.8 },
// Search
searchSection: { paddingHorizontal: 20, marginBottom: 12 },
searchBar: {
flexDirection: 'row', alignItems: 'center', backgroundColor: '#fff',
borderRadius: 16, paddingHorizontal: 16, height: 52,
...SHADOWS.soft, borderWidth: 1, borderColor: 'rgba(0,0,0,0.03)'
},
input: { flex: 1, marginLeft: 10, fontSize: 14, fontWeight: '600', color: COLORS.text },
clearSearchBtn: { padding: 4 },
// Tabs
tabContainer: {
flexDirection: 'row',
marginHorizontal: 20,
backgroundColor: 'rgba(0,0,0,0.03)',
borderRadius: 16,
padding: 4,
marginBottom: 16,
},
tabButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 12,
borderRadius: 12,
},
activeTabButton: {
backgroundColor: '#ffffff',
...SHADOWS.soft,
},
tabText: {
fontSize: 12.5,
fontWeight: '700',
color: COLORS.textMuted,
},
activeTabText: {
color: COLORS.text,
},
// Cards List
list: { paddingHorizontal: 20, paddingBottom: 120 },
userCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fff',
borderRadius: 24,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.03)',
...SHADOWS.soft
},
avatar: {
width: 46,
height: 46,
borderRadius: 14,
backgroundColor: COLORS.primaryLight,
justifyContent: 'center',
alignItems: 'center',
marginRight: 14
},
avatarText: { fontSize: 16, fontWeight: '800', color: COLORS.primary },
userInfo: { flex: 1, gap: 3 },
userName: { fontSize: 15, fontWeight: '800', color: COLORS.text },
metaRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
metaText: { fontSize: 12, color: COLORS.textMuted, fontWeight: '600', maxWidth: '90%' },
// Actions
actions: { flexDirection: 'row', gap: 8 },
actionBtn: { width: 38, height: 38, borderRadius: 12, justifyContent: 'center', alignItems: 'center', ...SHADOWS.soft },
approveBtn: { backgroundColor: COLORS.primary },
declineBtn: { backgroundColor: '#ef4444' },
viewDetailsBtn: { width: 36, height: 36, borderRadius: 10, backgroundColor: '#f3f4f6', justifyContent: 'center', alignItems: 'center' },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center', marginTop: 100 },
empty: { alignItems: 'center', marginTop: 100, gap: 12 },
emptyText: { fontSize: 14, color: COLORS.textMuted, fontWeight: '600', textAlign: 'center', paddingHorizontal: 40 },
// Sliding sheet modal styles
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.45)', justifyContent: 'flex-end' },
modalSheet: {
backgroundColor: '#fff',
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
padding: 24,
maxHeight: '85%',
...SHADOWS.soft
},
modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 },
modalAvatar: { width: 50, height: 50, borderRadius: 16, backgroundColor: COLORS.primaryLight, justifyContent: 'center', alignItems: 'center' },
modalAvatarText: { fontSize: 18, fontWeight: '900', color: COLORS.primary },
modalTitle: { fontSize: 18, fontWeight: '900', color: COLORS.text, maxWidth: 220 },
modalSubtitle: { fontSize: 12, color: COLORS.textMuted, fontWeight: '600', marginTop: 2, maxWidth: 220 },
closeBtn: { width: 36, height: 36, borderRadius: 18, backgroundColor: '#f3f4f6', justifyContent: 'center', alignItems: 'center' },
modalBody: { gap: 18 },
sectionHeader: { fontSize: 10.5, fontWeight: '800', color: COLORS.textMuted, letterSpacing: 1.2 },
metaCard: { backgroundColor: '#f8faf9', borderRadius: 20, paddingHorizontal: 16, paddingVertical: 8, borderWidth: 1, borderColor: 'rgba(0,0,0,0.03)' },
metaCardRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: 'rgba(0,0,0,0.04)' },
metaLabel: { fontSize: 9.5, fontWeight: '800', color: COLORS.textMuted, letterSpacing: 0.5 },
metaValue: { fontSize: 13, fontWeight: '700', color: COLORS.text, marginTop: 2 },
metaValueMono: { fontSize: 11.5, fontFamily: 'monospace', fontWeight: '700', color: COLORS.text, marginTop: 2 },
rolesRow: { flexDirection: 'row', gap: 12 },
roleOptionCard: { flex: 1, paddingVertical: 16, alignItems: 'center', gap: 8, borderRadius: 20, borderWidth: 1.5, borderColor: '#e5e7eb', backgroundColor: '#fff' },
activeRoleCard: { borderColor: COLORS.primary, backgroundColor: COLORS.primaryLight + '12' },
roleOptionText: { fontSize: 12.5, fontWeight: '700', color: COLORS.textMuted },
activeRoleText: { color: COLORS.text, fontWeight: '900' },
modalActions: { marginTop: 8 },
purgeBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
backgroundColor: '#ef4444',
height: 52,
borderRadius: 18,
...SHADOWS.soft
},
purgeBtnText: { fontSize: 13, fontWeight: '800', color: '#fff', textTransform: 'uppercase', letterSpacing: 0.5 }
});
export default AdminUsersScreen;