From f9e0887f105246502dbcea9e55d92ac91e866c7e Mon Sep 17 00:00:00 2001 From: Nurfog Date: Wed, 16 Sep 2026 11:31:28 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20p=C3=A1gina=20Correo=20con=20claves=20d?= =?UTF-8?q?e=20m=C3=B3dulos=20+=20lint=20en=20cero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 + src/app/api/correo/modulos/[modulo]/route.ts | 26 +++ src/app/api/correo/modulos/route.ts | 46 +++++ src/app/correo/page.tsx | 187 +++++++++++++++++++ src/app/layout.tsx | 17 +- src/app/login/page.tsx | 3 +- src/app/page.tsx | 3 +- src/components/SamShell.tsx | 7 +- src/components/TableroView.tsx | 1 - src/lib/correo-server.ts | 110 +++++++++++ src/lib/correo.ts | 72 +++++++ src/lib/sam.ts | 1 + src/middleware.ts | 4 +- 13 files changed, 471 insertions(+), 12 deletions(-) create mode 100644 src/app/api/correo/modulos/[modulo]/route.ts create mode 100644 src/app/api/correo/modulos/route.ts create mode 100644 src/app/correo/page.tsx create mode 100644 src/lib/correo-server.ts create mode 100644 src/lib/correo.ts diff --git a/README.md b/README.md index 04fdae2..c249dec 100644 --- a/README.md +++ b/README.md @@ -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= NEXT_PUBLIC_API_URL=https://api.devops.norteamericano.cl IT_USERS=cgaray@norteamericano.cl,fmena@norteamericano.cl,jallende@norteamericano.cl IT_SESSION_SECRET= +# Cuenta de servicio helpdesk (usuario sige real con rol admin) para +# administrar claves de correo desde /correo (solo servidor): +HELPDESK_SERVICE_USER= +HELPDESK_SERVICE_PASS= +# SAM_API_URL=https://api.devops.norteamericano.cl # default prod ``` ## Desarrollo diff --git a/src/app/api/correo/modulos/[modulo]/route.ts b/src/app/api/correo/modulos/[modulo]/route.ts new file mode 100644 index 0000000..18b78af --- /dev/null +++ b/src/app/api/correo/modulos/[modulo]/route.ts @@ -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 { + 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); + } +} diff --git a/src/app/api/correo/modulos/route.ts b/src/app/api/correo/modulos/route.ts new file mode 100644 index 0000000..ce4d8af --- /dev/null +++ b/src/app/api/correo/modulos/route.ts @@ -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 { + 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 { + 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); + } +} diff --git a/src/app/correo/page.tsx b/src/app/correo/page.tsx new file mode 100644 index 0000000..5dd1c31 --- /dev/null +++ b/src/app/correo/page.tsx @@ -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(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 ( + +
+ Correo · Módulos autorizados +
+ + {error &&
{error}
} + {correo.error &&
{correo.error}
} + {msg && ( +
+ {msg.text} + {msg.apiKey && ( +
+ {msg.apiKey} +
+ Guárdala en el .env del módulo como MAIL_API_KEY. + Registrar de nuevo rota la anterior. +
+
+ )} +
+ )} + +
+
+ + + + + + + + + + {rows.map((m) => ( + + + + + + ))} + {rows.length === 0 && ( + + + + )} + +
MóduloEstadoAcciones
+ + {m.module} + + {m.isActive ? ( + Activo + ) : ( + Revocado + )} + + +
+ Sin módulos registrados. +
+
+ +
+ setName(e.target.value)} + /> + +
+ + La clave plana se muestra una sola vez (POST /api/email/modules). + +
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 92fe3cc..b367016 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - + - - {children} + + {children} + ); } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 4e65b95..1055a0d 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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() {
- SAM + SAM

SAM

Welcome | Bienvenido/a

Portal IT

diff --git a/src/app/page.tsx b/src/app/page.tsx index 549355d..87b201d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 }} > - SAM + SAM
diff --git a/src/components/SamShell.tsx b/src/components/SamShell.tsx index d156020..9798aa3 100644 --- a/src/components/SamShell.tsx +++ b/src/components/SamShell.tsx @@ -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({
- SAM + SAM SAM { + 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 { + 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 { + 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 { + 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 } + ); +} diff --git a/src/lib/correo.ts b/src/lib/correo.ts new file mode 100644 index 0000000..7139e95 --- /dev/null +++ b/src/lib/correo.ts @@ -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 { + 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, + })); +} diff --git a/src/lib/sam.ts b/src/lib/sam.ts index b775dab..503b02f 100644 --- a/src/lib/sam.ts +++ b/src/lib/sam.ts @@ -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" }, ]; } diff --git a/src/middleware.ts b/src/middleware.ts index 3ea66d8..846d658 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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*", ],