← Volver al listado de tecnologías

Capítulo 8: Implementación de Autenticación - SignIn, SignUp y OAuth

Por: Artiko
react-nativeclerksigninsignupoauthtypescripttesting

Capítulo 8: Implementación de Autenticación - SignIn, SignUp y OAuth

En este capítulo implementaremos los componentes de autenticación completos, incluyendo formularios de login y registro, OAuth con Google y Apple, y toda la lógica de flujos de autenticación.

🎯 Objetivos del Capítulo

📋 Prerrequisitos

🏗️ Estructura de Componentes de Autenticación

Primero, creemos la estructura de carpetas para nuestros componentes:

// src/components/auth/
// ├── SignInScreen.tsx
// ├── SignUpScreen.tsx
// ├── OAuthButtons.tsx
// ├── AuthForm.tsx
// └── __tests__/

🔐 Componente SignIn

// src/components/auth/SignInScreen.tsx
import React, { useState } from 'react';
import {
  View,
  Text,
  TextInput,
  TouchableOpacity,
  StyleSheet,
  Alert,
  KeyboardAvoidingView,
  Platform,
  ScrollView,
} from 'react-native';
import { useSignIn } from '@clerk/clerk-expo';
import { OAuthButtons } from './OAuthButtons';

export const SignInScreen: React.FC = () => {
  const { signIn, setActive, isLoaded } = useSignIn();
  const [emailAddress, setEmailAddress] = useState('');
  const [password, setPassword] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  const onSignInPress = async () => {
    if (!isLoaded) return;

    setIsLoading(true);
    try {
      const completeSignIn = await signIn.create({
        identifier: emailAddress,
        password,
      });

      await setActive({ session: completeSignIn.createdSessionId });
    } catch (err: any) {
      Alert.alert('Error', err.errors?.[0]?.message || 'Error al iniciar sesión');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <KeyboardAvoidingView
      style={styles.container}
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
    >
      <ScrollView contentContainerStyle={styles.scrollContainer}>
        <View style={styles.formContainer}>
          <Text style={styles.title}>Iniciar Sesión</Text>
          
          <TextInput
            style={styles.input}
            placeholder="Email"
            value={emailAddress}
            onChangeText={setEmailAddress}
            keyboardType="email-address"
            autoCapitalize="none"
            autoComplete="email"
            testID="email-input"
          />

          <TextInput
            style={styles.input}
            placeholder="Contraseña"
            value={password}
            onChangeText={setPassword}
            secureTextEntry
            autoComplete="password"
            testID="password-input"
          />

          <TouchableOpacity
            style={[styles.button, isLoading && styles.buttonDisabled]}
            onPress={onSignInPress}
            disabled={isLoading}
            testID="signin-button"
          >
            <Text style={styles.buttonText}>
              {isLoading ? 'Iniciando sesión...' : 'Iniciar Sesión'}
            </Text>
          </TouchableOpacity>

          <View style={styles.divider}>
            <View style={styles.dividerLine} />
            <Text style={styles.dividerText}>O continúa con</Text>
            <View style={styles.dividerLine} />
          </View>

          <OAuthButtons />
        </View>
      </ScrollView>
    </KeyboardAvoidingView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
  },
  scrollContainer: {
    flexGrow: 1,
    justifyContent: 'center',
    padding: 20,
  },
  formContainer: {
    width: '100%',
    maxWidth: 400,
    alignSelf: 'center',
  },
  title: {
    fontSize: 28,
    fontWeight: 'bold',
    textAlign: 'center',
    marginBottom: 30,
    color: '#333',
  },
  input: {
    borderWidth: 1,
    borderColor: '#ddd',
    borderRadius: 8,
    padding: 15,
    marginBottom: 15,
    fontSize: 16,
    backgroundColor: '#f9f9f9',
  },
  button: {
    backgroundColor: '#007AFF',
    borderRadius: 8,
    padding: 15,
    alignItems: 'center',
    marginBottom: 20,
  },
  buttonDisabled: {
    backgroundColor: '#ccc',
  },
  buttonText: {
    color: '#fff',
    fontSize: 16,
    fontWeight: '600',
  },
  divider: {
    flexDirection: 'row',
    alignItems: 'center',
    marginVertical: 20,
  },
  dividerLine: {
    flex: 1,
    height: 1,
    backgroundColor: '#ddd',
  },
  dividerText: {
    marginHorizontal: 10,
    color: '#666',
    fontSize: 14,
  },
});

📝 Componente SignUp

// src/components/auth/SignUpScreen.tsx
import React, { useState } from 'react';
import {
  View,
  Text,
  TextInput,
  TouchableOpacity,
  StyleSheet,
  Alert,
  KeyboardAvoidingView,
  Platform,
  ScrollView,
} from 'react-native';
import { useSignUp } from '@clerk/clerk-expo';
import { OAuthButtons } from './OAuthButtons';

export const SignUpScreen: React.FC = () => {
  const { isLoaded, signUp, setActive } = useSignUp();
  const [emailAddress, setEmailAddress] = useState('');
  const [password, setPassword] = useState('');
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [pendingVerification, setPendingVerification] = useState(false);
  const [code, setCode] = useState('');

  const onSignUpPress = async () => {
    if (!isLoaded) return;

    setIsLoading(true);
    try {
      await signUp.create({
        emailAddress,
        password,
        firstName,
        lastName,
      });

      await signUp.prepareEmailAddressVerification({ strategy: 'email_code' });
      setPendingVerification(true);
    } catch (err: any) {
      Alert.alert('Error', err.errors?.[0]?.message || 'Error al registrarse');
    } finally {
      setIsLoading(false);
    }
  };

  const onPressVerify = async () => {
    if (!isLoaded) return;

    setIsLoading(true);
    try {
      const completeSignUp = await signUp.attemptEmailAddressVerification({
        code,
      });

      await setActive({ session: completeSignUp.createdSessionId });
    } catch (err: any) {
      Alert.alert('Error', err.errors?.[0]?.message || 'Código incorrecto');
    } finally {
      setIsLoading(false);
    }
  };

  if (pendingVerification) {
    return (
      <View style={styles.container}>
        <View style={styles.formContainer}>
          <Text style={styles.title}>Verificar Email</Text>
          <Text style={styles.subtitle}>
            Hemos enviado un código de verificación a {emailAddress}
          </Text>

          <TextInput
            style={styles.input}
            placeholder="Código de verificación"
            value={code}
            onChangeText={setCode}
            keyboardType="number-pad"
            testID="verification-code-input"
          />

          <TouchableOpacity
            style={[styles.button, isLoading && styles.buttonDisabled]}
            onPress={onPressVerify}
            disabled={isLoading}
            testID="verify-button"
          >
            <Text style={styles.buttonText}>
              {isLoading ? 'Verificando...' : 'Verificar'}
            </Text>
          </TouchableOpacity>
        </View>
      </View>
    );
  }

  return (
    <KeyboardAvoidingView
      style={styles.container}
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
    >
      <ScrollView contentContainerStyle={styles.scrollContainer}>
        <View style={styles.formContainer}>
          <Text style={styles.title}>Crear Cuenta</Text>

          <TextInput
            style={styles.input}
            placeholder="Nombre"
            value={firstName}
            onChangeText={setFirstName}
            autoCapitalize="words"
            testID="firstname-input"
          />

          <TextInput
            style={styles.input}
            placeholder="Apellido"
            value={lastName}
            onChangeText={setLastName}
            autoCapitalize="words"
            testID="lastname-input"
          />

          <TextInput
            style={styles.input}
            placeholder="Email"
            value={emailAddress}
            onChangeText={setEmailAddress}
            keyboardType="email-address"
            autoCapitalize="none"
            autoComplete="email"
            testID="email-input"
          />

          <TextInput
            style={styles.input}
            placeholder="Contraseña"
            value={password}
            onChangeText={setPassword}
            secureTextEntry
            testID="password-input"
          />

          <TouchableOpacity
            style={[styles.button, isLoading && styles.buttonDisabled]}
            onPress={onSignUpPress}
            disabled={isLoading}
            testID="signup-button"
          >
            <Text style={styles.buttonText}>
              {isLoading ? 'Creando cuenta...' : 'Crear Cuenta'}
            </Text>
          </TouchableOpacity>

          <View style={styles.divider}>
            <View style={styles.dividerLine} />
            <Text style={styles.dividerText}>O continúa con</Text>
            <View style={styles.dividerLine} />
          </View>

          <OAuthButtons />
        </View>
      </ScrollView>
    </KeyboardAvoidingView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
  },
  scrollContainer: {
    flexGrow: 1,
    justifyContent: 'center',
    padding: 20,
  },
  formContainer: {
    width: '100%',
    maxWidth: 400,
    alignSelf: 'center',
  },
  title: {
    fontSize: 28,
    fontWeight: 'bold',
    textAlign: 'center',
    marginBottom: 10,
    color: '#333',
  },
  subtitle: {
    fontSize: 16,
    textAlign: 'center',
    marginBottom: 30,
    color: '#666',
    lineHeight: 22,
  },
  input: {
    borderWidth: 1,
    borderColor: '#ddd',
    borderRadius: 8,
    padding: 15,
    marginBottom: 15,
    fontSize: 16,
    backgroundColor: '#f9f9f9',
  },
  button: {
    backgroundColor: '#007AFF',
    borderRadius: 8,
    padding: 15,
    alignItems: 'center',
    marginBottom: 20,
  },
  buttonDisabled: {
    backgroundColor: '#ccc',
  },
  buttonText: {
    color: '#fff',
    fontSize: 16,
    fontWeight: '600',
  },
  divider: {
    flexDirection: 'row',
    alignItems: 'center',
    marginVertical: 20,
  },
  dividerLine: {
    flex: 1,
    height: 1,
    backgroundColor: '#ddd',
  },
  dividerText: {
    marginHorizontal: 10,
    color: '#666',
    fontSize: 14,
  },
});

🔗 Componente OAuth

// src/components/auth/OAuthButtons.tsx
import React from 'react';
import {
  View,
  Text,
  TouchableOpacity,
  StyleSheet,
  Platform,
  Alert,
} from 'react-native';
import { useOAuth } from '@clerk/clerk-expo';
import { useWarmUpBrowser } from '../hooks/useWarmUpBrowser';

export const OAuthButtons: React.FC = () => {
  useWarmUpBrowser();

  const { startOAuthFlow: googleOAuth } = useOAuth({ strategy: 'oauth_google' });
  const { startOAuthFlow: appleOAuth } = useOAuth({ strategy: 'oauth_apple' });

  const onGooglePress = async () => {
    try {
      const { createdSessionId, setActive } = await googleOAuth();

      if (createdSessionId) {
        setActive!({ session: createdSessionId });
      }
    } catch (err: any) {
      Alert.alert('Error', 'Error al iniciar sesión con Google');
      console.error('OAuth error', err);
    }
  };

  const onApplePress = async () => {
    try {
      const { createdSessionId, setActive } = await appleOAuth();

      if (createdSessionId) {
        setActive!({ session: createdSessionId });
      }
    } catch (err: any) {
      Alert.alert('Error', 'Error al iniciar sesión con Apple');
      console.error('OAuth error', err);
    }
  };

  return (
    <View style={styles.container}>
      <TouchableOpacity
        style={[styles.oauthButton, styles.googleButton]}
        onPress={onGooglePress}
        testID="google-oauth-button"
      >
        <Text style={styles.googleButtonText}>Continuar con Google</Text>
      </TouchableOpacity>

      {Platform.OS === 'ios' && (
        <TouchableOpacity
          style={[styles.oauthButton, styles.appleButton]}
          onPress={onApplePress}
          testID="apple-oauth-button"
        >
          <Text style={styles.appleButtonText}>Continuar con Apple</Text>
        </TouchableOpacity>
      )}
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    gap: 12,
  },
  oauthButton: {
    borderRadius: 8,
    padding: 15,
    alignItems: 'center',
    borderWidth: 1,
  },
  googleButton: {
    backgroundColor: '#fff',
    borderColor: '#ddd',
  },
  appleButton: {
    backgroundColor: '#000',
    borderColor: '#000',
  },
  googleButtonText: {
    color: '#333',
    fontSize: 16,
    fontWeight: '600',
  },
  appleButtonText: {
    color: '#fff',
    fontSize: 16,
    fontWeight: '600',
  },
});

🔥 Hook para Optimización OAuth

// src/hooks/useWarmUpBrowser.ts
import { useEffect } from 'react';
import * as WebBrowser from 'expo-web-browser';

export const useWarmUpBrowser = () => {
  useEffect(() => {
    // Warm up the android browser to improve UX
    // https://docs.expo.dev/guides/authentication/#improving-user-experience
    void WebBrowser.warmUpAsync();
    return () => {
      void WebBrowser.coolDownAsync();
    };
  }, []);
};

🛡️ Componentes de Protección de Rutas

// src/components/auth/AuthGuard.tsx
import React from 'react';
import { useAuth } from '../../hooks/useAuth';
import { SignedIn, SignedOut } from '@clerk/clerk-expo';
import { AuthNavigator } from '../../navigation/AuthNavigator';

interface AuthGuardProps {
  children: React.ReactNode;
}

export const AuthGuard: React.FC<AuthGuardProps> = ({ children }) => {
  return (
    <>
      <SignedIn>{children}</SignedIn>
      <SignedOut>
        <AuthNavigator />
      </SignedOut>
    </>
  );
};

🧭 Navegación de Autenticación

// src/navigation/AuthNavigator.tsx
import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import { SignInScreen } from '../components/auth/SignInScreen';
import { SignUpScreen } from '../components/auth/SignUpScreen';

export type AuthStackParamList = {
  SignIn: undefined;
  SignUp: undefined;
};

const Stack = createStackNavigator<AuthStackParamList>();

export const AuthNavigator: React.FC = () => {
  return (
    <Stack.Navigator
      screenOptions={{
        headerShown: false,
      }}
    >
      <Stack.Screen name="SignIn" component={SignInScreen} />
      <Stack.Screen name="SignUp" component={SignUpScreen} />
    </Stack.Navigator>
  );
};

🧪 Testing de Componentes de Autenticación

// src/components/auth/__tests__/SignInScreen.test.tsx
import React from 'react';
import { fireEvent, waitFor } from '@testing-library/react-native';
import { Alert } from 'react-native';
import { SignInScreen } from '../SignInScreen';
import { useSignIn } from '@clerk/clerk-expo';
import { render } from '../../../utils/test-utils';

jest.mock('@clerk/clerk-expo');
jest.spyOn(Alert, 'alert');

const mockUseSignIn = useSignIn as jest.MockedFunction<typeof useSignIn>;

describe('SignInScreen', () => {
  const mockSignIn = {
    create: jest.fn(),
  };
  const mockSetActive = jest.fn();

  beforeEach(() => {
    jest.clearAllMocks();
    mockUseSignIn.mockReturnValue({
      signIn: mockSignIn,
      setActive: mockSetActive,
      isLoaded: true,
    });
  });

  it('should render signin form correctly', () => {
    const { getByTestId, getByText } = render(<SignInScreen />);

    expect(getByText('Iniciar Sesión')).toBeTruthy();
    expect(getByTestId('email-input')).toBeTruthy();
    expect(getByTestId('password-input')).toBeTruthy();
    expect(getByTestId('signin-button')).toBeTruthy();
  });

  it('should handle successful signin', async () => {
    const mockSession = { createdSessionId: 'session_123' };
    mockSignIn.create.mockResolvedValue(mockSession);

    const { getByTestId } = render(<SignInScreen />);

    fireEvent.changeText(getByTestId('email-input'), '[email protected]');
    fireEvent.changeText(getByTestId('password-input'), 'password123');
    fireEvent.press(getByTestId('signin-button'));

    await waitFor(() => {
      expect(mockSignIn.create).toHaveBeenCalledWith({
        identifier: '[email protected]',
        password: 'password123',
      });
      expect(mockSetActive).toHaveBeenCalledWith({
        session: 'session_123',
      });
    });
  });

  it('should handle signin error', async () => {
    const error = {
      errors: [{ message: 'Invalid credentials' }],
    };
    mockSignIn.create.mockRejectedValue(error);

    const { getByTestId } = render(<SignInScreen />);

    fireEvent.changeText(getByTestId('email-input'), '[email protected]');
    fireEvent.changeText(getByTestId('password-input'), 'wrongpassword');
    fireEvent.press(getByTestId('signin-button'));

    await waitFor(() => {
      expect(Alert.alert).toHaveBeenCalledWith('Error', 'Invalid credentials');
    });
  });

  it('should disable button while loading', async () => {
    mockSignIn.create.mockImplementation(
      () => new Promise((resolve) => setTimeout(resolve, 100))
    );

    const { getByTestId } = render(<SignInScreen />);

    fireEvent.press(getByTestId('signin-button'));

    expect(getByTestId('signin-button')).toBeDisabled();
  });
});

🧪 Testing OAuth

// src/components/auth/__tests__/OAuthButtons.test.tsx
import React from 'react';
import { Platform } from 'react-native';
import { fireEvent, waitFor } from '@testing-library/react-native';
import { OAuthButtons } from '../OAuthButtons';
import { useOAuth } from '@clerk/clerk-expo';
import { render } from '../../../utils/test-utils';

jest.mock('@clerk/clerk-expo');
jest.mock('../hooks/useWarmUpBrowser');

const mockUseOAuth = useOAuth as jest.MockedFunction<typeof useOAuth>;

describe('OAuthButtons', () => {
  const mockStartOAuthFlow = jest.fn();

  beforeEach(() => {
    jest.clearAllMocks();
    mockUseOAuth.mockReturnValue({
      startOAuthFlow: mockStartOAuthFlow,
    });
  });

  it('should render Google OAuth button', () => {
    const { getByTestId } = render(<OAuthButtons />);
    expect(getByTestId('google-oauth-button')).toBeTruthy();
  });

  it('should render Apple OAuth button on iOS', () => {
    Platform.OS = 'ios';
    const { getByTestId } = render(<OAuthButtons />);
    expect(getByTestId('apple-oauth-button')).toBeTruthy();
  });

  it('should not render Apple OAuth button on Android', () => {
    Platform.OS = 'android';
    const { queryByTestId } = render(<OAuthButtons />);
    expect(queryByTestId('apple-oauth-button')).toBeNull();
  });

  it('should handle Google OAuth flow', async () => {
    const mockSetActive = jest.fn();
    mockStartOAuthFlow.mockResolvedValue({
      createdSessionId: 'session_123',
      setActive: mockSetActive,
    });

    const { getByTestId } = render(<OAuthButtons />);

    fireEvent.press(getByTestId('google-oauth-button'));

    await waitFor(() => {
      expect(mockStartOAuthFlow).toHaveBeenCalled();
      expect(mockSetActive).toHaveBeenCalledWith({
        session: 'session_123',
      });
    });
  });
});

🔧 Actualización del Navegador Principal

// src/navigation/MainNavigator.tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { AuthGuard } from '../components/auth/AuthGuard';
import { AppNavigator } from './AppNavigator';

export const MainNavigator: React.FC = () => {
  return (
    <NavigationContainer>
      <AuthGuard>
        <AppNavigator />
      </AuthGuard>
    </NavigationContainer>
  );
};

📱 Pantalla de Perfil de Usuario

// src/components/UserProfile.tsx
import React from 'react';
import {
  View,
  Text,
  TouchableOpacity,
  StyleSheet,
  Alert,
} from 'react-native';
import { useAuth } from '../hooks/useAuth';

export const UserProfile: React.FC = () => {
  const { user, logout } = useAuth();

  const handleLogout = () => {
    Alert.alert(
      'Cerrar Sesión',
      '¿Estás seguro que deseas cerrar sesión?',
      [
        { text: 'Cancelar', style: 'cancel' },
        { text: 'Cerrar Sesión', onPress: logout },
      ]
    );
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Perfil</Text>
      
      {user && (
        <View style={styles.userInfo}>
          <Text style={styles.label}>Nombre:</Text>
          <Text style={styles.value}>
            {user.firstName} {user.lastName}
          </Text>
          
          <Text style={styles.label}>Email:</Text>
          <Text style={styles.value}>
            {user.emailAddresses[0]?.emailAddress}
          </Text>
        </View>
      )}

      <TouchableOpacity
        style={styles.logoutButton}
        onPress={handleLogout}
        testID="logout-button"
      >
        <Text style={styles.logoutButtonText}>Cerrar Sesión</Text>
      </TouchableOpacity>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 20,
    backgroundColor: '#fff',
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 30,
    textAlign: 'center',
  },
  userInfo: {
    marginBottom: 40,
  },
  label: {
    fontSize: 16,
    fontWeight: '600',
    color: '#333',
    marginTop: 15,
    marginBottom: 5,
  },
  value: {
    fontSize: 16,
    color: '#666',
    paddingBottom: 10,
    borderBottomWidth: 1,
    borderBottomColor: '#eee',
  },
  logoutButton: {
    backgroundColor: '#FF3B30',
    borderRadius: 8,
    padding: 15,
    alignItems: 'center',
  },
  logoutButtonText: {
    color: '#fff',
    fontSize: 16,
    fontWeight: '600',
  },
});

🚀 Siguientes Pasos

En el próximo capítulo implementaremos:

🎯 Resumen del Capítulo

Hemos implementado un sistema completo de autenticación:

Componentes de SignIn y SignUpOAuth con Google y AppleVerificación de emailProtección de rutas con AuthGuardTesting completo de flujosNavegación de autenticaciónPerfil de usuario básico


Próximo capítulo: Gestión de Usuario y Sesiones - Profundizaremos en la gestión del perfil de usuario y configuraciones avanzadas.