feat: página Correo con claves de módulos + lint en cero
This commit is contained in:
1 parent
1bf1e12273
commit
f9e0887f10
13 files changed
+471
-12
No files matched your search
@@ -16,6 +16,7 @@
|
|||||||
| `/upload` | sí | Integrar módulo (categoría, tipo, visibilidad) |
|
| `/upload` | sí | Integrar módulo (categoría, tipo, visibilidad) |
|
||||||
| `/actualizar` | sí | Redeploy + webhook + toggle visibilidad |
|
| `/actualizar` | sí | Redeploy + webhook + toggle visibilidad |
|
||||||
| `/eliminar` | sí | Baja de módulos (con/sin código) |
|
| `/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/modules`, `/api/stats` | no/sí | Proxy al deployer (token server-side) |
|
||||||
| `/api/deployer/*` | sí | Proxy genérico al deployer |
|
| `/api/deployer/*` | sí | Proxy genérico al deployer |
|
||||||
| `/api/auth`, `/api/me` | — | Sesión HMAC 12h en cookie httpOnly |
|
| `/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
|
NEXT_PUBLIC_API_URL=https://api.devops.norteamericano.cl
|
||||||
IT_USERS=cgaray@norteamericano.cl,fmena@norteamericano.cl,jallende@norteamericano.cl
|
IT_USERS=cgaray@norteamericano.cl,fmena@norteamericano.cl,jallende@norteamericano.cl
|
||||||
IT_SESSION_SECRET=<openssl rand -hex 24>
|
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
|
## 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,7 +1,14 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Fira_Sans } from "next/font/google";
|
||||||
import "bootstrap/dist/css/bootstrap.min.css";
|
import "bootstrap/dist/css/bootstrap.min.css";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
|
const firaSans = Fira_Sans({
|
||||||
|
variable: "--font-fira-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600", "700"],
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "SAM | Sistema de Administración de Matrículas",
|
title: "SAM | Sistema de Administración de Matrículas",
|
||||||
description: "SAM - Índice de módulos integrados y tableros por módulo",
|
description: "SAM - Índice de módulos integrados y tableros por módulo",
|
||||||
@@ -16,18 +23,16 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="es">
|
<html lang="es" className={firaSans.variable}>
|
||||||
<head>
|
<head>
|
||||||
<link
|
<link
|
||||||
href="https://unpkg.com/boxicons@2.1.4/css/boxicons.min.css"
|
href="https://unpkg.com/boxicons@2.1.4/css/boxicons.min.css"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css?family=Fira+Sans:400,500,600,700&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body>{children}</body>
|
<body style={{ fontFamily: "var(--font-fira-sans), sans-serif" }}>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, Suspense } from "react";
|
import { useState, Suspense } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
function LoginForm() {
|
function LoginForm() {
|
||||||
@@ -36,7 +37,7 @@ function LoginForm() {
|
|||||||
<div className="login-page">
|
<div className="login-page">
|
||||||
<div className="sidenav">
|
<div className="sidenav">
|
||||||
<div className="login-main-text text-light text-center">
|
<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>
|
<h1>SAM</h1>
|
||||||
<h3>Welcome | Bienvenido/a</h3>
|
<h3>Welcome | Bienvenido/a</h3>
|
||||||
<h3>Portal IT</h3>
|
<h3>Portal IT</h3>
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useModules, refreshModules } from "@/lib/store";
|
import { useModules, refreshModules } from "@/lib/store";
|
||||||
import {
|
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"
|
className="mx-auto d-flex align-items-center justify-content-center bg-white rounded-3 overflow-hidden"
|
||||||
style={{ width: 80, height: 80 }}
|
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>
|
</div>
|
||||||
<div className="col-12 text-center mt-3">
|
<div className="col-12 text-center mt-3">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { MenuItem } from "@/lib/sam";
|
import type { MenuItem } from "@/lib/sam";
|
||||||
|
|
||||||
@@ -31,12 +33,13 @@ export default function SamShell({
|
|||||||
children,
|
children,
|
||||||
}: SamShellProps) {
|
}: SamShellProps) {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
await fetch("/api/auth", { method: "DELETE" });
|
await fetch("/api/auth", { method: "DELETE" });
|
||||||
} finally {
|
} finally {
|
||||||
window.location.href = "/login";
|
router.push("/login");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,7 +48,7 @@ export default function SamShell({
|
|||||||
<div className="sam-sidebar">
|
<div className="sam-sidebar">
|
||||||
<div className="logo-details">
|
<div className="logo-details">
|
||||||
<span className="logo_name">
|
<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 className="brand-text">SAM</span>
|
||||||
</span>
|
</span>
|
||||||
<i
|
<i
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import SamShell from "@/components/SamShell";
|
|
||||||
import { useModules, useStats } from "@/lib/store";
|
import { useModules, useStats } from "@/lib/store";
|
||||||
import {
|
import {
|
||||||
displayName,
|
displayName,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -305,5 +305,6 @@ export function itMenu(modules: ModuleState[]): MenuItem[] {
|
|||||||
{ label: "Agregar", icon: "bx-plus", href: "/upload" },
|
{ label: "Agregar", icon: "bx-plus", href: "/upload" },
|
||||||
{ label: "Actualizar", icon: "bx-refresh", href: "/actualizar" },
|
{ label: "Actualizar", icon: "bx-refresh", href: "/actualizar" },
|
||||||
{ label: "Eliminar", icon: "bx-trash", href: "/eliminar" },
|
{ label: "Eliminar", icon: "bx-trash", href: "/eliminar" },
|
||||||
|
{ label: "Correo", icon: "bx-envelope", href: "/correo" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
+3
-1
@@ -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.
|
// Rutas IT (requieren sesión): tablero, gestión, tableros y APIs de acción.
|
||||||
// El índice (/) y el login quedan públicos.
|
// 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 {
|
function isProtected(path: string): boolean {
|
||||||
return PROTECTED.some((p) => path === p || path.startsWith(p + "/"));
|
return PROTECTED.some((p) => path === p || path.startsWith(p + "/"));
|
||||||
@@ -51,6 +51,8 @@ export const config = {
|
|||||||
"/actualizar/:path*",
|
"/actualizar/:path*",
|
||||||
"/eliminar",
|
"/eliminar",
|
||||||
"/eliminar/:path*",
|
"/eliminar/:path*",
|
||||||
|
"/correo",
|
||||||
|
"/correo/:path*",
|
||||||
"/m",
|
"/m",
|
||||||
"/m/:path*",
|
"/m/:path*",
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in new issue
Block a user