From cd1795b9ff282cce02ca92969d70807865d76b6d Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 13 Feb 2026 10:57:10 +0100 Subject: [PATCH] Add first and last name fields for users 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. --- .../versions/0009_user_profile_fields.py | 26 ++++ backend/app/api/routes/admin_users.py | 19 ++- backend/app/core/config.py | 2 +- backend/app/models/models.py | 2 + backend/app/schemas/user.py | 6 + frontend/src/App.jsx | 6 +- frontend/src/pages/AdminUsersPage.jsx | 122 +++++++++++++++++- frontend/src/styles.css | 42 ++++++ 8 files changed, 214 insertions(+), 11 deletions(-) create mode 100644 backend/alembic/versions/0009_user_profile_fields.py diff --git a/backend/alembic/versions/0009_user_profile_fields.py b/backend/alembic/versions/0009_user_profile_fields.py new file mode 100644 index 0000000..d5f074a --- /dev/null +++ b/backend/alembic/versions/0009_user_profile_fields.py @@ -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") + diff --git a/backend/app/api/routes/admin_users.py b/backend/app/api/routes/admin_users.py index b98eecd..22ff253 100644 --- a/backend/app/api/routes/admin_users.py +++ b/backend/app/api/routes/admin_users.py @@ -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() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index c526e70..ee7a87a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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): diff --git a/backend/app/models/models.py b/backend/app/models/models.py index e3507eb..0e044a1 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index ad725b6..a7eeb07 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ea8d3fc..58f005e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 (
@@ -101,8 +102,9 @@ function Layout({ children }) { {uiMode === "easy" ? "Simple health guidance" : "Advanced DBA metrics"}
-
{me?.email}
-
{me?.role}
+
{fullName || me?.email}
+ {fullName &&
{me?.email}
} +
{me?.role}
`profile-btn${isActive ? " active" : ""}`}> User Settings diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx index ee12dfd..1b95f90 100644 --- a/frontend/src/pages/AdminUsersPage.jsx +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -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() {

Create accounts and manage access roles.

+
+ + setForm({ ...form, first_name: e.target.value })} + /> +
+
+ + setForm({ ...form, last_name: e.target.value })} + /> +
setForm({ ...form, email: e.target.value })} /> @@ -197,6 +249,7 @@ export function AdminUsersPage() { ID + Name Email Role Action @@ -206,11 +259,70 @@ export function AdminUsersPage() { {users.map((u) => ( {u.id} - {u.email} - - {u.role} + + {editingUserId === u.id ? ( +
+ setEditForm({ ...editForm, first_name: e.target.value })} + /> + setEditForm({ ...editForm, last_name: e.target.value })} + /> +
+ ) : ( + {[u.first_name, u.last_name].filter(Boolean).join(" ") || "-"} + )} + + + {editingUserId === u.id ? ( + setEditForm({ ...editForm, email: e.target.value })} + /> + ) : ( + u.email + )} + {editingUserId === u.id ? ( + + ) : ( + {u.role} + )} + + + {editingUserId === u.id && ( + setEditForm({ ...editForm, password: e.target.value })} + /> + )} + {editingUserId === u.id ? ( + <> + + + + ) : ( + + )} {u.id !== me.id && (