feat: Add frontend permission option for user creation and enhance validation error handling

This commit introduces a new `give_frontend_permission` field in the user creation request, allowing admins to create users with frontend access. The frontend has been updated to include validation for required fields and improved error messaging for Pydantic validation errors. Additionally, the backend has been modified to handle the creation of users in the auth database if frontend permission is granted. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09 2025-11-24 14:21:56 -05:00
parent 100d39c556
commit dbffaef298
4 changed files with 289 additions and 18 deletions

View File

@ -18,6 +18,7 @@ export interface UserCreateRequest {
full_name: string
is_active?: boolean
is_admin?: boolean
give_frontend_permission?: boolean
}
export interface UserUpdateRequest {

View File

@ -4,6 +4,60 @@ import { authUsersApi, AuthUserResponse, AuthUserCreateRequest, AuthUserUpdateRe
type TabType = 'backend' | 'frontend'
/**
* Format Pydantic validation errors into user-friendly messages
*/
function formatValidationError(error: any): string {
// Get the field name from location array (e.g., ['body', 'email'] -> 'email')
const fieldPath = error.loc || []
const fieldName = fieldPath[fieldPath.length - 1] || 'field'
// Convert field names to friendly labels
const fieldLabels: Record<string, string> = {
email: 'Email',
password: 'Password',
username: 'Username',
full_name: 'Full Name',
name: 'Name',
is_admin: 'Role',
is_active: 'Status',
has_write_access: 'Write Access',
give_frontend_permission: 'Frontend Permission',
}
const friendlyFieldName = fieldLabels[fieldName] || fieldName.charAt(0).toUpperCase() + fieldName.slice(1)
// Get the error message
let message = error.msg || 'Invalid value'
// Simplify common error messages
if (message.includes('not a valid email address') || message.includes('email address must have an @-sign')) {
message = 'Please enter a valid email address'
} else if (message.includes('ensure this value has at least') || message.includes('at least')) {
const match = message.match(/at least (\d+)/i)
if (match && fieldName === 'password') {
message = `Password must be at least ${match[1]} characters long`
} else if (match) {
message = `Must be at least ${match[1]} characters long`
}
} else if (message.includes('ensure this value has at most') || message.includes('at most')) {
const match = message.match(/at most (\d+)/i)
if (match) {
message = `Must be no more than ${match[1]} characters long`
}
} else if (message.includes('field required') || message.includes('required')) {
message = 'This field is required'
} else if (message.includes('string type expected')) {
message = 'Please enter text'
} else if (message.includes('value is not a valid')) {
// Remove the technical "value is not a valid" prefix
message = message.replace(/^value is not a valid\s*/i, '')
message = `Please enter a valid ${message}`
}
return `${friendlyFieldName}: ${message}`
}
export default function ManageUsers() {
const [activeTab, setActiveTab] = useState<TabType>('backend')
@ -23,6 +77,7 @@ export default function ManageUsers() {
full_name: '',
is_active: true,
is_admin: false,
give_frontend_permission: false,
})
const [editForm, setEditForm] = useState<UserUpdateRequest>({
@ -95,6 +150,25 @@ export default function ManageUsers() {
const handleCreate = async () => {
try {
setError(null)
// Frontend validation
if (!createForm.username || createForm.username.trim() === '') {
setError('Username is required')
return
}
if (!createForm.password || createForm.password.length < 6) {
setError('Password must be at least 6 characters long')
return
}
if (!createForm.email || createForm.email.trim() === '') {
setError('Email is required')
return
}
if (!createForm.full_name || createForm.full_name.trim() === '') {
setError('Full name is required')
return
}
await usersApi.createUser(createForm)
setShowCreateModal(false)
setCreateForm({
@ -104,16 +178,56 @@ export default function ManageUsers() {
full_name: '',
is_active: true,
is_admin: false,
give_frontend_permission: false,
})
loadUsers()
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to create user')
// Handle validation errors (422) - Pydantic returns array of errors
if (err.response?.status === 422) {
const detail = err.response?.data?.detail
if (Array.isArray(detail)) {
// Format Pydantic validation errors into user-friendly messages
const errorMessages = detail.map(formatValidationError)
setError(errorMessages.join('; ') || 'Please check the form and try again')
} else {
setError(detail || 'Please check the form and try again')
}
} else {
// Handle other errors (400, 500, etc.)
const detail = err.response?.data?.detail
if (typeof detail === 'string') {
setError(detail)
} else if (Array.isArray(detail)) {
const errorMessages = detail.map((error: any) => {
if (typeof error === 'string') return error
return formatValidationError(error)
})
setError(errorMessages.join('; '))
} else {
setError(err.response?.data?.message || 'Failed to create user')
}
}
}
}
const handleAuthCreate = async () => {
try {
setAuthError(null)
// Frontend validation
if (!authCreateForm.email || authCreateForm.email.trim() === '') {
setAuthError('Email is required')
return
}
if (!authCreateForm.name || authCreateForm.name.trim() === '') {
setAuthError('Name is required')
return
}
if (!authCreateForm.password || authCreateForm.password.length < 6) {
setAuthError('Password must be at least 6 characters long')
return
}
await authUsersApi.createUser(authCreateForm)
setShowAuthCreateModal(false)
setAuthCreateForm({
@ -125,7 +239,31 @@ export default function ManageUsers() {
})
loadAuthUsers()
} catch (err: any) {
setAuthError(err.response?.data?.detail || 'Failed to create auth user')
// Handle validation errors (422) - Pydantic returns array of errors
if (err.response?.status === 422) {
const detail = err.response?.data?.detail
if (Array.isArray(detail)) {
// Format Pydantic validation errors into user-friendly messages
const errorMessages = detail.map(formatValidationError)
setAuthError(errorMessages.join('; ') || 'Please check the form and try again')
} else {
setAuthError(detail || 'Please check the form and try again')
}
} else {
// Handle other errors (400, 500, etc.)
const detail = err.response?.data?.detail
if (typeof detail === 'string') {
setAuthError(detail)
} else if (Array.isArray(detail)) {
const errorMessages = detail.map((error: any) => {
if (typeof error === 'string') return error
return formatValidationError(error)
})
setAuthError(errorMessages.join('; '))
} else {
setAuthError(err.response?.data?.message || 'Failed to create auth user')
}
}
}
}
@ -243,8 +381,26 @@ export default function ManageUsers() {
<button
onClick={() => {
if (activeTab === 'backend') {
setError(null)
setCreateForm({
username: '',
password: '',
email: '',
full_name: '',
is_active: true,
is_admin: false,
give_frontend_permission: false,
})
setShowCreateModal(true)
} else {
setAuthError(null)
setAuthCreateForm({
email: '',
name: '',
password: '',
is_admin: false,
has_write_access: false,
})
setShowAuthCreateModal(true)
}
}}
@ -280,18 +436,6 @@ export default function ManageUsers() {
</nav>
</div>
{/* Error Messages */}
{error && activeTab === 'backend' && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
{error}
</div>
)}
{authError && activeTab === 'frontend' && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
{authError}
</div>
)}
{/* Backend Users Tab */}
{activeTab === 'backend' && (
<>
@ -519,6 +663,11 @@ export default function ManageUsers() {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Create New User</h2>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
@ -607,10 +756,35 @@ export default function ManageUsers() {
<option value="admin">Admin</option>
</select>
</div>
<div className="flex items-center">
<label className="flex items-center">
<input
type="checkbox"
checked={createForm.give_frontend_permission || false}
onChange={(e) =>
setCreateForm({ ...createForm, give_frontend_permission: e.target.checked })
}
className="mr-2"
/>
<span className="text-sm text-gray-700">Give the user Frontend permission</span>
</label>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setShowCreateModal(false)}
onClick={() => {
setError(null)
setCreateForm({
username: '',
password: '',
email: '',
full_name: '',
is_active: true,
is_admin: false,
give_frontend_permission: false,
})
setShowCreateModal(false)
}}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
>
Cancel
@ -734,6 +908,11 @@ export default function ManageUsers() {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Create New Front End User</h2>
{authError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{authError}
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
@ -814,7 +993,17 @@ export default function ManageUsers() {
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setShowAuthCreateModal(false)}
onClick={() => {
setAuthError(null)
setAuthCreateForm({
email: '',
name: '',
password: '',
is_admin: false,
has_write_access: false,
})
setShowAuthCreateModal(false)
}}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
>
Cancel

View File

@ -5,10 +5,11 @@ 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.db.session import get_db
from src.web.db.session import get_auth_db, get_db
from src.web.db.models import User
from src.web.schemas.users import (
UserCreateRequest,
@ -21,6 +22,15 @@ from src.web.utils.password import hash_password
router = APIRouter(prefix="/users", tags=["users"])
def get_auth_db_optional() -> Session | None:
"""Get auth database session if available, otherwise return None."""
try:
return next(get_auth_db())
except ValueError:
# Auth database not configured
return None
def get_current_admin_user(
current_user: Annotated[dict, Depends(get_current_user)],
db: Session = Depends(get_db),
@ -104,7 +114,11 @@ def create_user(
request: UserCreateRequest,
db: Session = Depends(get_db),
) -> UserResponse:
"""Create a new user - admin only."""
"""Create a new user - admin only.
If give_frontend_permission is True, also creates the user in the auth database
for frontend access.
"""
# Check if username already exists
existing_user = db.query(User).filter(User.username == request.username).first()
if existing_user:
@ -137,6 +151,72 @@ def create_user(
db.commit()
db.refresh(user)
# If frontend permission is requested, create user in auth database
if request.give_frontend_permission:
auth_db = get_auth_db_optional()
if auth_db is None:
# Auth database not configured - this is okay, just continue
# The backend user was created successfully
pass
else:
try:
# Check if user with same email already exists in auth db
check_result = auth_db.execute(text("""
SELECT id FROM users
WHERE email = :email
"""), {"email": request.email})
existing_auth = check_result.first()
if existing_auth:
# User already exists in auth db, skip creation
# This is not an error - user might have been created separately
pass
else:
# Insert new user in auth database
# Check database dialect for RETURNING support
dialect = auth_db.bind.dialect.name if auth_db.bind else 'postgresql'
supports_returning = dialect == 'postgresql'
# Set has_write_access based on admin status
# Admins get write access by default, regular users don't
has_write_access = request.is_admin
# Use the same password hash
if supports_returning:
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.full_name,
"password_hash": password_hash,
"is_admin": request.is_admin,
"has_write_access": has_write_access,
})
auth_db.commit()
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.full_name,
"password_hash": password_hash,
"is_admin": request.is_admin,
"has_write_access": has_write_access,
})
auth_db.commit()
except Exception as e:
# If auth user creation fails, rollback and log but don't fail the whole request
# The backend user was already created successfully
auth_db.rollback()
# In production, you might want to log this to a proper logging system
import traceback
print(f"Warning: Failed to create auth user: {str(e)}\n{traceback.format_exc()}")
finally:
auth_db.close()
return UserResponse.model_validate(user)

View File

@ -35,6 +35,7 @@ class UserCreateRequest(BaseModel):
full_name: str = Field(..., min_length=1, max_length=200, description="Full name (required)")
is_active: bool = True
is_admin: bool = False
give_frontend_permission: bool = Field(False, description="Create user in auth database for frontend access")
class UserUpdateRequest(BaseModel):