Skip to content

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

sh
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

json
{
  "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

ts
export const firebaseConfig = {
  apiKey: "...",
  authDomain: "...",
  projectId: "...",
  storageBucket: "...",
  messagingSenderId: "...",
  appId: "...",
};

src\config\firebase-services.tsx

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

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

  1. FirebaseAppProvider - Inicializa Firebase
  2. FirebaseServices - Configura servicios específicos
  3. BrowserRouter - Habilita el enrutamiento
  4. App - Componente principal

🗺️ Configuración de Rutas

App.tsx

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

tsx
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

tsx
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)

tsx
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

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

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

tsx
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

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

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

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

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

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

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;

src\components\auth\auth-footer.tsx

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

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

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;