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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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):
|
||||||
|
|||||||
+11
-2
@@ -16,7 +16,15 @@ 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()
|
||||||
|
|
||||||
|
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}")
|
print(f"[seed] test user already exists: {TEST_EMAIL}")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -24,10 +32,11 @@ async def seed() -> None:
|
|||||||
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__":
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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" }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user