refactor: validadores compartidos y componentes de UI extraídos

- src/lib/validators.ts centraliza regex y validaciones (module name,
  subdominio, slug, repoUrl, safeNext) usados por las rutas API y login,
  con tests en validators.test.ts
- DeployStatus, HelpdeskUsage y PagosUsage se extraen de las páginas
  /upload y /m/[name] a componentes propios
- README actualizado con la arquitectura y el contrato de /api/modules
This commit is contained in:
Nurfog committed 2026-10-13 11:41:45 -03:00
1 parent ab6a5a148c
commit 1a891c3751
12 files changed
+753 -586

No files matched your search

+16 -3
View File
@@ -18,9 +18,11 @@
| `/eliminar` | sí | Baja de módulos (con/sin código) |
| `/correo` | sí | Claves de correo por módulo (`POST /api/email/modules`, banco `mail.*`) |
| `/correo/plantillas` | sí | Plantillas del banco `mail.templates` (listar, crear, editar, activar) |
| `/api/modules`, `/api/stats` | no/sí | Proxy al deployer (token server-side) |
| `/api/deployer/*` | sí | Proxy genérico al deployer |
| `/api/auth`, `/api/me` | — | Sesión HMAC 12h en cookie httpOnly |
| `/api/modules` | GET público sanitizado / POST sí | Proxy al deployer: GET filtra visibles sin repoUrl ni logs |
| `/api/stats`, `/api/modules/:name/verify`, `/api/inventory` | sí | Proxy/lecturas del deployer (token server-side) |
| `/api/deployer/*` | sí | Proxy genérico al deployer (segmentos validados) |
| `/api/auth` (POST) | rate-limit 10/min por IP | Sesión HMAC 12h en cookie httpOnly |
| `/api/me` | — | Sesión actual (email) |
## Auth IT
@@ -49,6 +51,17 @@ pnpm dev # http://localhost:3000
pnpm lint && pnpm typecheck && pnpm test && pnpm build
```
## Arquitectura
- `/` y `/tablero` son Server Components (caché 15s/30s en el servidor,
isla cliente para revalidar). El resto de vistas IT son client components
con stores `useSyncExternalStore` (`src/lib/store.ts`, `src/lib/correo.ts`).
- El inventario SAM.API (`src/lib/api-inventory.ts`) solo se importa en
`src/app/api/inventory/route.ts`: nunca viaja al navegador.
- Secretos (`DEPLOYER_TOKEN`, `IT_SESSION_SECRET`, `HELPDESK_SERVICE_PASS`)
solo se usan en route handlers y `*-server.ts` (verificado: nada
server-side llega al bundle cliente).
El deploy a producción (.4) es vía push a `main` del repo + rebuild del
contenedor (`portal-frontend`). El módulo está registrado en el deployer
(categoría `sam`, subdominio `sam`); sus pushes ya no autodespliegan solos
+2 -2
View File
@@ -1,11 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { assertIT } from "@/lib/correo-server";
import { SLUG_SEG_RE } from "@/lib/validators";
const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
// Solo segmentos seguros: evita path traversal (.., /, %, etc.)
const SEG_RE = /^[a-z0-9][a-z0-9_-]*$/i;
function badRequest(msg: string) {
return NextResponse.json({ error: msg }, { status: 400 });
@@ -27,7 +27,7 @@ async function proxy(req: NextRequest, path: string[]) {
return badRequest("Ruta inválida");
}
for (const seg of path) {
if (!seg || seg.length > 64 || seg === "." || seg === ".." || !SEG_RE.test(seg)) {
if (!seg || seg.length > 64 || seg === "." || seg === ".." || !SLUG_SEG_RE.test(seg)) {
return badRequest("Ruta inválida");
}
}
+3 -4
View File
@@ -1,12 +1,11 @@
import { NextResponse } from "next/server";
import { assertIT } from "@/lib/correo-server";
import { MODULE_NAME_RE } from "@/lib/validators";
import type { VerifyCheck, VerifyResult } from "@/lib/verify";
const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
const NAME_RE = /^[a-z0-9][a-z0-9-]{1,62}$/i;
function deployerHeaders(): Record<string, string> {
const h: Record<string, string> = { "Content-Type": "application/json" };
if (DEPLOYER_TOKEN) h["X-API-Token"] = DEPLOYER_TOKEN;
@@ -47,7 +46,7 @@ export async function GET(
return NextResponse.json({ error: "Requiere sesión IT" }, { status: 401 });
}
const rawName = (await params).name;
if (!rawName || rawName.length > 64 || !NAME_RE.test(rawName)) {
if (!rawName || rawName.length > 64 || !MODULE_NAME_RE.test(rawName)) {
return NextResponse.json({ error: "Nombre de módulo inválido" }, { status: 400 });
}
const name = rawName.toLowerCase();
@@ -159,7 +158,7 @@ export async function GET(
mod?.subdomain ||
(base.checks.find((c) => c.id === "caddy")?.detail.match(/https:\/\/([a-z0-9-]+)\./)?.[1] as string) ||
name;
const subdomain = NAME_RE.test(rawSubdomain) ? rawSubdomain.toLowerCase() : name;
const subdomain = MODULE_NAME_RE.test(rawSubdomain) ? rawSubdomain.toLowerCase() : name;
const publicUrl = `https://${subdomain}.devops.norteamericano.cl/`;
let publicStatus: number | null = null;
const caddyCheck = base.checks.find((c) => c.id === "caddy");
+2 -28
View File
@@ -1,13 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { assertIT } from "@/lib/correo-server";
import { sanitizeForPublic, type ModuleState } from "@/lib/sam";
import { MODULE_NAME_RE, SUBDOMAIN_RE, validRepoUrl } from "@/lib/validators";
const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
const NAME_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;
const SUBDOMAIN_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;
function headers() {
const h: Record<string, string> = { "Content-Type": "application/json" };
if (DEPLOYER_TOKEN) h["X-API-Token"] = DEPLOYER_TOKEN;
@@ -21,30 +19,6 @@ function isIT(req: NextRequest): Promise<boolean> {
);
}
function validRepoUrl(v: unknown): boolean {
if (typeof v !== "string" || v.length === 0 || v.length > 500) return false;
let u: URL;
try {
u = new URL(v);
} catch {
return false;
}
if (u.protocol !== "https:") return false;
if (u.username || u.password) return false;
const host = u.hostname.toLowerCase();
if (
host === "localhost" ||
host.startsWith("127.") ||
host.startsWith("10.") ||
host.startsWith("192.168.") ||
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
host.endsWith(".local") ||
host.endsWith(".internal")
)
return false;
return true;
}
export async function GET(req: NextRequest) {
try {
const res = await fetch(`${DEPLOYER_URL}/api/modules`, {
@@ -80,7 +54,7 @@ export async function POST(req: NextRequest) {
const repoUrl = String(body?.repoUrl ?? "").trim();
const subdomain =
body?.subdomain === undefined ? undefined : String(body.subdomain).trim().toLowerCase();
if (!NAME_RE.test(name)) {
if (!MODULE_NAME_RE.test(name)) {
return NextResponse.json(
{ error: "Nombre inválido ([a-z0-9-], 2-63, debe empezar con alfanumérico)" },
{ status: 400 }
+3 -9
View File
@@ -3,17 +3,11 @@
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;
}
import { safeNextPath } from "@/lib/validators";
function LoginForm() {
const router = useRouter();
const next = safeNext(useSearchParams().get("next"));
const next = safeNextPath(useSearchParams().get("next"));
const [rut, setRut] = useState("");
const [clave, setClave] = useState("");
const [loading, setLoading] = useState(false);
@@ -31,7 +25,7 @@ function LoginForm() {
});
const j = await res.json();
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
router.push(safeNext(next));
router.push(safeNextPath(next));
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Error al ingresar");
+2 -396
View File
@@ -1,9 +1,10 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import SamShell from "@/components/SamShell";
import { Bar } from "@/components/ProgressBar";
import { HelpdeskUsage } from "@/components/HelpdeskUsage";
import { PagosUsage } from "@/components/PagosUsage";
import { useModules, useStats, useMe } from "@/lib/store"
import { useParams } from "next/navigation";
import {
@@ -24,401 +25,6 @@ import {
const API_URL =
process.env.NEXT_PUBLIC_API_URL || "https://api.devops.norteamericano.cl";
interface HelpdeskMember {
idUsuario: string;
nombres?: string;
apPaterno?: string;
email?: string;
role?: string;
departmentName?: string;
isActive?: boolean;
}
interface HelpdeskStats {
totalTickets: number;
openTickets: number;
inProgressTickets: number;
resolvedTickets: number;
closedTickets: number;
unassignedTickets: number;
criticalTickets: number;
dailyCounts: { date: string; count: number }[];
}
interface Boleta {
folio: number;
alumnoRut?: string | null;
alumnoNombre?: string | null;
fecha: string;
monto: number;
tipo?: string;
estado?: string;
esValido?: boolean;
errorMotivo?: string | null;
}
// Fecha real = distinta de nula/vacía y del MinValue de .NET
// (0001-01-01: la función PG no informó fecha de caja).
function fechaReal(f: string | null | undefined): boolean {
return !!f && !f.startsWith("0001-01-01");
}
function fechaCorta(f: string | null | undefined): string {
return fechaReal(f) ? String(f).slice(0, 10) : "—";
}
function HelpdeskUsage() {
const [team, setTeam] = useState<HelpdeskMember[] | null>(null);
const [stats, setStats] = useState<HelpdeskStats | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const [tRes, sRes] = await Promise.all([
fetch("/api/portal/helpdesk-team", { cache: "no-store" }),
fetch("/api/portal/helpdesk-stats", { cache: "no-store" }),
]);
if (!tRes.ok || !sRes.ok) {
const t = await tRes.json().catch(() => ({}));
const s = await sRes.json().catch(() => ({}));
throw new Error(
(t as { error?: string }).error ||
(s as { error?: string }).error ||
`Helpdesk respondió ${tRes.status}/${sRes.status}`
);
}
const t = (await tRes.json()) as HelpdeskMember[];
const s = (await sRes.json()) as HelpdeskStats;
if (!cancelled) {
setTeam(Array.isArray(t) ? t : []);
setStats(s);
}
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Error al cargar uso");
}
}
load();
return () => {
cancelled = true;
};
}, []);
if (error) {
return (
<div className="alert alert-warning">
<i className="bx bx-error me-2" />
No se pudo cargar el uso real de Helpdesk: {error}
</div>
);
}
if (!team || !stats) {
return (
<div className="text-center py-4">
<div className="spinner-border text-primary" role="status" />
<p className="mt-2 text-muted small">Cargando equipo y estadísticas reales…</p>
</div>
);
}
const active = team.filter((m) => m.isActive !== false);
const maxDaily = Math.max(...stats.dailyCounts.map((d) => d.count), 1);
return (
<>
<div className="alert alert-light section-header mt-4 mb-3 py-2 px-3">
<i className="bx bx-group me-1" /> Equipo que lo usa ({active.length})
</div>
<div className="row row-cards">
<div className="col-12">
<div className="app-card p-3">
{active.length === 0 && (
<p className="text-muted mb-0">Sin integrantes registrados en el equipo.</p>
)}
<div className="list-group">
{active.map((m) => (
<div
key={m.idUsuario}
className="list-group-item d-flex justify-content-between align-items-center flex-wrap gap-2"
>
<span>
<i className="bx bx-user me-2" />
{[m.nombres, m.apPaterno].filter(Boolean).join(" ") || m.idUsuario}
<small className="text-muted d-block">
{m.idUsuario}
{m.email ? ` · ${m.email}` : ""}
{m.departmentName ? ` · ${m.departmentName}` : ""}
</small>
</span>
<span className="badge bg-primary rounded-pill">{m.role ?? "—"}</span>
</div>
))}
</div>
<small className="text-muted d-block mt-2">
Fuente: <code>GET /api/helpdesk/users</code> (SAM.API, cuenta de servicio).
</small>
</div>
</div>
</div>
<div className="alert alert-light section-header mt-4 mb-3 py-2 px-3">
<i className="bx bx-bar-chart-alt-2 me-1" /> Estadísticas de uso
</div>
<div className="row g-3 mb-3">
{[
{ label: "Total tickets", value: stats.totalTickets },
{ label: "Abiertos", value: stats.openTickets },
{ label: "En progreso", value: stats.inProgressTickets },
{ label: "Resueltos", value: stats.resolvedTickets },
{ label: "Críticos", value: stats.criticalTickets },
{ label: "Sin asignar", value: stats.unassignedTickets },
].map((c) => (
<div className="col-6 col-lg-4 col-xl-2" key={c.label}>
<div className="app-card p-3 text-center h-100">
<div className="fw-bold fs-4">{c.value}</div>
<small className="text-muted">{c.label}</small>
</div>
</div>
))}
</div>
{stats.dailyCounts.length > 0 && (
<div className="card mb-4">
<div className="card-header d-flex align-items-center gap-2">
<i className="bx bx-graph-up" style={{ color: "var(--primary)" }}></i>
Tickets por día (últimos 14 días)
</div>
<div className="card-body">
<div className="d-flex align-items-end gap-1" style={{ height: "120px" }}>
{stats.dailyCounts.map((d) => (
<div
key={d.date}
className="flex-fill rounded"
style={{
height: `${(d.count / maxDaily) * 100}%`,
backgroundColor: "var(--primary)",
opacity: 0.7,
minHeight: "4px",
}}
title={`${d.date}: ${d.count} tickets`}
></div>
))}
</div>
<small className="text-muted">
Fuente: <code>GET /api/helpdesk/stats</code> (SAM.API).
</small>
</div>
</div>
)}
</>
);
}
function PagosUsage() {
const now = new Date();
const [mes, setMes] = useState(String(now.getMonth() + 1));
const [year, setYear] = useState(String(now.getFullYear()));
const [rows, setRows] = useState<Boleta[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function load(m: string, y: string) {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/portal/pagos-validados?mes=${m}&year=${y}&limite=200`, {
cache: "no-store",
});
const data = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(
(data as { error?: string } | null)?.error || `Pagos respondió ${res.status}`
);
}
setRows(Array.isArray(data) ? data : []);
} catch (e) {
setError(e instanceof Error ? e.message : "Error al cargar pagos");
setRows(null);
} finally {
setLoading(false);
}
}
useEffect(() => {
// Carga inicial diferida a un callback: el setState ocurre fuera
// del cuerpo del efecto (regla react-hooks/set-state-in-effect).
// Se ejecuta una sola vez a propósito: los cambios de mes/año
// posteriores van por el submit del formulario, no por re-render.
const t = setTimeout(() => {
load(mes, year);
}, 0);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const valid = (rows ?? []).filter(
(r) => r.esValido !== false && fechaReal(r.fecha)
);
const errors = (rows ?? []).filter(
(r) => r.esValido === false || !fechaReal(r.fecha)
);
const total = valid.reduce((acc, r) => acc + (Number(r.monto) || 0), 0);
return (
<>
<div className="alert alert-light section-header mt-4 mb-3 py-2 px-3">
<i className="bx bx-receipt me-1" /> Pagos validados
</div>
<div className="row row-cards">
<div className="col-12">
<div className="app-card p-3">
<form
className="d-flex gap-2 flex-wrap align-items-end mb-3"
onSubmit={(e) => {
e.preventDefault();
load(mes, year);
}}
>
<div>
<label className="form-label small text-muted mb-1">Mes</label>
<select
className="form-select form-select-sm"
value={mes}
onChange={(e) => setMes(e.target.value)}
>
{Array.from({ length: 12 }, (_, i) => (
<option key={i + 1} value={String(i + 1)}>
{i + 1}
</option>
))}
</select>
</div>
<div>
<label className="form-label small text-muted mb-1">Año</label>
<input
className="form-control form-control-sm"
value={year}
onChange={(e) => setYear(e.target.value)}
inputMode="numeric"
style={{ width: 100 }}
/>
</div>
<button type="submit" className="btn btn-sm btn-primary" disabled={loading}>
{loading ? "Cargando…" : "Consultar"}
</button>
{rows && (
<span className="ms-auto small text-muted align-self-center">
{valid.length} válidos
{errors.length > 0 && ` · ${errors.length} observados`} · Total
${total.toLocaleString("es-CL")}
</span>
)}
</form>
{error && (
<div className="alert alert-warning mb-0">
<i className="bx bx-error me-2" />
No se pudieron cargar los pagos validados: {error}
</div>
)}
{!error && loading && (
<div className="text-center py-4">
<div className="spinner-border text-primary" role="status" />
</div>
)}
{!error && !loading && rows && valid.length === 0 && (
<p className="text-muted mb-0">Sin pagos válidos para {mes}/{year}.</p>
)}
{!error && !loading && rows && valid.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-hover mb-0">
<thead>
<tr>
<th>Folio</th>
<th>Alumno</th>
<th>Fecha</th>
<th className="text-end">Monto</th>
<th>Estado</th>
</tr>
</thead>
<tbody>
{valid.slice(0, 100).map((r, i) => (
<tr key={`${r.folio}-${i}`}>
<td>{r.folio}</td>
<td className="small">
{r.alumnoNombre ?? "—"}
{r.alumnoRut ? (
<span className="text-muted d-block">{r.alumnoRut}</span>
) : null}
</td>
<td className="small">{fechaCorta(r.fecha)}</td>
<td className="text-end">${Number(r.monto || 0).toLocaleString("es-CL")}</td>
<td className="small">{r.estado || r.tipo || "—"}</td>
</tr>
))}
</tbody>
</table>
{valid.length > 100 && (
<small className="text-muted">Mostrando 100 de {valid.length}.</small>
)}
</div>
)}
{!error && !loading && rows && errors.length > 0 && (
<details className="mt-3">
<summary className="small text-warning fw-semibold" style={{ cursor: "pointer" }}>
<i className="bx bx-error me-1" />
{errors.length} registro{errors.length !== 1 ? "s" : ""} observado{
errors.length !== 1 ? "s" : ""
} (no son ventas válidas)
</summary>
<div className="table-responsive mt-2">
<table className="table table-sm table-hover mb-0">
<thead>
<tr>
<th>Folio</th>
<th>Alumno</th>
<th>Fecha</th>
<th className="text-end">Monto</th>
<th>Motivo</th>
</tr>
</thead>
<tbody>
{errors.slice(0, 100).map((r, i) => (
<tr key={`err-${r.folio}-${i}`}>
<td>{r.folio || "—"}</td>
<td className="small">
{r.alumnoNombre ?? "—"}
{r.alumnoRut ? (
<span className="text-muted d-block">{r.alumnoRut}</span>
) : null}
</td>
<td className="small">{fechaCorta(r.fecha)}</td>
<td className="text-end">${Number(r.monto || 0).toLocaleString("es-CL")}</td>
<td className="small text-warning">{r.errorMotivo || "Sin fecha válida"}</td>
</tr>
))}
</tbody>
</table>
{errors.length > 100 && (
<small className="text-muted">Mostrando 100 de {errors.length}.</small>
)}
</div>
</details>
)}
<small className="text-muted d-block mt-2">
Fuente: <code>GET /api/Boletas/payments?mes=&amp;year=</code> (SAM.API).
</small>
</div>
</div>
</div>
</>
);
}
// Tablero funcional del módulo: estado + carga de trabajo +
// uso real (equipo + estadísticas). Ya no muestra la lista
// inventada "Funciones disponibles de la API": el uso de la API
+26 -144
View File
@@ -1,38 +1,20 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import SamShell from "@/components/SamShell";
import { useModules, useMe } from "@/lib/store"
import { itMenu, assignModuleColor } from "@/lib/sam";
import type { VerifyCheck, VerifyResult } from "@/lib/verify";
import { failedChecks, verifySummary } from "@/lib/verify";
import { useVerifyPoll, VERIFY_TOTAL } from "@/lib/use-verify-poll";
import type { VerifyResult } from "@/lib/verify";
import { failedChecks } from "@/lib/verify";
import { useVerifyPoll } from "@/lib/use-verify-poll";
import {
DeployAlerts,
VerifyTimeline,
type DeployInfo,
type DeployPhase,
} from "@/components/DeployStatus";
type Phase = "idle" | "deploying" | "verifying" | "done";
interface DeployInfo {
httpStatus: number;
deployLog: string;
webhook: boolean;
warnings: string[];
}
function StepIcon({ state }: { state: "pending" | "run" | "ok" | "fail" }) {
if (state === "run")
return <span className="spinner-border spinner-border-sm text-primary" />;
if (state === "ok") return <i className="bx bx-check-circle text-success fs-5" />;
if (state === "fail") return <i className="bx bx-x-circle text-danger fs-5" />;
return <i className="bx bx-circle text-muted fs-5" />;
}
function checkState(
check: VerifyCheck | undefined,
phase: Phase
): "pending" | "run" | "ok" | "fail" {
if (!check) return phase === "verifying" ? "run" : "pending";
return check.ok ? "ok" : "fail";
}
type Phase = DeployPhase;
export default function UploadPage() {
const { data } = useModules();
@@ -193,124 +175,24 @@ export default function UploadPage() {
)}
{/* Progreso de verificación post-importación */}
{(phase === "verifying" || (verify && phase === "done")) && (
<div className="app-card p-3 mb-3 border">
<h6 className="fw-semibold mb-1">
<i className="bx bx-check-shield me-1" />
Verificación de <code>{moduleName}</code>
{phase === "verifying" && (
<small className="text-muted fw-normal ms-2">
comprobando contenedor y funcionamiento… (intento {Math.max(attempt, 1)}/{VERIFY_TOTAL})
</small>
)}
</h6>
{verify && (
<small className="text-muted d-block mb-2">{verifySummary(verify)}</small>
)}
<ul className="list-group list-group-flush">
<li className="list-group-item d-flex gap-2 align-items-start px-0">
<StepIcon state={deployInfo ? "ok" : "run"} />
<span>
<strong>Registro y deploy</strong>
<small className="text-muted d-block">
{deployInfo
? `registrado (HTTP ${deployInfo.httpStatus})${deployInfo.webhook ? " · webhook creado" : " · sin webhook"}`
: "registrando módulo en el deployer…"}
</small>
</span>
</li>
{[
{ id: "container", label: "Contenedor creado y en ejecución" },
{ id: "logs", label: "Logs sin errores fatales" },
{ id: "http-root", label: "Aplicación responde HTTP" },
{ id: "http-extra", label: "Contenido válido (checks extra)" },
{ id: "caddy", label: "Ruta pública (Caddy)" },
{ id: "public-http", label: "URL pública accesible" },
].map((s) => {
const check = verify?.checks.find((c) => c.id === s.id);
return (
<li key={s.id} className="list-group-item d-flex gap-2 align-items-start px-0">
<StepIcon state={checkState(check, phase)} />
<span>
<strong>{s.label}</strong>
<small className="text-muted d-block">
{check ? check.detail : "pendiente…"}
</small>
</span>
</li>
);
})}
</ul>
{phase === "verifying" && (
<p className="text-muted small mb-0 mt-2">
Puedes seguir en esta página: si algún paso falla se te avisará aquí mismo
con el detalle. El estado también queda en <Link href="/tablero">Tablero</Link> y{" "}
<Link href="/actualizar">Actualizar</Link>.
</p>
)}
</div>
)}
<VerifyTimeline
moduleName={moduleName}
phase={phase}
verify={verify}
deployInfo={deployInfo}
attempt={attempt}
/>
{/* Aviso directo a quien sube: verificación fallida */}
{verifyFailed && (
<div className="alert alert-danger">
<i className="bx bx-error-circle me-2" />
<strong>
{me?.email ? `${me.email}, la importación de "${moduleName}" falló` : `La importación de "${moduleName}" falló`}
</strong>{" "}
en: {failed.map((c) => c.label).join(", ")}.
<ul className="mb-2 mt-2">
{failed.map((c) => (
<li key={c.id}>
<strong>{c.label}:</strong> {c.detail}
</li>
))}
</ul>
{deployInfo && deployInfo.warnings.length > 0 && (
<div className="small mb-2">
<strong>Advertencias de config de datos:</strong>
<ul className="mb-0">
{deployInfo.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
</div>
)}
{(verify.logsTail || deployInfo?.deployLog) && (
<details className="small mb-2">
<summary style={{ cursor: "pointer" }}>Ver logs del deploy</summary>
<pre className="bg-dark text-light p-2 mt-1 mb-0 small" style={{ whiteSpace: "pre-wrap", maxHeight: 240, overflow: "auto" }}>
{(verify.logsTail || deployInfo?.deployLog || "").slice(-3000)}
</pre>
</details>
)}
<div className="small mb-0">
El módulo quedó registrado pero <strong>no está funcionando</strong>. Revisa el
Dockerfile/puerto (<code>NEXT_PUBLIC_API_URL</code>, puerto interno 3000),
corrige el repo y reintenta desde <Link href="/actualizar">Actualizar</Link> o
elimínalo en <Link href="/eliminar">Eliminar</Link>. Su estado también es visible
en el <Link href={`/m/${moduleName}`}>tablero del módulo</Link>.
</div>
</div>
)}
{/* Éxito verificado */}
{phase === "done" && verify?.ok && (
<div className="alert alert-success">
<i className="bx bx-check-circle me-2" />
Módulo <strong>{moduleName}</strong> importado y <strong>verificado</strong>:
contenedor {verify.containerStatus || "running"} + HTTP {verify.httpStatus}
{verify.publicUrl ? <> + <code>{verify.publicUrl}</code></> : null}.
<span className="ms-2">
<a href={verify.publicUrl || `https://${moduleName}.devops.norteamericano.cl/`} target="_blank" rel="noopener noreferrer" className="btn btn-sm btn-success ms-1">
<i className="bx bx-window-open me-1" /> Abrir aplicación
</a>
<Link href={`/m/${moduleName}`} className="btn btn-sm btn-outline-success ms-1">
Ver tablero
</Link>
</span>
</div>
)}
<DeployAlerts
moduleName={moduleName}
phase={phase}
verify={verify}
deployInfo={deployInfo}
failed={failed}
verifyFailed={!!verifyFailed}
userEmail={me?.email || undefined}
/>
<form onSubmit={handleSubmit}>
<div className="row g-3">
+185
View File
@@ -0,0 +1,185 @@
import Link from "next/link";
import type { VerifyCheck, VerifyResult } from "@/lib/verify";
import { verifySummary } from "@/lib/verify";
import { VERIFY_TOTAL } from "@/lib/use-verify-poll";
export type DeployPhase = "idle" | "deploying" | "verifying" | "done";
export interface DeployInfo {
httpStatus: number;
deployLog: string;
webhook: boolean;
warnings: string[];
}
function StepIcon({ state }: { state: "pending" | "run" | "ok" | "fail" }) {
if (state === "run")
return <span className="spinner-border spinner-border-sm text-primary" />;
if (state === "ok") return <i className="bx bx-check-circle text-success fs-5" />;
if (state === "fail") return <i className="bx bx-x-circle text-danger fs-5" />;
return <i className="bx bx-circle text-muted fs-5" />;
}
function checkState(
check: VerifyCheck | undefined,
phase: DeployPhase
): "pending" | "run" | "ok" | "fail" {
if (!check) return phase === "verifying" ? "run" : "pending";
return check.ok ? "ok" : "fail";
}
// Línea de tiempo de la verificación post-importación.
export function VerifyTimeline({
moduleName,
phase,
verify,
deployInfo,
attempt,
}: {
moduleName: string;
phase: DeployPhase;
verify: VerifyResult | null;
deployInfo: DeployInfo | null;
attempt: number;
}) {
if (!(phase === "verifying" || (verify && phase === "done"))) return null;
return (
<div className="app-card p-3 mb-3 border">
<h6 className="fw-semibold mb-1">
<i className="bx bx-check-shield me-1" />
Verificación de <code>{moduleName}</code>
{phase === "verifying" && (
<small className="text-muted fw-normal ms-2">
comprobando contenedor y funcionamiento… (intento {Math.max(attempt, 1)}/{VERIFY_TOTAL})
</small>
)}
</h6>
{verify && (
<small className="text-muted d-block mb-2">{verifySummary(verify)}</small>
)}
<ul className="list-group list-group-flush">
<li className="list-group-item d-flex gap-2 align-items-start px-0">
<StepIcon state={deployInfo ? "ok" : "run"} />
<span>
<strong>Registro y deploy</strong>
<small className="text-muted d-block">
{deployInfo
? `registrado (HTTP ${deployInfo.httpStatus})${deployInfo.webhook ? " · webhook creado" : " · sin webhook"}`
: "registrando módulo en el deployer…"}
</small>
</span>
</li>
{[
{ id: "container", label: "Contenedor creado y en ejecución" },
{ id: "logs", label: "Logs sin errores fatales" },
{ id: "http-root", label: "Aplicación responde HTTP" },
{ id: "http-extra", label: "Contenido válido (checks extra)" },
{ id: "caddy", label: "Ruta pública (Caddy)" },
{ id: "public-http", label: "URL pública accesible" },
].map((s) => {
const check = verify?.checks.find((c) => c.id === s.id);
return (
<li key={s.id} className="list-group-item d-flex gap-2 align-items-start px-0">
<StepIcon state={checkState(check, phase)} />
<span>
<strong>{s.label}</strong>
<small className="text-muted d-block">
{check ? check.detail : "pendiente…"}
</small>
</span>
</li>
);
})}
</ul>
{phase === "verifying" && (
<p className="text-muted small mb-0 mt-2">
Puedes seguir en esta página: si algún paso falla se te avisará aquí mismo
con el detalle. El estado también queda en <Link href="/tablero">Tablero</Link> y{" "}
<Link href="/actualizar">Actualizar</Link>.
</p>
)}
</div>
);
}
// Avisos de resultado: verificación fallida y éxito verificado.
export function DeployAlerts({
moduleName,
phase,
verify,
deployInfo,
failed,
verifyFailed,
userEmail,
}: {
moduleName: string;
phase: DeployPhase;
verify: VerifyResult | null;
deployInfo: DeployInfo | null;
failed: VerifyCheck[];
verifyFailed: boolean;
userEmail?: string;
}) {
return (
<>
{verifyFailed && verify && (
<div className="alert alert-danger">
<i className="bx bx-error-circle me-2" />
<strong>
{userEmail ? `${userEmail}, la importación de "${moduleName}" falló` : `La importación de "${moduleName}" falló`}
</strong>{" "}
en: {failed.map((c) => c.label).join(", ")}.
<ul className="mb-2 mt-2">
{failed.map((c) => (
<li key={c.id}>
<strong>{c.label}:</strong> {c.detail}
</li>
))}
</ul>
{deployInfo && deployInfo.warnings.length > 0 && (
<div className="small mb-2">
<strong>Advertencias de config de datos:</strong>
<ul className="mb-0">
{deployInfo.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
</div>
)}
{(verify.logsTail || deployInfo?.deployLog) && (
<details className="small mb-2">
<summary style={{ cursor: "pointer" }}>Ver logs del deploy</summary>
<pre className="bg-dark text-light p-2 mt-1 mb-0 small" style={{ whiteSpace: "pre-wrap", maxHeight: 240, overflow: "auto" }}>
{(verify.logsTail || deployInfo?.deployLog || "").slice(-3000)}
</pre>
</details>
)}
<div className="small mb-0">
El módulo quedó registrado pero <strong>no está funcionando</strong>. Revisa el
Dockerfile/puerto (<code>NEXT_PUBLIC_API_URL</code>, puerto interno 3000),
corrige el repo y reintenta desde <Link href="/actualizar">Actualizar</Link> o
elimínalo en <Link href="/eliminar">Eliminar</Link>. Su estado también es visible
en el <Link href={`/m/${moduleName}`}>tablero del módulo</Link>.
</div>
</div>
)}
{phase === "done" && verify?.ok && (
<div className="alert alert-success">
<i className="bx bx-check-circle me-2" />
Módulo <strong>{moduleName}</strong> importado y <strong>verificado</strong>:
contenedor {verify.containerStatus || "running"} + HTTP {verify.httpStatus}
{verify.publicUrl ? <> + <code>{verify.publicUrl}</code></> : null}.
<span className="ms-2">
<a href={verify.publicUrl || `https://${moduleName}.devops.norteamericano.cl/`} target="_blank" rel="noopener noreferrer" className="btn btn-sm btn-success ms-1">
<i className="bx bx-window-open me-1" /> Abrir aplicación
</a>
<Link href={`/m/${moduleName}`} className="btn btn-sm btn-outline-success ms-1">
Ver tablero
</Link>
</span>
</div>
)}
</>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use client";
import { useEffect, useState } from "react";
interface HelpdeskMember {
idUsuario: string;
nombres?: string;
apPaterno?: string;
email?: string;
role?: string;
departmentName?: string;
isActive?: boolean;
}
interface HelpdeskStats {
totalTickets: number;
openTickets: number;
inProgressTickets: number;
resolvedTickets: number;
closedTickets: number;
unassignedTickets: number;
criticalTickets: number;
dailyCounts: { date: string; count: number }[];
}
export function HelpdeskUsage() {
const [team, setTeam] = useState<HelpdeskMember[] | null>(null);
const [stats, setStats] = useState<HelpdeskStats | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const [tRes, sRes] = await Promise.all([
fetch("/api/portal/helpdesk-team", { cache: "no-store" }),
fetch("/api/portal/helpdesk-stats", { cache: "no-store" }),
]);
if (!tRes.ok || !sRes.ok) {
const t = await tRes.json().catch(() => ({}));
const s = await sRes.json().catch(() => ({}));
throw new Error(
(t as { error?: string }).error ||
(s as { error?: string }).error ||
`Helpdesk respondió ${tRes.status}/${sRes.status}`
);
}
const t = (await tRes.json()) as HelpdeskMember[];
const s = (await sRes.json()) as HelpdeskStats;
if (!cancelled) {
setTeam(Array.isArray(t) ? t : []);
setStats(s);
}
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Error al cargar uso");
}
}
load();
return () => {
cancelled = true;
};
}, []);
if (error) {
return (
<div className="alert alert-warning">
<i className="bx bx-error me-2" />
No se pudo cargar el uso real de Helpdesk: {error}
</div>
);
}
if (!team || !stats) {
return (
<div className="text-center py-4">
<div className="spinner-border text-primary" role="status" />
<p className="mt-2 text-muted small">Cargando equipo y estadísticas reales…</p>
</div>
);
}
const active = team.filter((m) => m.isActive !== false);
const maxDaily = Math.max(...stats.dailyCounts.map((d) => d.count), 1);
return (
<>
<div className="alert alert-light section-header mt-4 mb-3 py-2 px-3">
<i className="bx bx-group me-1" /> Equipo que lo usa ({active.length})
</div>
<div className="row row-cards">
<div className="col-12">
<div className="app-card p-3">
{active.length === 0 && (
<p className="text-muted mb-0">Sin integrantes registrados en el equipo.</p>
)}
<div className="list-group">
{active.map((m) => (
<div
key={m.idUsuario}
className="list-group-item d-flex justify-content-between align-items-center flex-wrap gap-2"
>
<span>
<i className="bx bx-user me-2" />
{[m.nombres, m.apPaterno].filter(Boolean).join(" ") || m.idUsuario}
<small className="text-muted d-block">
{m.idUsuario}
{m.email ? ` · ${m.email}` : ""}
{m.departmentName ? ` · ${m.departmentName}` : ""}
</small>
</span>
<span className="badge bg-primary rounded-pill">{m.role ?? "—"}</span>
</div>
))}
</div>
<small className="text-muted d-block mt-2">
Fuente: <code>GET /api/helpdesk/users</code> (SAM.API, cuenta de servicio).
</small>
</div>
</div>
</div>
<div className="alert alert-light section-header mt-4 mb-3 py-2 px-3">
<i className="bx bx-bar-chart-alt-2 me-1" /> Estadísticas de uso
</div>
<div className="row g-3 mb-3">
{[
{ label: "Total tickets", value: stats.totalTickets },
{ label: "Abiertos", value: stats.openTickets },
{ label: "En progreso", value: stats.inProgressTickets },
{ label: "Resueltos", value: stats.resolvedTickets },
{ label: "Críticos", value: stats.criticalTickets },
{ label: "Sin asignar", value: stats.unassignedTickets },
].map((c) => (
<div className="col-6 col-lg-4 col-xl-2" key={c.label}>
<div className="app-card p-3 text-center h-100">
<div className="fw-bold fs-4">{c.value}</div>
<small className="text-muted">{c.label}</small>
</div>
</div>
))}
</div>
{stats.dailyCounts.length > 0 && (
<div className="card mb-4">
<div className="card-header d-flex align-items-center gap-2">
<i className="bx bx-graph-up" style={{ color: "var(--primary)" }}></i>
Tickets por día (últimos 14 días)
</div>
<div className="card-body">
<div className="d-flex align-items-end gap-1" style={{ height: "120px" }}>
{stats.dailyCounts.map((d) => (
<div
key={d.date}
className="flex-fill rounded"
style={{
height: `${(d.count / maxDaily) * 100}%`,
backgroundColor: "var(--primary)",
opacity: 0.7,
minHeight: "4px",
}}
title={`${d.date}: ${d.count} tickets`}
></div>
))}
</div>
<small className="text-muted">
Fuente: <code>GET /api/helpdesk/stats</code> (SAM.API).
</small>
</div>
</div>
)}
</>
);
}
+229
View File
@@ -0,0 +1,229 @@
"use client";
import { useEffect, useState } from "react";
interface Boleta {
folio: number;
alumnoRut?: string | null;
alumnoNombre?: string | null;
fecha: string;
monto: number;
tipo?: string;
estado?: string;
esValido?: boolean;
errorMotivo?: string | null;
}
// Fecha real = distinta de nula/vacía y del MinValue de .NET
// (0001-01-01: la función PG no informó fecha de caja).
function fechaReal(f: string | null | undefined): boolean {
return !!f && !f.startsWith("0001-01-01");
}
function fechaCorta(f: string | null | undefined): string {
return fechaReal(f) ? String(f).slice(0, 10) : "—";
}
export function PagosUsage() {
const now = new Date();
const [mes, setMes] = useState(String(now.getMonth() + 1));
const [year, setYear] = useState(String(now.getFullYear()));
const [rows, setRows] = useState<Boleta[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function load(m: string, y: string) {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/portal/pagos-validados?mes=${m}&year=${y}&limite=200`, {
cache: "no-store",
});
const data = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(
(data as { error?: string } | null)?.error || `Pagos respondió ${res.status}`
);
}
setRows(Array.isArray(data) ? data : []);
} catch (e) {
setError(e instanceof Error ? e.message : "Error al cargar pagos");
setRows(null);
} finally {
setLoading(false);
}
}
useEffect(() => {
// Carga inicial diferida a un callback: el setState ocurre fuera
// del cuerpo del efecto (regla react-hooks/set-state-in-effect).
// Se ejecuta una sola vez a propósito: los cambios de mes/año
// posteriores van por el submit del formulario, no por re-render.
const t = setTimeout(() => {
load(mes, year);
}, 0);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const valid = (rows ?? []).filter(
(r) => r.esValido !== false && fechaReal(r.fecha)
);
const errors = (rows ?? []).filter(
(r) => r.esValido === false || !fechaReal(r.fecha)
);
const total = valid.reduce((acc, r) => acc + (Number(r.monto) || 0), 0);
return (
<>
<div className="alert alert-light section-header mt-4 mb-3 py-2 px-3">
<i className="bx bx-receipt me-1" /> Pagos validados
</div>
<div className="row row-cards">
<div className="col-12">
<div className="app-card p-3">
<form
className="d-flex gap-2 flex-wrap align-items-end mb-3"
onSubmit={(e) => {
e.preventDefault();
load(mes, year);
}}
>
<div>
<label className="form-label small text-muted mb-1">Mes</label>
<select
className="form-select form-select-sm"
value={mes}
onChange={(e) => setMes(e.target.value)}
>
{Array.from({ length: 12 }, (_, i) => (
<option key={i + 1} value={String(i + 1)}>
{i + 1}
</option>
))}
</select>
</div>
<div>
<label className="form-label small text-muted mb-1">Año</label>
<input
className="form-control form-control-sm"
value={year}
onChange={(e) => setYear(e.target.value)}
inputMode="numeric"
style={{ width: 100 }}
/>
</div>
<button type="submit" className="btn btn-sm btn-primary" disabled={loading}>
{loading ? "Cargando…" : "Consultar"}
</button>
{rows && (
<span className="ms-auto small text-muted align-self-center">
{valid.length} válidos
{errors.length > 0 && ` · ${errors.length} observados`} · Total
${total.toLocaleString("es-CL")}
</span>
)}
</form>
{error && (
<div className="alert alert-warning mb-0">
<i className="bx bx-error me-2" />
No se pudieron cargar los pagos validados: {error}
</div>
)}
{!error && loading && (
<div className="text-center py-4">
<div className="spinner-border text-primary" role="status" />
</div>
)}
{!error && !loading && rows && valid.length === 0 && (
<p className="text-muted mb-0">Sin pagos válidos para {mes}/{year}.</p>
)}
{!error && !loading && rows && valid.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-hover mb-0">
<thead>
<tr>
<th>Folio</th>
<th>Alumno</th>
<th>Fecha</th>
<th className="text-end">Monto</th>
<th>Estado</th>
</tr>
</thead>
<tbody>
{valid.slice(0, 100).map((r, i) => (
<tr key={`${r.folio}-${i}`}>
<td>{r.folio}</td>
<td className="small">
{r.alumnoNombre ?? "—"}
{r.alumnoRut ? (
<span className="text-muted d-block">{r.alumnoRut}</span>
) : null}
</td>
<td className="small">{fechaCorta(r.fecha)}</td>
<td className="text-end">${Number(r.monto || 0).toLocaleString("es-CL")}</td>
<td className="small">{r.estado || r.tipo || "—"}</td>
</tr>
))}
</tbody>
</table>
{valid.length > 100 && (
<small className="text-muted">Mostrando 100 de {valid.length}.</small>
)}
</div>
)}
{!error && !loading && rows && errors.length > 0 && (
<details className="mt-3">
<summary className="small text-warning fw-semibold" style={{ cursor: "pointer" }}>
<i className="bx bx-error me-1" />
{errors.length} registro{errors.length !== 1 ? "s" : ""} observado{
errors.length !== 1 ? "s" : ""
} (no son ventas válidas)
</summary>
<div className="table-responsive mt-2">
<table className="table table-sm table-hover mb-0">
<thead>
<tr>
<th>Folio</th>
<th>Alumno</th>
<th>Fecha</th>
<th className="text-end">Monto</th>
<th>Motivo</th>
</tr>
</thead>
<tbody>
{errors.slice(0, 100).map((r, i) => (
<tr key={`err-${r.folio}-${i}`}>
<td>{r.folio || "—"}</td>
<td className="small">
{r.alumnoNombre ?? "—"}
{r.alumnoRut ? (
<span className="text-muted d-block">{r.alumnoRut}</span>
) : null}
</td>
<td className="small">{fechaCorta(r.fecha)}</td>
<td className="text-end">${Number(r.monto || 0).toLocaleString("es-CL")}</td>
<td className="small text-warning">{r.errorMotivo || "Sin fecha válida"}</td>
</tr>
))}
</tbody>
</table>
{errors.length > 100 && (
<small className="text-muted">Mostrando 100 de {errors.length}.</small>
)}
</div>
</details>
)}
<small className="text-muted d-block mt-2">
Fuente: <code>GET /api/Boletas/payments?mes=&amp;year=</code> (SAM.API).
</small>
</div>
</div>
</div>
</>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import {
MODULE_NAME_RE,
SLUG_SEG_RE,
SUBDOMAIN_RE,
safeNextPath,
validRepoUrl,
} from "./validators";
describe("MODULE_NAME_RE / SUBDOMAIN_RE", () => {
it("acepta nombres válidos", () => {
expect(MODULE_NAME_RE.test("ventas")).toBe(true);
expect(MODULE_NAME_RE.test("sam-api")).toBe(true);
expect(SUBDOMAIN_RE.test("academico-empresas")).toBe(true);
});
it("rechaza traversal y caracteres raros", () => {
for (const bad of ["..", ".", "../x", "a/b", "a b", "", "a".repeat(64), "-x"]) {
expect(MODULE_NAME_RE.test(bad)).toBe(false);
}
});
});
describe("SLUG_SEG_RE", () => {
it("acepta segmentos de ruta del proxy", () => {
expect(SLUG_SEG_RE.test("modules")).toBe(true);
expect(SLUG_SEG_RE.test("mi-modulo_1")).toBe(true);
});
it("rechaza .., vacío y %", () => {
for (const bad of ["..", ".", "", "a/b", "a%b", "a b"]) {
expect(SLUG_SEG_RE.test(bad)).toBe(false);
}
});
});
describe("validRepoUrl", () => {
it("acepta https pública", () => {
expect(validRepoUrl("https://git.norteamericano.cl/Nurfog/sam-portal.git")).toBe(true);
expect(validRepoUrl("https://github.com/org/repo")).toBe(true);
});
it("rechaza http, otras schemes y basura", () => {
expect(validRepoUrl("http://git.norteamericano.cl/x")).toBe(false);
expect(validRepoUrl("ssh://git@git.norteamericano.cl/x")).toBe(false);
expect(validRepoUrl("not a url")).toBe(false);
expect(validRepoUrl("")).toBe(false);
expect(validRepoUrl(null)).toBe(false);
});
it("rechaza hosts internos y credenciales", () => {
expect(validRepoUrl("https://localhost:3000/x")).toBe(false);
expect(validRepoUrl("https://127.0.0.1/x")).toBe(false);
expect(validRepoUrl("https://10.0.0.5/x")).toBe(false);
expect(validRepoUrl("https://192.168.1.10/x")).toBe(false);
expect(validRepoUrl("https://172.17.0.2/x")).toBe(false);
expect(validRepoUrl("https://svc.internal/x")).toBe(false);
expect(validRepoUrl("https://user:pass@git.norteamericano.cl/x")).toBe(false);
});
});
describe("safeNextPath", () => {
it("acepta rutas internas", () => {
expect(safeNextPath("/tablero")).toBe("/tablero");
expect(safeNextPath("/m/ventas")).toBe("/m/ventas");
});
it("bloquea open redirect y vacíos", () => {
expect(safeNextPath("//evil.com")).toBe("/tablero");
expect(safeNextPath("//evil.com/x")).toBe("/tablero");
expect(safeNextPath("/\\evil.com")).toBe("/tablero");
expect(safeNextPath("https://evil.com")).toBe("/tablero");
expect(safeNextPath("")).toBe("/tablero");
expect(safeNextPath(null)).toBe("/tablero");
});
});
+42
View File
@@ -0,0 +1,42 @@
// Validadores de seguridad compartidos por las rutas API y el login.
// Centralizados aquí para cubrirlos con tests (son la frontera que
// separa input de usuario del deployer y de redirects externos).
export const MODULE_NAME_RE = /^[a-z0-9][a-z0-9-]{1,62}$/i;
export const SUBDOMAIN_RE = /^[a-z0-9][a-z0-9-]{1,62}$/i;
export const SLUG_SEG_RE = /^[a-z0-9][a-z0-9_-]*$/i;
// repoUrl de un módulo: https pública, sin credenciales ni hosts
// internos (localhost, RFC1918, .local, .internal).
export function validRepoUrl(v: unknown): boolean {
if (typeof v !== "string" || v.length === 0 || v.length > 500) return false;
let u: URL;
try {
u = new URL(v);
} catch {
return false;
}
if (u.protocol !== "https:") return false;
if (u.username || u.password) return false;
const host = u.hostname.toLowerCase();
if (
host === "localhost" ||
host.startsWith("127.") ||
host.startsWith("10.") ||
host.startsWith("192.168.") ||
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
host.endsWith(".local") ||
host.endsWith(".internal")
)
return false;
return true;
}
// Destino post-login seguro: ruta interna con una sola barra inicial.
// Bloquea `//evil.com` y `/\evil.com` (resuelven a origen externo).
export function safeNextPath(v: string | null | undefined): string {
if (!v || !v.startsWith("/") || v.startsWith("//") || v.startsWith("/\\")) {
return "/tablero";
}
return v;
}