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

This commit introduces a new Auth User management feature, allowing admins to create, update, delete, and list users in the auth database. A dedicated API has been implemented with endpoints for managing auth users, including validation for unique email addresses. The frontend has been updated to include a Manage Users page with tabs for backend and frontend users, enhancing the user experience. Additionally, modals for creating and editing auth users have been added, along with appropriate error handling and loading states. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09 2025-11-21 13:33:13 -05:00
parent e6c66e564e
commit 93cb4eda5b
15 changed files with 1348 additions and 188 deletions

View 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}`)
},
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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>
}

View File

@ -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>
)
}

View 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

View 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()

View File

@ -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!")

View File

@ -59,3 +59,4 @@ echo "3. Run your application - it will connect to PostgreSQL automatically"

368
src/web/api/auth_users.py Normal file
View File

@ -0,0 +1,368 @@
"""Auth database user management endpoints - admin only."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import text
from sqlalchemy.orm import Session
from src.web.api.auth import get_current_user
from src.web.api.users import get_current_admin_user
from src.web.db.session import get_auth_db, get_db
from src.web.schemas.auth_users import (
AuthUserCreateRequest,
AuthUserResponse,
AuthUserUpdateRequest,
AuthUsersListResponse,
)
from src.web.utils.password import hash_password
router = APIRouter(prefix="/auth-users", tags=["auth-users"])
@router.get("", response_model=AuthUsersListResponse)
def list_auth_users(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
auth_db: Session = Depends(get_auth_db),
) -> AuthUsersListResponse:
"""List all users from auth database - admin only."""
try:
# Query users from auth database with all columns from schema
result = auth_db.execute(text("""
SELECT
id,
email,
name,
is_admin,
has_write_access,
created_at,
updated_at
FROM users
ORDER BY COALESCE(name, email) ASC
"""))
rows = result.fetchall()
users = []
for row in rows:
# Access row attributes directly - SQLAlchemy Row objects support attribute access
user_id = int(row.id)
email = str(row.email)
name = row.name if row.name is not None else None
# Get boolean fields - convert to proper boolean
# These columns have defaults so they should always have values
is_admin = bool(row.is_admin)
has_write_access = bool(row.has_write_access)
created_at = row.created_at
updated_at = row.updated_at
users.append(AuthUserResponse(
id=user_id,
name=name,
email=email,
is_admin=is_admin,
has_write_access=has_write_access,
created_at=created_at,
updated_at=updated_at,
))
return AuthUsersListResponse(items=users, total=len(users))
except Exception as e:
import traceback
error_detail = f"Failed to list auth users: {str(e)}\n{traceback.format_exc()}"
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=error_detail,
)
@router.post("", response_model=AuthUserResponse, status_code=status.HTTP_201_CREATED)
def create_auth_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
request: AuthUserCreateRequest,
auth_db: Session = Depends(get_auth_db),
) -> AuthUserResponse:
"""Create a new user in auth database - admin only."""
try:
# Check if user with same email already exists (email is unique)
check_result = auth_db.execute(text("""
SELECT id FROM users
WHERE email = :email
"""), {"email": request.email})
existing = check_result.first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"User with email '{request.email}' already exists",
)
# Insert new user
# Check database dialect for RETURNING support
dialect = auth_db.bind.dialect.name if auth_db.bind else 'postgresql'
supports_returning = dialect == 'postgresql'
# Hash the password
password_hash = hash_password(request.password)
if supports_returning:
result = auth_db.execute(text("""
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
RETURNING id, email, name, is_admin, has_write_access, created_at, updated_at
"""), {
"email": request.email,
"name": request.name,
"password_hash": password_hash,
"is_admin": request.is_admin,
"has_write_access": request.has_write_access,
})
auth_db.commit()
row = result.first()
else:
# SQLite - insert then select
auth_db.execute(text("""
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
"""), {
"email": request.email,
"name": request.name,
"password_hash": password_hash,
"is_admin": request.is_admin,
"has_write_access": request.has_write_access,
})
auth_db.commit()
# Get the last inserted row
result = auth_db.execute(text("""
SELECT id, email, name, is_admin, has_write_access, created_at, updated_at
FROM users
WHERE id = last_insert_rowid()
"""))
row = result.first()
if not row:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create user",
)
is_admin = bool(row.is_admin)
has_write_access = bool(row.has_write_access)
return AuthUserResponse(
id=row.id,
name=getattr(row, 'name', None),
email=row.email,
is_admin=is_admin,
has_write_access=has_write_access,
created_at=getattr(row, 'created_at', None),
updated_at=getattr(row, 'updated_at', None),
)
except HTTPException:
raise
except Exception as e:
auth_db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create auth user: {str(e)}",
)
@router.get("/{user_id}", response_model=AuthUserResponse)
def get_auth_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
user_id: int,
auth_db: Session = Depends(get_auth_db),
) -> AuthUserResponse:
"""Get a specific auth user by ID - admin only."""
try:
result = auth_db.execute(text("""
SELECT id, email, name, is_admin, has_write_access, created_at, updated_at
FROM users
WHERE id = :user_id
"""), {"user_id": user_id})
row = result.first()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Auth user with ID {user_id} not found",
)
is_admin = bool(row.is_admin)
has_write_access = bool(row.has_write_access)
return AuthUserResponse(
id=row.id,
name=getattr(row, 'name', None),
email=row.email,
is_admin=is_admin,
has_write_access=has_write_access,
created_at=getattr(row, 'created_at', None),
updated_at=getattr(row, 'updated_at', None),
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get auth user: {str(e)}",
)
@router.put("/{user_id}", response_model=AuthUserResponse)
def update_auth_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
user_id: int,
request: AuthUserUpdateRequest,
auth_db: Session = Depends(get_auth_db),
) -> AuthUserResponse:
"""Update an auth user - admin only."""
try:
# Check if user exists
check_result = auth_db.execute(text("""
SELECT id FROM users WHERE id = :user_id
"""), {"user_id": user_id})
if not check_result.first():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Auth user with ID {user_id} not found",
)
# Check if email conflicts with another user (email is unique)
check_conflict = auth_db.execute(text("""
SELECT id FROM users
WHERE id != :user_id AND email = :email
"""), {
"user_id": user_id,
"email": request.email,
})
if check_conflict.first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"User with email '{request.email}' already exists",
)
# Update all fields (all are required)
dialect = auth_db.bind.dialect.name if auth_db.bind else 'postgresql'
supports_returning = dialect == 'postgresql'
if supports_returning:
result = auth_db.execute(text("""
UPDATE users
SET email = :email,
name = :name,
is_admin = :is_admin,
has_write_access = :has_write_access
WHERE id = :user_id
RETURNING id, email, name, is_admin, has_write_access, created_at, updated_at
"""), {
"user_id": user_id,
"email": request.email,
"name": request.name,
"is_admin": request.is_admin,
"has_write_access": request.has_write_access,
})
auth_db.commit()
row = result.first()
else:
# SQLite - update then select
auth_db.execute(text("""
UPDATE users
SET email = :email,
name = :name,
is_admin = :is_admin,
has_write_access = :has_write_access
WHERE id = :user_id
"""), {
"user_id": user_id,
"email": request.email,
"name": request.name,
"is_admin": request.is_admin,
"has_write_access": request.has_write_access,
})
auth_db.commit()
# Get the updated row
result = auth_db.execute(text("""
SELECT id, email, name, is_admin, has_write_access, created_at, updated_at
FROM users
WHERE id = :user_id
"""), {"user_id": user_id})
row = result.first()
if not row:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update user",
)
is_admin = bool(row.is_admin)
has_write_access = bool(row.has_write_access)
return AuthUserResponse(
id=row.id,
name=getattr(row, 'name', None),
email=row.email,
is_admin=is_admin,
has_write_access=has_write_access,
created_at=getattr(row, 'created_at', None),
updated_at=getattr(row, 'updated_at', None),
)
except HTTPException:
raise
except Exception as e:
auth_db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update auth user: {str(e)}",
)
@router.delete("/{user_id}")
def delete_auth_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
user_id: int,
auth_db: Session = Depends(get_auth_db),
) -> Response:
"""Delete an auth user - admin only."""
try:
# Check if user exists
check_result = auth_db.execute(text("""
SELECT id FROM users WHERE id = :user_id
"""), {"user_id": user_id})
if not check_result.first():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Auth user with ID {user_id} not found",
)
# Delete user
auth_db.execute(text("""
DELETE FROM users WHERE id = :user_id
"""), {"user_id": user_id})
auth_db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except HTTPException:
raise
except Exception as e:
auth_db.rollback()
error_str = str(e)
# Check for permission errors
if "permission denied" in error_str.lower() or "insufficient privilege" in error_str.lower():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permission denied: The database user does not have DELETE permission on the users table. Please contact your database administrator.",
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete auth user: {error_str}",
)

View File

@ -113,6 +113,14 @@ def create_user(
detail=f"Username '{request.username}' already exists",
)
# Check if email already exists
existing_email = db.query(User).filter(User.email == request.email).first()
if existing_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Email address '{request.email}' is already in use",
)
# Hash the password before storing
password_hash = hash_password(request.password)
@ -174,6 +182,15 @@ def update_user(
detail="Cannot remove your own admin status",
)
# Check if email is being changed and if the new email already exists
if request.email is not None and request.email != user.email:
existing_email = db.query(User).filter(User.email == request.email).first()
if existing_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Email address '{request.email}' is already in use",
)
# Update fields if provided
if request.password is not None:
user.password_hash = hash_password(request.password)

View File

@ -22,6 +22,7 @@ from src.web.api.reported_photos import router as reported_photos_router
from src.web.api.pending_photos import router as pending_photos_router
from src.web.api.tags import router as tags_router
from src.web.api.users import router as users_router
from src.web.api.auth_users import router as auth_users_router
from src.web.api.version import router as version_router
from src.web.settings import APP_TITLE, APP_VERSION
from src.web.db.base import Base, engine
@ -172,6 +173,54 @@ def ensure_user_password_change_required_column(inspector) -> None:
print("✅ Added password_change_required column to users table")
def ensure_user_email_unique_constraint(inspector) -> None:
"""Ensure users table email column has a unique constraint."""
if "users" not in inspector.get_table_names():
return
# Check if email column exists
columns = {col["name"] for col in inspector.get_columns("users")}
if "email" not in columns:
print(" email column does not exist in users table yet")
return
# Check if unique constraint already exists on email
dialect = engine.dialect.name
with engine.connect() as connection:
if dialect == "postgresql":
# Check if unique constraint exists
result = connection.execute(text("""
SELECT constraint_name
FROM information_schema.table_constraints
WHERE table_name = 'users'
AND constraint_type = 'UNIQUE'
AND constraint_name LIKE '%email%'
"""))
if result.first():
print(" Unique constraint on email column already exists")
return
# Try to add unique constraint (will fail if duplicates exist)
try:
print("🔄 Adding unique constraint to email column...")
connection.execute(text("ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email)"))
connection.commit()
print("✅ Added unique constraint to email column")
except Exception as e:
# If constraint already exists or duplicates exist, that's okay
# API validation will prevent new duplicates
if "already exists" in str(e).lower() or "duplicate" in str(e).lower():
print(f" Could not add unique constraint (may have duplicates): {e}")
else:
print(f"⚠️ Could not add unique constraint: {e}")
else:
# SQLite - unique constraint is handled at column level
# Check if column already has unique constraint
# SQLite doesn't easily support adding unique constraints to existing columns
# The model definition will handle it for new tables
print(" SQLite: Unique constraint on email will be enforced by model definition for new tables")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown events."""
@ -205,6 +254,7 @@ async def lifespan(app: FastAPI):
# Ensure new columns exist (backward compatibility without migrations)
ensure_user_password_hash_column(inspector)
ensure_user_password_change_required_column(inspector)
ensure_user_email_unique_constraint(inspector)
except Exception as exc:
print(f"❌ Database initialization failed: {exc}")
raise
@ -243,7 +293,8 @@ def create_app() -> FastAPI:
app.include_router(reported_photos_router, prefix="/api/v1")
app.include_router(pending_photos_router, prefix="/api/v1")
app.include_router(tags_router, prefix="/api/v1")
app.include_router(users_router, prefix="/api/v1")
app.include_router(users_router, prefix="/api/v1")
app.include_router(auth_users_router, prefix="/api/v1")
return app

View File

@ -204,8 +204,8 @@ class User(Base):
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
username = Column(Text, unique=True, nullable=False, index=True)
password_hash = Column(Text, nullable=False) # Hashed password
email = Column(Text, nullable=True)
full_name = Column(Text, nullable=True)
email = Column(Text, unique=True, nullable=False, index=True)
full_name = Column(Text, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False, index=True)
password_change_required = Column(Boolean, default=True, nullable=False, index=True)
@ -214,6 +214,7 @@ class User(Base):
__table_args__ = (
Index("idx_users_username", "username"),
Index("idx_users_email", "email"),
Index("idx_users_is_admin", "is_admin"),
Index("idx_users_password_change_required", "password_change_required"),
)

View File

@ -0,0 +1,56 @@
"""Auth database user management schemas for web API."""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class AuthUserResponse(BaseModel):
"""Auth user DTO returned from API."""
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: int
name: Optional[str] = None
email: str
is_admin: Optional[bool] = None
has_write_access: Optional[bool] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class AuthUserCreateRequest(BaseModel):
"""Request payload to create a new auth user."""
model_config = ConfigDict(protected_namespaces=())
email: EmailStr = Field(..., description="Email address (unique, required)")
name: str = Field(..., min_length=1, max_length=200, description="Name (required)")
password: str = Field(..., min_length=6, description="Password (minimum 6 characters, required)")
is_admin: bool = Field(..., description="Admin role (required)")
has_write_access: bool = Field(..., description="Write access (required)")
class AuthUserUpdateRequest(BaseModel):
"""Request payload to update an auth user."""
model_config = ConfigDict(protected_namespaces=())
email: EmailStr = Field(..., description="Email address (required)")
name: str = Field(..., min_length=1, max_length=200, description="Name (required)")
is_admin: bool = Field(..., description="Admin role (required)")
has_write_access: bool = Field(..., description="Write access (required)")
class AuthUsersListResponse(BaseModel):
"""List of auth users."""
model_config = ConfigDict(protected_namespaces=())
items: list[AuthUserResponse]
total: int

View File

@ -31,8 +31,8 @@ class UserCreateRequest(BaseModel):
username: str = Field(..., min_length=1, max_length=100)
password: str = Field(..., min_length=6, description="Password (minimum 6 characters)")
email: Optional[EmailStr] = None
full_name: Optional[str] = Field(None, max_length=200)
email: EmailStr = Field(..., description="Email address (required)")
full_name: str = Field(..., min_length=1, max_length=200, description="Full name (required)")
is_active: bool = True
is_admin: bool = False
@ -43,8 +43,8 @@ class UserUpdateRequest(BaseModel):
model_config = ConfigDict(protected_namespaces=())
password: Optional[str] = Field(None, min_length=6, description="New password (minimum 6 characters, leave empty to keep current)")
email: Optional[EmailStr] = None
full_name: Optional[str] = Field(None, max_length=200)
email: EmailStr = Field(..., description="Email address (required)")
full_name: str = Field(..., min_length=1, max_length=200, description="Full name (required)")
is_active: Optional[bool] = None
is_admin: Optional[bool] = None