All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (pull_request) Successful in 12s
PostgreSQL Compatibility Matrix / PG15 smoke (pull_request) Successful in 11s
PostgreSQL Compatibility Matrix / PG16 smoke (pull_request) Successful in 9s
PostgreSQL Compatibility Matrix / PG17 smoke (pull_request) Successful in 10s
PostgreSQL Compatibility Matrix / PG18 smoke (pull_request) Successful in 11s
This commit introduces optional `first_name` and `last_name` fields to the user model, including database migrations, backend, and frontend support. It enhances user profiles, updates user creation and editing flows, and refines the UI to display full names where available.
530 lines
21 KiB
JavaScript
530 lines
21 KiB
JavaScript
import React, { useEffect, useState } from "react";
|
|
import { apiFetch } from "../api";
|
|
import { useAuth } from "../state";
|
|
|
|
const TEMPLATE_VARIABLES = [
|
|
"target_name",
|
|
"target_id",
|
|
"alert_name",
|
|
"severity",
|
|
"category",
|
|
"description",
|
|
"message",
|
|
"value",
|
|
"warning_threshold",
|
|
"alert_threshold",
|
|
"checked_at",
|
|
"alert_key",
|
|
];
|
|
|
|
export function AdminUsersPage() {
|
|
const { tokens, refresh, me } = useAuth();
|
|
const emptyCreateForm = { email: "", first_name: "", last_name: "", password: "", role: "viewer" };
|
|
const [users, setUsers] = useState([]);
|
|
const [form, setForm] = useState(emptyCreateForm);
|
|
const [editingUserId, setEditingUserId] = useState(null);
|
|
const [editForm, setEditForm] = useState({ email: "", first_name: "", last_name: "", password: "", role: "viewer" });
|
|
const [emailSettings, setEmailSettings] = useState({
|
|
enabled: false,
|
|
smtp_host: "",
|
|
smtp_port: 587,
|
|
smtp_username: "",
|
|
smtp_password: "",
|
|
clear_smtp_password: false,
|
|
from_name: "",
|
|
from_email: "",
|
|
use_starttls: true,
|
|
use_ssl: false,
|
|
warning_subject_template: "",
|
|
alert_subject_template: "",
|
|
warning_body_template: "",
|
|
alert_body_template: "",
|
|
});
|
|
const [smtpState, setSmtpState] = useState({ has_password: false, updated_at: null });
|
|
const [testRecipient, setTestRecipient] = useState("");
|
|
const [smtpInfo, setSmtpInfo] = useState("");
|
|
const [error, setError] = useState("");
|
|
|
|
const load = async () => {
|
|
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_name: smtp.from_name || "",
|
|
from_email: smtp.from_email || "",
|
|
use_starttls: !!smtp.use_starttls,
|
|
use_ssl: !!smtp.use_ssl,
|
|
warning_subject_template: smtp.warning_subject_template || "",
|
|
alert_subject_template: smtp.alert_subject_template || "",
|
|
warning_body_template: smtp.warning_body_template || "",
|
|
alert_body_template: smtp.alert_body_template || "",
|
|
}));
|
|
setSmtpState({ has_password: !!smtp.has_password, updated_at: smtp.updated_at });
|
|
setTestRecipient(smtp.from_email || "");
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (me?.role === "admin") load().catch((e) => setError(String(e.message || e)));
|
|
}, [me]);
|
|
|
|
if (me?.role !== "admin") return <div className="card">Admins only.</div>;
|
|
|
|
const create = async (e) => {
|
|
e.preventDefault();
|
|
try {
|
|
await apiFetch("/admin/users", { method: "POST", body: JSON.stringify(form) }, tokens, refresh);
|
|
setForm(emptyCreateForm);
|
|
await load();
|
|
} catch (e) {
|
|
setError(String(e.message || e));
|
|
}
|
|
};
|
|
|
|
const remove = async (id) => {
|
|
try {
|
|
await apiFetch(`/admin/users/${id}`, { method: "DELETE" }, tokens, refresh);
|
|
await load();
|
|
} catch (e) {
|
|
setError(String(e.message || e));
|
|
}
|
|
};
|
|
|
|
const startEdit = (user) => {
|
|
setEditingUserId(user.id);
|
|
setEditForm({
|
|
email: user.email || "",
|
|
first_name: user.first_name || "",
|
|
last_name: user.last_name || "",
|
|
password: "",
|
|
role: user.role || "viewer",
|
|
});
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
setEditingUserId(null);
|
|
setEditForm({ email: "", first_name: "", last_name: "", password: "", role: "viewer" });
|
|
};
|
|
|
|
const saveEdit = async (userId) => {
|
|
try {
|
|
const payload = {
|
|
email: editForm.email,
|
|
first_name: editForm.first_name.trim() || null,
|
|
last_name: editForm.last_name.trim() || null,
|
|
role: editForm.role,
|
|
};
|
|
if (editForm.password.trim()) payload.password = editForm.password;
|
|
await apiFetch(`/admin/users/${userId}`, { method: "PUT", body: JSON.stringify(payload) }, tokens, refresh);
|
|
cancelEdit();
|
|
await load();
|
|
} catch (e) {
|
|
setError(String(e.message || e));
|
|
}
|
|
};
|
|
|
|
const saveSmtp = async (e) => {
|
|
e.preventDefault();
|
|
setError("");
|
|
setSmtpInfo("");
|
|
try {
|
|
const payload = {
|
|
...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,
|
|
warning_subject_template: emailSettings.warning_subject_template.trim() || null,
|
|
alert_subject_template: emailSettings.alert_subject_template.trim() || null,
|
|
warning_body_template: emailSettings.warning_body_template.trim() || null,
|
|
alert_body_template: emailSettings.alert_body_template.trim() || null,
|
|
};
|
|
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));
|
|
}
|
|
};
|
|
|
|
const protocolMode = emailSettings.use_ssl ? "ssl" : emailSettings.use_starttls ? "starttls" : "plain";
|
|
const setProtocolMode = (mode) => {
|
|
if (mode === "ssl") {
|
|
setEmailSettings({ ...emailSettings, use_ssl: true, use_starttls: false });
|
|
return;
|
|
}
|
|
if (mode === "starttls") {
|
|
setEmailSettings({ ...emailSettings, use_ssl: false, use_starttls: true });
|
|
return;
|
|
}
|
|
setEmailSettings({ ...emailSettings, use_ssl: false, use_starttls: false });
|
|
};
|
|
|
|
return (
|
|
<div className="admin-settings-page">
|
|
<h2>Admin Settings</h2>
|
|
<p className="muted admin-page-subtitle">Manage users and outgoing notifications for this NexaPG instance.</p>
|
|
{error && <div className="card error">{error}</div>}
|
|
|
|
<div className="card">
|
|
<div className="admin-section-head">
|
|
<h3>User Management</h3>
|
|
<p className="muted">Create accounts and manage access roles.</p>
|
|
</div>
|
|
<form className="grid three admin-user-form" onSubmit={create}>
|
|
<div className="admin-field">
|
|
<label>First Name</label>
|
|
<input
|
|
value={form.first_name}
|
|
placeholder="Jane"
|
|
onChange={(e) => setForm({ ...form, first_name: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="admin-field">
|
|
<label>Last Name</label>
|
|
<input
|
|
value={form.last_name}
|
|
placeholder="Doe"
|
|
onChange={(e) => setForm({ ...form, last_name: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="admin-field">
|
|
<label>Email</label>
|
|
<input value={form.email} placeholder="user@example.com" onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label>Password</label>
|
|
<input
|
|
type="password"
|
|
value={form.password}
|
|
placeholder="Set initial password"
|
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="admin-field">
|
|
<label>Role</label>
|
|
<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>
|
|
<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>
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Name</th>
|
|
<th>Email</th>
|
|
<th>Role</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map((u) => (
|
|
<tr key={u.id} className="admin-user-row">
|
|
<td className="user-col-id">{u.id}</td>
|
|
<td className="user-col-name">
|
|
{editingUserId === u.id ? (
|
|
<div className="admin-inline-grid two">
|
|
<input
|
|
value={editForm.first_name}
|
|
placeholder="First name"
|
|
onChange={(e) => setEditForm({ ...editForm, first_name: e.target.value })}
|
|
/>
|
|
<input
|
|
value={editForm.last_name}
|
|
placeholder="Last name"
|
|
onChange={(e) => setEditForm({ ...editForm, last_name: e.target.value })}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<span className="user-col-name-value">{[u.first_name, u.last_name].filter(Boolean).join(" ") || "-"}</span>
|
|
)}
|
|
</td>
|
|
<td className="user-col-email">
|
|
{editingUserId === u.id ? (
|
|
<input
|
|
value={editForm.email}
|
|
placeholder="user@example.com"
|
|
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
|
/>
|
|
) : (
|
|
u.email
|
|
)}
|
|
</td>
|
|
<td>
|
|
{editingUserId === u.id ? (
|
|
<select value={editForm.role} onChange={(e) => setEditForm({ ...editForm, role: e.target.value })}>
|
|
<option value="viewer">viewer</option>
|
|
<option value="operator">operator</option>
|
|
<option value="admin">admin</option>
|
|
</select>
|
|
) : (
|
|
<span className={`pill role-pill role-${u.role}`}>{u.role}</span>
|
|
)}
|
|
</td>
|
|
<td className="admin-user-actions">
|
|
{editingUserId === u.id && (
|
|
<input
|
|
type="password"
|
|
className="admin-inline-password"
|
|
value={editForm.password}
|
|
placeholder="New password (optional)"
|
|
onChange={(e) => setEditForm({ ...editForm, password: e.target.value })}
|
|
/>
|
|
)}
|
|
{editingUserId === u.id ? (
|
|
<>
|
|
<button className="table-action-btn primary small-btn" onClick={() => saveEdit(u.id)}>
|
|
Save
|
|
</button>
|
|
<button className="table-action-btn small-btn" onClick={cancelEdit}>
|
|
Cancel
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button className="table-action-btn edit small-btn" onClick={() => startEdit(u)}>
|
|
Edit
|
|
</button>
|
|
)}
|
|
{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>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="admin-section-head">
|
|
<h3>Alert Email Notifications (SMTP)</h3>
|
|
<p className="muted">Configure send-only SMTP for warning and alert notifications.</p>
|
|
</div>
|
|
{smtpInfo && <div className="test-connection-result ok">{smtpInfo}</div>}
|
|
<form className="grid two admin-smtp-form" onSubmit={saveSmtp}>
|
|
<div className="admin-subcard field-full">
|
|
<h4>SMTP Settings</h4>
|
|
<div className="grid two">
|
|
<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 className="admin-field">
|
|
<label>SMTP host</label>
|
|
<input
|
|
value={emailSettings.smtp_host}
|
|
placeholder="smtp.example.com"
|
|
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_host: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="admin-field">
|
|
<label>SMTP port</label>
|
|
<input
|
|
type="number"
|
|
value={emailSettings.smtp_port}
|
|
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_port: Number(e.target.value || 587) })}
|
|
/>
|
|
</div>
|
|
|
|
<div className="admin-field">
|
|
<label>SMTP username</label>
|
|
<input
|
|
value={emailSettings.smtp_username}
|
|
placeholder="alerts@example.com"
|
|
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_username: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="admin-field">
|
|
<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 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
|
|
value={emailSettings.from_email}
|
|
placeholder="noreply@example.com"
|
|
onChange={(e) => setEmailSettings({ ...emailSettings, from_email: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
<div className="admin-field field-full">
|
|
<label>Transport mode</label>
|
|
<div className="smtp-mode-picker" role="radiogroup" aria-label="SMTP transport mode">
|
|
<button
|
|
type="button"
|
|
className={`smtp-mode-btn ${protocolMode === "starttls" ? "active" : ""}`}
|
|
aria-pressed={protocolMode === "starttls"}
|
|
onClick={() => setProtocolMode("starttls")}
|
|
>
|
|
STARTTLS
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`smtp-mode-btn ${protocolMode === "ssl" ? "active" : ""}`}
|
|
aria-pressed={protocolMode === "ssl"}
|
|
onClick={() => setProtocolMode("ssl")}
|
|
>
|
|
SSL/TLS (SMTPS)
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`smtp-mode-btn ${protocolMode === "plain" ? "active" : ""}`}
|
|
aria-pressed={protocolMode === "plain"}
|
|
onClick={() => setProtocolMode("plain")}
|
|
>
|
|
No TLS
|
|
</button>
|
|
</div>
|
|
<small className="muted">Select exactly one mode to avoid STARTTLS/SSL conflicts.</small>
|
|
</div>
|
|
|
|
<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>
|
|
</div>
|
|
|
|
<div className="admin-subcard field-full">
|
|
<h4>Template Settings</h4>
|
|
<p className="muted template-help-text">
|
|
If a template field is left empty, NexaPG automatically uses the built-in default template.
|
|
</p>
|
|
<div className="template-vars-grid">
|
|
{TEMPLATE_VARIABLES.map((item) => (
|
|
<code key={item} className="template-var-pill">
|
|
{"{" + item + "}"}
|
|
</code>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid two">
|
|
<div className="admin-field field-full">
|
|
<label>Warning subject template</label>
|
|
<input
|
|
value={emailSettings.warning_subject_template}
|
|
placeholder="[NexaPG][WARNING] {target_name} - {alert_name}"
|
|
onChange={(e) => setEmailSettings({ ...emailSettings, warning_subject_template: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="admin-field field-full">
|
|
<label>Alert subject template</label>
|
|
<input
|
|
value={emailSettings.alert_subject_template}
|
|
placeholder="[NexaPG][ALERT] {target_name} - {alert_name}"
|
|
onChange={(e) => setEmailSettings({ ...emailSettings, alert_subject_template: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="admin-field field-full">
|
|
<label>Warning body template</label>
|
|
<textarea
|
|
value={emailSettings.warning_body_template}
|
|
placeholder={"Severity: {severity}\nTarget: {target_name} (id={target_id})\nAlert: {alert_name}\nMessage: {message}\nChecked At: {checked_at}"}
|
|
onChange={(e) => setEmailSettings({ ...emailSettings, warning_body_template: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="admin-field field-full">
|
|
<label>Alert body template</label>
|
|
<textarea
|
|
value={emailSettings.alert_body_template}
|
|
placeholder={"Severity: {severity}\nTarget: {target_name} (id={target_id})\nAlert: {alert_name}\nMessage: {message}\nCurrent Value: {value}\nWarning Threshold: {warning_threshold}\nAlert Threshold: {alert_threshold}\nChecked At: {checked_at}\nAlert Key: {alert_key}"}
|
|
onChange={(e) => setEmailSettings({ ...emailSettings, alert_body_template: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
);
|
|
}
|