feat: página Correo con claves de módulos + lint en cero

This commit is contained in:
Nurfog committed 2026-09-16 11:31:28 -03:00
1 parent 1bf1e12273
commit f9e0887f10
13 files changed
+471 -12

No files matched your search

+6
View File
@@ -16,6 +16,7 @@
| `/upload` | sí | Integrar módulo (categoría, tipo, visibilidad) |
| `/actualizar` | sí | Redeploy + webhook + toggle visibilidad |
| `/eliminar` | sí | Baja de módulos (con/sin código) |
| `/correo` | sí | Claves de correo por módulo (`POST /api/email/modules`, banco `mail.*`) |
| `/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 |
@@ -32,6 +33,11 @@ DEPLOYER_TOKEN=<token>
NEXT_PUBLIC_API_URL=https://api.devops.norteamericano.cl
IT_USERS=cgaray@norteamericano.cl,fmena@norteamericano.cl,jallende@norteamericano.cl
IT_SESSION_SECRET=<openssl rand -hex 24>
# Cuenta de servicio helpdesk (usuario sige real con rol admin) para
# administrar claves de correo desde /correo (solo servidor):
HELPDESK_SERVICE_USER=<idUsuario>
HELPDESK_SERVICE_PASS=<clave>
# SAM_API_URL=https://api.devops.norteamericano.cl # default prod
```
## Desarrollo
@@ -0,0 +1,26 @@
import {
assertIT,
helpdeskFetch,
asResponse,
errResponse,
} from "@/lib/correo-server";
// DELETE: revoca la clave de un módulo (deja de poder usar
// POST /api/email/send hasta registrar una nueva).
export async function DELETE(
req: Request,
{ params }: { params: Promise<{ modulo: string }> }
): Promise<Response> {
try {
await assertIT(req);
const modulo = (await params).modulo.trim().toLowerCase();
return await asResponse(
await helpdeskFetch(
`/api/email/modules/${encodeURIComponent(modulo)}`,
{ method: "DELETE" }
)
);
} catch (e) {
return errResponse(e);
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import {
assertIT,
helpdeskFetch,
asResponse,
errResponse,
} from "@/lib/correo-server";
const MODULE_RE = /^[a-z0-9-]{2,32}$/;
// GET: lista módulos autorizados del banco de correo.
export async function GET(req: Request): Promise<Response> {
try {
await assertIT(req);
return await asResponse(await helpdeskFetch("/api/email/modules"));
} catch (e) {
return errResponse(e);
}
}
// POST {module}: registra (o rota) la clave. La apiKey plana se
// devuelve una sola vez: mostrarla y pedir guardarla en el .env
// del módulo como MAIL_API_KEY.
export async function POST(req: Request): Promise<Response> {
try {
await assertIT(req);
const body = (await req.json().catch(() => ({}))) as {
module?: string;
};
const modName = (body.module ?? "").trim().toLowerCase();
if (!MODULE_RE.test(modName)) {
return NextResponse.json(
{ message: "Módulo inválido ([a-z0-9-], 2-32)" },
{ status: 400 }
);
}
return await asResponse(
await helpdeskFetch("/api/email/modules", {
method: "POST",
body: JSON.stringify({ module: modName }),
})
);
} catch (e) {
return errResponse(e);
}
}
+187
View File
@@ -0,0 +1,187 @@
"use client";
import { useState } from "react";
import SamShell from "@/components/SamShell";
import { refreshCorreo, useCorreoModulos } from "@/lib/correo";
import { refreshModules, useModules, useMe } from "@/lib/store";
import { itMenu } from "@/lib/sam";
// Correo: claves de servicio por módulo (banco mail.*).
// Registrar/rotar muestra la apiKey UNA vez: va al .env del
// módulo como MAIL_API_KEY (nunca NEXT_PUBLIC_).
export default function Correo() {
const { data, error } = useModules();
const me = useMe();
const modules = data?.modules ?? [];
const correo = useCorreoModulos();
const rows = correo.data ?? [];
const [name, setName] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<{ ok: boolean; text: string; apiKey?: string } | null>(null);
const register = async () => {
const modName = name.trim().toLowerCase();
if (!/^[a-z0-9-]{2,32}$/.test(modName)) {
setMsg({ ok: false, text: "Módulo inválido ([a-z0-9-], 2-32)" });
return;
}
setBusy(`register:${modName}`);
setMsg(null);
try {
const res = await fetch("/api/correo/modulos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ module: modName }),
cache: "no-store",
});
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.message || j.error || `HTTP ${res.status}`);
setMsg({
ok: true,
text: `Clave de "${j.module}" generada. Se muestra una sola vez.`,
apiKey: j.apiKey,
});
setName("");
refreshCorreo();
} catch (e) {
setMsg({ ok: false, text: e instanceof Error ? e.message : "Error" });
} finally {
setBusy(null);
}
};
const revoke = async (module: string) => {
if (!window.confirm(`¿Revocar la clave de "${module}"? Dejará de poder usar POST /api/email/send.`)) return;
setBusy(`revoke:${module}`);
setMsg(null);
try {
const res = await fetch(`/api/correo/modulos/${encodeURIComponent(module)}`, {
method: "DELETE",
cache: "no-store",
});
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.message || j.error || `HTTP ${res.status}`);
setMsg({ ok: true, text: `Clave de "${module}" revocada.` });
refreshCorreo();
refreshModules();
} catch (e) {
setMsg({ ok: false, text: e instanceof Error ? e.message : "Error" });
} finally {
setBusy(null);
}
};
return (
<SamShell
brand="IT"
subtitle="Correo · Módulos"
menu={itMenu(modules)}
activeHref="/correo"
topUser={me?.email || "SAM"}
allowLogout
backHref="/"
>
<div className="alert alert-light section-header mb-3 py-2 px-3">
<i className="bx bx-envelope me-1" /> Correo · Módulos autorizados
</div>
{error && <div className="alert alert-danger">{error}</div>}
{correo.error && <div className="alert alert-danger">{correo.error}</div>}
{msg && (
<div className={`alert ${msg.ok ? "alert-success" : "alert-danger"}`}>
{msg.text}
{msg.apiKey && (
<div className="mt-2">
<code className="user-select-all">{msg.apiKey}</code>
<div className="small mt-1">
Guárdala en el <code>.env</code> del módulo como <code>MAIL_API_KEY</code>.
Registrar de nuevo rota la anterior.
</div>
</div>
)}
</div>
)}
<div className="app-card p-3">
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Módulo</th>
<th>Estado</th>
<th className="text-end">Acciones</th>
</tr>
</thead>
<tbody>
{rows.map((m) => (
<tr key={m.module}>
<td>
<i className="bx bx-cube me-2" />
<code className="small">{m.module}</code>
</td>
<td>
{m.isActive ? (
<span className="badge bg-success">Activo</span>
) : (
<span className="badge bg-secondary">Revocado</span>
)}
</td>
<td className="text-end">
<button
className="btn btn-sm btn-outline-danger"
disabled={busy !== null}
onClick={() => revoke(m.module)}
title="Revocar clave"
>
{busy === `revoke:${m.module}` ? (
<span className="spinner-border spinner-border-sm" />
) : (
<i className="bx bx-trash" />
)}
</button>
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={3} className="text-center text-muted py-4">
Sin módulos registrados.
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="d-flex gap-2 mt-3">
<input
type="text"
className="form-control"
style={{ maxWidth: 320 }}
placeholder="nombre-modulo (ej: webpay)"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<button
className="btn btn-primary"
disabled={busy !== null}
onClick={register}
>
{busy?.startsWith("register:") ? (
<span className="spinner-border spinner-border-sm" />
) : (
<>
<i className="bx bx-key me-1" />
Registrar / rotar clave
</>
)}
</button>
</div>
<small className="text-muted d-block mt-2">
La clave plana se muestra una sola vez (<code>POST /api/email/modules</code>).
</small>
</div>
</SamShell>
);
}
+11 -6
View File
@@ -1,7 +1,14 @@
import type { Metadata } from "next";
import { Fira_Sans } from "next/font/google";
import "bootstrap/dist/css/bootstrap.min.css";
import "./globals.css";
const firaSans = Fira_Sans({
variable: "--font-fira-sans",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
export const metadata: Metadata = {
title: "SAM | Sistema de Administración de Matrículas",
description: "SAM - Índice de módulos integrados y tableros por módulo",
@@ -16,18 +23,16 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="es">
<html lang="es" className={firaSans.variable}>
<head>
<link
href="https://unpkg.com/boxicons@2.1.4/css/boxicons.min.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css?family=Fira+Sans:400,500,600,700&display=swap"
rel="stylesheet"
/>
</head>
<body>{children}</body>
<body style={{ fontFamily: "var(--font-fira-sans), sans-serif" }}>
{children}
</body>
</html>
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, Suspense } from "react";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
function LoginForm() {
@@ -36,7 +37,7 @@ function LoginForm() {
<div className="login-page">
<div className="sidenav">
<div className="login-main-text text-light text-center">
<img src="/favicon.png" alt="SAM" />
<Image src="/favicon.png" alt="SAM" width={80} height={80} />
<h1>SAM</h1>
<h3>Welcome | Bienvenido/a</h3>
<h3>Portal IT</h3>
+2 -1
View File
@@ -1,5 +1,6 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useModules, refreshModules } from "@/lib/store";
import {
@@ -121,7 +122,7 @@ export default function Home() {
className="mx-auto d-flex align-items-center justify-content-center bg-white rounded-3 overflow-hidden"
style={{ width: 80, height: 80 }}
>
<img src="/logo-sam.png" alt="SAM" style={{ width: 64, height: 64, objectFit: "contain" }} />
<Image src="/logo-sam.png" alt="SAM" width={64} height={64} style={{ objectFit: "contain" }} />
</div>
</div>
<div className="col-12 text-center mt-3">
+5 -2
View File
@@ -1,6 +1,8 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import type { MenuItem } from "@/lib/sam";
@@ -31,12 +33,13 @@ export default function SamShell({
children,
}: SamShellProps) {
const [open, setOpen] = useState(true);
const router = useRouter();
const logout = async () => {
try {
await fetch("/api/auth", { method: "DELETE" });
} finally {
window.location.href = "/login";
router.push("/login");
}
};
@@ -45,7 +48,7 @@ export default function SamShell({
<div className="sam-sidebar">
<div className="logo-details">
<span className="logo_name">
<img src="/favicon.png" alt="SAM" width={28} height={28} />
<Image src="/favicon.png" alt="SAM" width={28} height={28} />
<span className="brand-text">SAM</span>
</span>
<i
-1
View File
@@ -1,7 +1,6 @@
"use client";
import Link from "next/link";
import SamShell from "@/components/SamShell";
import { useModules, useStats } from "@/lib/store";
import {
displayName,
+110
View File
@@ -0,0 +1,110 @@
// ============================================================
// Acceso server-side al banco de correo (SAM.API /api/email/*).
// Auth en dos capas: sesión IT (cookie, solo personal autorizado)
// + cuenta de servicio helpdesk (env, debe ser admin). Nada de
// esto sale al navegador. Solo importado por route handlers.
// Env: SAM_API_URL (default prod), HELPDESK_SERVICE_USER,
// HELPDESK_SERVICE_PASS (usuario sige real con rol admin).
// ============================================================
import { COOKIE, readSession } from "./auth";
const SAM_API_URL =
process.env.SAM_API_URL ??
process.env.NEXT_PUBLIC_API_URL ??
"https://api.devops.norteamericano.cl";
let cached: { token: string; exp: number } | null = null;
async function serviceLogin(): Promise<string> {
const now = Math.floor(Date.now() / 1000);
if (cached && cached.exp - 60 > now) return cached.token;
const idUsuario = process.env.HELPDESK_SERVICE_USER ?? "";
const password = process.env.HELPDESK_SERVICE_PASS ?? "";
if (!idUsuario || !password) {
throw Object.assign(
new Error("HELPDESK_SERVICE_USER/PASS no configurados en el servidor"),
{ status: 503 }
);
}
const res = await fetch(`${SAM_API_URL}/api/helpdesk/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idUsuario, password }),
cache: "no-store",
});
const data = (await res.json().catch(() => ({}))) as {
success?: boolean;
message?: string;
token?: string;
};
if (!res.ok || !data.success || !data.token) {
throw Object.assign(
new Error(data.message || "No se pudo autenticar la cuenta de servicio"),
{ status: 502 }
);
}
cached = { token: data.token, exp: now + 11 * 3600 };
return cached.token;
}
export async function assertIT(req: Request): Promise<string> {
const cookie = req.headers.get("cookie") ?? "";
const m = cookie.match(new RegExp(`(?:^|;\\s*)${COOKIE}=([^;]*)`));
const sess = await readSession(m ? decodeURIComponent(m[1]) : undefined);
if (!sess) {
throw Object.assign(new Error("Requiere sesión IT"), { status: 401 });
}
return sess.email;
}
export async function helpdeskFetch(
path: string,
init?: RequestInit
): Promise<Response> {
const doFetch = (token: string) =>
fetch(`${SAM_API_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers || {}),
Authorization: `Bearer ${token}`,
},
cache: "no-store",
});
let res = await doFetch(await serviceLogin());
if (res.status === 401 || res.status === 403) {
cached = null;
res = await doFetch(await serviceLogin());
}
return res;
}
export function asResponse(res: Response): Promise<Response> {
return res
.text()
.then((text) => {
try {
return Response.json(JSON.parse(text), { status: res.status });
} catch {
return new Response(text, { status: res.status });
}
})
.catch((e: unknown) =>
Response.json(
{ error: e instanceof Error ? e.message : "Error" },
{ status: 502 }
)
);
}
export function errResponse(e: unknown): Response {
const status =
typeof e === "object" && e !== null && "status" in e
? Number((e as { status: unknown }).status) || 500
: 500;
return Response.json(
{ error: e instanceof Error ? e.message : "Error" },
{ status }
);
}
+72
View File
@@ -0,0 +1,72 @@
// ============================================================
// Claves de correo por módulo (banco mail.*): tipos + hook de
// lectura con auto-refresh, sin setState en efectos
// (misma regla react-hooks que lib/store.ts).
// Las claves se administran vía /api/correo/modulos (server-side).
// ============================================================
import { useSyncExternalStore } from "react";
export interface CorreoModule {
module: string;
isActive: boolean;
}
interface CorreoSnap {
data: CorreoModule[] | null;
error: string | null;
}
async function fetchCorreo(): Promise<CorreoSnap> {
try {
const res = await fetch("/api/correo/modulos", { cache: "no-store" });
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.message || j.error || `HTTP ${res.status}`);
return { data: Array.isArray(j) ? j : [], error: null };
} catch (e) {
return {
data: null,
error: e instanceof Error ? e.message : "Error al cargar módulos",
};
}
}
let snap: CorreoSnap = { data: null, error: null };
let started = false;
const listeners = new Set<() => void>();
function notify() {
listeners.forEach((l) => l());
}
export function refreshCorreo() {
fetchCorreo()
.then((next) => {
snap = next;
})
.catch(() => {})
.finally(() => notify());
}
function subscribe(cb: () => void) {
if (!started) {
started = true;
refreshCorreo();
}
listeners.add(cb);
return () => {
listeners.delete(cb);
if (listeners.size === 0) started = false;
};
}
function snapshot(): CorreoSnap {
return snap;
}
export function useCorreoModulos(): CorreoSnap {
return useSyncExternalStore(subscribe, snapshot, () => ({
data: null,
error: null,
}));
}
+1
View File
@@ -305,5 +305,6 @@ export function itMenu(modules: ModuleState[]): MenuItem[] {
{ label: "Agregar", icon: "bx-plus", href: "/upload" },
{ label: "Actualizar", icon: "bx-refresh", href: "/actualizar" },
{ label: "Eliminar", icon: "bx-trash", href: "/eliminar" },
{ label: "Correo", icon: "bx-envelope", href: "/correo" },
];
}
+3 -1
View File
@@ -6,7 +6,7 @@ const IT_BOARD = "https://it.sam.devops.norteamericano.cl/tablero";
// Rutas IT (requieren sesión): tablero, gestión, tableros y APIs de acción.
// El índice (/) y el login quedan públicos.
const PROTECTED = ["/tablero", "/upload", "/actualizar", "/eliminar", "/m"];
const PROTECTED = ["/tablero", "/upload", "/actualizar", "/eliminar", "/correo", "/m"];
function isProtected(path: string): boolean {
return PROTECTED.some((p) => path === p || path.startsWith(p + "/"));
@@ -51,6 +51,8 @@ export const config = {
"/actualizar/:path*",
"/eliminar",
"/eliminar/:path*",
"/correo",
"/correo/:path*",
"/m",
"/m/:path*",
],