Cierra autenticación en /api/deployer, POST /api/modules, /api/stats y /verify (assertIT + validación de slugs/nombres). GET /api/modules público sanitizado (solo visibles, sin repoUrl ni logs). Corrige estado unhealthy, XSS en plantillas, open redirect en login. Sesión HMAC en tiempo constante, secret obligatorio, 503 si el backend cae, cookie secure solo en prod. Migra middleware a proxy, rate-limit en login, vitest + typecheck, Tailwind/CDN fuera, Boxicons local, polling pausado y código muerto eliminado. Estandariza pnpm (lock, Dockerfile, packageManager).
136 lines
4.8 KiB
TypeScript
136 lines
4.8 KiB
TypeScript
"use client";
|
|
|
|
import { useState, Suspense } from "react";
|
|
import Image from "next/image";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
|
|
function safeNext(v: string | null): string {
|
|
if (!v || !v.startsWith("/") || v.startsWith("//") || v.startsWith("/\\")) {
|
|
return "/tablero";
|
|
}
|
|
return v;
|
|
}
|
|
|
|
function LoginForm() {
|
|
const router = useRouter();
|
|
const next = safeNext(useSearchParams().get("next"));
|
|
const [rut, setRut] = useState("");
|
|
const [clave, setClave] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const submit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch("/api/auth", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ idUsuario: rut, password: clave }),
|
|
});
|
|
const j = await res.json();
|
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
|
router.push(safeNext(next));
|
|
router.refresh();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Error al ingresar");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="login-page">
|
|
<div className="sidenav">
|
|
<div className="login-main-text text-light text-center">
|
|
<Image src="/favicon.png" alt="SAM" width={80} height={80} />
|
|
<h1>SAM</h1>
|
|
<h3>Welcome | Bienvenido/a</h3>
|
|
<h3>Portal IT</h3>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="main">
|
|
<div
|
|
className="row justify-content-center align-items-center"
|
|
style={{ minHeight: "85vh" }}
|
|
>
|
|
<div className="col-md-12">
|
|
<div className="col-md-12 text-center">
|
|
<div className="row">
|
|
<div className="col-md-2" />
|
|
<div className="col-md-8">
|
|
<div className="card alert alert-secondary">
|
|
<div className="card-body">
|
|
<form onSubmit={submit}>
|
|
<div className="row">
|
|
<div className="col-md-12 mb-3 text-center">
|
|
<strong>USUARIO | CLAVE</strong>
|
|
</div>
|
|
{error && (
|
|
<div className="col-md-12 mb-3">
|
|
<div className="alert alert-danger py-2 mb-0">
|
|
{error}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="col-md-3" />
|
|
<div className="col-md-6">
|
|
<input
|
|
name="TextBoxRut"
|
|
type="text"
|
|
value={rut}
|
|
onChange={(e) => setRut(e.target.value)}
|
|
className="form-control shadow mb-2"
|
|
required
|
|
placeholder="Usuario"
|
|
autoComplete="username"
|
|
/>
|
|
<input
|
|
name="TextBoxClave"
|
|
type="password"
|
|
value={clave}
|
|
onChange={(e) => setClave(e.target.value)}
|
|
className="form-control shadow mb-2"
|
|
required
|
|
placeholder="Clave"
|
|
autoComplete="current-password"
|
|
/>
|
|
<div className="d-grid">
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="btn btn-success btn-sm"
|
|
>
|
|
{loading ? (
|
|
<span className="spinner-border spinner-border-sm" />
|
|
) : (
|
|
"Ingresar"
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-3" />
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function Login() {
|
|
return (
|
|
<Suspense>
|
|
<LoginForm />
|
|
</Suspense>
|
|
);
|
|
}
|