Add first and last name fields for users
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.
This commit is contained in:
2026-02-13 10:57:10 +01:00
parent e0242bc823
commit cd1795b9ff
8 changed files with 214 additions and 11 deletions

View File

@@ -22,6 +22,7 @@ function Layout({ children }) {
const { me, logout, uiMode, setUiMode, alertToasts, dismissAlertToast, serviceUpdateAvailable } = useAuth();
const navigate = useNavigate();
const navClass = ({ isActive }) => `nav-btn${isActive ? " active" : ""}`;
const fullName = [me?.first_name, me?.last_name].filter(Boolean).join(" ").trim();
return (
<div className="shell">
@@ -101,8 +102,9 @@ function Layout({ children }) {
</button>
<small>{uiMode === "easy" ? "Simple health guidance" : "Advanced DBA metrics"}</small>
</div>
<div>{me?.email}</div>
<div className="role">{me?.role}</div>
<div className="profile-name">{fullName || me?.email}</div>
{fullName && <div className="profile-email">{me?.email}</div>}
<div className="role profile-role">{me?.role}</div>
<NavLink to="/user-settings" className={({ isActive }) => `profile-btn${isActive ? " active" : ""}`}>
User Settings
</NavLink>

View File

@@ -19,8 +19,11 @@ const TEMPLATE_VARIABLES = [
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({ email: "", password: "", role: "viewer" });
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: "",
@@ -79,7 +82,7 @@ export function AdminUsersPage() {
e.preventDefault();
try {
await apiFetch("/admin/users", { method: "POST", body: JSON.stringify(form) }, tokens, refresh);
setForm({ email: "", password: "", role: "viewer" });
setForm(emptyCreateForm);
await load();
} catch (e) {
setError(String(e.message || e));
@@ -95,6 +98,39 @@ export function AdminUsersPage() {
}
};
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("");
@@ -165,6 +201,22 @@ export function AdminUsersPage() {
<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 })} />
@@ -197,6 +249,7 @@ export function AdminUsersPage() {
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Action</th>
@@ -206,11 +259,70 @@ export function AdminUsersPage() {
{users.map((u) => (
<tr key={u.id} className="admin-user-row">
<td className="user-col-id">{u.id}</td>
<td className="user-col-email">{u.email}</td>
<td>
<span className={`pill role-pill role-${u.role}`}>{u.role}</span>
<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">

View File

@@ -195,6 +195,23 @@ a {
color: #d7e4fa;
}
.profile-name {
font-size: 15px;
font-weight: 700;
line-height: 1.25;
}
.profile-email {
margin-top: 2px;
font-size: 12px;
color: #a6bcda;
word-break: break-all;
}
.profile-role {
margin-top: 4px;
}
.mode-switch-block {
margin-bottom: 12px;
padding: 10px;
@@ -1255,6 +1272,31 @@ td {
font-weight: 600;
}
.user-col-name-value {
font-weight: 600;
}
.admin-inline-grid {
display: grid;
gap: 8px;
}
.admin-inline-grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-inline-password {
min-width: 190px;
}
.admin-user-actions {
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
}
.role-pill {
display: inline-flex;
align-items: center;