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:
@@ -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}",
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user