Julián necesita dos cosas de ReservaYa que parecen una sola: que cada cliente tenga su cuenta con su historial de citas, y que solo él pueda entrar al panel donde se ven las ventas del mes. Un cliente curioso que escriba /admin en la URL no puede ver la caja de la barbería.
Eso son dos problemas distintos: autenticación (¿quién eres?) y autorización (¿qué puedes hacer?). Este módulo resuelve ambos.
1.1. Autenticación vs. autorización
| Concepto | Pregunta que responde | Ejemplo en ReservaYa |
|---|---|---|
| Autenticación | ¿Quién eres? | Iniciar sesión con email y contraseña |
| Autorización | ¿Qué puedes hacer? | Solo el rol admin ve el panel de ventas |
1.2. El problema: HTTP no tiene memoria
Cada petición al servidor es independiente: el servidor no recuerda que hace 5 segundos iniciaste sesión. Para "recordarte", después del login el servidor te entrega una credencial que el navegador reenvía en cada petición. Hay dos formas clásicas de hacerlo:
Un JWT son tres bloques separados por puntos: header.payload.firma. El payload es legible por cualquiera (dice tu id, tu email y cuándo expira) — lo que lo hace confiable es la firma: solo el servidor con la clave secreta pudo generarla. Si alguien modifica el payload, la firma deja de coincidir y el token se rechaza.
Supabase Auth usa exactamente este esquema: un access_token JWT de corta vida más un refresh_token para renovarlo, ambos guardados en cookies.
Las contraseñas se almacenan hasheadas (bcrypt/argon2): una transformación irreversible. Ni siquiera tú, dueño del sistema, puedes leer la contraseña de un cliente. Supabase hace esto por ti — otra razón para no inventar tu propio sistema de login.
Supabase Auth ya trae el trabajo pesado: hashing de contraseñas, emails de verificación, tokens y renovación de sesión. Nuestro trabajo es conectarlo bien con Next.js.
2.1. El cliente de servidor y el middleware
En el módulo 1 creamos el cliente de navegador. Ahora el de servidor, que lee la sesión desde las cookies, en lib/supabase/server.ts:
// lib/supabase/server.ts
// Cliente de Supabase para Server Components y Server Actions
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
// Server Components no pueden escribir cookies; el middleware lo hará
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {}
},
},
}
);
}
El middleware renueva la sesión en cada petición para que el JWT nunca llegue vencido. Crea middleware.ts en la raíz:
// middleware.ts — corre antes de cada petición
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
);
},
},
}
);
// Renueva el token si está por vencer y obtiene el usuario actual
const { data: { user } } = await supabase.auth.getUser();
// Regla de negocio: /admin y /mis-citas requieren sesión
const rutaProtegida = ["/admin", "/mis-citas"].some((r) =>
request.nextUrl.pathname.startsWith(r)
);
if (rutaProtegida && !user) {
return NextResponse.redirect(new URL("/login", request.url));
}
return response;
}
export const config = {
// Evita correr el middleware en archivos estáticos
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg)$).*)"],
};
2.2. Registro de clientes
El formulario de registro llama a una Server Action — la validación vive en el servidor, como manda la regla de oro:
// app/registro/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export async function registrar(formData: FormData) {
const email = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? "");
const nombre = String(formData.get("nombre") ?? "").trim();
// Validación en servidor: el frontend puede saltarse la del navegador
if (!email.includes("@") || password.length < 8 || nombre.length < 2) {
redirect("/registro?error=datos-invalidos");
}
const supabase = await createClient();
const { error } = await supabase.auth.signUp({
email,
password,
options: {
// Datos extra que viajan al perfil del usuario
data: { nombre },
emailRedirectTo: "http://localhost:3000/auth/confirmado",
},
});
if (error) redirect("/registro?error=" + encodeURIComponent(error.message));
redirect("/registro/revisa-tu-correo");
}
2.3. Login y logout
// app/login/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export async function iniciarSesion(formData: FormData) {
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email: String(formData.get("email") ?? ""),
password: String(formData.get("password") ?? ""),
});
// Mensaje genérico: nunca reveles si el email existe o no
if (error) redirect("/login?error=credenciales-invalidas");
redirect("/mis-citas");
}
export async function cerrarSesion() {
const supabase = await createClient();
await supabase.auth.signOut();
redirect("/");
}
Ante un login fallido responde siempre "credenciales inválidas", nunca "ese correo no existe". Si distingues los casos, un atacante puede descubrir qué emails están registrados en tu negocio (enumeración de usuarios).
Un cliente que no puede entrar es una cita que no se agenda. El flujo de recuperación tiene dos pasos: pedir el enlace por email, y definir la nueva contraseña.
// app/recuperar/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export async function solicitarRecuperacion(formData: FormData) {
const supabase = await createClient();
await supabase.auth.resetPasswordForEmail(
String(formData.get("email") ?? ""),
{ redirectTo: "http://localhost:3000/recuperar/nueva-clave" }
);
// Respondemos igual exista o no el correo (anti-enumeración)
redirect("/recuperar/enviado");
}
// En la página /recuperar/nueva-clave, ya con el enlace del correo:
export async function definirNuevaClave(formData: FormData) {
const password = String(formData.get("password") ?? "");
if (password.length < 8) redirect("/recuperar/nueva-clave?error=corta");
const supabase = await createClient();
const { error } = await supabase.auth.updateUser({ password });
if (error) redirect("/recuperar/nueva-clave?error=expirado");
redirect("/login?mensaje=clave-actualizada");
}
Supabase envía el correo de confirmación automáticamente al registrarse (configurable en Authentication → Providers → Email). Mantenla activada: garantiza que el email de cada reserva llega a una casilla real. En el módulo 5 personalizaremos estos correos con la marca de la barbería.
ReservaYa tiene dos roles: cliente (reserva y ve sus propias citas) y admin (Julián: gestiona servicios, barberos y ve todas las citas). El rol se guarda en una tabla perfiles conectada a los usuarios de Supabase Auth.
4.1. La tabla de perfiles
Ejecuta esto en el SQL Editor de Supabase:
-- Tabla de perfiles: extiende auth.users con datos del negocio
create table public.perfiles (
id uuid primary key references auth.users(id) on delete cascade,
nombre text not null,
telefono text,
rol text not null default 'cliente' check (rol in ('cliente', 'admin')),
creado_en timestamptz not null default now()
);
-- Cada registro nuevo en auth.users crea su perfil automáticamente
create function public.crear_perfil()
returns trigger language plpgsql security definer as $$
begin
insert into public.perfiles (id, nombre)
values (new.id, coalesce(new.raw_user_meta_data->>'nombre', 'Sin nombre'));
return new;
end; $$;
create trigger al_crear_usuario
after insert on auth.users
for each row execute function public.crear_perfil();
4.2. Proteger el panel de administración
El middleware ya exige sesión para /admin. Falta la autorización: verificar el rol en el servidor, en el layout del panel:
// app/admin/layout.tsx — puerta de entrada del panel
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export default async function AdminLayout({
children,
}: { children: React.ReactNode }) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/login");
// Autorización: consultamos el rol en la base de datos
const { data: perfil } = await supabase
.from("perfiles")
.select("rol")
.eq("id", user.id)
.single();
if (perfil?.rol !== "admin") redirect("/"); // cliente intentando entrar
return <section>{children}</section>;
}
Esconder el enlace "Panel admin" en el menú del cliente es cosmética, no protección. La verificación real ocurre en el servidor (layout + middleware + RLS en el módulo 3). Asume siempre que el usuario conoce todas tus URLs.
- Construir tu propio sistema de login desde cero. Solución: usa un proveedor probado (Supabase Auth, Auth0, Clerk). El hashing, la expiración de tokens y los correos ya están resueltos y auditados.
- Validar el rol solo en el frontend.
Solución: la verificación de
rol === 'admin'debe ejecutarse en el servidor en cada ruta protegida, como en el layout de arriba. - Guardar el rol dentro del formulario o del localStorage. Solución: el rol vive en la base de datos y se consulta con el id del usuario autenticado. Nada que el usuario pueda editar decide sus permisos.
- Revelar en el login si el email existe. Solución: mensajes genéricos ("credenciales inválidas", "si el correo existe, enviamos el enlace").
El restaurante "La Sazón de la Abuela" en Barranquilla quiere digitalizar sus pedidos. Trabajan: la dueña, 2 cajeros y 4 meseros. Diseña su esquema de autorización:
- Define 3 roles y qué puede hacer cada uno (la dueña ve reportes de ventas; el cajero cobra y cierra pedidos; el mesero crea pedidos pero no puede anularlos).
- Escribe el
checkSQL de la columnarolpara esos 3 roles, siguiendo el ejemplo de la tablaperfiles. - Decide: ¿qué rutas protegería el middleware y qué verificaría cada layout? Escríbelo como reglas "si el rol es X, puede entrar a Y".
Descarga el checklist de seguridad de autenticación en los recursos del módulo y aplícalo a tu solución.
4.3. Mini-proyecto del módulo
Tu app ahora tiene: registro con verificación de email, login/logout, recuperación de contraseña, la tabla perfiles con roles, y el panel /admin al que solo entra Julián. Crea dos cuentas de prueba, promueve una a admin desde el SQL Editor (update perfiles set rol = 'admin' where id = '...') y verifica que la otra no puede entrar a /admin.
- Autenticación ≠ autorización: una dice quién eres (sesión), la otra qué puedes hacer (rol). Ambas se verifican en el servidor.
- JWT + cookies es el mecanismo con el que Supabase mantiene la sesión; el middleware la renueva en cada petición.
- Los roles viven en la base de datos (tabla
perfiles), nunca en algo que el usuario pueda editar. - ReservaYa ya tiene usuarios: clientes que se registran y un panel de administración que solo el dueño puede abrir.