Add support for "from_name" field in email notifications
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

Introduced a new optional "from_name" attribute to email settings, allowing customization of the sender's display name in outgoing emails. Updated backend models, APIs, and front-end components to include and handle this field properly. This enhances email clarity and personalization for users.
This commit is contained in:
2026-02-12 15:31:03 +01:00
parent ea26ef4d33
commit 648ff07651
8 changed files with 177 additions and 61 deletions

View File

@@ -13,6 +13,7 @@ export function AdminUsersPage() {
smtp_username: "",
smtp_password: "",
clear_smtp_password: false,
from_name: "",
from_email: "",
use_starttls: true,
use_ssl: false,
@@ -37,6 +38,7 @@ export function AdminUsersPage() {
smtp_username: smtp.smtp_username || "",
smtp_password: "",
clear_smtp_password: false,
from_name: smtp.from_name || "",
from_email: smtp.from_email || "",
use_starttls: !!smtp.use_starttls,
use_ssl: !!smtp.use_ssl,
@@ -85,6 +87,7 @@ export function AdminUsersPage() {
...emailSettings,
smtp_host: emailSettings.smtp_host.trim() || null,
smtp_username: emailSettings.smtp_username.trim() || null,
from_name: emailSettings.from_name.trim() || null,
from_email: emailSettings.from_email.trim() || null,
smtp_password: emailSettings.smtp_password || null,
alert_recipients: recipients,
@@ -249,6 +252,14 @@ export function AdminUsersPage() {
/>
</div>
<div className="admin-field">
<label>From name</label>
<input
value={emailSettings.from_name}
placeholder="NexaPG Alerts"
onChange={(e) => setEmailSettings({ ...emailSettings, from_name: e.target.value })}
/>
</div>
<div className="admin-field">
<label>From email</label>
<input

View File

@@ -29,6 +29,64 @@ const emptyEditForm = {
owner_user_ids: [],
};
function toggleOwner(ids, userId) {
return ids.includes(userId) ? ids.filter((id) => id !== userId) : [...ids, userId];
}
function OwnerPicker({ candidates, selectedIds, onToggle, query, onQueryChange }) {
const filtered = candidates.filter((item) =>
item.email.toLowerCase().includes(query.trim().toLowerCase())
);
const selected = candidates.filter((item) => selectedIds.includes(item.user_id));
return (
<div className="owner-picker">
<div className="owner-selected">
{selected.length > 0 ? (
selected.map((item) => (
<button
key={`selected-${item.user_id}`}
type="button"
className="owner-selected-chip"
onClick={() => onToggle(item.user_id)}
title="Remove owner"
>
<span>{item.email}</span>
<span aria-hidden="true">x</span>
</button>
))
) : (
<span className="muted">No owners selected yet.</span>
)}
</div>
<input
type="text"
className="owner-search-input"
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Search users by email..."
/>
<div className="owner-search-results">
{filtered.map((item) => {
const active = selectedIds.includes(item.user_id);
return (
<button
key={item.user_id}
type="button"
className={`owner-result ${active ? "active" : ""}`}
onClick={() => onToggle(item.user_id)}
>
<span>{item.email}</span>
<small>{item.role}</small>
</button>
);
})}
{filtered.length === 0 && <div className="owner-result-empty">No matching users.</div>}
</div>
</div>
);
}
export function TargetsPage() {
const { tokens, refresh, me } = useAuth();
const [targets, setTargets] = useState([]);
@@ -40,6 +98,8 @@ export function TargetsPage() {
const [testState, setTestState] = useState({ loading: false, message: "", ok: null });
const [saveState, setSaveState] = useState({ loading: false, message: "" });
const [ownerCandidates, setOwnerCandidates] = useState([]);
const [createOwnerQuery, setCreateOwnerQuery] = useState("");
const [editOwnerQuery, setEditOwnerQuery] = useState("");
const canManage = me?.role === "admin" || me?.role === "operator";
@@ -117,6 +177,7 @@ export function TargetsPage() {
const startEdit = (target) => {
setEditing(true);
setSaveState({ loading: false, message: "" });
setEditOwnerQuery("");
setEditForm({
id: target.id,
name: target.name,
@@ -231,27 +292,13 @@ export function TargetsPage() {
</div>
<div className="field field-full">
<label>Responsible Users (Target Owners)</label>
<div className="owner-grid">
{ownerCandidates.map((candidate) => {
const checked = form.owner_user_ids.includes(candidate.user_id);
return (
<label key={candidate.user_id} className={`owner-chip ${checked ? "active" : ""}`}>
<input
type="checkbox"
checked={checked}
onChange={(e) => {
const next = e.target.checked
? [...form.owner_user_ids, candidate.user_id]
: form.owner_user_ids.filter((id) => id !== candidate.user_id);
setForm({ ...form, owner_user_ids: next });
}}
/>
<span>{candidate.email}</span>
</label>
);
})}
{ownerCandidates.length === 0 && <small className="muted">No users available.</small>}
</div>
<OwnerPicker
candidates={ownerCandidates}
selectedIds={form.owner_user_ids}
query={createOwnerQuery}
onQueryChange={setCreateOwnerQuery}
onToggle={(userId) => setForm({ ...form, owner_user_ids: toggleOwner(form.owner_user_ids, userId) })}
/>
<small>Only selected users will receive email notifications for this target's alerts.</small>
</div>
<div className="field submit-field field-full">
@@ -322,27 +369,13 @@ export function TargetsPage() {
</div>
<div className="field field-full">
<label>Responsible Users (Target Owners)</label>
<div className="owner-grid">
{ownerCandidates.map((candidate) => {
const checked = editForm.owner_user_ids.includes(candidate.user_id);
return (
<label key={candidate.user_id} className={`owner-chip ${checked ? "active" : ""}`}>
<input
type="checkbox"
checked={checked}
onChange={(e) => {
const next = e.target.checked
? [...editForm.owner_user_ids, candidate.user_id]
: editForm.owner_user_ids.filter((id) => id !== candidate.user_id);
setEditForm({ ...editForm, owner_user_ids: next });
}}
/>
<span>{candidate.email}</span>
</label>
);
})}
{ownerCandidates.length === 0 && <small className="muted">No users available.</small>}
</div>
<OwnerPicker
candidates={ownerCandidates}
selectedIds={editForm.owner_user_ids}
query={editOwnerQuery}
onQueryChange={setEditOwnerQuery}
onToggle={(userId) => setEditForm({ ...editForm, owner_user_ids: toggleOwner(editForm.owner_user_ids, userId) })}
/>
<small>Only selected users will receive email notifications for this target's alerts.</small>
</div>
<div className="field submit-field field-full">