← Volver al listado de tecnologías
Capítulo 9: Gestión de Usuario y Sesiones - Perfil y Configuración
Capítulo 9: Gestión de Usuario y Sesiones - Perfil y Configuración
En este capítulo profundizaremos en la gestión completa del perfil de usuario, configuraciones de cuenta, manejo de sesiones y funcionalidades avanzadas de Clerk.
🎯 Objetivos del Capítulo
- Crear pantallas de perfil de usuario completas
- Implementar edición de información personal
- Gestionar métodos de autenticación
- Manejar cambio de contraseña y configuraciones
- Integrar profundamente con nuestro store de estado
📋 Prerrequisitos
- Capítulo 8 completado (Implementación de Autenticación)
- Conocimiento de hooks de Clerk
- Store de autenticación configurado
👤 Pantalla de Perfil Completa
Vamos a crear una pantalla de perfil completa y profesional:
// src/screens/ProfileScreen.tsx
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Alert,
Switch,
Image,
} from 'react-native';
import { useUser } from '@clerk/clerk-expo';
import { useAuth } from '../hooks/useAuth';
import { ProfileHeader } from '../components/profile/ProfileHeader';
import { ProfileSection } from '../components/profile/ProfileSection';
import { ProfileItem } from '../components/profile/ProfileItem';
export const ProfileScreen: React.FC = () => {
const { user } = useUser();
const { logout } = useAuth();
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
const handleLogout = () => {
Alert.alert(
'Cerrar Sesión',
'¿Estás seguro que deseas cerrar sesión?',
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Cerrar Sesión',
style: 'destructive',
onPress: logout
},
]
);
};
const handleEditProfile = () => {
// Navegación a pantalla de edición
console.log('Navegar a edición de perfil');
};
const handleChangePassword = () => {
// Navegación a cambio de contraseña
console.log('Navegar a cambio de contraseña');
};
const handleManageAuth = () => {
// Navegación a gestión de métodos de auth
console.log('Navegar a gestión de autenticación');
};
if (!user) {
return (
<View style={styles.loadingContainer}>
<Text>Cargando perfil...</Text>
</View>
);
}
return (
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
<ProfileHeader
user={user}
onEditPress={handleEditProfile}
/>
<ProfileSection title="Información Personal">
<ProfileItem
icon="👤"
label="Nombre completo"
value={`${user.firstName} ${user.lastName}`}
onPress={handleEditProfile}
showChevron
/>
<ProfileItem
icon="📧"
label="Email"
value={user.emailAddresses[0]?.emailAddress}
onPress={handleEditProfile}
showChevron
/>
<ProfileItem
icon="📱"
label="Teléfono"
value={user.phoneNumbers[0]?.phoneNumber || 'No configurado'}
onPress={handleEditProfile}
showChevron
/>
</ProfileSection>
<ProfileSection title="Seguridad">
<ProfileItem
icon="🔒"
label="Cambiar contraseña"
onPress={handleChangePassword}
showChevron
/>
<ProfileItem
icon="🔐"
label="Métodos de autenticación"
value={`${user.externalAccounts.length} conectados`}
onPress={handleManageAuth}
showChevron
/>
</ProfileSection>
<ProfileSection title="Configuraciones">
<ProfileItem
icon="🔔"
label="Notificaciones"
rightElement={
<Switch
value={notificationsEnabled}
onValueChange={setNotificationsEnabled}
testID="notifications-switch"
/>
}
/>
<ProfileItem
icon="🌙"
label="Tema oscuro"
rightElement={
<Switch
value={false}
onValueChange={() => {}}
testID="dark-mode-switch"
/>
}
/>
</ProfileSection>
<ProfileSection title="Información">
<ProfileItem
icon="📄"
label="Términos y condiciones"
onPress={() => {}}
showChevron
/>
<ProfileItem
icon="🔒"
label="Política de privacidad"
onPress={() => {}}
showChevron
/>
<ProfileItem
icon="ℹ️"
label="Acerca de"
value="Versión 1.0.0"
onPress={() => {}}
showChevron
/>
</ProfileSection>
<View style={styles.logoutSection}>
<TouchableOpacity
style={styles.logoutButton}
onPress={handleLogout}
testID="logout-button"
>
<Text style={styles.logoutButtonText}>Cerrar Sesión</Text>
</TouchableOpacity>
</View>
</ScrollView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8f9fa',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
logoutSection: {
padding: 20,
marginTop: 20,
},
logoutButton: {
backgroundColor: '#dc3545',
borderRadius: 12,
padding: 16,
alignItems: 'center',
},
logoutButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});
🎨 Componentes de Perfil Reutilizables
ProfileHeader Component
// src/components/profile/ProfileHeader.tsx
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
} from 'react-native';
import { User } from '@clerk/clerk-expo';
interface ProfileHeaderProps {
user: User;
onEditPress: () => void;
}
export const ProfileHeader: React.FC<ProfileHeaderProps> = ({
user,
onEditPress,
}) => {
const getInitials = () => {
const firstName = user.firstName || '';
const lastName = user.lastName || '';
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
};
const getAvatarUrl = () => {
return user.imageUrl || user.profileImageUrl;
};
return (
<View style={styles.container}>
<View style={styles.avatarContainer}>
{getAvatarUrl() ? (
<Image
source={{ uri: getAvatarUrl() }}
style={styles.avatar}
testID="user-avatar"
/>
) : (
<View style={styles.avatarPlaceholder}>
<Text style={styles.avatarText}>{getInitials()}</Text>
</View>
)}
</View>
<View style={styles.userInfo}>
<Text style={styles.userName}>
{user.firstName} {user.lastName}
</Text>
<Text style={styles.userEmail}>
{user.emailAddresses[0]?.emailAddress}
</Text>
<Text style={styles.userStatus}>
Miembro desde {new Date(user.createdAt).getFullYear()}
</Text>
</View>
<TouchableOpacity
style={styles.editButton}
onPress={onEditPress}
testID="edit-profile-button"
>
<Text style={styles.editButtonText}>Editar</Text>
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: '#fff',
padding: 20,
alignItems: 'center',
borderBottomWidth: 1,
borderBottomColor: '#e9ecef',
},
avatarContainer: {
marginBottom: 16,
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
},
avatarPlaceholder: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#007AFF',
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
color: '#fff',
fontSize: 24,
fontWeight: 'bold',
},
userInfo: {
alignItems: 'center',
marginBottom: 16,
},
userName: {
fontSize: 24,
fontWeight: 'bold',
color: '#333',
marginBottom: 4,
},
userEmail: {
fontSize: 16,
color: '#666',
marginBottom: 4,
},
userStatus: {
fontSize: 14,
color: '#999',
},
editButton: {
backgroundColor: '#007AFF',
paddingHorizontal: 24,
paddingVertical: 8,
borderRadius: 20,
},
editButtonText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
});
ProfileSection Component
// src/components/profile/ProfileSection.tsx
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
interface ProfileSectionProps {
title: string;
children: React.ReactNode;
}
export const ProfileSection: React.FC<ProfileSectionProps> = ({
title,
children,
}) => {
return (
<View style={styles.container}>
<Text style={styles.title}>{title}</Text>
<View style={styles.content}>
{children}
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginTop: 20,
},
title: {
fontSize: 16,
fontWeight: '600',
color: '#333',
marginBottom: 8,
marginLeft: 20,
},
content: {
backgroundColor: '#fff',
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: '#e9ecef',
},
});
ProfileItem Component
// src/components/profile/ProfileItem.tsx
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
} from 'react-native';
interface ProfileItemProps {
icon: string;
label: string;
value?: string;
onPress?: () => void;
showChevron?: boolean;
rightElement?: React.ReactNode;
}
export const ProfileItem: React.FC<ProfileItemProps> = ({
icon,
label,
value,
onPress,
showChevron = false,
rightElement,
}) => {
const Content = (
<View style={styles.container}>
<View style={styles.leftContent}>
<Text style={styles.icon}>{icon}</Text>
<View style={styles.textContent}>
<Text style={styles.label}>{label}</Text>
{value && <Text style={styles.value}>{value}</Text>}
</View>
</View>
<View style={styles.rightContent}>
{rightElement}
{showChevron && (
<Text style={styles.chevron}>›</Text>
)}
</View>
</View>
);
if (onPress) {
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
{Content}
</TouchableOpacity>
);
}
return Content;
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#f1f3f4',
},
leftContent: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
icon: {
fontSize: 20,
marginRight: 12,
},
textContent: {
flex: 1,
},
label: {
fontSize: 16,
color: '#333',
fontWeight: '500',
},
value: {
fontSize: 14,
color: '#666',
marginTop: 2,
},
rightContent: {
flexDirection: 'row',
alignItems: 'center',
},
chevron: {
fontSize: 20,
color: '#c7c7cc',
marginLeft: 8,
},
});
✏️ Pantalla de Edición de Perfil
// src/screens/EditProfileScreen.tsx
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TextInput,
TouchableOpacity,
Alert,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { useUser } from '@clerk/clerk-expo';
import { useNavigation } from '@react-navigation/native';
export const EditProfileScreen: React.FC = () => {
const { user } = useUser();
const navigation = useNavigation();
const [firstName, setFirstName] = useState(user?.firstName || '');
const [lastName, setLastName] = useState(user?.lastName || '');
const [isLoading, setIsLoading] = useState(false);
const handleSave = async () => {
if (!user) return;
setIsLoading(true);
try {
await user.update({
firstName: firstName.trim(),
lastName: lastName.trim(),
});
Alert.alert(
'Éxito',
'Tu perfil ha sido actualizado correctamente',
[{ text: 'OK', onPress: () => navigation.goBack() }]
);
} catch (error: any) {
Alert.alert(
'Error',
error.errors?.[0]?.message || 'No se pudo actualizar el perfil'
);
} finally {
setIsLoading(false);
}
};
const isFormValid = firstName.trim().length > 0 && lastName.trim().length > 0;
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView style={styles.scrollView}>
<View style={styles.form}>
<View style={styles.inputGroup}>
<Text style={styles.label}>Nombre</Text>
<TextInput
style={styles.input}
value={firstName}
onChangeText={setFirstName}
placeholder="Ingresa tu nombre"
autoCapitalize="words"
testID="first-name-input"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Apellido</Text>
<TextInput
style={styles.input}
value={lastName}
onChangeText={setLastName}
placeholder="Ingresa tu apellido"
autoCapitalize="words"
testID="last-name-input"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Email</Text>
<TextInput
style={[styles.input, styles.disabledInput]}
value={user?.emailAddresses[0]?.emailAddress}
editable={false}
testID="email-input"
/>
<Text style={styles.helpText}>
Para cambiar tu email, contacta con soporte
</Text>
</View>
</View>
</ScrollView>
<View style={styles.footer}>
<TouchableOpacity
style={[
styles.saveButton,
(!isFormValid || isLoading) && styles.saveButtonDisabled,
]}
onPress={handleSave}
disabled={!isFormValid || isLoading}
testID="save-button"
>
<Text style={styles.saveButtonText}>
{isLoading ? 'Guardando...' : 'Guardar Cambios'}
</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8f9fa',
},
scrollView: {
flex: 1,
},
form: {
padding: 20,
},
inputGroup: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: '600',
color: '#333',
marginBottom: 8,
},
input: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#e9ecef',
borderRadius: 8,
padding: 16,
fontSize: 16,
},
disabledInput: {
backgroundColor: '#f8f9fa',
color: '#6c757d',
},
helpText: {
fontSize: 12,
color: '#6c757d',
marginTop: 4,
},
footer: {
padding: 20,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e9ecef',
},
saveButton: {
backgroundColor: '#007AFF',
borderRadius: 8,
padding: 16,
alignItems: 'center',
},
saveButtonDisabled: {
backgroundColor: '#c7c7cc',
},
saveButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});
🔒 Gestión de Métodos de Autenticación
// src/screens/AuthMethodsScreen.tsx
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Alert,
} from 'react-native';
import { useUser } from '@clerk/clerk-expo';
export const AuthMethodsScreen: React.FC = () => {
const { user } = useUser();
const [isLoading, setIsLoading] = useState(false);
const handleConnectGoogle = async () => {
setIsLoading(true);
try {
// Lógica para conectar Google OAuth
console.log('Conectar Google OAuth');
} catch (error) {
Alert.alert('Error', 'No se pudo conectar con Google');
} finally {
setIsLoading(false);
}
};
const handleDisconnectAccount = async (accountId: string, provider: string) => {
Alert.alert(
'Desconectar Cuenta',
`¿Estás seguro que deseas desconectar tu cuenta de ${provider}?`,
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Desconectar',
style: 'destructive',
onPress: async () => {
try {
await user?.externalAccounts.find(
(account) => account.id === accountId
)?.destroy();
Alert.alert('Éxito', 'Cuenta desconectada correctamente');
} catch (error) {
Alert.alert('Error', 'No se pudo desconectar la cuenta');
}
},
},
]
);
};
const getProviderName = (provider: string) => {
const providers: Record<string, string> = {
oauth_google: 'Google',
oauth_apple: 'Apple',
oauth_facebook: 'Facebook',
};
return providers[provider] || provider;
};
return (
<ScrollView style={styles.container}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Cuentas Conectadas</Text>
{user?.externalAccounts.map((account) => (
<View key={account.id} style={styles.accountItem}>
<View style={styles.accountInfo}>
<Text style={styles.providerName}>
{getProviderName(account.provider)}
</Text>
<Text style={styles.accountEmail}>
{account.emailAddress}
</Text>
</View>
<TouchableOpacity
style={styles.disconnectButton}
onPress={() => handleDisconnectAccount(
account.id,
getProviderName(account.provider)
)}
>
<Text style={styles.disconnectButtonText}>Desconectar</Text>
</TouchableOpacity>
</View>
))}
{user?.externalAccounts.length === 0 && (
<Text style={styles.emptyText}>
No tienes cuentas externas conectadas
</Text>
)}
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Conectar Nueva Cuenta</Text>
<TouchableOpacity
style={styles.connectButton}
onPress={handleConnectGoogle}
disabled={isLoading}
>
<Text style={styles.connectButtonText}>
Conectar con Google
</Text>
</TouchableOpacity>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Configuración de Seguridad</Text>
<View style={styles.securityItem}>
<Text style={styles.securityLabel}>
Verificación en dos pasos
</Text>
<Text style={styles.securityStatus}>
{user?.twoFactorEnabled ? 'Activada' : 'Desactivada'}
</Text>
</View>
</View>
</ScrollView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8f9fa',
},
section: {
backgroundColor: '#fff',
margin: 16,
borderRadius: 12,
padding: 16,
},
sectionTitle: {
fontSize: 18,
fontWeight: '600',
color: '#333',
marginBottom: 16,
},
accountItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#f1f3f4',
},
accountInfo: {
flex: 1,
},
providerName: {
fontSize: 16,
fontWeight: '500',
color: '#333',
},
accountEmail: {
fontSize: 14,
color: '#666',
marginTop: 2,
},
disconnectButton: {
backgroundColor: '#dc3545',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 6,
},
disconnectButtonText: {
color: '#fff',
fontSize: 12,
fontWeight: '500',
},
emptyText: {
fontSize: 14,
color: '#666',
textAlign: 'center',
paddingVertical: 20,
},
connectButton: {
backgroundColor: '#007AFF',
borderRadius: 8,
padding: 16,
alignItems: 'center',
},
connectButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
securityItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
},
securityLabel: {
fontSize: 16,
color: '#333',
},
securityStatus: {
fontSize: 14,
color: '#666',
},
});
🧪 Testing para Gestión de Usuario
// src/screens/__tests__/ProfileScreen.test.tsx
import React from 'react';
import { fireEvent, waitFor } from '@testing-library/react-native';
import { Alert } from 'react-native';
import { ProfileScreen } from '../ProfileScreen';
import { useUser } from '@clerk/clerk-expo';
import { useAuth } from '../../hooks/useAuth';
import { render } from '../../utils/test-utils';
jest.mock('@clerk/clerk-expo');
jest.mock('../../hooks/useAuth');
jest.spyOn(Alert, 'alert');
const mockUseUser = useUser as jest.MockedFunction<typeof useUser>;
const mockUseAuth = useAuth as jest.MockedFunction<typeof useAuth>;
describe('ProfileScreen', () => {
const mockUser = {
id: 'user_123',
firstName: 'John',
lastName: 'Doe',
emailAddresses: [{ emailAddress: '[email protected]' }],
phoneNumbers: [],
externalAccounts: [],
imageUrl: null,
profileImageUrl: null,
createdAt: new Date('2023-01-01'),
};
const mockLogout = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockUseUser.mockReturnValue({ user: mockUser });
mockUseAuth.mockReturnValue({ logout: mockLogout });
});
it('should render user profile correctly', () => {
const { getByText } = render(<ProfileScreen />);
expect(getByText('John Doe')).toBeTruthy();
expect(getByText('[email protected]')).toBeTruthy();
expect(getByText('Miembro desde 2023')).toBeTruthy();
});
it('should show logout confirmation', () => {
const { getByTestId } = render(<ProfileScreen />);
fireEvent.press(getByTestId('logout-button'));
expect(Alert.alert).toHaveBeenCalledWith(
'Cerrar Sesión',
'¿Estás seguro que deseas cerrar sesión?',
expect.arrayContaining([
expect.objectContaining({ text: 'Cancelar' }),
expect.objectContaining({ text: 'Cerrar Sesión' }),
])
);
});
it('should toggle notifications switch', () => {
const { getByTestId } = render(<ProfileScreen />);
const notificationsSwitch = getByTestId('notifications-switch');
fireEvent(notificationsSwitch, 'onValueChange', false);
// Verificar que el estado cambió
expect(notificationsSwitch.props.value).toBe(false);
});
it('should show loading state when user is null', () => {
mockUseUser.mockReturnValue({ user: null });
const { getByText } = render(<ProfileScreen />);
expect(getByText('Cargando perfil...')).toBeTruthy();
});
});
🔄 Hook Personalizado para Gestión de Perfil
// src/hooks/useProfile.ts
import { useState, useCallback } from 'react';
import { useUser } from '@clerk/clerk-expo';
import { Alert } from 'react-native';
export const useProfile = () => {
const { user } = useUser();
const [isLoading, setIsLoading] = useState(false);
const updateProfile = useCallback(async (updates: {
firstName?: string;
lastName?: string;
}) => {
if (!user) return false;
setIsLoading(true);
try {
await user.update(updates);
return true;
} catch (error: any) {
Alert.alert(
'Error',
error.errors?.[0]?.message || 'No se pudo actualizar el perfil'
);
return false;
} finally {
setIsLoading(false);
}
}, [user]);
const updateAvatar = useCallback(async (imageUri: string) => {
if (!user) return false;
setIsLoading(true);
try {
await user.setProfileImage({ file: imageUri });
return true;
} catch (error: any) {
Alert.alert(
'Error',
'No se pudo actualizar la imagen de perfil'
);
return false;
} finally {
setIsLoading(false);
}
}, [user]);
return {
user,
isLoading,
updateProfile,
updateAvatar,
};
};
🚀 Siguientes Pasos
En el próximo capítulo implementaremos:
- Configuración de TanStack Query
- Cliente HTTP con autenticación
- Queries y mutations para datos del servidor
- Integración con backend autenticado
🎯 Resumen del Capítulo
Hemos implementado un sistema completo de gestión de usuario:
✅ Pantalla de perfil completa ✅ Edición de información personal ✅ Gestión de métodos de autenticación ✅ Componentes reutilizables de perfil ✅ Testing completo de funcionalidades ✅ Hooks personalizados para gestión
Próximo capítulo: Configuración de TanStack Query - Configuraremos el manejo de datos del servidor con autenticación.