← Volver al listado de tecnologías

Capítulo 9: Gestión de Usuario y Sesiones - Perfil y Configuración

Por: Artiko
react-nativeclerkusuariosesionesperfiltypescripttesting

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

📋 Prerrequisitos

👤 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:

🎯 Resumen del Capítulo

Hemos implementado un sistema completo de gestión de usuario:

Pantalla de perfil completaEdición de información personalGestión de métodos de autenticaciónComponentes reutilizables de perfilTesting completo de funcionalidadesHooks personalizados para gestión


Próximo capítulo: Configuración de TanStack Query - Configuraremos el manejo de datos del servidor con autenticación.

🔗 Navegación