Login
BetaAcceso con correo institucional y contraseña, con validación al enviar, error de servidor y acceso alternativo.
registry/examples/templates/login.tsx"use client";
import { useState, type FormEvent } from "react";
import { Alert } from "@/registry/ds/alert";
import { Avatar } from "@/registry/ds/avatar";
import { Button } from "@/registry/ds/button";
import { Checkbox } from "@/registry/ds/checkbox";
import { Divider } from "@/registry/ds/divider";
import { CheckCircle, Globe } from "@/registry/ds/icons";
import { Input } from "@/registry/ds/input";
import { Link } from "@/registry/ds/link";
const Logo = ({ className = "" }: { className?: string }) => (
<svg viewBox="0 0 24 24" aria-hidden className={`size-8 ${className}`}>
<rect width="24" height="24" rx="6" fill="var(--primary)" />
<path d="M7 12.5l3.5 3.5L17 9" stroke="var(--primary-fg)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
);
const beneficios = ["Inscripciones y horarios en un solo lugar", "Notas y asistencia siempre al día", "Certificados sin pasar por ventanilla"];
/**
* Template Login — Acceso con correo institucional y contraseña: validación al enviar, error de servidor en un Alert, mostrar/ocultar contraseña y acceso alternativo.
*/
export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
const [status, setStatus] = useState<"idle" | "loading" | "failed" | "ok">("idle");
const submit = (event: FormEvent) => {
event.preventDefault();
const next: typeof errors = {};
if (!/^\S+@\S+\.\S+$/.test(email)) next.email = "Ingresá un correo válido.";
if (password.length < 8) next.password = "La contraseña tiene al menos 8 caracteres.";
setErrors(next);
if (Object.keys(next).length) return;
setStatus("loading");
// Simula el pedido al servidor: acá va tu llamada real
window.setTimeout(() => setStatus(email === "julia@campus.edu" ? "ok" : "failed"), 800);
};
return (
<div className="grid min-h-dvh lg:grid-cols-2">
{/* Panel de marca: solo en pantallas anchas */}
<aside className="relative hidden flex-col justify-between overflow-hidden bg-bg-inverse p-10 text-fg-inverse lg:flex">
<div className="absolute -top-40 -right-40 size-[480px] rounded-full bg-primary opacity-20 blur-3xl" aria-hidden />
<div className="relative flex items-center gap-2.5 text-lg font-semibold">
<Logo />
Campus
</div>
<div className="relative max-w-md">
<h2 className="text-heading-sm font-semibold tracking-tight">Todo tu recorrido académico, en un solo lugar.</h2>
<ul className="mt-6 space-y-3 text-md opacity-80">
{beneficios.map((b) => (
<li key={b} className="flex items-center gap-3">
<CheckCircle className="size-5 shrink-0 text-primary-accent" />
{b}
</li>
))}
</ul>
</div>
<figure className="relative max-w-md rounded-xl border border-fg-inverse/15 bg-fg-inverse/5 p-5">
<blockquote className="text-md">«Me inscribí a cinco materias en tres minutos, desde el colectivo.»</blockquote>
<figcaption className="mt-4 flex items-center gap-3 text-sm opacity-80">
<Avatar name="Julia Arce" size="sm" />
Julia Arce · Ingeniería en Sistemas
</figcaption>
</figure>
</aside>
<main className="flex items-center justify-center p-6 sm:p-10">
<div className="w-full max-w-sm">
<div className="mb-8 flex items-center gap-2.5 text-lg font-semibold text-fg lg:hidden">
<Logo />
Campus
</div>
{status === "ok" ? (
<div className="text-center">
<div className="mx-auto flex size-14 items-center justify-center rounded-full bg-primary-subtle text-primary-accent">
<CheckCircle className="size-7" />
</div>
<h1 className="mt-5 text-heading-xs font-semibold text-fg">Hola de nuevo, Julia</h1>
<p className="mt-2 text-sm text-fg-muted">Te llevamos a tu panel.</p>
<Button className="mt-6" fullWidth onClick={() => setStatus("idle")}>
Ir al panel
</Button>
</div>
) : (
<>
<h1 className="text-heading-xs font-semibold text-fg">Ingresá a Campus</h1>
<p className="mt-2 text-sm text-fg-muted">Usá tu correo institucional.</p>
<form onSubmit={submit} noValidate className="mt-8 space-y-5">
{status === "failed" && (
<Alert color="danger" role="alert" title="No pudimos iniciar sesión" onDismiss={() => setStatus("idle")}>
Revisá el correo y la contraseña, o restablecela.
</Alert>
)}
<Input
label="Correo institucional"
type="email"
name="email"
autoComplete="email"
placeholder="nombre@campus.edu"
value={email}
onChange={(e) => setEmail(e.target.value)}
error={errors.email}
required
/>
<Input
label="Contraseña"
type={showPassword ? "text" : "password"}
name="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
error={errors.password}
required
iconRight={
<button
type="button"
onClick={() => setShowPassword((s) => !s)}
aria-pressed={showPassword}
className="text-xs font-medium text-fg-muted underline-offset-4 hover:text-fg hover:underline focus-visible:outline-none focus-visible:underline"
>
{showPassword ? "Ocultar" : "Mostrar"}
</button>
}
/>
<div className="flex items-center justify-between gap-4">
<Checkbox size="sm" name="remember" label="Recordarme" />
<Link href="#" className="text-sm">
¿Olvidaste tu contraseña?
</Link>
</div>
<Button type="submit" fullWidth loading={status === "loading"}>
Ingresar
</Button>
</form>
<Divider className="my-6">o</Divider>
<Button variant="stroke" fullWidth iconLeft={<Globe />}>
Continuar con cuenta institucional
</Button>
<p className="mt-8 text-center text-sm text-fg-muted">
¿Todavía no tenés cuenta? <Link href="#">Crear cuenta</Link>
</p>
</>
)}
</div>
</main>
</div>
);
}Para probarlo: julia@campus.edu con cualquier contraseña de 8 caracteres o
más. Con otro correo muestra el error de servidor.
Qué incluye
- Input con
type="email"ytype="password", y el botón Mostrar/Ocultar eniconRight. - Checkbox "Recordarme" y Link de recuperación en la misma fila.
- Button
fullWidthconloadingmientras responde el servidor; el acceso alternativo esstroke. - Divider con texto, Alert
color="danger"conrole="alert", y Avatar en la cita del panel de marca.
Decisiones
- Validación al enviar, no al tipear. Nadie quiere ver "correo inválido" antes de terminar de escribirlo. Los errores aparecen con el primer envío, pegados al campo, y desaparecen cuando el campo se corrige y se vuelve a enviar.
- El error de servidor va arriba, con
role="alert". Bloquea el flujo, así que interrumpe al lector de pantalla. Se puede cerrar. - Panel de marca solo desde 1024 px. En móvil el formulario es toda la pantalla y el logo pasa arriba del título.
autoCompletecorrectos (email,current-password) para que el navegador y los gestores de contraseñas rellenen solos.- Estado de éxito dentro de la misma pantalla. En una app real redirigís; acá lo mostramos para que se vea el cierre del flujo.
Cómo adaptarlo
- Reemplazá el
setTimeoutdesubmitpor tu llamada de autenticación y manejá los tres resultados: éxito, credenciales inválidas, error de red. - Sin acceso alternativo, sacá el
Dividery el segundo botón. - Para la pantalla de registro, mismo layout: cambiá los campos y la acción primaria, y mantené el panel de marca.
Instalación
npx shadcn@latest add https://polen.cdc.cool/r/template-login.jsonSe instala en app/login/page.tsx con los imports apuntando a
components/ds/. Los componentes que usa vienen como dependencias.