feat: add email settings management

- Introduced EmailSettings component for managing SMTP services.
- Added API endpoints for organization email services including CRUD operations.
- Created database migrations for organization_email_settings and organization_email_services tables.
- Updated the settings page to include EmailSettings component.
- Implemented validation and error handling for email service operations.
This commit is contained in:
2026-04-15 09:33:50 -04:00
parent 44facf7f4a
commit e1d5975e57
12 changed files with 1877 additions and 18 deletions
+2
View File
@@ -1,6 +1,7 @@
"use client";
import BrandingSettings from "@/components/BrandingSettings";
import EmailSettings from "@/components/EmailSettings";
import ExerciseFeatureSettings from "@/components/ExerciseFeatureSettings";
import PageLayout from "@/components/PageLayout";
import { useAuth } from "@/context/AuthContext";
@@ -28,6 +29,7 @@ export default function SettingsPage() {
>
<div className="space-y-8">
<BrandingSettings />
<EmailSettings />
<ExerciseFeatureSettings />
</div>
</PageLayout>
+374
View File
@@ -0,0 +1,374 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
cmsApi,
OrganizationEmailService,
UpsertOrganizationEmailServicePayload,
} from "@/lib/api";
type EditableService = {
id?: string;
display_name: string;
provider_key: string;
smtp_enabled: boolean;
is_default: boolean;
smtp_host: string;
smtp_port: number;
smtp_from: string;
smtp_username: string;
smtp_starttls: boolean;
has_password: boolean;
};
function toEditable(service: OrganizationEmailService): EditableService {
return {
id: service.id,
display_name: service.display_name,
provider_key: service.provider_key,
smtp_enabled: service.smtp_enabled,
is_default: service.is_default,
smtp_host: service.smtp_host || "",
smtp_port: service.smtp_port || 587,
smtp_from: service.smtp_from || "",
smtp_username: service.smtp_username || "",
smtp_starttls: service.smtp_starttls,
has_password: service.has_password,
};
}
function newServiceTemplate(): EditableService {
return {
display_name: "Nuevo servicio SMTP",
provider_key: "custom",
smtp_enabled: true,
is_default: false,
smtp_host: "",
smtp_port: 587,
smtp_from: "",
smtp_username: "",
smtp_starttls: true,
has_password: false,
};
}
export default function EmailSettings() {
const [services, setServices] = useState<OrganizationEmailService[]>([]);
const [selectedId, setSelectedId] = useState<string>("");
const [form, setForm] = useState<EditableService>(newServiceTemplate());
const [smtpPassword, setSmtpPassword] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const selectedService = useMemo(
() => services.find((svc) => svc.id === selectedId),
[services, selectedId],
);
const loadServices = async () => {
const data = await cmsApi.listOrganizationEmailServices();
setServices(data);
const defaultService = data.find((svc) => svc.is_default) || data[0];
if (defaultService) {
setSelectedId(defaultService.id);
setForm(toEditable(defaultService));
} else {
setSelectedId("");
setForm(newServiceTemplate());
}
};
useEffect(() => {
const run = async () => {
try {
await loadServices();
} catch (error) {
console.error("Failed to load email services:", error);
} finally {
setLoading(false);
}
};
run();
}, []);
const setField = <K extends keyof EditableService>(key: K, value: EditableService[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleSelect = async (id: string) => {
try {
await cmsApi.selectOrganizationEmailService(id);
await loadServices();
alert("Servicio por defecto actualizado.");
} catch (error) {
console.error("Failed to select email service:", error);
alert("No se pudo seleccionar el servicio por defecto.");
}
};
const handleDelete = async (id: string) => {
if (!confirm("¿Eliminar este servicio SMTP?")) return;
try {
await cmsApi.deleteOrganizationEmailService(id);
await loadServices();
setSmtpPassword("");
alert("Servicio eliminado.");
} catch (error) {
console.error("Failed to delete email service:", error);
alert("No se pudo eliminar el servicio.");
}
};
const handleNew = () => {
setSelectedId("");
setForm(newServiceTemplate());
setSmtpPassword("");
};
const toPayload = (): UpsertOrganizationEmailServicePayload => ({
service_type: "smtp",
provider_key: (form.provider_key || "custom").trim().toLowerCase(),
display_name: form.display_name.trim() || "Servicio SMTP",
smtp_enabled: form.smtp_enabled,
is_default: form.is_default,
smtp_host: form.smtp_host.trim() || undefined,
smtp_port: Number(form.smtp_port) || 587,
smtp_from: form.smtp_from.trim() || undefined,
smtp_username: form.smtp_username.trim() || undefined,
smtp_starttls: form.smtp_starttls,
...(smtpPassword.trim() ? { smtp_password: smtpPassword.trim() } : {}),
});
const handleSave = async () => {
setSaving(true);
try {
const payload = toPayload();
if (form.id) {
await cmsApi.updateOrganizationEmailService(form.id, payload);
} else {
await cmsApi.createOrganizationEmailService(payload);
}
await loadServices();
setSmtpPassword("");
alert("Servicio SMTP guardado correctamente.");
} catch (error) {
console.error("Failed to save email service:", error);
alert("No se pudo guardar el servicio SMTP.");
} finally {
setSaving(false);
}
};
if (loading) {
return <div className="p-8 text-center text-gray-400 animate-pulse">Cargando servicios de correo...</div>;
}
return (
<fieldset className="border border-slate-200 dark:border-white/10 rounded-2xl p-6 bg-white dark:bg-white/5 backdrop-blur-sm shadow-sm">
<legend className="px-2 text-xl font-bold flex items-center gap-2 text-slate-900 dark:text-white">
<span aria-hidden="true"></span> Servicios de Correo por Empresa
</legend>
<p className="text-sm text-slate-600 dark:text-gray-400 mt-4">
Cada empresa puede registrar varios servicios de correo (SMTP) y seleccionar cuál usar como predeterminado.
Si no hay registros, se crea uno automáticamente con valores de entorno.
</p>
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
{services.map((svc) => (
<div
key={svc.id}
className={`rounded-xl border p-4 ${svc.is_default ? "border-emerald-300 bg-emerald-50 dark:border-emerald-500/30 dark:bg-emerald-500/10" : "border-slate-200 bg-slate-50 dark:border-white/10 dark:bg-black/20"}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-bold text-slate-900 dark:text-white">{svc.display_name}</h3>
<p className="text-xs text-slate-500 dark:text-gray-400 mt-1">
provider: {svc.provider_key} · {svc.smtp_enabled ? "Activo" : "Inactivo"}
</p>
</div>
{svc.is_default && (
<span className="text-[10px] font-bold uppercase tracking-wider rounded-full px-2 py-1 bg-emerald-600 text-white">
Predeterminado
</span>
)}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
className="px-3 py-1.5 text-xs rounded-lg bg-blue-600 text-white hover:bg-blue-700"
onClick={() => {
setSelectedId(svc.id);
setForm(toEditable(svc));
setSmtpPassword("");
}}
>
Editar
</button>
{!svc.is_default && (
<button
type="button"
className="px-3 py-1.5 text-xs rounded-lg bg-emerald-600 text-white hover:bg-emerald-700"
onClick={() => handleSelect(svc.id)}
>
Usar este
</button>
)}
{!svc.is_default && services.length > 1 && (
<button
type="button"
className="px-3 py-1.5 text-xs rounded-lg bg-rose-600 text-white hover:bg-rose-700"
onClick={() => handleDelete(svc.id)}
>
Eliminar
</button>
)}
</div>
</div>
))}
</div>
<div className="mt-5 flex justify-end">
<button
type="button"
onClick={handleNew}
className="px-4 py-2 rounded-lg bg-slate-800 text-white hover:bg-slate-900 dark:bg-slate-700 dark:hover:bg-slate-600"
>
Nuevo servicio
</button>
</div>
<div className="mt-6 rounded-xl border border-slate-200 dark:border-white/10 p-4 bg-slate-50 dark:bg-black/20">
<h4 className="text-sm font-bold text-slate-900 dark:text-white">
{form.id ? "Editar servicio" : "Crear servicio"}
</h4>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="block md:col-span-2">
<span className="block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-gray-400 mb-1">Nombre del servicio</span>
<input
type="text"
value={form.display_name}
onChange={(e) => setField("display_name", e.target.value)}
className="w-full rounded-lg border border-slate-300 dark:border-white/20 bg-white dark:bg-slate-900/40 px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-gray-400 mb-1">Provider</span>
<input
type="text"
value={form.provider_key}
onChange={(e) => setField("provider_key", e.target.value)}
placeholder="gmail, sendgrid, ses, mailpit..."
className="w-full rounded-lg border border-slate-300 dark:border-white/20 bg-white dark:bg-slate-900/40 px-3 py-2 text-sm"
/>
</label>
<label className="flex items-center gap-3 mt-6">
<input
type="checkbox"
checked={form.smtp_enabled}
onChange={(e) => setField("smtp_enabled", e.target.checked)}
/>
<span className="text-sm text-slate-800 dark:text-gray-200">Habilitado</span>
</label>
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-gray-400 mb-1">Servidor SMTP</span>
<input
type="text"
value={form.smtp_host}
onChange={(e) => setField("smtp_host", e.target.value)}
placeholder="smtp.gmail.com"
className="w-full rounded-lg border border-slate-300 dark:border-white/20 bg-white dark:bg-slate-900/40 px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-gray-400 mb-1">Puerto</span>
<input
type="number"
min={1}
max={65535}
value={form.smtp_port}
onChange={(e) => setField("smtp_port", Number(e.target.value))}
className="w-full rounded-lg border border-slate-300 dark:border-white/20 bg-white dark:bg-slate-900/40 px-3 py-2 text-sm"
/>
</label>
<label className="block md:col-span-2">
<span className="block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-gray-400 mb-1">Remitente</span>
<input
type="text"
value={form.smtp_from}
onChange={(e) => setField("smtp_from", e.target.value)}
placeholder="OpenCCB <no-reply@dominio.com>"
className="w-full rounded-lg border border-slate-300 dark:border-white/20 bg-white dark:bg-slate-900/40 px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-gray-400 mb-1">Usuario SMTP</span>
<input
type="text"
value={form.smtp_username}
onChange={(e) => setField("smtp_username", e.target.value)}
className="w-full rounded-lg border border-slate-300 dark:border-white/20 bg-white dark:bg-slate-900/40 px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-gray-400 mb-1">
Contraseña SMTP {form.has_password ? "(guardada)" : ""}
</span>
<input
type="password"
value={smtpPassword}
onChange={(e) => setSmtpPassword(e.target.value)}
placeholder={form.has_password ? "•••••••• (dejar vacío para mantener)" : "Nueva contraseña"}
className="w-full rounded-lg border border-slate-300 dark:border-white/20 bg-white dark:bg-slate-900/40 px-3 py-2 text-sm"
/>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={form.smtp_starttls}
onChange={(e) => setField("smtp_starttls", e.target.checked)}
/>
<span className="text-sm text-slate-800 dark:text-gray-200">Usar STARTTLS</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={form.is_default}
onChange={(e) => setField("is_default", e.target.checked)}
/>
<span className="text-sm text-slate-800 dark:text-gray-200">Marcar como predeterminado</span>
</label>
</div>
<div className="mt-6 flex justify-end">
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-3 rounded-xl bg-blue-600 text-white font-bold hover:bg-blue-700 transition-all disabled:opacity-50"
>
{saving ? "Guardando..." : form.id ? "Guardar cambios" : "Crear servicio"}
</button>
</div>
</div>
{selectedService && (
<p className="mt-4 text-xs text-slate-500 dark:text-gray-400">
Editando: <strong>{selectedService.display_name}</strong>
</p>
)}
</fieldset>
);
}
+42
View File
@@ -302,6 +302,36 @@ export interface OrganizationSSOConfig {
updated_at: string;
}
export interface OrganizationEmailService {
id: string;
organization_id: string;
service_type: string;
provider_key: string;
display_name: string;
smtp_enabled: boolean;
is_default: boolean;
smtp_host?: string;
smtp_port: number;
smtp_from?: string;
smtp_username?: string;
smtp_starttls: boolean;
has_password: boolean;
}
export interface UpsertOrganizationEmailServicePayload {
service_type: string;
provider_key: string;
display_name: string;
smtp_enabled: boolean;
is_default: boolean;
smtp_host?: string;
smtp_port: number;
smtp_from?: string;
smtp_username?: string;
smtp_password?: string;
smtp_starttls: boolean;
}
export interface ProvisionPayload {
org_name: string;
org_domain?: string;
@@ -881,6 +911,18 @@ export const cmsApi = {
},
getSSOConfig: (): Promise<OrganizationSSOConfig> => apiFetch('/organization/sso'),
updateSSOConfig: (payload: Partial<OrganizationSSOConfig>): Promise<void> => apiFetch('/organization/sso', { method: 'PUT', body: JSON.stringify(payload) }),
getOrganizationEmailSettings: (): Promise<OrganizationEmailService> => apiFetch('/organization/email-settings'),
updateOrganizationEmailSettings: (payload: UpsertOrganizationEmailServicePayload): Promise<OrganizationEmailService> =>
apiFetch('/organization/email-settings', { method: 'PUT', body: JSON.stringify(payload) }),
listOrganizationEmailServices: (): Promise<OrganizationEmailService[]> => apiFetch('/organization/email-services'),
createOrganizationEmailService: (payload: UpsertOrganizationEmailServicePayload): Promise<OrganizationEmailService> =>
apiFetch('/organization/email-services', { method: 'POST', body: JSON.stringify(payload) }),
updateOrganizationEmailService: (id: string, payload: UpsertOrganizationEmailServicePayload): Promise<OrganizationEmailService> =>
apiFetch(`/organization/email-services/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
deleteOrganizationEmailService: (id: string): Promise<void> =>
apiFetch(`/organization/email-services/${id}`, { method: 'DELETE' }),
selectOrganizationEmailService: (id: string): Promise<void> =>
apiFetch(`/organization/email-services/${id}/select`, { method: 'POST' }),
getOrganizationExerciseSettings: (): Promise<OrganizationExerciseSettings> => apiFetch('/organization/exercise-settings'),
updateOrganizationExerciseSettings: (payload: Omit<OrganizationExerciseSettings, 'organization_id'>): Promise<OrganizationExerciseSettings> =>
apiFetch('/organization/exercise-settings', { method: 'PUT', body: JSON.stringify(payload) }),