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:
parent
100d39c556
commit
dbffaef298
@ -18,6 +18,7 @@ export interface UserCreateRequest {
|
||||
full_name: string
|
||||
is_active?: boolean
|
||||
is_admin?: boolean
|
||||
give_frontend_permission?: boolean
|
||||
}
|
||||
|
||||
export interface UserUpdateRequest {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user