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:
parent
e6c66e564e
commit
93cb4eda5b
65
frontend/src/api/authUsers.ts
Normal file
65
frontend/src/api/authUsers.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface AuthUserResponse {
|
||||
id: number
|
||||
name: string | null
|
||||
email: string
|
||||
is_admin: boolean | null
|
||||
has_write_access: boolean | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface AuthUserCreateRequest {
|
||||
email: string
|
||||
name: string
|
||||
password: string
|
||||
is_admin: boolean
|
||||
has_write_access: boolean
|
||||
}
|
||||
|
||||
export interface AuthUserUpdateRequest {
|
||||
email: string
|
||||
name: string
|
||||
is_admin: boolean
|
||||
has_write_access: boolean
|
||||
}
|
||||
|
||||
export interface AuthUsersListResponse {
|
||||
items: AuthUserResponse[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export const authUsersApi = {
|
||||
listUsers: async (): Promise<AuthUsersListResponse> => {
|
||||
const { data } = await apiClient.get<AuthUsersListResponse>('/api/v1/auth-users')
|
||||
return data
|
||||
},
|
||||
|
||||
getUser: async (userId: number): Promise<AuthUserResponse> => {
|
||||
const { data } = await apiClient.get<AuthUserResponse>(`/api/v1/auth-users/${userId}`)
|
||||
return data
|
||||
},
|
||||
|
||||
createUser: async (request: AuthUserCreateRequest): Promise<AuthUserResponse> => {
|
||||
const { data } = await apiClient.post<AuthUserResponse>('/api/v1/auth-users', request)
|
||||
return data
|
||||
},
|
||||
|
||||
updateUser: async (
|
||||
userId: number,
|
||||
request: AuthUserUpdateRequest
|
||||
): Promise<AuthUserResponse> => {
|
||||
const { data } = await apiClient.put<AuthUserResponse>(
|
||||
`/api/v1/auth-users/${userId}`,
|
||||
request
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
deleteUser: async (userId: number): Promise<void> => {
|
||||
await apiClient.delete(`/api/v1/auth-users/${userId}`)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -23,11 +23,20 @@ apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Don't redirect if we're already on the login page (prevents clearing error messages)
|
||||
const isLoginPage = window.location.pathname === '/login'
|
||||
|
||||
// Always clear tokens on 401, but only redirect if not already on login page
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
// Clear sessionStorage settings on authentication failure
|
||||
sessionStorage.removeItem('identify_settings')
|
||||
window.location.href = '/login'
|
||||
|
||||
// Only redirect if not already on login page
|
||||
if (!isLoginPage) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
// If on login page, just reject the error so the login component can handle it
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
@ -14,16 +14,16 @@ export interface UserResponse {
|
||||
export interface UserCreateRequest {
|
||||
username: string
|
||||
password: string
|
||||
email?: string | null
|
||||
full_name?: string | null
|
||||
email: string
|
||||
full_name: string
|
||||
is_active?: boolean
|
||||
is_admin?: boolean
|
||||
}
|
||||
|
||||
export interface UserUpdateRequest {
|
||||
password?: string | null
|
||||
email?: string | null
|
||||
full_name?: string | null
|
||||
email: string
|
||||
full_name: string
|
||||
is_active?: boolean
|
||||
is_admin?: boolean
|
||||
}
|
||||
|
||||
@ -11,13 +11,16 @@ export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated) {
|
||||
// Only redirect if user is already authenticated (e.g., visiting /login while logged in)
|
||||
// Don't redirect on isLoading changes during login attempts
|
||||
if (isAuthenticated && !isLoading) {
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
}, [isAuthenticated, isLoading, navigate])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
@ -35,7 +38,8 @@ export default function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
// Only show loading screen on initial auth check, not during login attempts
|
||||
if (isLoading && !loading) {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { usersApi, UserResponse, UserCreateRequest, UserUpdateRequest } from '../api/users'
|
||||
import { authUsersApi, AuthUserResponse, AuthUserCreateRequest, AuthUserUpdateRequest } from '../api/authUsers'
|
||||
|
||||
type TabType = 'backend' | 'frontend'
|
||||
|
||||
export default function ManageUsers() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('backend')
|
||||
|
||||
// Backend users state
|
||||
const [users, setUsers] = useState<UserResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@ -27,9 +33,35 @@ export default function ManageUsers() {
|
||||
is_admin: false,
|
||||
})
|
||||
|
||||
// Frontend users state
|
||||
const [authUsers, setAuthUsers] = useState<AuthUserResponse[]>([])
|
||||
const [authLoading, setAuthLoading] = useState(true)
|
||||
const [authError, setAuthError] = useState<string | null>(null)
|
||||
const [showAuthCreateModal, setShowAuthCreateModal] = useState(false)
|
||||
const [editingAuthUser, setEditingAuthUser] = useState<AuthUserResponse | null>(null)
|
||||
|
||||
const [authCreateForm, setAuthCreateForm] = useState<AuthUserCreateRequest>({
|
||||
email: '',
|
||||
name: '',
|
||||
password: '',
|
||||
is_admin: false,
|
||||
has_write_access: false,
|
||||
})
|
||||
|
||||
const [authEditForm, setAuthEditForm] = useState<AuthUserUpdateRequest>({
|
||||
email: '',
|
||||
name: '',
|
||||
is_admin: false,
|
||||
has_write_access: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [filterActive, filterAdmin])
|
||||
if (activeTab === 'backend') {
|
||||
loadUsers()
|
||||
} else {
|
||||
loadAuthUsers()
|
||||
}
|
||||
}, [activeTab, filterActive, filterAdmin])
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
@ -47,6 +79,19 @@ export default function ManageUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadAuthUsers = async () => {
|
||||
try {
|
||||
setAuthLoading(true)
|
||||
setAuthError(null)
|
||||
const response = await authUsersApi.listUsers()
|
||||
setAuthUsers(response.items)
|
||||
} catch (err: any) {
|
||||
setAuthError(err.response?.data?.detail || 'Failed to load auth users')
|
||||
} finally {
|
||||
setAuthLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
@ -66,11 +111,28 @@ export default function ManageUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAuthCreate = async () => {
|
||||
try {
|
||||
setAuthError(null)
|
||||
await authUsersApi.createUser(authCreateForm)
|
||||
setShowAuthCreateModal(false)
|
||||
setAuthCreateForm({
|
||||
email: '',
|
||||
name: '',
|
||||
password: '',
|
||||
is_admin: false,
|
||||
has_write_access: false,
|
||||
})
|
||||
loadAuthUsers()
|
||||
} catch (err: any) {
|
||||
setAuthError(err.response?.data?.detail || 'Failed to create auth user')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editingUser) return
|
||||
try {
|
||||
setError(null)
|
||||
// Only send password if it's not empty
|
||||
const updateData: UserUpdateRequest = {
|
||||
...editForm,
|
||||
password: editForm.password && editForm.password.trim() !== ''
|
||||
@ -92,6 +154,30 @@ export default function ManageUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAuthUpdate = async () => {
|
||||
if (!editingAuthUser) return
|
||||
try {
|
||||
setAuthError(null)
|
||||
const updateData: AuthUserUpdateRequest = {
|
||||
email: authEditForm.email,
|
||||
name: authEditForm.name,
|
||||
is_admin: authEditForm.is_admin,
|
||||
has_write_access: authEditForm.has_write_access,
|
||||
}
|
||||
await authUsersApi.updateUser(editingAuthUser.id, updateData)
|
||||
setEditingAuthUser(null)
|
||||
setAuthEditForm({
|
||||
email: '',
|
||||
name: '',
|
||||
is_admin: false,
|
||||
has_write_access: false,
|
||||
})
|
||||
loadAuthUsers()
|
||||
} catch (err: any) {
|
||||
setAuthError(err.response?.data?.detail || 'Failed to update auth user')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (userId: number, username: string) => {
|
||||
if (!confirm(`Are you sure you want to delete user "${username}"?`)) {
|
||||
return
|
||||
@ -105,6 +191,19 @@ export default function ManageUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAuthDelete = async (userId: number, name: string) => {
|
||||
if (!confirm(`Are you sure you want to delete user "${name}"?`)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
setAuthError(null)
|
||||
await authUsersApi.deleteUser(userId)
|
||||
loadAuthUsers()
|
||||
} catch (err: any) {
|
||||
setAuthError(err.response?.data?.detail || 'Failed to delete auth user')
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (user: UserResponse) => {
|
||||
setEditingUser(user)
|
||||
setEditForm({
|
||||
@ -116,160 +215,306 @@ export default function ManageUsers() {
|
||||
})
|
||||
}
|
||||
|
||||
const startAuthEdit = (user: AuthUserResponse) => {
|
||||
setEditingAuthUser(user)
|
||||
setAuthEditForm({
|
||||
email: user.email || '',
|
||||
name: user.name || '',
|
||||
is_admin: user.is_admin === true,
|
||||
has_write_access: user.has_write_access === true,
|
||||
})
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Never'
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
|
||||
const formatDateShort = (dateString: string | null) => {
|
||||
if (!dateString) return '-'
|
||||
const date = new Date(dateString)
|
||||
return date.toISOString().split('T')[0] // YYYY-MM-DD format
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Manage Users</h1>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
onClick={() => {
|
||||
if (activeTab === 'backend') {
|
||||
setShowCreateModal(true)
|
||||
} else {
|
||||
setShowAuthCreateModal(true)
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
+ Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('backend')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'backend'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Back end users
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('frontend')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'frontend'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Front End Users
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div className="flex gap-4 items-center">
|
||||
<label className="text-sm font-medium text-gray-700">Filters:</label>
|
||||
<select
|
||||
value={filterActive === null ? 'all' : filterActive ? 'active' : 'inactive'}
|
||||
onChange={(e) =>
|
||||
setFilterActive(
|
||||
e.target.value === 'all' ? null : e.target.value === 'active'
|
||||
)
|
||||
}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterAdmin === null ? 'all' : filterAdmin ? 'admin' : 'user'}
|
||||
onChange={(e) =>
|
||||
setFilterAdmin(
|
||||
e.target.value === 'all' ? null : e.target.value === 'admin'
|
||||
)
|
||||
}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="all">All Roles</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">User</option>
|
||||
</select>
|
||||
{authError && activeTab === 'frontend' && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
|
||||
{authError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">Loading users...</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No users found</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Username
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Full Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Last Login
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{user.username}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{user.email || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{user.full_name || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
user.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
user.is_admin
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{user.is_admin ? 'Admin' : 'User'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(user.created_date)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(user.last_login)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => startEdit(user)}
|
||||
className="text-blue-600 hover:text-blue-900 mr-4"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user.id, user.username)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
{/* Backend Users Tab */}
|
||||
{activeTab === 'backend' && (
|
||||
<>
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div className="flex gap-4 items-center">
|
||||
<label className="text-sm font-medium text-gray-700">Filters:</label>
|
||||
<select
|
||||
value={filterActive === null ? 'all' : filterActive ? 'active' : 'inactive'}
|
||||
onChange={(e) =>
|
||||
setFilterActive(
|
||||
e.target.value === 'all' ? null : e.target.value === 'active'
|
||||
)
|
||||
}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterAdmin === null ? 'all' : filterAdmin ? 'admin' : 'user'}
|
||||
onChange={(e) =>
|
||||
setFilterAdmin(
|
||||
e.target.value === 'all' ? null : e.target.value === 'admin'
|
||||
)
|
||||
}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="all">All Roles</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">User</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">Loading users...</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No users found</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Username
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Full Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Last Login
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{user.username}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{user.email || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{user.full_name || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
user.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
user.is_admin
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{user.is_admin ? 'Admin' : 'User'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(user.created_date)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(user.last_login)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => startEdit(user)}
|
||||
className="text-blue-600 hover:text-blue-900 mr-4"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user.id, user.username)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Frontend Users Tab */}
|
||||
{activeTab === 'frontend' && (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{authLoading ? (
|
||||
<div className="p-8 text-center text-gray-500">Loading users...</div>
|
||||
) : authUsers.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No users found</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Write Access
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{authUsers.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{user.email || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{user.name || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
user.is_admin === true
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{user.is_admin === true ? 'Admin' : 'User'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
user.has_write_access === true
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{user.has_write_access === true ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(user.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => startAuthEdit(user)}
|
||||
className="text-blue-600 hover:text-blue-900 mr-4"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAuthDelete(user.id, user.name || user.email)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
{/* Backend Create Modal */}
|
||||
{showCreateModal && (
|
||||
<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">
|
||||
@ -306,7 +551,7 @@ export default function ManageUsers() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
@ -315,11 +560,12 @@ export default function ManageUsers() {
|
||||
setCreateForm({ ...createForm, email: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Full Name
|
||||
Full Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@ -328,6 +574,8 @@ export default function ManageUsers() {
|
||||
setCreateForm({ ...createForm, full_name: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
minLength={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
@ -342,17 +590,22 @@ export default function ManageUsers() {
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Active</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createForm.is_admin}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, is_admin: e.target.checked })
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Admin</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Role *
|
||||
</label>
|
||||
<select
|
||||
value={createForm.is_admin ? 'admin' : 'user'}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, is_admin: e.target.value === 'admin' })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
@ -373,7 +626,7 @@ export default function ManageUsers() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{/* Backend Edit Modal */}
|
||||
{editingUser && (
|
||||
<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">
|
||||
@ -401,7 +654,7 @@ export default function ManageUsers() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
@ -410,11 +663,12 @@ export default function ManageUsers() {
|
||||
setEditForm({ ...editForm, email: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Full Name
|
||||
Full Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@ -423,6 +677,8 @@ export default function ManageUsers() {
|
||||
setEditForm({ ...editForm, full_name: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
minLength={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
@ -437,17 +693,22 @@ export default function ManageUsers() {
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Active</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.is_admin ?? false}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, is_admin: e.target.checked })
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Admin</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Role *
|
||||
</label>
|
||||
<select
|
||||
value={editForm.is_admin ?? false ? 'admin' : 'user'}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, is_admin: e.target.value === 'admin' })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
@ -467,7 +728,186 @@ export default function ManageUsers() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Frontend Create Modal */}
|
||||
{showAuthCreateModal && (
|
||||
<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>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={authCreateForm.email}
|
||||
onChange={(e) =>
|
||||
setAuthCreateForm({ ...authCreateForm, email: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={authCreateForm.name || ''}
|
||||
onChange={(e) =>
|
||||
setAuthCreateForm({ ...authCreateForm, name: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
minLength={1}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password * (min 6 characters)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={authCreateForm.password}
|
||||
onChange={(e) =>
|
||||
setAuthCreateForm({ ...authCreateForm, password: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Role *
|
||||
</label>
|
||||
<select
|
||||
value={authCreateForm.is_admin ? 'admin' : 'user'}
|
||||
onChange={(e) =>
|
||||
setAuthCreateForm({ ...authCreateForm, is_admin: e.target.value === 'admin' })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Write Access *
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={authCreateForm.has_write_access}
|
||||
onChange={(e) =>
|
||||
setAuthCreateForm({ ...authCreateForm, has_write_access: e.target.checked })
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Grant write access</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowAuthCreateModal(false)}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAuthCreate}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Frontend Edit Modal */}
|
||||
{editingAuthUser && (
|
||||
<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">
|
||||
Edit Front End User: {editingAuthUser.name || editingAuthUser.email}
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={authEditForm.email}
|
||||
onChange={(e) =>
|
||||
setAuthEditForm({ ...authEditForm, email: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={authEditForm.name}
|
||||
onChange={(e) =>
|
||||
setAuthEditForm({ ...authEditForm, name: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={authEditForm.is_admin}
|
||||
onChange={(e) =>
|
||||
setAuthEditForm({ ...authEditForm, is_admin: e.target.checked })
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Admin</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={authEditForm.has_write_access}
|
||||
onChange={(e) =>
|
||||
setAuthEditForm({ ...authEditForm, has_write_access: e.target.checked })
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Write Access</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setEditingAuthUser(null)}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAuthUpdate}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
64
scripts/grant_auth_db_delete_permission.sh
Executable file
64
scripts/grant_auth_db_delete_permission.sh
Executable file
@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
# Grant DELETE permission on auth database users table
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔧 Granting DELETE permission on auth database users table..."
|
||||
|
||||
# Check if .env file exists
|
||||
ENV_FILE=".env"
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "❌ Error: .env file not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract DATABASE_URL_AUTH from .env file
|
||||
AUTH_DB_URL=$(grep "^DATABASE_URL_AUTH=" "$ENV_FILE" | cut -d '=' -f2- | tr -d '"' | tr -d "'")
|
||||
|
||||
if [ -z "$AUTH_DB_URL" ]; then
|
||||
echo "❌ Error: DATABASE_URL_AUTH not found in .env file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse the database URL
|
||||
# Format: postgresql+psycopg2://user:password@host:port/database
|
||||
# or: postgresql://user:password@host:port/database
|
||||
|
||||
# Remove postgresql+psycopg2:// prefix if present
|
||||
AUTH_DB_URL=${AUTH_DB_URL#postgresql+psycopg2://}
|
||||
AUTH_DB_URL=${AUTH_DB_URL#postgresql://}
|
||||
|
||||
# Extract components
|
||||
# Split by @ to get user:pass and host:port/db
|
||||
IFS='@' read -r CREDENTIALS REST <<< "$AUTH_DB_URL"
|
||||
IFS=':' read -r DB_USER DB_PASS <<< "$CREDENTIALS"
|
||||
|
||||
# Extract host, port, and database
|
||||
IFS='/' read -r HOST_PORT DB_NAME <<< "$REST"
|
||||
IFS=':' read -r DB_HOST DB_PORT <<< "$HOST_PORT"
|
||||
|
||||
# Set defaults
|
||||
DB_PORT=${DB_PORT:-5432}
|
||||
DB_HOST=${DB_HOST:-localhost}
|
||||
|
||||
echo "📋 Database information:"
|
||||
echo " Host: $DB_HOST"
|
||||
echo " Port: $DB_PORT"
|
||||
echo " Database: $DB_NAME"
|
||||
echo " User: $DB_USER"
|
||||
echo ""
|
||||
|
||||
# Grant DELETE permission using psql as postgres superuser
|
||||
echo "🔐 Granting DELETE permission (requires sudo)..."
|
||||
sudo -u postgres psql -d "$DB_NAME" << EOF
|
||||
GRANT DELETE ON TABLE users TO "$DB_USER";
|
||||
\q
|
||||
EOF
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Successfully granted DELETE permission to user '$DB_USER'"
|
||||
else
|
||||
echo "❌ Failed to grant permission"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
99
scripts/grant_auth_db_permissions.py
Executable file
99
scripts/grant_auth_db_permissions.py
Executable file
@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Grant DELETE permission on auth database users table.
|
||||
|
||||
This script grants DELETE permission to the database user specified in DATABASE_URL_AUTH.
|
||||
It requires superuser access (postgres user) to grant permissions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
# Load environment variables
|
||||
env_path = Path(__file__).parent.parent.parent / ".env"
|
||||
load_dotenv(dotenv_path=env_path)
|
||||
|
||||
|
||||
def parse_database_url(db_url: str) -> dict:
|
||||
"""Parse database URL into components."""
|
||||
# Handle postgresql+psycopg2:// format
|
||||
if db_url.startswith("postgresql+psycopg2://"):
|
||||
db_url = db_url.replace("postgresql+psycopg2://", "postgresql://")
|
||||
|
||||
parsed = urlparse(db_url)
|
||||
return {
|
||||
"user": parsed.username,
|
||||
"password": parsed.password,
|
||||
"host": parsed.hostname or "localhost",
|
||||
"port": parsed.port or 5432,
|
||||
"database": parsed.path.lstrip("/"),
|
||||
}
|
||||
|
||||
|
||||
def grant_delete_permission() -> None:
|
||||
"""Grant DELETE permission on users table in auth database."""
|
||||
auth_db_url = os.getenv("DATABASE_URL_AUTH")
|
||||
if not auth_db_url:
|
||||
print("❌ Error: DATABASE_URL_AUTH environment variable not set")
|
||||
sys.exit(1)
|
||||
|
||||
if not auth_db_url.startswith("postgresql"):
|
||||
print("ℹ️ Auth database is not PostgreSQL. No permissions to grant.")
|
||||
return
|
||||
|
||||
db_info = parse_database_url(auth_db_url)
|
||||
db_user = db_info["user"]
|
||||
db_name = db_info["database"]
|
||||
|
||||
print(f"📋 Granting DELETE permission on users table...")
|
||||
print(f" Database: {db_name}")
|
||||
print(f" User: {db_user}")
|
||||
|
||||
# Connect as postgres superuser to grant permissions
|
||||
# Try to connect as postgres user (superuser)
|
||||
try:
|
||||
# Try to get postgres password from environment or use peer authentication
|
||||
postgres_url = f"postgresql://postgres@{db_info['host']}:{db_info['port']}/{db_name}"
|
||||
engine = create_engine(postgres_url)
|
||||
|
||||
with engine.connect() as conn:
|
||||
# Grant DELETE permission
|
||||
conn.execute(text(f"""
|
||||
GRANT DELETE ON TABLE users TO {db_user}
|
||||
"""))
|
||||
conn.commit()
|
||||
|
||||
print(f"✅ Successfully granted DELETE permission to user '{db_user}'")
|
||||
return
|
||||
except Exception as e:
|
||||
# If connecting as postgres fails, try with the same user (might have grant privileges)
|
||||
print(f"⚠️ Could not connect as postgres user: {e}")
|
||||
print(f" Trying with current database user...")
|
||||
|
||||
try:
|
||||
engine = create_engine(auth_db_url)
|
||||
with engine.connect() as conn:
|
||||
# Try to grant permission
|
||||
conn.execute(text(f"""
|
||||
GRANT DELETE ON TABLE users TO {db_user}
|
||||
"""))
|
||||
conn.commit()
|
||||
|
||||
print(f"✅ Successfully granted DELETE permission to user '{db_user}'")
|
||||
return
|
||||
except Exception as e2:
|
||||
print(f"❌ Failed to grant permission: {e2}")
|
||||
print(f"\n💡 To grant permission manually, run as postgres superuser:")
|
||||
print(f" sudo -u postgres psql -d {db_name} -c \"GRANT DELETE ON TABLE users TO {db_user};\"")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
grant_delete_permission()
|
||||
|
||||
@ -21,21 +21,6 @@ def recreate_tables():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
print("✅ All tables created successfully!")
|
||||
|
||||
# Stamp Alembic to latest migration
|
||||
print("\nMarking database as up-to-date with migrations...")
|
||||
from alembic.config import Config
|
||||
from alembic import command
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
script = ScriptDirectory.from_config(alembic_cfg)
|
||||
|
||||
# Get the latest revision
|
||||
head = script.get_current_head()
|
||||
print(f"Stamping database to revision: {head}")
|
||||
command.stamp(alembic_cfg, head)
|
||||
|
||||
print("✅ Database is now fresh and ready to use!")
|
||||
|
||||
|
||||
|
||||
@ -59,3 +59,4 @@ echo "3. Run your application - it will connect to PostgreSQL automatically"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
368
src/web/api/auth_users.py
Normal file
368
src/web/api/auth_users.py
Normal 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}",
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"),
|
||||
)
|
||||
|
||||
56
src/web/schemas/auth_users.py
Normal file
56
src/web/schemas/auth_users.py
Normal 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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user