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
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:
26
backend/alembic/versions/0009_user_profile_fields.py
Normal file
26
backend/alembic/versions/0009_user_profile_fields.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""add user first and last name fields
|
||||
|
||||
Revision ID: 0009_user_profile_fields
|
||||
Revises: 0008_service_settings
|
||||
Create Date: 2026-02-13
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0009_user_profile_fields"
|
||||
down_revision = "0008_service_settings"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("first_name", sa.String(length=120), nullable=True))
|
||||
op.add_column("users", sa.Column("last_name", sa.String(length=120), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "last_name")
|
||||
op.drop_column("users", "first_name")
|
||||
|
||||
@@ -23,7 +23,13 @@ async def create_user(payload: UserCreate, admin: User = Depends(require_roles("
|
||||
exists = await db.scalar(select(User).where(User.email == payload.email))
|
||||
if exists:
|
||||
raise HTTPException(status_code=409, detail="Email already exists")
|
||||
user = User(email=payload.email, password_hash=hash_password(payload.password), role=payload.role)
|
||||
user = User(
|
||||
email=payload.email,
|
||||
first_name=payload.first_name,
|
||||
last_name=payload.last_name,
|
||||
password_hash=hash_password(payload.password),
|
||||
role=payload.role,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
@@ -42,8 +48,15 @@ async def update_user(
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
if "password" in update_data and update_data["password"]:
|
||||
user.password_hash = hash_password(update_data.pop("password"))
|
||||
next_email = update_data.get("email")
|
||||
if next_email and next_email != user.email:
|
||||
existing = await db.scalar(select(User).where(User.email == next_email))
|
||||
if existing and existing.id != user.id:
|
||||
raise HTTPException(status_code=409, detail="Email already exists")
|
||||
if "password" in update_data:
|
||||
raw_password = update_data.pop("password")
|
||||
if raw_password:
|
||||
user.password_hash = hash_password(raw_password)
|
||||
for key, value in update_data.items():
|
||||
setattr(user, key, value)
|
||||
await db.commit()
|
||||
|
||||
@@ -2,7 +2,7 @@ from functools import lru_cache
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
NEXAPG_VERSION = "0.1.3"
|
||||
NEXAPG_VERSION = "0.1.4"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
@@ -9,6 +9,8 @@ class User(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
||||
first_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
last_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False, default="viewer")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
@@ -5,6 +5,8 @@ from pydantic import BaseModel, EmailStr, field_validator
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
email: EmailStr
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
role: str
|
||||
created_at: datetime
|
||||
|
||||
@@ -13,12 +15,16 @@ class UserOut(BaseModel):
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
password: str
|
||||
role: str = "viewer"
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: EmailStr | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
password: str | None = None
|
||||
role: str | None = None
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user