feat: Add is_active and role fields to AuthUser schema and update user management logic

This commit introduces new fields `is_active` and `role` to the `AuthUserResponse` and `AuthUserUpdateRequest` schemas, enhancing user management capabilities. The `deleteUser` and `updateUser` functions are updated to handle user deactivation instead of deletion when linked data exists. Additionally, the ManageUsers component is enhanced with filtering options for active status and roles, improving user experience. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09
2025-12-05 14:20:45 -05:00
parent 0e65eac206
commit e9e8fbf3f5
8 changed files with 737 additions and 134 deletions
+8 -2
View File
@@ -6,6 +6,8 @@ export interface AuthUserResponse {
email: string
is_admin: boolean | null
has_write_access: boolean | null
is_active: boolean | null
role: string | null
created_at: string | null
updated_at: string | null
}
@@ -23,6 +25,8 @@ export interface AuthUserUpdateRequest {
name: string
is_admin: boolean
has_write_access: boolean
is_active?: boolean
role?: string
}
export interface AuthUsersListResponse {
@@ -57,8 +61,10 @@ export const authUsersApi = {
return data
},
deleteUser: async (userId: number): Promise<void> => {
await apiClient.delete(`/api/v1/auth-users/${userId}`)
deleteUser: async (userId: number): Promise<{ message?: string; deactivated?: boolean }> => {
const response = await apiClient.delete(`/api/v1/auth-users/${userId}`)
// Return data if present (200 OK with deactivation message), otherwise empty object (204 No Content)
return response.data || {}
},
}
+4 -2
View File
@@ -79,8 +79,10 @@ export const usersApi = {
return data
},
deleteUser: async (userId: number): Promise<void> => {
await apiClient.delete(`/api/v1/users/${userId}`)
deleteUser: async (userId: number): Promise<{ message?: string; deactivated?: boolean }> => {
const response = await apiClient.delete(`/api/v1/users/${userId}`)
// Return data if present (200 OK with deactivation message), otherwise empty object (204 No Content)
return response.data || {}
},
}
+154 -14
View File
@@ -34,6 +34,7 @@ type AuthUserSortKey =
| 'name'
| 'is_admin'
| 'has_write_access'
| 'is_active'
| 'created_at'
| 'updated_at'
const DEFAULT_ADMIN_ROLE: UserRoleValue = 'admin'
@@ -114,7 +115,7 @@ export default function ManageUsers() {
const [error, setError] = useState<string | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [editingUser, setEditingUser] = useState<UserResponse | null>(null)
const [filterActive, setFilterActive] = useState<boolean | null>(null)
const [filterActive, setFilterActive] = useState<boolean | null>(true)
const [filterRole, setFilterRole] = useState<UserRoleValue | null>(null)
const [createForm, setCreateForm] = useState<UserCreateRequest>({
@@ -146,6 +147,8 @@ export default function ManageUsers() {
const [authError, setAuthError] = useState<string | null>(null)
const [showAuthCreateModal, setShowAuthCreateModal] = useState(false)
const [editingAuthUser, setEditingAuthUser] = useState<AuthUserResponse | null>(null)
const [authFilterActive, setAuthFilterActive] = useState<boolean | null>(true)
const [authFilterRole, setAuthFilterRole] = useState<string | null>(null) // 'Admin' or 'User'
const [authCreateForm, setAuthCreateForm] = useState<AuthUserCreateRequest>({
email: '',
@@ -160,6 +163,8 @@ export default function ManageUsers() {
name: '',
is_admin: false,
has_write_access: false,
is_active: true,
role: 'User',
})
const [grantFrontendPermission, setGrantFrontendPermission] = useState(false)
@@ -598,6 +603,8 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
return user.is_admin ? 1 : 0
case 'has_write_access':
return user.has_write_access ? 1 : 0
case 'is_active':
return user.is_active !== false ? 1 : 0
case 'created_at':
return user.created_at ? new Date(user.created_at).getTime() : 0
case 'updated_at':
@@ -634,8 +641,31 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
return cloned
}, [filteredUsers, userSort])
const filteredAuthUsers = useMemo(() => {
let filtered = [...authUsers]
// Filter by active status
if (authFilterActive !== null) {
filtered = filtered.filter((user) => {
const isActive = user.is_active !== false // Default to true if null/undefined
return isActive === authFilterActive
})
}
// Filter by role (Admin/User)
if (authFilterRole !== null) {
filtered = filtered.filter((user) => {
// Use role field if available, otherwise derive from is_admin
const userRole = user.role || (user.is_admin === true ? 'Admin' : 'User')
return userRole === authFilterRole
})
}
return filtered
}, [authUsers, authFilterActive, authFilterRole])
const sortedAuthUsers = useMemo(() => {
const cloned = [...authUsers]
const cloned = [...filteredAuthUsers]
cloned.sort((a, b) => {
const valueA = getAuthSortValue(a, authSort.key)
const valueB = getAuthSortValue(b, authSort.key)
@@ -646,7 +676,7 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
return authSort.direction === 'asc' ? comparison : -comparison
})
return cloned
}, [authUsers, authSort])
}, [filteredAuthUsers, authSort])
const getUserSortIndicator = (key: UserSortKey) =>
userSort.key === key ? (userSort.direction === 'asc' ? '▲' : '▼') : '↕'
@@ -669,6 +699,8 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
name: authEditForm.name,
is_admin: authEditForm.is_admin,
has_write_access: authEditForm.has_write_access,
is_active: authEditForm.is_active,
role: authEditForm.role,
}
await authUsersApi.updateUser(editingAuthUser.id, updateData)
setEditingAuthUser(null)
@@ -677,6 +709,8 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
name: '',
is_admin: false,
has_write_access: false,
is_active: true,
role: 'User',
})
loadAuthUsers()
} catch (err: any) {
@@ -690,17 +724,18 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
}
try {
setError(null)
await usersApi.deleteUser(userId)
const result = await usersApi.deleteUser(userId)
loadUsers()
// If user was deactivated instead of deleted, show informational message
if (result.deactivated && result.message) {
// Show as info message, not error
setError(null)
// You might want to add a success/info message state here
// For now, we'll just reload and the user will see the status changed
alert(result.message)
}
} catch (err: any) {
const responseDetail = err.response?.data?.detail
if (err.response?.status === 409) {
setError(
responseDetail ||
'This user identified faces and cannot be deleted. Set them inactive instead.'
)
return
}
setError(responseDetail || 'Failed to delete user')
}
}
@@ -711,8 +746,16 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
}
try {
setAuthError(null)
await authUsersApi.deleteUser(userId)
const result = await authUsersApi.deleteUser(userId)
loadAuthUsers()
// If user was deactivated instead of deleted, show informational message
if (result.deactivated && result.message) {
// Show as info message, not error
setAuthError(null)
// You might want to add a success/info message state here
// For now, we'll just reload and the user will see the status changed
alert(result.message)
}
} catch (err: any) {
setAuthError(err.response?.data?.detail || 'Failed to delete auth user')
}
@@ -735,11 +778,16 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
const startAuthEdit = (user: AuthUserResponse) => {
setEditingAuthUser(user)
// Determine role: if is_admin is true, role is 'Admin', otherwise 'User'
// Use user.role if available, otherwise derive from is_admin
const userRole = user.role || (user.is_admin === true ? 'Admin' : 'User')
setAuthEditForm({
email: user.email || '',
name: user.name || '',
is_admin: user.is_admin === true,
has_write_access: user.has_write_access === true,
is_active: user.is_active !== false, // Default to true if null/undefined
role: userRole,
})
}
@@ -1033,7 +1081,50 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
{/* Frontend Users Tab */}
{activeTab === 'frontend' && (
<div className="bg-white rounded-lg shadow overflow-hidden">
<>
{/* 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={authFilterActive === null ? 'all' : authFilterActive ? 'active' : 'inactive'}
onChange={(e) =>
setAuthFilterActive(
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={authFilterRole ?? 'all'}
onChange={(e) => {
const { value } = e.target
if (value === 'all') {
setAuthFilterRole(null)
return
}
setAuthFilterRole(value)
}}
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>
{authError && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg text-sm">
{authError}
</div>
)}
<div className="bg-white rounded-lg shadow overflow-hidden">
{authLoading ? (
<div className="p-8 text-center text-gray-500">Loading users...</div>
) : sortedAuthUsers.length === 0 ? (
@@ -1084,6 +1175,16 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
</span>
</button>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<button
type="button"
onClick={() => handleAuthSortChange('is_active')}
className="flex items-center gap-1 w-full text-left"
>
Status
<span className="text-[10px]">{getAuthSortIndicator('is_active')}</span>
</button>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<button
type="button"
@@ -1140,6 +1241,17 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
{user.has_write_access === true ? 'Yes' : 'No'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
user.is_active !== false
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{user.is_active !== false ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(user.created_at)}
</td>
@@ -1165,7 +1277,8 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
</tbody>
</table>
)}
</div>
</div>
</>
)}
{/* Manage Roles Tab */}
@@ -1679,6 +1792,22 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Role *
</label>
<select
value={authEditForm.role || 'User'}
onChange={(e) =>
setAuthEditForm({ ...authEditForm, role: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
required
>
<option value="Admin">Admin</option>
<option value="User">User</option>
</select>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center">
<input
@@ -1702,6 +1831,17 @@ const getDisplayRoleLabel = (user: UserResponse): string => {
/>
<span className="text-sm text-gray-700">Write Access</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={authEditForm.is_active ?? true}
onChange={(e) =>
setAuthEditForm({ ...authEditForm, is_active: e.target.checked })
}
className="mr-2"
/>
<span className="text-sm text-gray-700">Active</span>
</label>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
+21 -11
View File
@@ -167,8 +167,10 @@ export default function Search() {
restoredSearchType === 'unprocessed' ||
restoredSearchType === 'favorites')) {
// Use a small delay to ensure state is fully restored
// Don't show validation errors for auto-searches
// Pass the restored search type directly to avoid stale state issues
setTimeout(() => {
performSearch()
performSearch(undefined, false, restoredSearchType ?? undefined)
}, 150)
}
}, 100)
@@ -211,24 +213,26 @@ export default function Search() {
}
}, [searchType, selectedTags, matchAll, dateFrom, dateTo, mediaType, selectedPeople, inputValue, tagsExpanded, filtersExpanded, configExpanded, sortColumn, sortDir, page, results, total])
const performSearch = async (pageNum: number = page) => {
const performSearch = async (pageNum: number = page, showValidationErrors: boolean = true, searchTypeOverride?: SearchType) => {
setLoading(true)
try {
// Use override if provided (for state restoration), otherwise use current state
const currentSearchType = searchTypeOverride ?? searchType
const params: any = {
search_type: searchType,
search_type: currentSearchType,
page: pageNum,
page_size: pageSize,
}
// For "Photos without faces" search, always exclude videos
if (searchType === 'no_faces') {
if (currentSearchType === 'no_faces') {
params.media_type = 'image'
} else if (mediaType && mediaType !== 'all') {
// Add media type filter if not 'all' for other search types
params.media_type = mediaType
}
if (searchType === 'name') {
if (currentSearchType === 'name') {
// Combine selected people names and free text input
// For selected people, use last name (most unique) or first+last if last name is empty
const selectedNames = selectedPeople.map(p => {
@@ -243,22 +247,28 @@ export default function Search() {
const allNames = [...selectedNames, freeText].filter(Boolean)
if (allNames.length === 0) {
alert('Please enter at least one name or select a person to search.')
if (showValidationErrors) {
alert('Please enter at least one name or select a person to search.')
}
setLoading(false)
return
}
params.person_name = allNames.join(', ')
} else if (searchType === 'date') {
} else if (currentSearchType === 'date') {
if (!dateFrom && !dateTo) {
alert('Please enter at least one date (from date or to date).')
if (showValidationErrors) {
alert('Please enter at least one date (from date or to date).')
}
setLoading(false)
return
}
params.date_from = dateFrom || undefined
params.date_to = dateTo || undefined
} else if (searchType === 'tags') {
} else if (currentSearchType === 'tags') {
if (selectedTags.length === 0) {
alert('Please select at least one tag to search for.')
if (showValidationErrors) {
alert('Please select at least one tag to search for.')
}
setLoading(false)
return
}
@@ -283,7 +293,7 @@ export default function Search() {
const handleSearch = () => {
setPage(1)
setSelectedPhotos(new Set())
performSearch(1)
performSearch(1, true) // Show validation errors when user explicitly clicks search
}
// Filter people for dropdown based on input