diff --git a/README.md b/README.md index 91fc8d1..10e2d38 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ A fullstack SaaS web application built with FastAPI, React, and PostgreSQL. - `/api/users/me` — authenticated user info - `/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 -- 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) - 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) diff --git a/TODO.md b/TODO.md index 0c616a4..3db4144 100644 --- a/TODO.md +++ b/TODO.md @@ -8,6 +8,10 @@ ## 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) - [ ] **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 diff --git a/backend/app/deps.py b/backend/app/deps.py index 49b4f1d..df0cc68 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -30,3 +30,14 @@ async def get_current_user( if not user or not user.is_active: raise credentials_exception 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 diff --git a/backend/app/main.py b/backend/app/main.py index 2e21373..0e34990 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware 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") @@ -17,6 +17,7 @@ app.add_middleware( app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(users.router, prefix="/api/users", tags=["users"]) app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) +app.include_router(admin.router, prefix="/api/admin", tags=["admin"]) @app.get("/api/health") diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..e34adc9 --- /dev/null +++ b/backend/app/routers/admin.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index d727fdb..af65d04 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,6 +1,6 @@ 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 @@ -68,8 +68,29 @@ class UserOut(BaseModel): email: str full_name: str | None 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): diff --git a/backend/scripts/seed.py b/backend/scripts/seed.py index 9dd3a82..bd25807 100644 --- a/backend/scripts/seed.py +++ b/backend/scripts/seed.py @@ -16,18 +16,27 @@ TEST_NAME = "Test User" async def seed() -> None: async with AsyncSessionLocal() as db: result = await db.execute(select(User).where(User.email == TEST_EMAIL)) - if result.scalar_one_or_none(): - print(f"[seed] test user already exists: {TEST_EMAIL}") + existing = result.scalar_one_or_none() + + 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 user = User( email=TEST_EMAIL, hashed_password=hash_password(TEST_PASSWORD), full_name=TEST_NAME, + is_superuser=True, ) db.add(user) 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__": diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9666424..44ba8ec 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,16 +1,30 @@ import { Routes, Route, Navigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { useAuth } from "./hooks/useAuth"; +import { getMe } from "./api/client"; import LoginPage from "./pages/LoginPage"; import DashboardPage from "./pages/DashboardPage"; import ProfilePage from "./pages/ProfilePage"; import AppsPage from "./pages/AppsPage"; import SettingsPage from "./pages/SettingsPage"; +import AdminPage from "./pages/AdminPage"; function PrivateRoute({ children }: { children: React.ReactNode }) { const { token } = useAuth(); return token ? <>{children} : ; } +function AdminRoute({ children }: { children: React.ReactNode }) { + const { token } = useAuth(); + const { data: user, isLoading } = useQuery({ queryKey: ["me"], queryFn: getMe }); + + if (!token) return ; + // Wait for the me query before deciding — prevents a flash redirect + if (isLoading) return null; + if (!user?.is_admin) return ; + return <>{children}; +} + export default function App() { return ( @@ -20,6 +34,7 @@ export default function App() { } /> } /> } /> + } /> {/* Catch-all */} } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 287bb80..24dfb3c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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); // --- 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("/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("/admin/users").then((r) => r.data); + +export const adminCreateUser = (data: AdminUserCreate) => + api.post("/admin/users", data).then((r) => r.data); + +export const adminDeleteUser = (userId: string) => + api.delete(`/admin/users/${userId}`); + +export const adminToggleActive = (userId: string) => + api.patch(`/admin/users/${userId}/active`).then((r) => r.data); // --- Profile --- export interface ProfileData { diff --git a/frontend/src/components/Nav.tsx b/frontend/src/components/Nav.tsx index 1e770fe..d3e1871 100644 --- a/frontend/src/components/Nav.tsx +++ b/frontend/src/components/Nav.tsx @@ -1,8 +1,11 @@ import { Link } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { useAuth } from "../hooks/useAuth"; +import { getMe } from "../api/client"; export default function Nav() { const { logout } = useAuth(); + const { data: user } = useQuery({ queryKey: ["me"], queryFn: getMe }); return (