Add admin user management with role-gated access

Backend:
- schemas/user.py: is_admin (validation_alias=is_superuser) on UserOut and
  UserAdminOut; UserAdminCreate extends UserCreate with is_admin flag
- deps.py: get_current_admin dependency — 403 for non-superusers
- routers/admin.py: GET/POST /api/admin/users, DELETE and PATCH /active per
  user; self-delete and self-deactivate blocked
- main.py: register /api/admin router
- scripts/seed.py: seed test user with is_superuser=True; promotes existing
  user if already created without the flag

Frontend:
- api/client.ts: UserData type with is_admin, admin API functions
- components/Nav.tsx: Admin link visible only when user.is_admin is true
- pages/AdminPage.tsx: user table with add-user form, delete, toggle active
- App.tsx: AdminRoute guard (403-redirects non-admins to /); /admin route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-13 18:40:05 +02:00
parent d46191789d
commit 456681fdfa
11 changed files with 359 additions and 8 deletions
+2 -1
View File
@@ -17,7 +17,8 @@ A fullstack SaaS web application built with FastAPI, React, and PostgreSQL.
- `/api/users/me` — authenticated user info - `/api/users/me` — authenticated user info
- `/api/profile/me` — GET/PUT personal profile (position, phone, date of birth, address) - `/api/profile/me` — GET/PUT personal profile (position, phone, date of birth, address)
- Profile data stored in a dedicated `profiles` table; auto-created on first access - Profile data stored in a dedicated `profiles` table; auto-created on first access
- Admin role flag (`is_superuser`) stored in `users` table; hidden from all API responses - Admin role flag (`is_superuser`) stored in `users` table; exposed as `is_admin` in API (false for regular users, true for admins)
- Admin-only user management at `/admin`: list all users, add users, delete users, toggle active status
- All input sanitized before reaching the DB (null-byte rejection, length caps, format validation) - All input sanitized before reaching the DB (null-byte rejection, length caps, format validation)
- 3 separate Docker containers: `db` (PostgreSQL), `backend` (FastAPI), `frontend` (nginx) - 3 separate Docker containers: `db` (PostgreSQL), `backend` (FastAPI), `frontend` (nginx)
- All containers run as non-root users (UID 1001 for backend and frontend, UID 70 for db) - All containers run as non-root users (UID 1001 for backend and frontend, UID 70 for db)
+4
View File
@@ -8,6 +8,10 @@
## Infrastructure ## Infrastructure
- [ ] **Docker port hardening** — expose only port 80 externally; backend (8000) and db (5432) must not be reachable from outside the Docker network. Prepare for deployment behind Traefik or nginx proxy manager (SSL termination, reverse proxy, no direct container exposure).
## Infrastructure (existing)
- [x] **Rootless containers** — run backend and frontend containers as non-root users (add `USER` directive to Dockerfiles, map UID/GID appropriately) - [x] **Rootless containers** — run backend and frontend containers as non-root users (add `USER` directive to Dockerfiles, map UID/GID appropriately)
- [ ] **Persistent storage** — ensure database data, config files, and any uploaded assets survive container restarts and rebuilds (named volumes, bind mounts for config) - [ ] **Persistent storage** — ensure database data, config files, and any uploaded assets survive container restarts and rebuilds (named volumes, bind mounts for config)
- [ ] **Docker development workflow** — document and streamline the full dev loop: hot reload, one-command startup, migration handling, seed data, and how to attach a debugger - [ ] **Docker development workflow** — document and streamline the full dev loop: hot reload, one-command startup, migration handling, seed data, and how to attach a debugger
+11
View File
@@ -30,3 +30,14 @@ async def get_current_user(
if not user or not user.is_active: if not user or not user.is_active:
raise credentials_exception raise credentials_exception
return user return user
async def get_current_admin(
current_user: User = Depends(get_current_user),
) -> User:
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return current_user
+2 -1
View File
@@ -2,7 +2,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings from app.core.config import settings
from app.routers import auth, profile, users from app.routers import admin, auth, profile, users
app = FastAPI(title=settings.PROJECT_NAME, version="0.1.0") app = FastAPI(title=settings.PROJECT_NAME, version="0.1.0")
@@ -17,6 +17,7 @@ app.add_middleware(
app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(users.router, prefix="/api/users", tags=["users"]) app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
@app.get("/api/health") @app.get("/api/health")
+80
View File
@@ -0,0 +1,80 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password
from app.database import get_db
from app.deps import get_current_admin
from app.models.user import User
from app.schemas.user import UserAdminCreate, UserAdminOut
router = APIRouter()
@router.get("/users", response_model=list[UserAdminOut])
async def list_users(
_admin: User = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
) -> list[User]:
result = await db.execute(select(User).order_by(User.email))
return list(result.scalars().all())
@router.post("/users", response_model=UserAdminOut, status_code=status.HTTP_201_CREATED)
async def create_user(
body: UserAdminCreate,
_admin: User = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
) -> User:
existing = await db.execute(select(User).where(User.email == body.email))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
user = User(
email=body.email,
hashed_password=hash_password(body.password),
full_name=body.full_name,
is_superuser=body.is_admin,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: str,
admin: User = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
) -> None:
if user_id == admin.id:
raise HTTPException(status_code=400, detail="Cannot delete your own account")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
await db.delete(user)
await db.commit()
@router.patch("/users/{user_id}/active", response_model=UserAdminOut)
async def toggle_active(
user_id: str,
admin: User = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
) -> User:
if user_id == admin.id:
raise HTTPException(status_code=400, detail="Cannot change your own active status")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.is_active = not user.is_active
await db.commit()
await db.refresh(user)
return user
+23 -2
View File
@@ -1,6 +1,6 @@
import re import re
from pydantic import BaseModel, EmailStr, field_validator from pydantic import BaseModel, EmailStr, Field, field_validator
from app.core.sanitize import normalize_email, sanitize_str from app.core.sanitize import normalize_email, sanitize_str
@@ -68,8 +68,29 @@ class UserOut(BaseModel):
email: str email: str
full_name: str | None full_name: str | None
is_active: bool is_active: bool
# validation_alias reads is_superuser from the ORM object; the JSON key
# in the response is the field name "is_admin" (not the alias).
is_admin: bool = Field(validation_alias="is_superuser", default=False)
model_config = {"from_attributes": True} model_config = {"from_attributes": True, "populate_by_name": True}
# ── Admin-facing schemas ───────────────────────────────────────────────────────
class UserAdminOut(BaseModel):
"""Full user record returned to admin endpoints."""
id: str
email: str
full_name: str | None
is_active: bool
is_admin: bool = Field(validation_alias="is_superuser", default=False)
model_config = {"from_attributes": True, "populate_by_name": True}
class UserAdminCreate(UserCreate):
"""Admin creates a user and can optionally grant admin rights."""
is_admin: bool = False
class Token(BaseModel): class Token(BaseModel):
+12 -3
View File
@@ -16,18 +16,27 @@ TEST_NAME = "Test User"
async def seed() -> None: async def seed() -> None:
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
result = await db.execute(select(User).where(User.email == TEST_EMAIL)) result = await db.execute(select(User).where(User.email == TEST_EMAIL))
if result.scalar_one_or_none(): existing = result.scalar_one_or_none()
print(f"[seed] test user already exists: {TEST_EMAIL}")
if existing:
# Ensure the dev test user is always an admin
if not existing.is_superuser:
existing.is_superuser = True
await db.commit()
print(f"[seed] promoted test user to admin: {TEST_EMAIL}")
else:
print(f"[seed] test user already exists: {TEST_EMAIL}")
return return
user = User( user = User(
email=TEST_EMAIL, email=TEST_EMAIL,
hashed_password=hash_password(TEST_PASSWORD), hashed_password=hash_password(TEST_PASSWORD),
full_name=TEST_NAME, full_name=TEST_NAME,
is_superuser=True,
) )
db.add(user) db.add(user)
await db.commit() await db.commit()
print(f"[seed] created test user — email: {TEST_EMAIL} pwd: {TEST_PASSWORD}") print(f"[seed] created test admin — email: {TEST_EMAIL} pwd: {TEST_PASSWORD}")
if __name__ == "__main__": if __name__ == "__main__":
+15
View File
@@ -1,16 +1,30 @@
import { Routes, Route, Navigate } from "react-router-dom"; import { Routes, Route, Navigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "./hooks/useAuth"; import { useAuth } from "./hooks/useAuth";
import { getMe } from "./api/client";
import LoginPage from "./pages/LoginPage"; import LoginPage from "./pages/LoginPage";
import DashboardPage from "./pages/DashboardPage"; import DashboardPage from "./pages/DashboardPage";
import ProfilePage from "./pages/ProfilePage"; import ProfilePage from "./pages/ProfilePage";
import AppsPage from "./pages/AppsPage"; import AppsPage from "./pages/AppsPage";
import SettingsPage from "./pages/SettingsPage"; import SettingsPage from "./pages/SettingsPage";
import AdminPage from "./pages/AdminPage";
function PrivateRoute({ children }: { children: React.ReactNode }) { function PrivateRoute({ children }: { children: React.ReactNode }) {
const { token } = useAuth(); const { token } = useAuth();
return token ? <>{children}</> : <Navigate to="/login" replace />; return token ? <>{children}</> : <Navigate to="/login" replace />;
} }
function AdminRoute({ children }: { children: React.ReactNode }) {
const { token } = useAuth();
const { data: user, isLoading } = useQuery({ queryKey: ["me"], queryFn: getMe });
if (!token) return <Navigate to="/login" replace />;
// Wait for the me query before deciding — prevents a flash redirect
if (isLoading) return null;
if (!user?.is_admin) return <Navigate to="/" replace />;
return <>{children}</>;
}
export default function App() { export default function App() {
return ( return (
<Routes> <Routes>
@@ -20,6 +34,7 @@ export default function App() {
<Route path="/apps" element={<PrivateRoute><AppsPage /></PrivateRoute>} /> <Route path="/apps" element={<PrivateRoute><AppsPage /></PrivateRoute>} />
<Route path="/settings" element={<PrivateRoute><SettingsPage /></PrivateRoute>} /> <Route path="/settings" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
<Route path="/profile" element={<PrivateRoute><ProfilePage /></PrivateRoute>} /> <Route path="/profile" element={<PrivateRoute><ProfilePage /></PrivateRoute>} />
<Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} />
{/* Catch-all */} {/* Catch-all */}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
+29 -1
View File
@@ -20,7 +20,35 @@ export const register = (email: string, password: string, full_name?: string) =>
api.post("/auth/register", { email, password, full_name }).then((r) => r.data); api.post("/auth/register", { email, password, full_name }).then((r) => r.data);
// --- Users --- // --- Users ---
export const getMe = () => api.get("/users/me").then((r) => r.data); export interface UserData {
id: string;
email: string;
full_name: string | null;
is_active: boolean;
is_admin: boolean;
}
export const getMe = () => api.get<UserData>("/users/me").then((r) => r.data);
// --- Admin ---
export interface AdminUserCreate {
email: string;
password: string;
full_name?: string;
is_admin?: boolean;
}
export const adminGetUsers = () =>
api.get<UserData[]>("/admin/users").then((r) => r.data);
export const adminCreateUser = (data: AdminUserCreate) =>
api.post<UserData>("/admin/users", data).then((r) => r.data);
export const adminDeleteUser = (userId: string) =>
api.delete(`/admin/users/${userId}`);
export const adminToggleActive = (userId: string) =>
api.patch<UserData>(`/admin/users/${userId}/active`).then((r) => r.data);
// --- Profile --- // --- Profile ---
export interface ProfileData { export interface ProfileData {
+4
View File
@@ -1,8 +1,11 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { getMe } from "../api/client";
export default function Nav() { export default function Nav() {
const { logout } = useAuth(); const { logout } = useAuth();
const { data: user } = useQuery({ queryKey: ["me"], queryFn: getMe });
return ( return (
<nav style={{ <nav style={{
@@ -15,6 +18,7 @@ export default function Nav() {
<Link to="/">Home</Link> <Link to="/">Home</Link>
<Link to="/apps">Apps</Link> <Link to="/apps">Apps</Link>
<Link to="/settings">Settings</Link> <Link to="/settings">Settings</Link>
{user?.is_admin && <Link to="/admin">Admin</Link>}
<button <button
onClick={logout} onClick={logout}
style={{ marginLeft: "auto", cursor: "pointer" }} style={{ marginLeft: "auto", cursor: "pointer" }}
+177
View File
@@ -0,0 +1,177 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
adminCreateUser,
adminDeleteUser,
adminGetUsers,
adminToggleActive,
getMe,
type AdminUserCreate,
type UserData,
} from "../api/client";
import Nav from "../components/Nav";
export default function AdminPage() {
const queryClient = useQueryClient();
const { data: me } = useQuery({ queryKey: ["me"], queryFn: getMe });
const { data: users = [], isLoading } = useQuery({
queryKey: ["admin-users"],
queryFn: adminGetUsers,
});
const [showForm, setShowForm] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [form, setForm] = useState<AdminUserCreate>({
email: "",
password: "",
full_name: "",
is_admin: false,
});
const createMutation = useMutation({
mutationFn: adminCreateUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-users"] });
setShowForm(false);
setForm({ email: "", password: "", full_name: "", is_admin: false });
setFormError(null);
},
onError: (err: any) => {
const detail = err?.response?.data?.detail;
if (Array.isArray(detail)) {
setFormError(detail.map((d: any) => d.msg).join("; "));
} else {
setFormError(detail ?? "Failed to create user");
}
},
});
const deleteMutation = useMutation({
mutationFn: adminDeleteUser,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["admin-users"] }),
});
const toggleActiveMutation = useMutation({
mutationFn: adminToggleActive,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["admin-users"] }),
});
const handleDelete = (user: UserData) => {
if (!window.confirm(`Delete user "${user.email}"? This cannot be undone.`)) return;
deleteMutation.mutate(user.id);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createMutation.mutate(form);
};
return (
<>
<Nav />
<div style={{ padding: 32, maxWidth: 800 }}>
<h1>User Management</h1>
{isLoading ? (
<p>Loading</p>
) : (
<table style={{ width: "100%", borderCollapse: "collapse", marginBottom: 24 }}>
<thead>
<tr style={{ borderBottom: "2px solid #ccc", textAlign: "left" }}>
<th style={{ padding: "8px 12px 8px 0" }}>Email</th>
<th style={{ padding: "8px 12px 8px 0" }}>Name</th>
<th style={{ padding: "8px 12px 8px 0" }}>Status</th>
<th style={{ padding: "8px 12px 8px 0" }}>Role</th>
<th style={{ padding: "8px 0" }}>Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} style={{ borderBottom: "1px solid #eee" }}>
<td style={{ padding: "8px 12px 8px 0" }}>{u.email}</td>
<td style={{ padding: "8px 12px 8px 0" }}>{u.full_name ?? "—"}</td>
<td style={{ padding: "8px 12px 8px 0" }}>
{u.is_active ? "Active" : "Inactive"}
</td>
<td style={{ padding: "8px 12px 8px 0" }}>
{u.is_admin ? "Admin" : "User"}
</td>
<td style={{ padding: "8px 0", display: "flex", gap: 8 }}>
{u.id !== me?.id && (
<>
<button onClick={() => toggleActiveMutation.mutate(u.id)}>
{u.is_active ? "Deactivate" : "Activate"}
</button>
<button
onClick={() => handleDelete(u)}
style={{ color: "red" }}
>
Delete
</button>
</>
)}
{u.id === me?.id && (
<span style={{ color: "#999", fontSize: 13 }}>you</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{!showForm ? (
<button onClick={() => setShowForm(true)}>+ Add User</button>
) : (
<form onSubmit={handleSubmit} style={{ maxWidth: 400 }}>
<h2 style={{ marginTop: 0 }}>New User</h2>
<FormField label="Email" type="email" value={form.email}
onChange={(v) => setForm((f) => ({ ...f, email: v }))} required />
<FormField label="Full name" value={form.full_name ?? ""}
onChange={(v) => setForm((f) => ({ ...f, full_name: v }))} />
<FormField label="Password" type="password" value={form.password}
onChange={(v) => setForm((f) => ({ ...f, password: v }))} required />
<div style={{ marginBottom: 12, display: "flex", alignItems: "center", gap: 8 }}>
<input
id="is_admin"
type="checkbox"
checked={form.is_admin ?? false}
onChange={(e) => setForm((f) => ({ ...f, is_admin: e.target.checked }))}
/>
<label htmlFor="is_admin">Grant admin access</label>
</div>
{formError && <p style={{ color: "red" }}>{formError}</p>}
<div style={{ display: "flex", gap: 8 }}>
<button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? "Creating…" : "Create"}
</button>
<button type="button" onClick={() => { setShowForm(false); setFormError(null); }}>
Cancel
</button>
</div>
</form>
)}
</div>
</>
);
}
function FormField({
label, value, onChange, type = "text", required = false,
}: {
label: string; value: string; onChange: (v: string) => void;
type?: string; required?: boolean;
}) {
return (
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", marginBottom: 4 }}>{label}</label>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
style={{ width: "100%", padding: "6px 8px", boxSizing: "border-box" }}
/>
</div>
);
}