From 1a891c3751775044a069760d78f07bc4e78d6cce Mon Sep 17 00:00:00 2001 From: Nurfog Date: Tue, 13 Oct 2026 11:41:45 -0300 Subject: [PATCH] =?UTF-8?q?refactor:=20validadores=20compartidos=20y=20com?= =?UTF-8?q?ponentes=20de=20UI=20extra=C3=ADdos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 19 +- src/app/api/deployer/[...slug]/route.ts | 4 +- src/app/api/modules/[name]/verify/route.ts | 7 +- src/app/api/modules/route.ts | 30 +- src/app/login/page.tsx | 12 +- src/app/m/[name]/page.tsx | 398 +-------------------- src/app/upload/page.tsx | 170 ++------- src/components/DeployStatus.tsx | 185 ++++++++++ src/components/HelpdeskUsage.tsx | 172 +++++++++ src/components/PagosUsage.tsx | 229 ++++++++++++ src/lib/validators.test.ts | 71 ++++ src/lib/validators.ts | 42 +++ 12 files changed, 753 insertions(+), 586 deletions(-) create mode 100644 src/components/DeployStatus.tsx create mode 100644 src/components/HelpdeskUsage.tsx create mode 100644 src/components/PagosUsage.tsx create mode 100644 src/lib/validators.test.ts create mode 100644 src/lib/validators.ts diff --git a/README.md b/README.md index 399a266..4ac7fb0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app/api/deployer/[...slug]/route.ts b/src/app/api/deployer/[...slug]/route.ts index 729abcd..14b16d3 100644 --- a/src/app/api/deployer/[...slug]/route.ts +++ b/src/app/api/deployer/[...slug]/route.ts @@ -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"); } } diff --git a/src/app/api/modules/[name]/verify/route.ts b/src/app/api/modules/[name]/verify/route.ts index fc335b4..3c8ad70 100644 --- a/src/app/api/modules/[name]/verify/route.ts +++ b/src/app/api/modules/[name]/verify/route.ts @@ -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 { const h: Record = { "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"); diff --git a/src/app/api/modules/route.ts b/src/app/api/modules/route.ts index 2e36028..3bcb4ee 100644 --- a/src/app/api/modules/route.ts +++ b/src/app/api/modules/route.ts @@ -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 = { "Content-Type": "application/json" }; if (DEPLOYER_TOKEN) h["X-API-Token"] = DEPLOYER_TOKEN; @@ -21,30 +19,6 @@ function isIT(req: NextRequest): Promise { ); } -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 } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index f3f7a47..0ce0a2c 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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"); diff --git a/src/app/m/[name]/page.tsx b/src/app/m/[name]/page.tsx index d5650fd..47187bf 100644 --- a/src/app/m/[name]/page.tsx +++ b/src/app/m/[name]/page.tsx @@ -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(null); - const [stats, setStats] = useState(null); - const [error, setError] = useState(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 ( -
- - No se pudo cargar el uso real de Helpdesk: {error} -
- ); - } - - if (!team || !stats) { - return ( -
-
-

Cargando equipo y estadísticas reales…

-
- ); - } - - const active = team.filter((m) => m.isActive !== false); - const maxDaily = Math.max(...stats.dailyCounts.map((d) => d.count), 1); - - return ( - <> -
- Equipo que lo usa ({active.length}) -
-
-
-
- {active.length === 0 && ( -

Sin integrantes registrados en el equipo.

- )} -
- {active.map((m) => ( -
- - - {[m.nombres, m.apPaterno].filter(Boolean).join(" ") || m.idUsuario} - - {m.idUsuario} - {m.email ? ` · ${m.email}` : ""} - {m.departmentName ? ` · ${m.departmentName}` : ""} - - - {m.role ?? "—"} -
- ))} -
- - Fuente: GET /api/helpdesk/users (SAM.API, cuenta de servicio). - -
-
-
- -
- Estadísticas de uso -
-
- {[ - { 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) => ( -
-
-
{c.value}
- {c.label} -
-
- ))} -
- {stats.dailyCounts.length > 0 && ( -
-
- - Tickets por día (últimos 14 días) -
-
-
- {stats.dailyCounts.map((d) => ( -
- ))} -
- - Fuente: GET /api/helpdesk/stats (SAM.API). - -
-
- )} - - ); -} - -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(null); - const [error, setError] = useState(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 ( - <> -
- Pagos validados -
-
-
-
-
{ - e.preventDefault(); - load(mes, year); - }} - > -
- - -
-
- - setYear(e.target.value)} - inputMode="numeric" - style={{ width: 100 }} - /> -
- - {rows && ( - - {valid.length} válidos - {errors.length > 0 && ` · ${errors.length} observados`} · Total - ${total.toLocaleString("es-CL")} - - )} -
- - {error && ( -
- - No se pudieron cargar los pagos validados: {error} -
- )} - - {!error && loading && ( -
-
-
- )} - - {!error && !loading && rows && valid.length === 0 && ( -

Sin pagos válidos para {mes}/{year}.

- )} - - {!error && !loading && rows && valid.length > 0 && ( -
- - - - - - - - - - - - {valid.slice(0, 100).map((r, i) => ( - - - - - - - - ))} - -
FolioAlumnoFechaMontoEstado
{r.folio} - {r.alumnoNombre ?? "—"} - {r.alumnoRut ? ( - {r.alumnoRut} - ) : null} - {fechaCorta(r.fecha)}${Number(r.monto || 0).toLocaleString("es-CL")}{r.estado || r.tipo || "—"}
- {valid.length > 100 && ( - Mostrando 100 de {valid.length}. - )} -
- )} - {!error && !loading && rows && errors.length > 0 && ( -
- - - {errors.length} registro{errors.length !== 1 ? "s" : ""} observado{ - errors.length !== 1 ? "s" : "" - } (no son ventas válidas) - -
- - - - - - - - - - - - {errors.slice(0, 100).map((r, i) => ( - - - - - - - - ))} - -
FolioAlumnoFechaMontoMotivo
{r.folio || "—"} - {r.alumnoNombre ?? "—"} - {r.alumnoRut ? ( - {r.alumnoRut} - ) : null} - {fechaCorta(r.fecha)}${Number(r.monto || 0).toLocaleString("es-CL")}{r.errorMotivo || "Sin fecha válida"}
- {errors.length > 100 && ( - Mostrando 100 de {errors.length}. - )} -
-
- )} - - Fuente: GET /api/Boletas/payments?mes=&year= (SAM.API). - -
-
-
- - ); -} - // 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 diff --git a/src/app/upload/page.tsx b/src/app/upload/page.tsx index 530a698..5a46dc2 100644 --- a/src/app/upload/page.tsx +++ b/src/app/upload/page.tsx @@ -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 ; - if (state === "ok") return ; - if (state === "fail") return ; - return ; -} - -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")) && ( -
-
- - Verificación de {moduleName} - {phase === "verifying" && ( - - comprobando contenedor y funcionamiento… (intento {Math.max(attempt, 1)}/{VERIFY_TOTAL}) - - )} -
- {verify && ( - {verifySummary(verify)} - )} -
    -
  • - - - Registro y deploy - - {deployInfo - ? `registrado (HTTP ${deployInfo.httpStatus})${deployInfo.webhook ? " · webhook creado" : " · sin webhook"}` - : "registrando módulo en el deployer…"} - - -
  • - {[ - { 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 ( -
  • - - - {s.label} - - {check ? check.detail : "pendiente…"} - - -
  • - ); - })} -
- {phase === "verifying" && ( -

- 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 Tablero y{" "} - Actualizar. -

- )} -
- )} + {/* Aviso directo a quien sube: verificación fallida */} - {verifyFailed && ( -
- - - {me?.email ? `${me.email}, la importación de "${moduleName}" falló` : `La importación de "${moduleName}" falló`} - {" "} - en: {failed.map((c) => c.label).join(", ")}. -
    - {failed.map((c) => ( -
  • - {c.label}: {c.detail} -
  • - ))} -
- {deployInfo && deployInfo.warnings.length > 0 && ( -
- Advertencias de config de datos: -
    - {deployInfo.warnings.map((w, i) => ( -
  • {w}
  • - ))} -
-
- )} - {(verify.logsTail || deployInfo?.deployLog) && ( -
- Ver logs del deploy -
-                  {(verify.logsTail || deployInfo?.deployLog || "").slice(-3000)}
-                
-
- )} -
- El módulo quedó registrado pero no está funcionando. Revisa el - Dockerfile/puerto (NEXT_PUBLIC_API_URL, puerto interno 3000), - corrige el repo y reintenta desde Actualizar o - elimínalo en Eliminar. Su estado también es visible - en el tablero del módulo. -
-
- )} - - {/* Éxito verificado */} - {phase === "done" && verify?.ok && ( -
- - Módulo {moduleName} importado y verificado: - contenedor {verify.containerStatus || "running"} + HTTP {verify.httpStatus} - {verify.publicUrl ? <> + {verify.publicUrl} : null}. - - - Abrir aplicación - - - Ver tablero - - -
- )} +
diff --git a/src/components/DeployStatus.tsx b/src/components/DeployStatus.tsx new file mode 100644 index 0000000..c8ff925 --- /dev/null +++ b/src/components/DeployStatus.tsx @@ -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 ; + if (state === "ok") return ; + if (state === "fail") return ; + return ; +} + +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 ( +
+
+ + Verificación de {moduleName} + {phase === "verifying" && ( + + comprobando contenedor y funcionamiento… (intento {Math.max(attempt, 1)}/{VERIFY_TOTAL}) + + )} +
+ {verify && ( + {verifySummary(verify)} + )} +
    +
  • + + + Registro y deploy + + {deployInfo + ? `registrado (HTTP ${deployInfo.httpStatus})${deployInfo.webhook ? " · webhook creado" : " · sin webhook"}` + : "registrando módulo en el deployer…"} + + +
  • + {[ + { 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 ( +
  • + + + {s.label} + + {check ? check.detail : "pendiente…"} + + +
  • + ); + })} +
+ {phase === "verifying" && ( +

+ 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 Tablero y{" "} + Actualizar. +

+ )} +
+ ); +} + +// 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 && ( +
+ + + {userEmail ? `${userEmail}, la importación de "${moduleName}" falló` : `La importación de "${moduleName}" falló`} + {" "} + en: {failed.map((c) => c.label).join(", ")}. +
    + {failed.map((c) => ( +
  • + {c.label}: {c.detail} +
  • + ))} +
+ {deployInfo && deployInfo.warnings.length > 0 && ( +
+ Advertencias de config de datos: +
    + {deployInfo.warnings.map((w, i) => ( +
  • {w}
  • + ))} +
+
+ )} + {(verify.logsTail || deployInfo?.deployLog) && ( +
+ Ver logs del deploy +
+                {(verify.logsTail || deployInfo?.deployLog || "").slice(-3000)}
+              
+
+ )} +
+ El módulo quedó registrado pero no está funcionando. Revisa el + Dockerfile/puerto (NEXT_PUBLIC_API_URL, puerto interno 3000), + corrige el repo y reintenta desde Actualizar o + elimínalo en Eliminar. Su estado también es visible + en el tablero del módulo. +
+
+ )} + + {phase === "done" && verify?.ok && ( +
+ + Módulo {moduleName} importado y verificado: + contenedor {verify.containerStatus || "running"} + HTTP {verify.httpStatus} + {verify.publicUrl ? <> + {verify.publicUrl} : null}. + + + Abrir aplicación + + + Ver tablero + + +
+ )} + + ); +} diff --git a/src/components/HelpdeskUsage.tsx b/src/components/HelpdeskUsage.tsx new file mode 100644 index 0000000..53cfa16 --- /dev/null +++ b/src/components/HelpdeskUsage.tsx @@ -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(null); + const [stats, setStats] = useState(null); + const [error, setError] = useState(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 ( +
+ + No se pudo cargar el uso real de Helpdesk: {error} +
+ ); + } + + if (!team || !stats) { + return ( +
+
+

Cargando equipo y estadísticas reales…

+
+ ); + } + + const active = team.filter((m) => m.isActive !== false); + const maxDaily = Math.max(...stats.dailyCounts.map((d) => d.count), 1); + + return ( + <> +
+ Equipo que lo usa ({active.length}) +
+
+
+
+ {active.length === 0 && ( +

Sin integrantes registrados en el equipo.

+ )} +
+ {active.map((m) => ( +
+ + + {[m.nombres, m.apPaterno].filter(Boolean).join(" ") || m.idUsuario} + + {m.idUsuario} + {m.email ? ` · ${m.email}` : ""} + {m.departmentName ? ` · ${m.departmentName}` : ""} + + + {m.role ?? "—"} +
+ ))} +
+ + Fuente: GET /api/helpdesk/users (SAM.API, cuenta de servicio). + +
+
+
+ +
+ Estadísticas de uso +
+
+ {[ + { 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) => ( +
+
+
{c.value}
+ {c.label} +
+
+ ))} +
+ {stats.dailyCounts.length > 0 && ( +
+
+ + Tickets por día (últimos 14 días) +
+
+
+ {stats.dailyCounts.map((d) => ( +
+ ))} +
+ + Fuente: GET /api/helpdesk/stats (SAM.API). + +
+
+ )} + + ); +} diff --git a/src/components/PagosUsage.tsx b/src/components/PagosUsage.tsx new file mode 100644 index 0000000..5027537 --- /dev/null +++ b/src/components/PagosUsage.tsx @@ -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(null); + const [error, setError] = useState(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 ( + <> +
+ Pagos validados +
+
+
+
+ { + e.preventDefault(); + load(mes, year); + }} + > +
+ + +
+
+ + setYear(e.target.value)} + inputMode="numeric" + style={{ width: 100 }} + /> +
+ + {rows && ( + + {valid.length} válidos + {errors.length > 0 && ` · ${errors.length} observados`} · Total + ${total.toLocaleString("es-CL")} + + )} + + + {error && ( +
+ + No se pudieron cargar los pagos validados: {error} +
+ )} + + {!error && loading && ( +
+
+
+ )} + + {!error && !loading && rows && valid.length === 0 && ( +

Sin pagos válidos para {mes}/{year}.

+ )} + + {!error && !loading && rows && valid.length > 0 && ( +
+ + + + + + + + + + + + {valid.slice(0, 100).map((r, i) => ( + + + + + + + + ))} + +
FolioAlumnoFechaMontoEstado
{r.folio} + {r.alumnoNombre ?? "—"} + {r.alumnoRut ? ( + {r.alumnoRut} + ) : null} + {fechaCorta(r.fecha)}${Number(r.monto || 0).toLocaleString("es-CL")}{r.estado || r.tipo || "—"}
+ {valid.length > 100 && ( + Mostrando 100 de {valid.length}. + )} +
+ )} + {!error && !loading && rows && errors.length > 0 && ( +
+ + + {errors.length} registro{errors.length !== 1 ? "s" : ""} observado{ + errors.length !== 1 ? "s" : "" + } (no son ventas válidas) + +
+ + + + + + + + + + + + {errors.slice(0, 100).map((r, i) => ( + + + + + + + + ))} + +
FolioAlumnoFechaMontoMotivo
{r.folio || "—"} + {r.alumnoNombre ?? "—"} + {r.alumnoRut ? ( + {r.alumnoRut} + ) : null} + {fechaCorta(r.fecha)}${Number(r.monto || 0).toLocaleString("es-CL")}{r.errorMotivo || "Sin fecha válida"}
+ {errors.length > 100 && ( + Mostrando 100 de {errors.length}. + )} +
+
+ )} + + Fuente: GET /api/Boletas/payments?mes=&year= (SAM.API). + +
+
+
+ + ); +} diff --git a/src/lib/validators.test.ts b/src/lib/validators.test.ts new file mode 100644 index 0000000..abee893 --- /dev/null +++ b/src/lib/validators.test.ts @@ -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"); + }); +}); diff --git a/src/lib/validators.ts b/src/lib/validators.ts new file mode 100644 index 0000000..9f3b41a --- /dev/null +++ b/src/lib/validators.ts @@ -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; +}