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 && (