NOTIFICATION_ADMIN_EMAILS made "add Beto to the summaries" a redeploy — the wrong unit of work for a list that changes when office staff change. Adds `app_settings`, a key/value table for the configuration staff must be able to change without a deploy, and `SettingsService`, which resolves every key db -> env -> default and reports which of the three a value came from. That ladder is what makes the move safe: a deployment behaves exactly as before until somebody saves in the UI, and the screen can say "this is still coming from the deployment" rather than implying somebody chose it. - new ability `setting:manage` (ADMIN) — deliberately above `notification:send`, since redirecting the audit summaries is how someone would quietly stop them being read - GET/PUT /notifications/settings/admin-emails; read is open to any logged-in user so the UI can display the list, write is gated - resolved per job, not cached at boot, or we would reintroduce exactly the restart-to-apply behaviour being removed - a saved empty list means "nobody" and does NOT fall through to the env, or clearing the field would keep mailing the people just removed Credentials stay in env — see the model doc for where the line is drawn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
197 lines
5.9 KiB
TypeScript
197 lines
5.9 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useCan } from "@/lib/abilities";
|
|
import {
|
|
getNotificationAdminEmails,
|
|
setNotificationAdminEmails,
|
|
type NotificationAdminEmails,
|
|
} from "@/lib/api";
|
|
import { formatDateTime } from "@/lib/labels";
|
|
|
|
/**
|
|
* Who receives the per-job summary email.
|
|
*
|
|
* This used to be NOTIFICATION_ADMIN_EMAILS in the deployment environment,
|
|
* which made "add Beto to the summaries" a redeploy. It is now a stored
|
|
* setting; the env var still acts as the fallback until someone saves here,
|
|
* so nothing changes for a deployment that never touches this screen.
|
|
*/
|
|
|
|
const SOURCE_NOTE: Record<NotificationAdminEmails["source"], string> = {
|
|
db: "Guardado desde esta pantalla.",
|
|
env: "Viene de la configuración del despliegue (NOTIFICATION_ADMIN_EMAILS). Al guardar aquí, este valor toma precedencia.",
|
|
default: "Nadie lo ha configurado; se están usando los valores por omisión.",
|
|
};
|
|
|
|
export function AdminEmailsSetting() {
|
|
const canEdit = useCan("setting:manage");
|
|
|
|
const [setting, setSetting] = useState<NotificationAdminEmails | null>(null);
|
|
const [draft, setDraft] = useState("");
|
|
const [editing, setEditing] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [saved, setSaved] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const data = await getNotificationAdminEmails();
|
|
setSetting(data);
|
|
setDraft(data.value.join(", "));
|
|
setError(null);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
async function save() {
|
|
setSaving(true);
|
|
setError(null);
|
|
setSaved(false);
|
|
try {
|
|
const emails = draft
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
const data = await setNotificationAdminEmails(emails);
|
|
setSetting(data);
|
|
setDraft(data.value.join(", "));
|
|
setEditing(false);
|
|
setSaved(true);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
function cancel() {
|
|
setDraft(setting?.value.join(", ") ?? "");
|
|
setEditing(false);
|
|
setError(null);
|
|
}
|
|
|
|
if (!setting) {
|
|
return (
|
|
<section className="card" style={{ padding: 20 }}>
|
|
<h2 className="section-title">Destinatarios del resumen</h2>
|
|
{error ? (
|
|
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
|
{error}
|
|
</div>
|
|
) : (
|
|
<p className="muted small" style={{ marginTop: 8, marginBottom: 0 }}>
|
|
Cargando…
|
|
</p>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="card" style={{ padding: 20 }}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "flex-start",
|
|
gap: 12,
|
|
flexWrap: "wrap",
|
|
}}
|
|
>
|
|
<div>
|
|
<h2 className="section-title">Destinatarios del resumen</h2>
|
|
<p className="muted small" style={{ marginTop: 4, marginBottom: 0, maxWidth: 620 }}>
|
|
Después de cada envío se manda un correo interno con el resultado
|
|
(enviados, omitidos, fallidos). Estas son las direcciones que lo
|
|
reciben. No afecta a los correos que reciben los clientes.
|
|
</p>
|
|
</div>
|
|
{canEdit && !editing && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline btn-sm"
|
|
onClick={() => setEditing(true)}
|
|
>
|
|
Editar
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{editing ? (
|
|
<div style={{ marginTop: 14 }}>
|
|
<label className="field" style={{ marginBottom: 8 }}>
|
|
<span className="field-label">
|
|
Correos separados por coma (vacío = no enviar resumen a nadie)
|
|
</span>
|
|
<input
|
|
className="input"
|
|
type="text"
|
|
value={draft}
|
|
disabled={saving}
|
|
placeholder="alguien@ejemplo.com, otro@ejemplo.com"
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
/>
|
|
</label>
|
|
<div className="row-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sm"
|
|
disabled={saving}
|
|
onClick={() => void save()}
|
|
>
|
|
{saving ? "Guardando…" : "Guardar"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline btn-sm"
|
|
disabled={saving}
|
|
onClick={cancel}
|
|
>
|
|
Cancelar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div style={{ marginTop: 14 }}>
|
|
{setting.value.length === 0 ? (
|
|
<span className="empty-inline">
|
|
Nadie recibe el resumen de los envíos.
|
|
</span>
|
|
) : (
|
|
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
|
|
{setting.value.map((email) => (
|
|
<li key={email} className="mono">
|
|
{email}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
<p className="section-note" style={{ marginTop: 10, marginBottom: 0 }}>
|
|
{SOURCE_NOTE[setting.source]}
|
|
{setting.updatedAt &&
|
|
` Última edición: ${formatDateTime(setting.updatedAt)}.`}
|
|
{saved && " Guardado."}
|
|
</p>
|
|
{!canEdit && (
|
|
<p className="section-note" style={{ marginTop: 6, marginBottom: 0 }}>
|
|
Solo un ADMIN puede cambiar esta lista.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|