feat: Implement auth user management API and UI for admin users

This commit introduces a new Auth User management feature, allowing admins to create, update, delete, and list users in the auth database. A dedicated API has been implemented with endpoints for managing auth users, including validation for unique email addresses. The frontend has been updated to include a Manage Users page with tabs for backend and frontend users, enhancing the user experience. Additionally, modals for creating and editing auth users have been added, along with appropriate error handling and loading states. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09
2025-11-21 13:33:13 -05:00
parent e6c66e564e
commit 93cb4eda5b
15 changed files with 1348 additions and 188 deletions
+368
View File
@@ -0,0 +1,368 @@
"""Auth database user management endpoints - admin only."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import text
from sqlalchemy.orm import Session
from src.web.api.auth import get_current_user
from src.web.api.users import get_current_admin_user
from src.web.db.session import get_auth_db, get_db
from src.web.schemas.auth_users import (
AuthUserCreateRequest,
AuthUserResponse,
AuthUserUpdateRequest,
AuthUsersListResponse,
)
from src.web.utils.password import hash_password
router = APIRouter(prefix="/auth-users", tags=["auth-users"])
@router.get("", response_model=AuthUsersListResponse)
def list_auth_users(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
auth_db: Session = Depends(get_auth_db),
) -> AuthUsersListResponse:
"""List all users from auth database - admin only."""
try:
# Query users from auth database with all columns from schema
result = auth_db.execute(text("""
SELECT
id,
email,
name,
is_admin,
has_write_access,
created_at,
updated_at
FROM users
ORDER BY COALESCE(name, email) ASC
"""))
rows = result.fetchall()
users = []
for row in rows:
# Access row attributes directly - SQLAlchemy Row objects support attribute access
user_id = int(row.id)
email = str(row.email)
name = row.name if row.name is not None else None
# Get boolean fields - convert to proper boolean
# These columns have defaults so they should always have values
is_admin = bool(row.is_admin)
has_write_access = bool(row.has_write_access)
created_at = row.created_at
updated_at = row.updated_at
users.append(AuthUserResponse(
id=user_id,
name=name,
email=email,
is_admin=is_admin,
has_write_access=has_write_access,
created_at=created_at,
updated_at=updated_at,
))
return AuthUsersListResponse(items=users, total=len(users))
except Exception as e:
import traceback
error_detail = f"Failed to list auth users: {str(e)}\n{traceback.format_exc()}"
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=error_detail,
)
@router.post("", response_model=AuthUserResponse, status_code=status.HTTP_201_CREATED)
def create_auth_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
request: AuthUserCreateRequest,
auth_db: Session = Depends(get_auth_db),
) -> AuthUserResponse:
"""Create a new user in auth database - admin only."""
try:
# Check if user with same email already exists (email is unique)
check_result = auth_db.execute(text("""
SELECT id FROM users
WHERE email = :email
"""), {"email": request.email})
existing = check_result.first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"User with email '{request.email}' already exists",
)
# Insert new user
# Check database dialect for RETURNING support
dialect = auth_db.bind.dialect.name if auth_db.bind else 'postgresql'
supports_returning = dialect == 'postgresql'
# Hash the password
password_hash = hash_password(request.password)
if supports_returning:
result = auth_db.execute(text("""
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
RETURNING id, email, name, is_admin, has_write_access, created_at, updated_at
"""), {
"email": request.email,
"name": request.name,
"password_hash": password_hash,
"is_admin": request.is_admin,
"has_write_access": request.has_write_access,
})
auth_db.commit()
row = result.first()
else:
# SQLite - insert then select
auth_db.execute(text("""
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
"""), {
"email": request.email,
"name": request.name,
"password_hash": password_hash,
"is_admin": request.is_admin,
"has_write_access": request.has_write_access,
})
auth_db.commit()
# Get the last inserted row
result = auth_db.execute(text("""
SELECT id, email, name, is_admin, has_write_access, created_at, updated_at
FROM users
WHERE id = last_insert_rowid()
"""))
row = result.first()
if not row:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create user",
)
is_admin = bool(row.is_admin)
has_write_access = bool(row.has_write_access)
return AuthUserResponse(
id=row.id,
name=getattr(row, 'name', None),
email=row.email,
is_admin=is_admin,
has_write_access=has_write_access,
created_at=getattr(row, 'created_at', None),
updated_at=getattr(row, 'updated_at', None),
)
except HTTPException:
raise
except Exception as e:
auth_db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create auth user: {str(e)}",
)
@router.get("/{user_id}", response_model=AuthUserResponse)
def get_auth_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
user_id: int,
auth_db: Session = Depends(get_auth_db),
) -> AuthUserResponse:
"""Get a specific auth user by ID - admin only."""
try:
result = auth_db.execute(text("""
SELECT id, email, name, is_admin, has_write_access, created_at, updated_at
FROM users
WHERE id = :user_id
"""), {"user_id": user_id})
row = result.first()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Auth user with ID {user_id} not found",
)
is_admin = bool(row.is_admin)
has_write_access = bool(row.has_write_access)
return AuthUserResponse(
id=row.id,
name=getattr(row, 'name', None),
email=row.email,
is_admin=is_admin,
has_write_access=has_write_access,
created_at=getattr(row, 'created_at', None),
updated_at=getattr(row, 'updated_at', None),
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get auth user: {str(e)}",
)
@router.put("/{user_id}", response_model=AuthUserResponse)
def update_auth_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
user_id: int,
request: AuthUserUpdateRequest,
auth_db: Session = Depends(get_auth_db),
) -> AuthUserResponse:
"""Update an auth user - admin only."""
try:
# Check if user exists
check_result = auth_db.execute(text("""
SELECT id FROM users WHERE id = :user_id
"""), {"user_id": user_id})
if not check_result.first():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Auth user with ID {user_id} not found",
)
# Check if email conflicts with another user (email is unique)
check_conflict = auth_db.execute(text("""
SELECT id FROM users
WHERE id != :user_id AND email = :email
"""), {
"user_id": user_id,
"email": request.email,
})
if check_conflict.first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"User with email '{request.email}' already exists",
)
# Update all fields (all are required)
dialect = auth_db.bind.dialect.name if auth_db.bind else 'postgresql'
supports_returning = dialect == 'postgresql'
if supports_returning:
result = auth_db.execute(text("""
UPDATE users
SET email = :email,
name = :name,
is_admin = :is_admin,
has_write_access = :has_write_access
WHERE id = :user_id
RETURNING id, email, name, is_admin, has_write_access, created_at, updated_at
"""), {
"user_id": user_id,
"email": request.email,
"name": request.name,
"is_admin": request.is_admin,
"has_write_access": request.has_write_access,
})
auth_db.commit()
row = result.first()
else:
# SQLite - update then select
auth_db.execute(text("""
UPDATE users
SET email = :email,
name = :name,
is_admin = :is_admin,
has_write_access = :has_write_access
WHERE id = :user_id
"""), {
"user_id": user_id,
"email": request.email,
"name": request.name,
"is_admin": request.is_admin,
"has_write_access": request.has_write_access,
})
auth_db.commit()
# Get the updated row
result = auth_db.execute(text("""
SELECT id, email, name, is_admin, has_write_access, created_at, updated_at
FROM users
WHERE id = :user_id
"""), {"user_id": user_id})
row = result.first()
if not row:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update user",
)
is_admin = bool(row.is_admin)
has_write_access = bool(row.has_write_access)
return AuthUserResponse(
id=row.id,
name=getattr(row, 'name', None),
email=row.email,
is_admin=is_admin,
has_write_access=has_write_access,
created_at=getattr(row, 'created_at', None),
updated_at=getattr(row, 'updated_at', None),
)
except HTTPException:
raise
except Exception as e:
auth_db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update auth user: {str(e)}",
)
@router.delete("/{user_id}")
def delete_auth_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
user_id: int,
auth_db: Session = Depends(get_auth_db),
) -> Response:
"""Delete an auth user - admin only."""
try:
# Check if user exists
check_result = auth_db.execute(text("""
SELECT id FROM users WHERE id = :user_id
"""), {"user_id": user_id})
if not check_result.first():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Auth user with ID {user_id} not found",
)
# Delete user
auth_db.execute(text("""
DELETE FROM users WHERE id = :user_id
"""), {"user_id": user_id})
auth_db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except HTTPException:
raise
except Exception as e:
auth_db.rollback()
error_str = str(e)
# Check for permission errors
if "permission denied" in error_str.lower() or "insufficient privilege" in error_str.lower():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permission denied: The database user does not have DELETE permission on the users table. Please contact your database administrator.",
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete auth user: {error_str}",
)
+17
View File
@@ -113,6 +113,14 @@ def create_user(
detail=f"Username '{request.username}' already exists",
)
# Check if email already exists
existing_email = db.query(User).filter(User.email == request.email).first()
if existing_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Email address '{request.email}' is already in use",
)
# Hash the password before storing
password_hash = hash_password(request.password)
@@ -174,6 +182,15 @@ def update_user(
detail="Cannot remove your own admin status",
)
# Check if email is being changed and if the new email already exists
if request.email is not None and request.email != user.email:
existing_email = db.query(User).filter(User.email == request.email).first()
if existing_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Email address '{request.email}' is already in use",
)
# Update fields if provided
if request.password is not None:
user.password_hash = hash_password(request.password)
+52 -1
View File
@@ -22,6 +22,7 @@ from src.web.api.reported_photos import router as reported_photos_router
from src.web.api.pending_photos import router as pending_photos_router
from src.web.api.tags import router as tags_router
from src.web.api.users import router as users_router
from src.web.api.auth_users import router as auth_users_router
from src.web.api.version import router as version_router
from src.web.settings import APP_TITLE, APP_VERSION
from src.web.db.base import Base, engine
@@ -172,6 +173,54 @@ def ensure_user_password_change_required_column(inspector) -> None:
print("✅ Added password_change_required column to users table")
def ensure_user_email_unique_constraint(inspector) -> None:
"""Ensure users table email column has a unique constraint."""
if "users" not in inspector.get_table_names():
return
# Check if email column exists
columns = {col["name"] for col in inspector.get_columns("users")}
if "email" not in columns:
print("️ email column does not exist in users table yet")
return
# Check if unique constraint already exists on email
dialect = engine.dialect.name
with engine.connect() as connection:
if dialect == "postgresql":
# Check if unique constraint exists
result = connection.execute(text("""
SELECT constraint_name
FROM information_schema.table_constraints
WHERE table_name = 'users'
AND constraint_type = 'UNIQUE'
AND constraint_name LIKE '%email%'
"""))
if result.first():
print("️ Unique constraint on email column already exists")
return
# Try to add unique constraint (will fail if duplicates exist)
try:
print("🔄 Adding unique constraint to email column...")
connection.execute(text("ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email)"))
connection.commit()
print("✅ Added unique constraint to email column")
except Exception as e:
# If constraint already exists or duplicates exist, that's okay
# API validation will prevent new duplicates
if "already exists" in str(e).lower() or "duplicate" in str(e).lower():
print(f"️ Could not add unique constraint (may have duplicates): {e}")
else:
print(f"⚠️ Could not add unique constraint: {e}")
else:
# SQLite - unique constraint is handled at column level
# Check if column already has unique constraint
# SQLite doesn't easily support adding unique constraints to existing columns
# The model definition will handle it for new tables
print("️ SQLite: Unique constraint on email will be enforced by model definition for new tables")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown events."""
@@ -205,6 +254,7 @@ async def lifespan(app: FastAPI):
# Ensure new columns exist (backward compatibility without migrations)
ensure_user_password_hash_column(inspector)
ensure_user_password_change_required_column(inspector)
ensure_user_email_unique_constraint(inspector)
except Exception as exc:
print(f"❌ Database initialization failed: {exc}")
raise
@@ -243,7 +293,8 @@ def create_app() -> FastAPI:
app.include_router(reported_photos_router, prefix="/api/v1")
app.include_router(pending_photos_router, prefix="/api/v1")
app.include_router(tags_router, prefix="/api/v1")
app.include_router(users_router, prefix="/api/v1")
app.include_router(users_router, prefix="/api/v1")
app.include_router(auth_users_router, prefix="/api/v1")
return app
+3 -2
View File
@@ -204,8 +204,8 @@ class User(Base):
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
username = Column(Text, unique=True, nullable=False, index=True)
password_hash = Column(Text, nullable=False) # Hashed password
email = Column(Text, nullable=True)
full_name = Column(Text, nullable=True)
email = Column(Text, unique=True, nullable=False, index=True)
full_name = Column(Text, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False, index=True)
password_change_required = Column(Boolean, default=True, nullable=False, index=True)
@@ -214,6 +214,7 @@ class User(Base):
__table_args__ = (
Index("idx_users_username", "username"),
Index("idx_users_email", "email"),
Index("idx_users_is_admin", "is_admin"),
Index("idx_users_password_change_required", "password_change_required"),
)
+56
View File
@@ -0,0 +1,56 @@
"""Auth database user management schemas for web API."""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class AuthUserResponse(BaseModel):
"""Auth user DTO returned from API."""
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: int
name: Optional[str] = None
email: str
is_admin: Optional[bool] = None
has_write_access: Optional[bool] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class AuthUserCreateRequest(BaseModel):
"""Request payload to create a new auth user."""
model_config = ConfigDict(protected_namespaces=())
email: EmailStr = Field(..., description="Email address (unique, required)")
name: str = Field(..., min_length=1, max_length=200, description="Name (required)")
password: str = Field(..., min_length=6, description="Password (minimum 6 characters, required)")
is_admin: bool = Field(..., description="Admin role (required)")
has_write_access: bool = Field(..., description="Write access (required)")
class AuthUserUpdateRequest(BaseModel):
"""Request payload to update an auth user."""
model_config = ConfigDict(protected_namespaces=())
email: EmailStr = Field(..., description="Email address (required)")
name: str = Field(..., min_length=1, max_length=200, description="Name (required)")
is_admin: bool = Field(..., description="Admin role (required)")
has_write_access: bool = Field(..., description="Write access (required)")
class AuthUsersListResponse(BaseModel):
"""List of auth users."""
model_config = ConfigDict(protected_namespaces=())
items: list[AuthUserResponse]
total: int
+4 -4
View File
@@ -31,8 +31,8 @@ class UserCreateRequest(BaseModel):
username: str = Field(..., min_length=1, max_length=100)
password: str = Field(..., min_length=6, description="Password (minimum 6 characters)")
email: Optional[EmailStr] = None
full_name: Optional[str] = Field(None, max_length=200)
email: EmailStr = Field(..., description="Email address (required)")
full_name: str = Field(..., min_length=1, max_length=200, description="Full name (required)")
is_active: bool = True
is_admin: bool = False
@@ -43,8 +43,8 @@ class UserUpdateRequest(BaseModel):
model_config = ConfigDict(protected_namespaces=())
password: Optional[str] = Field(None, min_length=6, description="New password (minimum 6 characters, leave empty to keep current)")
email: Optional[EmailStr] = None
full_name: Optional[str] = Field(None, max_length=200)
email: EmailStr = Field(..., description="Email address (required)")
full_name: str = Field(..., min_length=1, max_length=200, description="Full name (required)")
is_active: Optional[bool] = None
is_admin: Optional[bool] = None