🔐 Authentication and User System

Index

  1. Opciones de Autenticación
  2. Implementación con Expo Auth
  3. Implementación con Backend Propio
  4. Implementación con Supabase
  5. Implementación con Firebase
  6. Contexto de Autenticación
  7. Protección de Rutas
  8. Flujos de Usuario

Opciones de Autenticación

OpciónComplejidadIdeal paraProsContras
AsyncStorage (local)BajaPrototipos, apps offlineSimple, sin backendSin sincronización entre dispositivos
SupabaseMediaApps con DB y authAuth + DB integrados, fácil setupDependencia externa
FirebaseMediaApps con ecosistema GoogleEscalable, muchos serviciosMás complejo, vendor lock-in
Backend propio (tRPC)AltaControl totalPersonalizableMás desarrollo y mantenimiento
Clerk/Auth0BajaEmpresas, seguridad críticaMuy robusto, SSOCostoso a escala

Implementación con Expo Auth (OAuth)

Instalación

npx expo install expo-auth-session expo-crypto expo-web-browser

Google Sign-In

// hooks/useGoogleAuth.ts
import * as Google from 'expo-auth-session/providers/google';
import * as WebBrowser from 'expo-web-browser';
import { useEffect } from 'react';

WebBrowser.maybeCompleteAuthSession();

export function useGoogleAuth() {
  const [request, response, promptAsync] = Google.useAuthRequest({
    expoClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID,
    iosClientId: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID,
    androidClientId: process.env.EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID,
    webClientId: process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID,
  });

  useEffect(() => {
    if (response?.type === 'success') {
      const { authentication } = response;
      // Usar authentication.accessToken para obtener datos del usuario
      fetchUserInfo(authentication?.accessToken);
    }
  }, [response]);

  const fetchUserInfo = async (token: string | undefined) => {
    if (!token) return;
    
    const response = await fetch(
      'https://www.googleapis.com/userinfo/v2/me',
      { headers: { Authorization: `Bearer ${token}` } }
    );
    const user = await response.json();
    return user;
  };

  return {
    signIn: () => promptAsync(),
    isLoading: !request,
  };
}

Apple Sign-In

// hooks/useAppleAuth.ts
import * as AppleAuthentication from 'expo-apple-authentication';
import { Platform } from 'react-native';

export function useAppleAuth() {
  const signIn = async () => {
    if (Platform.OS !== 'ios') {
      throw new Error('Apple Sign-In only available on iOS');
    }

    try {
      const credential = await AppleAuthentication.signInAsync({
        requestedScopes: [
          AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
          AppleAuthentication.AppleAuthenticationScope.EMAIL,
        ],
      });

      return {
        id: credential.user,
        email: credential.email,
        fullName: credential.fullName,
        identityToken: credential.identityToken,
      };
    } catch (error: any) {
      if (error.code === 'ERR_REQUEST_CANCELED') {
        return null;
      }
      throw error;
    }
  };

  return { signIn };
}

Implementación con Backend Propio

1. Estructura del Backend (tRPC)

backend/
├── trpc/
│   ├── auth/
│   │   ├── register/route.ts
│   │   ├── login/route.ts
│   │   ├── logout/route.ts
│   │   ├── me/route.ts
│   │   └── refresh/route.ts
│   └── app-router.ts
├── lib/
│   ├── jwt.ts
│   ├── password.ts
│   └── session.ts
└── hono.ts

2. Procedimientos de Auth

// backend/trpc/auth/register/route.ts
import { z } from 'zod';
import { publicProcedure } from '../../trpc';
import { hashPassword } from '../../lib/password';
import { createSession } from '../../lib/session';

const registerSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  name: z.string().min(2),
});

export const registerProcedure = publicProcedure
  .input(registerSchema)
  .mutation(async ({ input, ctx }) => {
    // 1. Verificar si el usuario ya existe
    const existingUser = await ctx.db.user.findUnique({
      where: { email: input.email },
    });
    
    if (existingUser) {
      throw new Error('El email ya está registrado');
    }

    // 2. Hashear contraseña
    const hashedPassword = await hashPassword(input.password);

    // 3. Crear usuario
    const user = await ctx.db.user.create({
      data: {
        email: input.email,
        password: hashedPassword,
        name: input.name,
      },
    });

    // 4. Crear sesión
    const session = await createSession(user.id);

    return {
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
      },
      token: session.token,
    };
  });
// backend/trpc/auth/login/route.ts
import { z } from 'zod';
import { publicProcedure } from '../../trpc';
import { verifyPassword } from '../../lib/password';
import { createSession } from '../../lib/session';

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string(),
});

export const loginProcedure = publicProcedure
  .input(loginSchema)
  .mutation(async ({ input, ctx }) => {
    // 1. Buscar usuario
    const user = await ctx.db.user.findUnique({
      where: { email: input.email },
    });

    if (!user) {
      throw new Error('Credenciales inválidas');
    }

    // 2. Verificar contraseña
    const isValid = await verifyPassword(input.password, user.password);

    if (!isValid) {
      throw new Error('Credenciales inválidas');
    }

    // 3. Crear sesión
    const session = await createSession(user.id);

    return {
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
        avatar: user.avatar,
      },
      token: session.token,
    };
  });
// backend/trpc/auth/me/route.ts
import { protectedProcedure } from '../../trpc';

export const meProcedure = protectedProcedure
  .query(async ({ ctx }) => {
    const user = await ctx.db.user.findUnique({
      where: { id: ctx.userId },
      select: {
        id: true,
        email: true,
        name: true,
        avatar: true,
        createdAt: true,
      },
    });

    if (!user) {
      throw new Error('Usuario no encontrado');
    }

    return user;
  });

3. Utilidades de Autenticación

// backend/lib/password.ts
import * as bcrypt from 'bcryptjs';

const SALT_ROUNDS = 12;

export async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, SALT_ROUNDS);
}

export async function verifyPassword(
  password: string, 
  hashedPassword: string
): Promise<boolean> {
  return bcrypt.compare(password, hashedPassword);
}
// backend/lib/jwt.ts
import jwt from 'jsonwebtoken';

const SECRET = process.env.JWT_SECRET!;
const EXPIRES_IN = '7d';

export interface TokenPayload {
  userId: string;
  email: string;
}

export function signToken(payload: TokenPayload): string {
  return jwt.sign(payload, SECRET, { expiresIn: EXPIRES_IN });
}

export function verifyToken(token: string): TokenPayload | null {
  try {
    return jwt.verify(token, SECRET) as TokenPayload;
  } catch {
    return null;
  }
}

Implementación con Supabase

Instalación

npx expo install @supabase/supabase-js @react-native-async-storage/async-storage

Configuración

// lib/supabase.ts
import 'react-native-url-polyfill/auto';
import { createClient } from '@supabase/supabase-js';
import AsyncStorage from '@react-native-async-storage/async-storage';

const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!;

export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
  auth: {
    storage: AsyncStorage,
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: false,
  },
});

Contexto de Auth con Supabase

// contexts/AuthContext.tsx
import createContextHook from '@nkzw/create-context-hook';
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/lib/supabase';
import { Session, User } from '@supabase/supabase-js';

interface AuthState {
  user: User | null;
  session: Session | null;
  isLoading: boolean;
}

export const [AuthProvider, useAuth] = createContextHook(() => {
  const queryClient = useQueryClient();
  const [session, setSession] = useState<Session | null>(null);

  // Escuchar cambios de sesión
  useEffect(() => {
    supabase.auth.getSession().then(({ data: { session } }) => {
      setSession(session);
    });

    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (_event, session) => {
        setSession(session);
        queryClient.invalidateQueries({ queryKey: ['user'] });
      }
    );

    return () => subscription.unsubscribe();
  }, []);

  // Query para datos del usuario
  const userQuery = useQuery({
    queryKey: ['user', session?.user?.id],
    queryFn: async () => {
      if (!session?.user?.id) return null;
      
      const { data, error } = await supabase
        .from('profiles')
        .select('*')
        .eq('id', session.user.id)
        .single();

      if (error) throw error;
      return data;
    },
    enabled: !!session?.user?.id,
  });

  // Registro
  const signUpMutation = useMutation({
    mutationFn: async ({ 
      email, 
      password, 
      name 
    }: { 
      email: string; 
      password: string; 
      name: string;
    }) => {
      const { data, error } = await supabase.auth.signUp({
        email,
        password,
        options: {
          data: { name },
        },
      });

      if (error) throw error;
      return data;
    },
  });

  // Login
  const signInMutation = useMutation({
    mutationFn: async ({ 
      email, 
      password 
    }: { 
      email: string; 
      password: string;
    }) => {
      const { data, error } = await supabase.auth.signInWithPassword({
        email,
        password,
      });

      if (error) throw error;
      return data;
    },
  });

  // Login con OAuth
  const signInWithOAuth = async (provider: 'google' | 'apple' | 'github') => {
    const { data, error } = await supabase.auth.signInWithOAuth({
      provider,
      options: {
        redirectTo: 'myapp://auth/callback',
      },
    });

    if (error) throw error;
    return data;
  };

  // Logout
  const signOutMutation = useMutation({
    mutationFn: async () => {
      const { error } = await supabase.auth.signOut();
      if (error) throw error;
    },
    onSuccess: () => {
      queryClient.clear();
    },
  });

  // Recuperar contraseña
  const resetPasswordMutation = useMutation({
    mutationFn: async (email: string) => {
      const { error } = await supabase.auth.resetPasswordForEmail(email, {
        redirectTo: 'myapp://auth/reset-password',
      });
      if (error) throw error;
    },
  });

  return {
    user: session?.user ?? null,
    profile: userQuery.data,
    session,
    isLoading: userQuery.isLoading,
    isAuthenticated: !!session,
    
    // Mutations
    signUp: signUpMutation.mutateAsync,
    signIn: signInMutation.mutateAsync,
    signInWithOAuth,
    signOut: signOutMutation.mutateAsync,
    resetPassword: resetPasswordMutation.mutateAsync,
    
    // Estados de mutations
    isSigningUp: signUpMutation.isPending,
    isSigningIn: signInMutation.isPending,
    isSigningOut: signOutMutation.isPending,
  };
});

Implementación con Firebase

Instalación

npx expo install firebase @react-native-firebase/app @react-native-firebase/auth

Configuración

// lib/firebase.ts
import { initializeApp } from 'firebase/app';
import { 
  getAuth, 
  initializeAuth, 
  getReactNativePersistence 
} from 'firebase/auth';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';

const firebaseConfig = {
  apiKey: process.env.EXPO_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.EXPO_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.EXPO_PUBLIC_FIREBASE_APP_ID,
};

const app = initializeApp(firebaseConfig);

// Usar persistencia con AsyncStorage en mobile
export const auth = Platform.OS === 'web' 
  ? getAuth(app)
  : initializeAuth(app, {
      persistence: getReactNativePersistence(AsyncStorage),
    });

Contexto de Auth con Firebase

// contexts/FirebaseAuthContext.tsx
import createContextHook from '@nkzw/create-context-hook';
import { useState, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { 
  User,
  signInWithEmailAndPassword,
  createUserWithEmailAndPassword,
  signOut as firebaseSignOut,
  onAuthStateChanged,
  sendPasswordResetEmail,
  updateProfile,
} from 'firebase/auth';
import { auth } from '@/lib/firebase';

export const [FirebaseAuthProvider, useFirebaseAuth] = createContextHook(() => {
  const queryClient = useQueryClient();
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, (user) => {
      setUser(user);
      setIsLoading(false);
    });

    return () => unsubscribe();
  }, []);

  const signUpMutation = useMutation({
    mutationFn: async ({ 
      email, 
      password, 
      name 
    }: { 
      email: string; 
      password: string; 
      name: string;
    }) => {
      const { user } = await createUserWithEmailAndPassword(auth, email, password);
      await updateProfile(user, { displayName: name });
      return user;
    },
  });

  const signInMutation = useMutation({
    mutationFn: async ({ email, password }: { email: string; password: string }) => {
      const { user } = await signInWithEmailAndPassword(auth, email, password);
      return user;
    },
  });

  const signOutMutation = useMutation({
    mutationFn: async () => {
      await firebaseSignOut(auth);
      queryClient.clear();
    },
  });

  const resetPasswordMutation = useMutation({
    mutationFn: async (email: string) => {
      await sendPasswordResetEmail(auth, email);
    },
  });

  return {
    user,
    isLoading,
    isAuthenticated: !!user,
    
    signUp: signUpMutation.mutateAsync,
    signIn: signInMutation.mutateAsync,
    signOut: signOutMutation.mutateAsync,
    resetPassword: resetPasswordMutation.mutateAsync,
    
    isSigningUp: signUpMutation.isPending,
    isSigningIn: signInMutation.isPending,
  };
});

Contexto de Autenticación (Genérico)

Estructura Recomendada

// contexts/AuthContext.tsx
import createContextHook from '@nkzw/create-context-hook';
import { useState, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import AsyncStorage from '@react-native-async-storage/async-storage';

interface User {
  id: string;
  email: string;
  name: string;
  avatar?: string;
}

interface AuthState {
  user: User | null;
  token: string | null;
  isLoading: boolean;
  isAuthenticated: boolean;
}

const TOKEN_KEY = 'auth_token';
const USER_KEY = 'auth_user';

export const [AuthProvider, useAuth] = createContextHook(() => {
  const queryClient = useQueryClient();
  const [token, setToken] = useState<string | null>(null);

  // Cargar token guardado
  const tokenQuery = useQuery({
    queryKey: ['auth-token'],
    queryFn: async () => {
      const stored = await AsyncStorage.getItem(TOKEN_KEY);
      return stored;
    },
    staleTime: Infinity,
  });

  useEffect(() => {
    if (tokenQuery.data) {
      setToken(tokenQuery.data);
    }
  }, [tokenQuery.data]);

  // Obtener usuario actual
  const userQuery = useQuery({
    queryKey: ['current-user', token],
    queryFn: async () => {
      if (!token) return null;
      
      // Llamar a tu API para obtener el usuario
      const response = await fetch('/api/auth/me', {
        headers: { Authorization: `Bearer ${token}` },
      });
      
      if (!response.ok) {
        throw new Error('No autorizado');
      }
      
      return response.json();
    },
    enabled: !!token,
    retry: false,
  });

  // Login
  const loginMutation = useMutation({
    mutationFn: async ({ email, password }: { email: string; password: string }) => {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });
      
      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message || 'Error de login');
      }
      
      return response.json();
    },
    onSuccess: async (data) => {
      await AsyncStorage.setItem(TOKEN_KEY, data.token);
      await AsyncStorage.setItem(USER_KEY, JSON.stringify(data.user));
      setToken(data.token);
      queryClient.setQueryData(['current-user', data.token], data.user);
    },
  });

  // Registro
  const registerMutation = useMutation({
    mutationFn: async ({ 
      email, 
      password, 
      name 
    }: { 
      email: string; 
      password: string; 
      name: string;
    }) => {
      const response = await fetch('/api/auth/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password, name }),
      });
      
      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message || 'Error de registro');
      }
      
      return response.json();
    },
    onSuccess: async (data) => {
      await AsyncStorage.setItem(TOKEN_KEY, data.token);
      await AsyncStorage.setItem(USER_KEY, JSON.stringify(data.user));
      setToken(data.token);
      queryClient.setQueryData(['current-user', data.token], data.user);
    },
  });

  // Logout
  const logoutMutation = useMutation({
    mutationFn: async () => {
      await AsyncStorage.multiRemove([TOKEN_KEY, USER_KEY]);
    },
    onSuccess: () => {
      setToken(null);
      queryClient.clear();
    },
  });

  // Actualizar perfil
  const updateProfileMutation = useMutation({
    mutationFn: async (updates: Partial<User>) => {
      const response = await fetch('/api/auth/profile', {
        method: 'PATCH',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify(updates),
      });
      
      if (!response.ok) throw new Error('Error actualizando perfil');
      return response.json();
    },
    onSuccess: (data) => {
      queryClient.setQueryData(['current-user', token], data);
    },
  });

  return {
    user: userQuery.data ?? null,
    token,
    isLoading: tokenQuery.isLoading || userQuery.isLoading,
    isAuthenticated: !!userQuery.data,

    // Acciones
    login: loginMutation.mutateAsync,
    register: registerMutation.mutateAsync,
    logout: logoutMutation.mutateAsync,
    updateProfile: updateProfileMutation.mutateAsync,

    // Estados
    isLoggingIn: loginMutation.isPending,
    isRegistering: registerMutation.isPending,
    loginError: loginMutation.error?.message,
    registerError: registerMutation.error?.message,
  };
});

Protección de Rutas

En el Layout Raíz

// app/_layout.tsx
import { useAuth } from '@/contexts/AuthContext';
import { useRouter, useSegments } from 'expo-router';
import { useEffect } from 'react';

function useProtectedRoute() {
  const { isAuthenticated, isLoading } = useAuth();
  const segments = useSegments();
  const router = useRouter();

  useEffect(() => {
    if (isLoading) return;

    const inAuthGroup = segments[0] === '(auth)';
    const inProtectedGroup = segments[0] === '(tabs)';

    if (!isAuthenticated && inProtectedGroup) {
      // Redirigir a login si no está autenticado
      router.replace('/login');
    } else if (isAuthenticated && inAuthGroup) {
      // Redirigir a home si ya está autenticado
      router.replace('/(tabs)/(home)');
    }
  }, [isAuthenticated, isLoading, segments]);
}

function RootLayoutNav() {
  useProtectedRoute();
  
  return (
    <Stack>
      <Stack.Screen name="(auth)" options={{ headerShown: false }} />
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
    </Stack>
  );
}

Estructura de Archivos con Auth

app/
├── (auth)/                    # Rutas públicas (login, register)
│   ├── _layout.tsx
│   ├── login.tsx
│   ├── register.tsx
│   └── forgot-password.tsx
├── (tabs)/                    # Rutas protegidas
│   ├── _layout.tsx
│   └── ...
└── _layout.tsx

Flujos de Usuario

Pantalla de Login

// app/(auth)/login.tsx
import { View, StyleSheet } from 'react-native';
import { useState } from 'react';
import { useRouter } from 'expo-router';
import { useAuth } from '@/contexts/AuthContext';
import { Input } from '@/components/Input';
import { Button } from '@/components/Button';
import { useToast } from '@/contexts/ToastContext';

export default function LoginScreen() {
  const router = useRouter();
  const { login, isLoggingIn } = useAuth();
  const { showToast } = useToast();
  
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState<{ email?: string; password?: string }>({});

  const validate = () => {
    const newErrors: typeof errors = {};
    
    if (!email) {
      newErrors.email = 'El email es requerido';
    } else if (!/\S+@\S+\.\S+/.test(email)) {
      newErrors.email = 'Email inválido';
    }
    
    if (!password) {
      newErrors.password = 'La contraseña es requerida';
    } else if (password.length < 8) {
      newErrors.password = 'Mínimo 8 caracteres';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleLogin = async () => {
    if (!validate()) return;

    try {
      await login({ email, password });
      showToast('Bienvenido de nuevo', 'success');
    } catch (error: any) {
      showToast(error.message || 'Error al iniciar sesión', 'error');
    }
  };

  return (
    <View style={styles.container}>
      <Input
        label="Email"
        value={email}
        onChangeText={setEmail}
        keyboardType="email-address"
        autoCapitalize="none"
        error={errors.email}
        testID="login-email"
      />
      
      <Input
        label="Contraseña"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
        error={errors.password}
        testID="login-password"
      />
      
      <Button
        title="Iniciar Sesión"
        onPress={handleLogin}
        loading={isLoggingIn}
        testID="login-button"
      />
      
      <Button
        title="¿No tienes cuenta? Regístrate"
        variant="ghost"
        onPress={() => router.push('/register')}
      />
      
      <Button
        title="¿Olvidaste tu contraseña?"
        variant="ghost"
        onPress={() => router.push('/forgot-password')}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 24,
    justifyContent: 'center',
    gap: 16,
  },
});

Pantalla de Registro

// app/(auth)/register.tsx
import { View, StyleSheet, ScrollView } from 'react-native';
import { useState } from 'react';
import { useRouter } from 'expo-router';
import { useAuth } from '@/contexts/AuthContext';
import { Input } from '@/components/Input';
import { Button } from '@/components/Button';
import { useToast } from '@/contexts/ToastContext';

export default function RegisterScreen() {
  const router = useRouter();
  const { register, isRegistering } = useAuth();
  const { showToast } = useToast();
  
  const [form, setForm] = useState({
    name: '',
    email: '',
    password: '',
    confirmPassword: '',
  });
  const [errors, setErrors] = useState<Record<string, string>>({});

  const validate = () => {
    const newErrors: Record<string, string> = {};
    
    if (!form.name || form.name.length < 2) {
      newErrors.name = 'El nombre debe tener al menos 2 caracteres';
    }
    
    if (!form.email || !/\S+@\S+\.\S+/.test(form.email)) {
      newErrors.email = 'Email inválido';
    }
    
    if (!form.password || form.password.length < 8) {
      newErrors.password = 'La contraseña debe tener al menos 8 caracteres';
    }
    
    if (form.password !== form.confirmPassword) {
      newErrors.confirmPassword = 'Las contraseñas no coinciden';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleRegister = async () => {
    if (!validate()) return;

    try {
      await register({
        name: form.name,
        email: form.email,
        password: form.password,
      });
      showToast('Cuenta creada exitosamente', 'success');
    } catch (error: any) {
      showToast(error.message || 'Error al registrarse', 'error');
    }
  };

  return (
    <ScrollView contentContainerStyle={styles.container}>
      <Input
        label="Nombre"
        value={form.name}
        onChangeText={(text) => setForm({ ...form, name: text })}
        error={errors.name}
        testID="register-name"
      />
      
      <Input
        label="Email"
        value={form.email}
        onChangeText={(text) => setForm({ ...form, email: text })}
        keyboardType="email-address"
        autoCapitalize="none"
        error={errors.email}
        testID="register-email"
      />
      
      <Input
        label="Contraseña"
        value={form.password}
        onChangeText={(text) => setForm({ ...form, password: text })}
        secureTextEntry
        error={errors.password}
        testID="register-password"
      />
      
      <Input
        label="Confirmar Contraseña"
        value={form.confirmPassword}
        onChangeText={(text) => setForm({ ...form, confirmPassword: text })}
        secureTextEntry
        error={errors.confirmPassword}
        testID="register-confirm-password"
      />
      
      <Button
        title="Crear Cuenta"
        onPress={handleRegister}
        loading={isRegistering}
        testID="register-button"
      />
      
      <Button
        title="¿Ya tienes cuenta? Inicia sesión"
        variant="ghost"
        onPress={() => router.push('/login')}
      />
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: {
    flexGrow: 1,
    padding: 24,
    justifyContent: 'center',
    gap: 16,
  },
});

Checklist de Implementación

  • Elegir proveedor de autenticación
  • Configurar variables de entorno necesarias
  • Crear contexto de autenticación
  • Implementar pantallas de login/registro
  • Proteger rutas que requieren autenticación
  • Manejar estados de carga
  • Manejar errores y mostrar mensajes
  • Implementar "recordar sesión"
  • Implementar recuperación de contraseña
  • Implementar logout
  • Probar flujo completo en iOS, Android y Web