Práctica FireChat
En esta práctica vamos a crear una aplicación de chat en tiempo real utilizando React y TypeScript. Utilizaremos Firebase como backend para manejar la autenticación y el almacenamiento de mensajes.
Enlaces útiles
- https://bluuweb-reactfire.netlify.app/
- https://reactrouter.com/start/declarative/installation
- firebase documentation
- https://github.com/FirebaseExtended/reactfire
- github codelab react
npm create vite@latest .
npm i react-router
npm install --save firebase reactfire
📚 Conceptos Clave
Este proyecto demuestra la implementación de:
- Rutas Protegidas: Control de acceso basado en autenticación
- Layouts Anidados: Organización de componentes usando React Router
- Firebase Authentication: Gestión de usuarios con ReactFire
- TypeScript: Tipado estático para mayor seguridad
- Arquitectura por Capas: Separación clara de responsabilidades
🏗️ Estructura del Proyecto (Mejorada)
src/
├── layouts/
│ ├── admin.layout.tsx # Layout con protección de autenticación
│ ├── auth.layout.tsx # Layout para usuarios no autenticados
│ ├── public.layout.tsx # Layout público sin protección
│ └── root.layout.tsx # Layout raíz
├── pages/
│ ├── admin/ # Páginas protegidas
│ │ ├── chat.page.tsx
│ │ ├── dashboard.page.tsx
│ │ └── profile.page.tsx
│ ├── auth/ # Páginas de autenticación
│ │ ├── login.page.tsx
│ │ └── register.page.tsx
│ └── public/ # Páginas públicas
│ ├── home.page.tsx
│ └── not-found.page.tsx
├── config/
│ ├── firebase.ts # Configuración de Firebase
│ └── firebase-services.tsx # Providers de Firebase
├── App.tsx # Configuración de rutas
└── main.tsx # Punto de entrada
🔧 Dependencias Principales
{
"dependencies": {
"react": "^19.1.0",
"react-router": "^7.7.0",
"firebase": "^9.23.0",
"reactfire": "^4.2.3"
}
}
🔥 Configuración de Firebase
src\config\firebase.ts
export const firebaseConfig = {
apiKey: "...",
authDomain: "...",
projectId: "...",
storageBucket: "...",
messagingSenderId: "...",
appId: "...",
};
src\config\firebase-services.tsx
import { getAuth } from "firebase/auth";
import { getFirestore } from "firebase/firestore";
import { getStorage } from "firebase/storage";
import {
AuthProvider,
FirestoreProvider,
StorageProvider,
useFirebaseApp,
} from "reactfire";
const FirebaseServices = ({ children }: Props) => {
const app = useFirebaseApp();
const auth = getAuth(app);
const firestore = getFirestore(app);
const storage = getStorage(app);
return (
<AuthProvider sdk={auth}>
<FirestoreProvider sdk={firestore}>
<StorageProvider sdk={storage}>{children}</StorageProvider>
</FirestoreProvider>
</AuthProvider>
);
};
main.tsx
- Configuración de Providers
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { BrowserRouter } from "react-router";
import { FirebaseAppProvider } from "reactfire";
import FirebaseServices from "./config/firebase-services.tsx";
import { firebaseConfig } from "./config/firebase.ts";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<FirebaseAppProvider firebaseConfig={firebaseConfig}>
<FirebaseServices>
<BrowserRouter>
<App />
</BrowserRouter>
</FirebaseServices>
</FirebaseAppProvider>
</StrictMode>
);
🔍 Orden de Providers:
FirebaseAppProvider
- Inicializa FirebaseFirebaseServices
- Configura servicios específicosBrowserRouter
- Habilita el enrutamientoApp
- Componente principal
🗺️ Configuración de Rutas
App.tsx
import { Route, Routes } from "react-router";
// ... imports de layouts y páginas
const App = () => {
return (
<Routes>
<Route element={<RootLayout />}>
{/* Rutas Públicas */}
<Route element={<PublicLayout />}>
<Route
index
element={<HomePage />}
/>
<Route
path="*"
element={<NotFoundPage />}
/>
</Route>
{/* Rutas Protegidas */}
<Route
path="admin"
element={<AdminLayout />}
>
<Route
index
element={<DashboardPage />}
/>
<Route
path="profile"
element={<ProfilePage />}
/>
<Route
path="chat"
element={<ChatPage />}
/>
</Route>
{/* Rutas de Autenticación */}
<Route
path="auth"
element={<AuthLayout />}
>
<Route
path="login"
element={<LoginPage />}
/>
<Route
path="register"
element={<RegisterPage />}
/>
</Route>
</Route>
</Routes>
);
};
🔍 Estructura de rutas:
/
- Página principal (pública)/admin/*
- Rutas protegidas (requieren autenticación)/auth/*
- Rutas de autenticación (solo para no autenticados)
🎯 Layouts con Protección Integrada
src\layouts\admin.layout.tsx
- Para rutas protegidas
import { Navigate, Outlet } from "react-router";
import { useSigninCheck } from "reactfire";
const AdminLayout = () => {
const { status, data: signInCheckResult, hasEmitted } = useSigninCheck();
// Mostrar loading mientras verificamos el estado de autenticación
if (status === "loading" || !hasEmitted) {
return <div>Loading...</div>;
}
// Redirigir a login si no está autenticado
if (status === "success" && !signInCheckResult.signedIn) {
return (
<Navigate
to="/auth/login"
replace
/>
);
}
return <Outlet />;
};
export default AdminLayout;
src\layouts\auth.layout.tsx
- Para páginas de autenticación
import { Navigate, Outlet } from "react-router";
import { useSigninCheck } from "reactfire";
const AuthLayout = () => {
const { status, data: signInCheckResult, hasEmitted } = useSigninCheck();
// Mostrar loading mientras verificamos el estado de autenticación
if (status === "loading" || !hasEmitted) {
return <div>Loading...</div>;
}
// Redirigir al dashboard si ya está autenticado
if (status === "success" && signInCheckResult.signedIn) {
return (
<Navigate
to="/admin"
replace
/>
);
}
return <Outlet />;
};
export default AuthLayout;
src\layouts\public.layout.tsx
- Para rutas públicas (sin protección)
import { Outlet } from "react-router";
const PublicLayout = () => {
return <Outlet />;
};
export default PublicLayout;
🔍 Ventajas de integrar la protección en los layouts:
- Cohesión: La lógica de protección está donde realmente se necesita
- Menos dependencias: No necesitas importar componentes adicionales
- Más control: Puedes personalizar el UI de loading por layout
- Mejor rendimiento: Menos niveles de anidamiento de componentes
- Flexibilidad: Cada layout puede tener su propia lógica de redirección
Simple Login and logout
src\pages\auth\login.page.tsx
import { GoogleAuthProvider, signInWithPopup } from "firebase/auth";
import { useAuth } from "reactfire";
const LoginPage = () => {
const auth = useAuth();
const handleGoogleSignIn = async () => {
const provider = new GoogleAuthProvider();
try {
await signInWithPopup(auth, provider);
} catch (error) {
console.log("Error signing in with Google:", error);
}
};
return (
<div>
<h1>Login Page</h1>
<button onClick={handleGoogleSignIn}>Sign in with Google</button>
</div>
);
};
export default LoginPage;
src\pages\admin\dashboard.page.tsx
import { useAuth, useUser } from "reactfire";
const DashboardPage = () => {
const auth = useAuth();
const { data: user } = useUser();
const handleSignOut = async () => {
try {
await auth.signOut();
} catch (error) {
console.log("Error signing out:", error);
}
};
return (
<div>
<h1>Dashboard Page</h1>
<button onClick={handleSignOut}>Sign out</button>
{user ? (
<div>
<h2>Welcome, {user.displayName || "User"}!</h2>
<p>Email: {user.email}</p>
</div>
) : (
<p>No user is signed in.</p>
)}
</div>
);
};
export default DashboardPage;
Shadcn
Para mejorar la UI, puedes integrar Shadcn que proporciona componentes estilizados y personalizables. Sus componentes están creados con Tailwind CSS, lo que facilita la personalización y el diseño responsivo.
src\hooks\use-auth-actions.ts
import {
createUserWithEmailAndPassword,
GoogleAuthProvider,
signInWithEmailAndPassword,
signInWithPopup,
updateProfile,
type AuthError,
} from "firebase/auth";
import { useState } from "react";
import { useAuth } from "reactfire";
export const useAuthActions = () => {
const [loading, setLoading] = useState(false);
const auth = useAuth();
const login = async (data: { email: string; password: string }) => {
try {
setLoading(true);
await signInWithEmailAndPassword(auth, data.email, data.password);
return {
success: true,
error: null,
};
} catch (error) {
const authError = error as AuthError;
return {
success: false,
error: authError,
};
} finally {
setLoading(false);
}
};
const register = async (data: {
email: string;
password: string;
displayName: string;
/* photoURL?: File */
}) => {
try {
setLoading(true);
const { user } = await createUserWithEmailAndPassword(
auth,
data.email,
data.password
);
// update display name
if (user) {
await updateProfile(user, {
displayName: data.displayName,
// photoURL: data.photoURL ? URL.createObjectURL(data.photoURL) : null, // Uncomment if photoURL is used
});
console.log("Profile updated");
}
return {
success: true,
error: null,
};
} catch (error) {
const authError = error as AuthError;
return {
success: false,
error: authError,
};
} finally {
setLoading(false);
}
};
const loginWithGoogle = async () => {
const provider = new GoogleAuthProvider();
try {
setLoading(true);
await signInWithPopup(auth, provider);
return {
success: true,
error: null,
};
} catch (error) {
const authError = error as AuthError;
return {
success: false,
error: authError,
};
} finally {
setLoading(false);
}
};
const logout = async () => {
try {
setLoading(true);
await auth.signOut();
return {
success: true,
error: null,
};
} catch (error) {
const authError = error as AuthError;
return {
success: false,
error: authError,
};
} finally {
setLoading(false);
}
};
return {
login,
logout,
register,
loginWithGoogle,
loading,
};
};
src\pages\auth\login.page.tsx
import { Button } from "../../components/ui/button";
import { useAuthActions } from "../../hooks/use-auth-actions";
const LoginPage = () => {
const { loginWithGoogle } = useAuthActions();
return (
<div>
<h1>Login Page</h1>
<Button
onClick={loginWithGoogle}
variant={"outline"}
>
Sign in with Google
</Button>
</div>
);
};
export default LoginPage;
src\pages\admin\dashboard.page.tsx
import { useUser } from "reactfire";
import { Button } from "../../components/ui/button";
import { useAuthActions } from "../../hooks/use-auth-actions";
const DashboardPage = () => {
const { data: user } = useUser();
const { logout } = useAuthActions();
return (
<div>
<h1>Dashboard Page</h1>
<Button
onClick={logout}
variant={"outline"}
>
Sign out
</Button>
{user ? (
<div>
<h2>Welcome, {user.displayName || "User"}!</h2>
<p>Email: {user.email}</p>
<p>Display Name: {user.displayName || "No display name set"}</p>
</div>
) : (
<p>No user is signed in.</p>
)}
</div>
);
};
export default DashboardPage;
Login and Register Pages
src\layouts\root.layout.tsx
import { Toaster } from "@/components/ui/sonner";
import { Outlet } from "react-router";
const RootLayout = () => {
return (
<div>
<Outlet />
<Toaster
position="top-right"
richColors
/>
</div>
);
};
export default RootLayout;
src\layouts\auth.layout.tsx
import { Navigate, Outlet } from "react-router";
import { useSigninCheck } from "reactfire";
const AuthLayout = () => {
const { status, data: signInCheckResult, hasEmitted } = useSigninCheck();
if (status === "loading" || !hasEmitted) {
return <div>Loading...</div>;
}
if (status === "success" && signInCheckResult.signedIn) {
return <Navigate to="/admin" />;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full">
<Outlet />
</div>
</div>
);
};
export default AuthLayout;
Zod
src\lib\zod.ts
import { z } from "zod";
const isValidEmail = (email: string): boolean => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
export const loginZodSchema = z.object({
email: z.string().refine(isValidEmail, {
message: "Invalid email format",
}),
password: z.string().min(6, "Password must be at least 6 characters long"),
});
export type LoginZodSchemaType = z.infer<typeof loginZodSchema>;
Login Page
src\pages\auth\login.page.tsx
import { AuthFooter } from "../../components/auth/auth-footer";
import { useAuthActions } from "../../hooks/use-auth-actions";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { loginZodSchema, type LoginZodSchemaType } from "../../lib/zod";
const LoginPage = () => {
const { loginWithGoogle, loading, login } = useAuthActions();
const form = useForm<LoginZodSchemaType>({
resolver: zodResolver(loginZodSchema),
defaultValues: {
email: "",
password: "",
},
});
const onSubmit = async (values: LoginZodSchemaType) => {
const response = await login(values);
if (response.error) {
console.log(response.error.code);
if (response.error.code === "auth/invalid-login-credentials") {
toast.error("Invalid email or password.");
form.setError("email", {
type: "manual",
message: "Invalid email or password.",
});
form.setError("password", {
type: "manual",
message: "Invalid email or password.",
});
} else {
toast.error("An error occurred while logging in.");
}
}
};
return (
<Card className="bg-white">
<CardHeader>
<CardTitle>Login</CardTitle>
<CardDescription>
Sign in to your account or continue with Google.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="example@mail.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="******"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={loading}
>
Sign In
</Button>
</form>
</Form>
</CardContent>
<AuthFooter
type="login"
onGoogleAuth={loginWithGoogle}
loading={loading}
/>
</Card>
);
};
export default LoginPage;
Auth Footer
src\components\auth\auth-footer.tsx
import { Button } from "@/components/ui/button";
import { CardFooter } from "@/components/ui/card";
import { Mail } from "lucide-react";
import { Link } from "react-router";
interface AuthFooterProps {
type: "login" | "register";
onGoogleAuth: () => void;
loading: boolean;
}
export const AuthFooter = ({
type,
onGoogleAuth,
loading,
}: AuthFooterProps) => {
const isLogin = type === "login";
return (
<CardFooter className="flex-col gap-2">
<Button
className="w-full"
variant="outline"
onClick={onGoogleAuth}
disabled={loading}
>
<Mail className="mr-2" />
{isLogin ? "Sign in with Google" : "Register with Google"}
</Button>
<p className="text-center text-sm text-muted-foreground">
{isLogin ? "Don't have an account? " : "Already have an account? "}
<Link to={isLogin ? "/auth/register" : "/auth/login"}>
<Button
variant="link"
className="p-0 h-auto font-normal"
>
{isLogin ? "Register" : "Sign in"}
</Button>
</Link>
</p>
</CardFooter>
);
};
Register Page
src\lib\zod.ts
...
export const registerZodSchema = z
.object({
displayName: z
.string()
.min(1, "Display name is required")
.max(50, "Display name must be 50 characters or less"),
email: z.string().email("Please enter a valid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
confirmPassword: z
.string()
.min(6, "Password must be at least 6 characters"),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords must match",
path: ["confirmPassword"], // Esto hace que el error aparezca en el campo confirmPassword
});
export type RegisterZodSchemaType = z.infer<typeof registerZodSchema>;
src\pages\auth\register.page.tsx
import { AuthFooter } from "@/components/auth/auth-footer";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useAuthActions } from "@/hooks/use-auth-actions";
import { registerZodSchema, type RegisterZodSchemaType } from "@/lib/zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
const RegisterPage = () => {
const { register, loading, loginWithGoogle } = useAuthActions();
const form = useForm<RegisterZodSchemaType>({
resolver: zodResolver(registerZodSchema),
defaultValues: {
// photoURL: undefined,
displayName: "",
email: "",
password: "",
confirmPassword: "",
},
});
const onSubmit = async (values: RegisterZodSchemaType) => {
const response = await register(values);
if (response.error) {
console.log(response.error.code);
if (response.error.code === "auth/email-already-in-use") {
form.setError("email", {
type: "manual",
message: "Email is already in use.",
});
} else {
console.error("Registration error:", response.error);
}
} else {
// Handle successful registration, e.g., redirect or show a success message
console.log("Registration successful", values);
}
};
return (
<Card className="bg-white">
<CardHeader>
<CardTitle>Register</CardTitle>
<CardDescription>Create a new account</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
>
{/* <FormField
control={form.control}
name="photoURL"
render={({ field }) => (
<FormItem>
<FormLabel>Profile Picture</FormLabel>
<FormControl>
<Input
type="file"
accept="image/*"
onChange={(e) => field.onChange(e.target.files?.[0])}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/> */}
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem>
<FormLabel>Display Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Enter your display name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Enter your email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
{...field}
placeholder="Enter your password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
type="password"
{...field}
placeholder="Confirm your password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={loading}
>
Register
</Button>
</form>
</Form>
</CardContent>
<AuthFooter
type="register"
onGoogleAuth={loginWithGoogle}
loading={loading}
/>
</Card>
);
};
export default RegisterPage;