Add email notification settings management
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 6s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 6s
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 6s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 6s
Implemented backend and frontend support for managing SMTP settings for email notifications. Includes API endpoints, database migration, and UI integration for configuring and testing email alerts.
This commit is contained in:
38
backend/alembic/versions/0004_email_settings.py
Normal file
38
backend/alembic/versions/0004_email_settings.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""add email notification settings
|
||||||
|
|
||||||
|
Revision ID: 0004_email_settings
|
||||||
|
Revises: 0003_pg_stat_statements_flag
|
||||||
|
Create Date: 2026-02-12
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0004_email_settings"
|
||||||
|
down_revision = "0003_pg_stat_statements_flag"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"email_notification_settings",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("smtp_host", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("smtp_port", sa.Integer(), nullable=False, server_default=sa.text("587")),
|
||||||
|
sa.Column("smtp_username", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("encrypted_smtp_password", sa.Text(), nullable=True),
|
||||||
|
sa.Column("from_email", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("use_starttls", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("use_ssl", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("alert_recipients", sa.JSON(), nullable=False, server_default=sa.text("'[]'::json")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("email_notification_settings")
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.routes import admin_users, alerts, auth, health, me, targets
|
from app.api.routes import admin_settings, admin_users, alerts, auth, health, me, targets
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(health.router, tags=["health"])
|
api_router.include_router(health.router, tags=["health"])
|
||||||
@@ -8,3 +8,4 @@ api_router.include_router(me.router, tags=["auth"])
|
|||||||
api_router.include_router(targets.router, prefix="/targets", tags=["targets"])
|
api_router.include_router(targets.router, prefix="/targets", tags=["targets"])
|
||||||
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
|
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
|
||||||
api_router.include_router(admin_users.router, prefix="/admin/users", tags=["admin"])
|
api_router.include_router(admin_users.router, prefix="/admin/users", tags=["admin"])
|
||||||
|
api_router.include_router(admin_settings.router, prefix="/admin/settings", tags=["admin"])
|
||||||
|
|||||||
124
backend/app/api/routes/admin_settings.py
Normal file
124
backend/app/api/routes/admin_settings.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
from email.message import EmailMessage
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.core.deps import require_roles
|
||||||
|
from app.models.models import EmailNotificationSettings, User
|
||||||
|
from app.schemas.admin_settings import EmailSettingsOut, EmailSettingsTestRequest, EmailSettingsUpdate
|
||||||
|
from app.services.audit import write_audit_log
|
||||||
|
from app.services.crypto import decrypt_secret, encrypt_secret
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_create_settings(db: AsyncSession) -> EmailNotificationSettings:
|
||||||
|
settings = await db.scalar(select(EmailNotificationSettings).limit(1))
|
||||||
|
if settings:
|
||||||
|
return settings
|
||||||
|
settings = EmailNotificationSettings()
|
||||||
|
db.add(settings)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(settings)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(settings: EmailNotificationSettings) -> EmailSettingsOut:
|
||||||
|
recipients = settings.alert_recipients if isinstance(settings.alert_recipients, list) else []
|
||||||
|
return EmailSettingsOut(
|
||||||
|
enabled=settings.enabled,
|
||||||
|
smtp_host=settings.smtp_host,
|
||||||
|
smtp_port=settings.smtp_port,
|
||||||
|
smtp_username=settings.smtp_username,
|
||||||
|
from_email=settings.from_email,
|
||||||
|
use_starttls=settings.use_starttls,
|
||||||
|
use_ssl=settings.use_ssl,
|
||||||
|
alert_recipients=recipients,
|
||||||
|
has_password=bool(settings.encrypted_smtp_password),
|
||||||
|
updated_at=settings.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/email", response_model=EmailSettingsOut)
|
||||||
|
async def get_email_settings(
|
||||||
|
admin: User = Depends(require_roles("admin")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> EmailSettingsOut:
|
||||||
|
_ = admin
|
||||||
|
settings = await _get_or_create_settings(db)
|
||||||
|
return _to_out(settings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/email", response_model=EmailSettingsOut)
|
||||||
|
async def update_email_settings(
|
||||||
|
payload: EmailSettingsUpdate,
|
||||||
|
admin: User = Depends(require_roles("admin")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> EmailSettingsOut:
|
||||||
|
settings = await _get_or_create_settings(db)
|
||||||
|
settings.enabled = payload.enabled
|
||||||
|
settings.smtp_host = payload.smtp_host.strip() if payload.smtp_host else None
|
||||||
|
settings.smtp_port = payload.smtp_port
|
||||||
|
settings.smtp_username = payload.smtp_username.strip() if payload.smtp_username else None
|
||||||
|
settings.from_email = str(payload.from_email) if payload.from_email else None
|
||||||
|
settings.use_starttls = payload.use_starttls
|
||||||
|
settings.use_ssl = payload.use_ssl
|
||||||
|
settings.alert_recipients = [str(item) for item in payload.alert_recipients]
|
||||||
|
|
||||||
|
if payload.clear_smtp_password:
|
||||||
|
settings.encrypted_smtp_password = None
|
||||||
|
elif payload.smtp_password:
|
||||||
|
settings.encrypted_smtp_password = encrypt_secret(payload.smtp_password)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(settings)
|
||||||
|
await write_audit_log(db, "admin.email_settings.update", admin.id, {"enabled": settings.enabled})
|
||||||
|
return _to_out(settings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/email/test")
|
||||||
|
async def test_email_settings(
|
||||||
|
payload: EmailSettingsTestRequest,
|
||||||
|
admin: User = Depends(require_roles("admin")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
settings = await _get_or_create_settings(db)
|
||||||
|
if not settings.smtp_host:
|
||||||
|
raise HTTPException(status_code=400, detail="SMTP host is not configured")
|
||||||
|
if not settings.from_email:
|
||||||
|
raise HTTPException(status_code=400, detail="From email is not configured")
|
||||||
|
|
||||||
|
password = decrypt_secret(settings.encrypted_smtp_password) if settings.encrypted_smtp_password else None
|
||||||
|
message = EmailMessage()
|
||||||
|
message["From"] = settings.from_email
|
||||||
|
message["To"] = str(payload.recipient)
|
||||||
|
message["Subject"] = payload.subject
|
||||||
|
message.set_content(payload.message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if settings.use_ssl:
|
||||||
|
with smtplib.SMTP_SSL(
|
||||||
|
settings.smtp_host,
|
||||||
|
settings.smtp_port,
|
||||||
|
timeout=10,
|
||||||
|
context=ssl.create_default_context(),
|
||||||
|
) as smtp:
|
||||||
|
if settings.smtp_username:
|
||||||
|
smtp.login(settings.smtp_username, password or "")
|
||||||
|
smtp.send_message(message)
|
||||||
|
else:
|
||||||
|
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
|
||||||
|
if settings.use_starttls:
|
||||||
|
smtp.starttls(context=ssl.create_default_context())
|
||||||
|
if settings.smtp_username:
|
||||||
|
smtp.login(settings.smtp_username, password or "")
|
||||||
|
smtp.send_message(message)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=f"SMTP test failed: {exc}")
|
||||||
|
|
||||||
|
await write_audit_log(db, "admin.email_settings.test", admin.id, {"recipient": str(payload.recipient)})
|
||||||
|
return {"status": "sent", "recipient": str(payload.recipient)}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
from app.models.models import AlertDefinition, AuditLog, Metric, QueryStat, Target, User
|
from app.models.models import AlertDefinition, AuditLog, EmailNotificationSettings, Metric, QueryStat, Target, User
|
||||||
|
|
||||||
__all__ = ["User", "Target", "Metric", "QueryStat", "AuditLog", "AlertDefinition"]
|
__all__ = ["User", "Target", "Metric", "QueryStat", "AuditLog", "AlertDefinition", "EmailNotificationSettings"]
|
||||||
|
|||||||
@@ -99,3 +99,25 @@ class AlertDefinition(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
target: Mapped[Target | None] = relationship(back_populates="alert_definitions")
|
target: Mapped[Target | None] = relationship(back_populates="alert_definitions")
|
||||||
|
|
||||||
|
|
||||||
|
class EmailNotificationSettings(Base):
|
||||||
|
__tablename__ = "email_notification_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
smtp_port: Mapped[int] = mapped_column(Integer, nullable=False, default=587)
|
||||||
|
smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
encrypted_smtp_password: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
use_starttls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
use_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
alert_recipients: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|||||||
48
backend/app/schemas/admin_settings.py
Normal file
48
backend/app/schemas/admin_settings.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettingsOut(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
smtp_host: str | None
|
||||||
|
smtp_port: int
|
||||||
|
smtp_username: str | None
|
||||||
|
from_email: EmailStr | None
|
||||||
|
use_starttls: bool
|
||||||
|
use_ssl: bool
|
||||||
|
alert_recipients: list[EmailStr]
|
||||||
|
has_password: bool
|
||||||
|
updated_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettingsUpdate(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
smtp_host: str | None = None
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_username: str | None = None
|
||||||
|
smtp_password: str | None = None
|
||||||
|
clear_smtp_password: bool = False
|
||||||
|
from_email: EmailStr | None = None
|
||||||
|
use_starttls: bool = True
|
||||||
|
use_ssl: bool = False
|
||||||
|
alert_recipients: list[EmailStr] = []
|
||||||
|
|
||||||
|
@field_validator("smtp_port")
|
||||||
|
@classmethod
|
||||||
|
def validate_port(cls, value: int) -> int:
|
||||||
|
if value < 1 or value > 65535:
|
||||||
|
raise ValueError("smtp_port must be between 1 and 65535")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_tls_combo(self):
|
||||||
|
if self.use_starttls and self.use_ssl:
|
||||||
|
raise ValueError("use_starttls and use_ssl cannot both be true")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettingsTestRequest(BaseModel):
|
||||||
|
recipient: EmailStr
|
||||||
|
subject: str = "NexaPG test notification"
|
||||||
|
message: str = "This is a test alert notification from NexaPG."
|
||||||
@@ -6,10 +6,44 @@ export function AdminUsersPage() {
|
|||||||
const { tokens, refresh, me } = useAuth();
|
const { tokens, refresh, me } = useAuth();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [form, setForm] = useState({ email: "", password: "", role: "viewer" });
|
const [form, setForm] = useState({ email: "", password: "", role: "viewer" });
|
||||||
|
const [emailSettings, setEmailSettings] = useState({
|
||||||
|
enabled: false,
|
||||||
|
smtp_host: "",
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_username: "",
|
||||||
|
smtp_password: "",
|
||||||
|
clear_smtp_password: false,
|
||||||
|
from_email: "",
|
||||||
|
use_starttls: true,
|
||||||
|
use_ssl: false,
|
||||||
|
alert_recipients: "",
|
||||||
|
});
|
||||||
|
const [smtpState, setSmtpState] = useState({ has_password: false, updated_at: null });
|
||||||
|
const [testRecipient, setTestRecipient] = useState("");
|
||||||
|
const [smtpInfo, setSmtpInfo] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setUsers(await apiFetch("/admin/users", {}, tokens, refresh));
|
const [userRows, smtp] = await Promise.all([
|
||||||
|
apiFetch("/admin/users", {}, tokens, refresh),
|
||||||
|
apiFetch("/admin/settings/email", {}, tokens, refresh),
|
||||||
|
]);
|
||||||
|
setUsers(userRows);
|
||||||
|
setEmailSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabled: !!smtp.enabled,
|
||||||
|
smtp_host: smtp.smtp_host || "",
|
||||||
|
smtp_port: smtp.smtp_port || 587,
|
||||||
|
smtp_username: smtp.smtp_username || "",
|
||||||
|
smtp_password: "",
|
||||||
|
clear_smtp_password: false,
|
||||||
|
from_email: smtp.from_email || "",
|
||||||
|
use_starttls: !!smtp.use_starttls,
|
||||||
|
use_ssl: !!smtp.use_ssl,
|
||||||
|
alert_recipients: (smtp.alert_recipients || []).join(", "),
|
||||||
|
}));
|
||||||
|
setSmtpState({ has_password: !!smtp.has_password, updated_at: smtp.updated_at });
|
||||||
|
setTestRecipient(smtp.from_email || "");
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,26 +72,78 @@ export function AdminUsersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveSmtp = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setSmtpInfo("");
|
||||||
|
try {
|
||||||
|
const recipients = emailSettings.alert_recipients
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const payload = {
|
||||||
|
...emailSettings,
|
||||||
|
smtp_host: emailSettings.smtp_host.trim() || null,
|
||||||
|
smtp_username: emailSettings.smtp_username.trim() || null,
|
||||||
|
from_email: emailSettings.from_email.trim() || null,
|
||||||
|
smtp_password: emailSettings.smtp_password || null,
|
||||||
|
alert_recipients: recipients,
|
||||||
|
};
|
||||||
|
await apiFetch("/admin/settings/email", { method: "PUT", body: JSON.stringify(payload) }, tokens, refresh);
|
||||||
|
setSmtpInfo("SMTP settings saved.");
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendTestMail = async () => {
|
||||||
|
setError("");
|
||||||
|
setSmtpInfo("");
|
||||||
|
try {
|
||||||
|
const recipient = testRecipient.trim();
|
||||||
|
if (!recipient) {
|
||||||
|
throw new Error("Please enter a test recipient email.");
|
||||||
|
}
|
||||||
|
await apiFetch(
|
||||||
|
"/admin/settings/email/test",
|
||||||
|
{ method: "POST", body: JSON.stringify({ recipient }) },
|
||||||
|
tokens,
|
||||||
|
refresh
|
||||||
|
);
|
||||||
|
setSmtpInfo(`Test email sent to ${recipient}.`);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="admin-settings-page">
|
||||||
<h2>Admin Users</h2>
|
<h2>Admin Settings - Users</h2>
|
||||||
{error && <div className="card error">{error}</div>}
|
{error && <div className="card error">{error}</div>}
|
||||||
<form className="card grid three" onSubmit={create}>
|
|
||||||
<input value={form.email} placeholder="email" onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={form.password}
|
|
||||||
placeholder="password"
|
|
||||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
|
||||||
/>
|
|
||||||
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
|
||||||
<option value="viewer">viewer</option>
|
|
||||||
<option value="operator">operator</option>
|
|
||||||
<option value="admin">admin</option>
|
|
||||||
</select>
|
|
||||||
<button>Create user</button>
|
|
||||||
</form>
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
<h3>User Management</h3>
|
||||||
|
<form className="grid three admin-user-form" onSubmit={create}>
|
||||||
|
<input value={form.email} placeholder="E-Mail" onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
placeholder="Password"
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
/>
|
||||||
|
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
||||||
|
<option value="viewer">viewer</option>
|
||||||
|
<option value="operator">operator</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
<div className="form-actions field-full">
|
||||||
|
<button className="primary-btn" type="submit">Create User</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card admin-users-table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -69,16 +155,148 @@ export function AdminUsersPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<tr key={u.id}>
|
<tr key={u.id} className="admin-user-row">
|
||||||
<td>{u.id}</td>
|
<td className="user-col-id">{u.id}</td>
|
||||||
<td>{u.email}</td>
|
<td className="user-col-email">{u.email}</td>
|
||||||
<td>{u.role}</td>
|
<td>
|
||||||
<td>{u.id !== me.id && <button onClick={() => remove(u.id)}>Delete</button>}</td>
|
<span className={`pill role-pill role-${u.role}`}>{u.role}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{u.id !== me.id && (
|
||||||
|
<button className="table-action-btn delete small-btn" onClick={() => remove(u.id)}>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="12" height="12">
|
||||||
|
<path
|
||||||
|
d="M9 3h6l1 2h4v2H4V5h4l1-2zm1 6h2v8h-2V9zm4 0h2v8h-2V9zM7 9h2v8H7V9z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Alert Email Notifications (SMTP)</h3>
|
||||||
|
<p className="muted">
|
||||||
|
Configure outgoing SMTP for alert emails. This is send-only; no inbound mailbox is used.
|
||||||
|
</p>
|
||||||
|
{smtpInfo && <div className="test-connection-result ok">{smtpInfo}</div>}
|
||||||
|
<form className="grid two admin-smtp-form" onSubmit={saveSmtp}>
|
||||||
|
<label className="toggle-check field-full">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={emailSettings.enabled}
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-ui" />
|
||||||
|
<span>
|
||||||
|
<strong>Enable alert emails</strong>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label>SMTP host</label>
|
||||||
|
<input
|
||||||
|
value={emailSettings.smtp_host}
|
||||||
|
placeholder="smtp.example.com"
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_host: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>SMTP port</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={emailSettings.smtp_port}
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_port: Number(e.target.value || 587) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label>SMTP username</label>
|
||||||
|
<input
|
||||||
|
value={emailSettings.smtp_username}
|
||||||
|
placeholder="alerts@example.com"
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_username: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>SMTP password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={emailSettings.smtp_password}
|
||||||
|
placeholder={smtpState.has_password ? "Stored (enter to replace)" : "Set password"}
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_password: e.target.value, clear_smtp_password: false })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label>From email</label>
|
||||||
|
<input
|
||||||
|
value={emailSettings.from_email}
|
||||||
|
placeholder="noreply@example.com"
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, from_email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Alert recipients (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
value={emailSettings.alert_recipients}
|
||||||
|
placeholder="dba@example.com, oncall@example.com"
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, alert_recipients: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="toggle-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={emailSettings.use_starttls}
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, use_starttls: e.target.checked, use_ssl: e.target.checked ? false : emailSettings.use_ssl })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-ui" />
|
||||||
|
<span>Use STARTTLS</span>
|
||||||
|
</label>
|
||||||
|
<label className="toggle-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={emailSettings.use_ssl}
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, use_ssl: e.target.checked, use_starttls: e.target.checked ? false : emailSettings.use_starttls })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-ui" />
|
||||||
|
<span>Use SSL/TLS (SMTPS)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="toggle-check field-full">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={emailSettings.clear_smtp_password}
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, clear_smtp_password: e.target.checked, smtp_password: e.target.checked ? "" : emailSettings.smtp_password })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-ui" />
|
||||||
|
<span>Clear stored SMTP password</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="form-actions field-full">
|
||||||
|
<input
|
||||||
|
className="admin-test-recipient"
|
||||||
|
value={testRecipient}
|
||||||
|
placeholder="test recipient email"
|
||||||
|
onChange={(e) => setTestRecipient(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="secondary-btn" type="button" onClick={sendTestMail}>Send Test Mail</button>
|
||||||
|
<button className="primary-btn" type="submit">Save SMTP Settings</button>
|
||||||
|
</div>
|
||||||
|
<small className="muted field-full">
|
||||||
|
Last updated: {smtpState.updated_at ? new Date(smtpState.updated_at).toLocaleString() : "not configured yet"}
|
||||||
|
</small>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -979,6 +979,74 @@ td {
|
|||||||
border-color: #7f1d1d;
|
border-color: #7f1d1d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-settings-page h2 {
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-settings-page h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user-form {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-table-wrap table tbody .admin-user-row td {
|
||||||
|
padding-top: 11px;
|
||||||
|
padding-bottom: 11px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-col-id {
|
||||||
|
width: 64px;
|
||||||
|
color: #a8c0df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-col-email {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-pill.role-admin {
|
||||||
|
color: #e9dcff;
|
||||||
|
border-color: #8f77e3;
|
||||||
|
background: #31235d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-pill.role-operator {
|
||||||
|
color: #c7e7ff;
|
||||||
|
border-color: #4e8dc8;
|
||||||
|
background: #18375a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-pill.role-viewer {
|
||||||
|
color: #cde2f8;
|
||||||
|
border-color: #4a6692;
|
||||||
|
background: #192b49;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-smtp-form {
|
||||||
|
margin-top: 8px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-test-recipient {
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: #9eb8d6;
|
color: #9eb8d6;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user