feat: Add Manage Photos page and inactivity timeout hook
This commit introduces a new Manage Photos page in the frontend, allowing users to manage their photos effectively. The Layout component has been updated to include navigation to the new page. Additionally, a custom hook for handling user inactivity timeouts has been implemented, enhancing security by logging users out after a specified period of inactivity. The user management functionality has also been improved with new sorting options and validation for frontend permissions. Documentation has been updated to reflect these changes.
This commit is contained in:
parent
a036169b0f
commit
51eaf6a52b
@ -16,6 +16,7 @@ import ApproveIdentified from './pages/ApproveIdentified'
|
||||
import ManageUsers from './pages/ManageUsers'
|
||||
import ReportedPhotos from './pages/ReportedPhotos'
|
||||
import PendingPhotos from './pages/PendingPhotos'
|
||||
import ManagePhotos from './pages/ManagePhotos'
|
||||
import Settings from './pages/Settings'
|
||||
import Help from './pages/Help'
|
||||
import Layout from './components/Layout'
|
||||
@ -74,6 +75,7 @@ function AppRoutes() {
|
||||
<Route path="auto-match" element={<AutoMatch />} />
|
||||
<Route path="modify" element={<Modify />} />
|
||||
<Route path="tags" element={<Tags />} />
|
||||
<Route path="manage-photos" element={<ManagePhotos />} />
|
||||
<Route path="faces-maintenance" element={<FacesMaintenance />} />
|
||||
<Route
|
||||
path="approve-identified"
|
||||
|
||||
@ -27,6 +27,7 @@ export interface UserUpdateRequest {
|
||||
full_name: string
|
||||
is_active?: boolean
|
||||
is_admin?: boolean
|
||||
give_frontend_permission?: boolean
|
||||
}
|
||||
|
||||
export interface UsersListResponse {
|
||||
|
||||
@ -1,9 +1,23 @@
|
||||
import { useCallback } from 'react'
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useInactivityTimeout } from '../hooks/useInactivityTimeout'
|
||||
|
||||
const INACTIVITY_TIMEOUT_MS = 30 * 60 * 1000
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation()
|
||||
const { username, logout, isAdmin } = useAuth()
|
||||
const { username, logout, isAdmin, isAuthenticated } = useAuth()
|
||||
|
||||
const handleInactivityLogout = useCallback(() => {
|
||||
logout()
|
||||
}, [logout])
|
||||
|
||||
useInactivityTimeout({
|
||||
timeoutMs: INACTIVITY_TIMEOUT_MS,
|
||||
onTimeout: handleInactivityLogout,
|
||||
isEnabled: isAuthenticated,
|
||||
})
|
||||
|
||||
const allNavItems = [
|
||||
{ path: '/scan', label: 'Scan', icon: '🗂️', adminOnly: false },
|
||||
@ -13,6 +27,7 @@ export default function Layout() {
|
||||
{ path: '/auto-match', label: 'Auto-Match', icon: '🤖', adminOnly: false },
|
||||
{ path: '/modify', label: 'Modify', icon: '✏️', adminOnly: false },
|
||||
{ path: '/tags', label: 'Tag', icon: '🏷️', adminOnly: false },
|
||||
{ path: '/manage-photos', label: 'Manage Photos', icon: '📷', adminOnly: false },
|
||||
{ path: '/faces-maintenance', label: 'Faces Maintenance', icon: '🔧', adminOnly: false },
|
||||
{ path: '/approve-identified', label: 'Approve identified', icon: '✅', adminOnly: true },
|
||||
{ path: '/manage-users', label: 'Manage users', icon: '👥', adminOnly: true },
|
||||
|
||||
68
frontend/src/hooks/useInactivityTimeout.ts
Normal file
68
frontend/src/hooks/useInactivityTimeout.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface UseInactivityTimeoutOptions {
|
||||
timeoutMs: number
|
||||
onTimeout: () => void
|
||||
isEnabled?: boolean
|
||||
}
|
||||
|
||||
const ACTIVITY_EVENTS: Array<keyof WindowEventMap> = [
|
||||
'mousemove',
|
||||
'mousedown',
|
||||
'keydown',
|
||||
'scroll',
|
||||
'touchstart',
|
||||
'focus',
|
||||
]
|
||||
|
||||
export function useInactivityTimeout({
|
||||
timeoutMs,
|
||||
onTimeout,
|
||||
isEnabled = true,
|
||||
}: UseInactivityTimeoutOptions) {
|
||||
const timeoutRef = useRef<number | null>(null)
|
||||
const callbackRef = useRef(onTimeout)
|
||||
|
||||
useEffect(() => {
|
||||
callbackRef.current = onTimeout
|
||||
}, [onTimeout])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) {
|
||||
if (timeoutRef.current) {
|
||||
window.clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const resetTimer = () => {
|
||||
if (timeoutRef.current) {
|
||||
window.clearTimeout(timeoutRef.current)
|
||||
}
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
callbackRef.current()
|
||||
}, timeoutMs)
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (!document.hidden) {
|
||||
resetTimer()
|
||||
}
|
||||
}
|
||||
|
||||
resetTimer()
|
||||
ACTIVITY_EVENTS.forEach((event) => window.addEventListener(event, resetTimer))
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
window.clearTimeout(timeoutRef.current)
|
||||
}
|
||||
ACTIVITY_EVENTS.forEach((event) => window.removeEventListener(event, resetTimer))
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [timeoutMs, isEnabled])
|
||||
}
|
||||
|
||||
|
||||
12
frontend/src/pages/ManagePhotos.tsx
Normal file
12
frontend/src/pages/ManagePhotos.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
export default function ManagePhotos() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Manage Photos</h1>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-gray-600">Photo management functionality coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,8 +1,24 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { usersApi, UserResponse, UserCreateRequest, UserUpdateRequest } from '../api/users'
|
||||
import { authUsersApi, AuthUserResponse, AuthUserCreateRequest, AuthUserUpdateRequest } from '../api/authUsers'
|
||||
|
||||
type TabType = 'backend' | 'frontend'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
type UserSortKey =
|
||||
| 'username'
|
||||
| 'email'
|
||||
| 'full_name'
|
||||
| 'is_active'
|
||||
| 'is_admin'
|
||||
| 'created_date'
|
||||
| 'last_login'
|
||||
type AuthUserSortKey =
|
||||
| 'email'
|
||||
| 'name'
|
||||
| 'is_admin'
|
||||
| 'has_write_access'
|
||||
| 'created_at'
|
||||
| 'updated_at'
|
||||
|
||||
/**
|
||||
* Format Pydantic validation errors into user-friendly messages
|
||||
@ -91,6 +107,7 @@ export default function ManageUsers() {
|
||||
// Frontend users state
|
||||
const [authUsers, setAuthUsers] = useState<AuthUserResponse[]>([])
|
||||
const [authLoading, setAuthLoading] = useState(true)
|
||||
const [authUsersLoaded, setAuthUsersLoaded] = useState(false)
|
||||
const [authError, setAuthError] = useState<string | null>(null)
|
||||
const [showAuthCreateModal, setShowAuthCreateModal] = useState(false)
|
||||
const [editingAuthUser, setEditingAuthUser] = useState<AuthUserResponse | null>(null)
|
||||
@ -110,6 +127,16 @@ export default function ManageUsers() {
|
||||
has_write_access: false,
|
||||
})
|
||||
|
||||
const [grantFrontendPermission, setGrantFrontendPermission] = useState(false)
|
||||
const [userSort, setUserSort] = useState<{ key: UserSortKey; direction: SortDirection }>({
|
||||
key: 'username',
|
||||
direction: 'asc',
|
||||
})
|
||||
const [authSort, setAuthSort] = useState<{ key: AuthUserSortKey; direction: SortDirection }>({
|
||||
key: 'email',
|
||||
direction: 'asc',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'backend') {
|
||||
loadUsers()
|
||||
@ -118,6 +145,10 @@ export default function ManageUsers() {
|
||||
}
|
||||
}, [activeTab, filterActive, filterAdmin])
|
||||
|
||||
useEffect(() => {
|
||||
loadAuthUsers()
|
||||
}, [])
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
@ -144,9 +175,33 @@ export default function ManageUsers() {
|
||||
setAuthError(err.response?.data?.detail || 'Failed to load auth users')
|
||||
} finally {
|
||||
setAuthLoading(false)
|
||||
setAuthUsersLoaded(true)
|
||||
}
|
||||
}
|
||||
|
||||
const usernameExists = (username: string): boolean => {
|
||||
const normalized = username.trim().toLowerCase()
|
||||
return users.some((user) => user.username?.toLowerCase() === normalized)
|
||||
}
|
||||
|
||||
const emailExists = (email: string, excludeId?: number): boolean => {
|
||||
const normalized = email.trim().toLowerCase()
|
||||
return users.some(
|
||||
(user) =>
|
||||
user.id !== excludeId &&
|
||||
(user.email || '').toLowerCase() === normalized
|
||||
)
|
||||
}
|
||||
|
||||
const authEmailExists = (email: string, excludeId?: number): boolean => {
|
||||
const normalized = email.trim().toLowerCase()
|
||||
return authUsers.some(
|
||||
(user) =>
|
||||
user.id !== excludeId &&
|
||||
(user.email || '').toLowerCase() === normalized
|
||||
)
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
@ -156,11 +211,17 @@ export default function ManageUsers() {
|
||||
setError('Username is required')
|
||||
return
|
||||
}
|
||||
const trimmedUsername = createForm.username.trim()
|
||||
if (!trimmedUsername) {
|
||||
setError('Username is required')
|
||||
return
|
||||
}
|
||||
if (!createForm.password || createForm.password.length < 6) {
|
||||
setError('Password must be at least 6 characters long')
|
||||
return
|
||||
}
|
||||
if (!createForm.email || createForm.email.trim() === '') {
|
||||
const trimmedEmail = createForm.email.trim()
|
||||
if (!trimmedEmail) {
|
||||
setError('Email is required')
|
||||
return
|
||||
}
|
||||
@ -168,8 +229,22 @@ export default function ManageUsers() {
|
||||
setError('Full name is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (usernameExists(trimmedUsername)) {
|
||||
setError('Username already exists (case-insensitive match)')
|
||||
return
|
||||
}
|
||||
|
||||
if (emailExists(trimmedEmail)) {
|
||||
setError('Email already exists (case-insensitive match)')
|
||||
return
|
||||
}
|
||||
|
||||
await usersApi.createUser(createForm)
|
||||
await usersApi.createUser({
|
||||
...createForm,
|
||||
username: trimmedUsername,
|
||||
email: trimmedEmail,
|
||||
})
|
||||
setShowCreateModal(false)
|
||||
setCreateForm({
|
||||
username: '',
|
||||
@ -215,7 +290,8 @@ export default function ManageUsers() {
|
||||
setAuthError(null)
|
||||
|
||||
// Frontend validation
|
||||
if (!authCreateForm.email || authCreateForm.email.trim() === '') {
|
||||
const trimmedEmail = authCreateForm.email.trim()
|
||||
if (!trimmedEmail) {
|
||||
setAuthError('Email is required')
|
||||
return
|
||||
}
|
||||
@ -228,7 +304,15 @@ export default function ManageUsers() {
|
||||
return
|
||||
}
|
||||
|
||||
await authUsersApi.createUser(authCreateForm)
|
||||
if (authEmailExists(trimmedEmail)) {
|
||||
setAuthError('Email already exists (case-insensitive match)')
|
||||
return
|
||||
}
|
||||
|
||||
await authUsersApi.createUser({
|
||||
...authCreateForm,
|
||||
email: trimmedEmail,
|
||||
})
|
||||
setShowAuthCreateModal(false)
|
||||
setAuthCreateForm({
|
||||
email: '',
|
||||
@ -271,11 +355,19 @@ export default function ManageUsers() {
|
||||
if (!editingUser) return
|
||||
try {
|
||||
setError(null)
|
||||
const trimmedEmail = editForm.email?.trim() ?? ''
|
||||
if (trimmedEmail && emailExists(trimmedEmail, editingUser.id)) {
|
||||
setError('Email already exists (case-insensitive match)')
|
||||
return
|
||||
}
|
||||
|
||||
const updateData: UserUpdateRequest = {
|
||||
...editForm,
|
||||
email: trimmedEmail || undefined,
|
||||
password: editForm.password && editForm.password.trim() !== ''
|
||||
? editForm.password
|
||||
: undefined,
|
||||
give_frontend_permission: grantFrontendPermission || undefined,
|
||||
}
|
||||
await usersApi.updateUser(editingUser.id, updateData)
|
||||
setEditingUser(null)
|
||||
@ -286,18 +378,129 @@ export default function ManageUsers() {
|
||||
is_active: true,
|
||||
is_admin: false,
|
||||
})
|
||||
setGrantFrontendPermission(false)
|
||||
loadUsers()
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to update user')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserSortChange = (key: UserSortKey) => {
|
||||
setUserSort((prev) => {
|
||||
if (prev.key === key) {
|
||||
return { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
|
||||
}
|
||||
return { key, direction: 'asc' }
|
||||
})
|
||||
}
|
||||
|
||||
const handleAuthSortChange = (key: AuthUserSortKey) => {
|
||||
setAuthSort((prev) => {
|
||||
if (prev.key === key) {
|
||||
return { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
|
||||
}
|
||||
return { key, direction: 'asc' }
|
||||
})
|
||||
}
|
||||
|
||||
const compareValues = (a: string | number, b: string | number): number => {
|
||||
if (typeof a === 'number' && typeof b === 'number') {
|
||||
return a - b
|
||||
}
|
||||
const stringA = (a ?? '').toString()
|
||||
const stringB = (b ?? '').toString()
|
||||
return stringA.localeCompare(stringB)
|
||||
}
|
||||
|
||||
const getUserSortValue = (user: UserResponse, key: UserSortKey): string | number => {
|
||||
switch (key) {
|
||||
case 'username':
|
||||
return user.username.toLowerCase()
|
||||
case 'email':
|
||||
return (user.email || '').toLowerCase()
|
||||
case 'full_name':
|
||||
return (user.full_name || '').toLowerCase()
|
||||
case 'is_active':
|
||||
return user.is_active ? 1 : 0
|
||||
case 'is_admin':
|
||||
return user.is_admin ? 1 : 0
|
||||
case 'created_date':
|
||||
return new Date(user.created_date).getTime()
|
||||
case 'last_login':
|
||||
return user.last_login ? new Date(user.last_login).getTime() : 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
const getAuthSortValue = (
|
||||
user: AuthUserResponse,
|
||||
key: AuthUserSortKey
|
||||
): string | number => {
|
||||
switch (key) {
|
||||
case 'email':
|
||||
return (user.email || '').toLowerCase()
|
||||
case 'name':
|
||||
return (user.name || '').toLowerCase()
|
||||
case 'is_admin':
|
||||
return user.is_admin ? 1 : 0
|
||||
case 'has_write_access':
|
||||
return user.has_write_access ? 1 : 0
|
||||
case 'created_at':
|
||||
return user.created_at ? new Date(user.created_at).getTime() : 0
|
||||
case 'updated_at':
|
||||
return user.updated_at ? new Date(user.updated_at).getTime() : 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
const sortedUsers = useMemo(() => {
|
||||
const cloned = [...users]
|
||||
cloned.sort((a, b) => {
|
||||
const valueA = getUserSortValue(a, userSort.key)
|
||||
const valueB = getUserSortValue(b, userSort.key)
|
||||
let comparison = compareValues(valueA, valueB)
|
||||
if (comparison === 0) {
|
||||
comparison = compareValues(a.username.toLowerCase(), b.username.toLowerCase())
|
||||
}
|
||||
return userSort.direction === 'asc' ? comparison : -comparison
|
||||
})
|
||||
return cloned
|
||||
}, [users, userSort])
|
||||
|
||||
const sortedAuthUsers = useMemo(() => {
|
||||
const cloned = [...authUsers]
|
||||
cloned.sort((a, b) => {
|
||||
const valueA = getAuthSortValue(a, authSort.key)
|
||||
const valueB = getAuthSortValue(b, authSort.key)
|
||||
let comparison = compareValues(valueA, valueB)
|
||||
if (comparison === 0) {
|
||||
comparison = compareValues((a.email || '').toLowerCase(), (b.email || '').toLowerCase())
|
||||
}
|
||||
return authSort.direction === 'asc' ? comparison : -comparison
|
||||
})
|
||||
return cloned
|
||||
}, [authUsers, authSort])
|
||||
|
||||
const getUserSortIndicator = (key: UserSortKey) =>
|
||||
userSort.key === key ? (userSort.direction === 'asc' ? '▲' : '▼') : '↕'
|
||||
|
||||
const getAuthSortIndicator = (key: AuthUserSortKey) =>
|
||||
authSort.key === key ? (authSort.direction === 'asc' ? '▲' : '▼') : '↕'
|
||||
|
||||
const handleAuthUpdate = async () => {
|
||||
if (!editingAuthUser) return
|
||||
try {
|
||||
setAuthError(null)
|
||||
const trimmedEmail = authEditForm.email?.trim() ?? ''
|
||||
if (trimmedEmail && authEmailExists(trimmedEmail, editingAuthUser.id)) {
|
||||
setAuthError('Email already exists (case-insensitive match)')
|
||||
return
|
||||
}
|
||||
|
||||
const updateData: AuthUserUpdateRequest = {
|
||||
email: authEditForm.email,
|
||||
email: trimmedEmail,
|
||||
name: authEditForm.name,
|
||||
is_admin: authEditForm.is_admin,
|
||||
has_write_access: authEditForm.has_write_access,
|
||||
@ -351,6 +554,7 @@ export default function ManageUsers() {
|
||||
is_active: user.is_active,
|
||||
is_admin: user.is_admin,
|
||||
})
|
||||
setGrantFrontendPermission(false)
|
||||
}
|
||||
|
||||
const startAuthEdit = (user: AuthUserResponse) => {
|
||||
@ -363,6 +567,16 @@ export default function ManageUsers() {
|
||||
})
|
||||
}
|
||||
|
||||
const editingUserHasFrontendAccount =
|
||||
editingUser?.email ? authEmailExists(editingUser.email.trim()) : false
|
||||
const canGrantFrontendPermission =
|
||||
Boolean(
|
||||
editingUser &&
|
||||
authUsersLoaded &&
|
||||
editingUser.email &&
|
||||
!editingUserHasFrontendAccount
|
||||
)
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Never'
|
||||
return new Date(dateString).toLocaleString()
|
||||
@ -476,32 +690,81 @@ export default function ManageUsers() {
|
||||
<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 ? (
|
||||
) : sortedUsers.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
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUserSortChange('username')}
|
||||
className="flex items-center gap-1 w-full text-left"
|
||||
>
|
||||
Username
|
||||
<span className="text-[10px]">{getUserSortIndicator('username')}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Email
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUserSortChange('email')}
|
||||
className="flex items-center gap-1 w-full text-left"
|
||||
>
|
||||
Email
|
||||
<span className="text-[10px]">{getUserSortIndicator('email')}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Full Name
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUserSortChange('full_name')}
|
||||
className="flex items-center gap-1 w-full text-left"
|
||||
>
|
||||
Full Name
|
||||
<span className="text-[10px]">{getUserSortIndicator('full_name')}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUserSortChange('is_active')}
|
||||
className="flex items-center gap-1 w-full text-left"
|
||||
>
|
||||
Status
|
||||
<span className="text-[10px]">{getUserSortIndicator('is_active')}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Role
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUserSortChange('is_admin')}
|
||||
className="flex items-center gap-1 w-full text-left"
|
||||
>
|
||||
Role
|
||||
<span className="text-[10px]">{getUserSortIndicator('is_admin')}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUserSortChange('created_date')}
|
||||
className="flex items-center gap-1 w-full text-left"
|
||||
>
|
||||
Created
|
||||
<span className="text-[10px]">{getUserSortIndicator('created_date')}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Last Login
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUserSortChange('last_login')}
|
||||
className="flex items-center gap-1 w-full text-left"
|
||||
>
|
||||
Last Login
|
||||
<span className="text-[10px]">{getUserSortIndicator('last_login')}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
@ -509,7 +772,7 @@ export default function ManageUsers() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
{sortedUsers.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}
|
||||
@ -576,26 +839,73 @@ export default function ManageUsers() {
|
||||
<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 ? (
|
||||
) : sortedAuthUsers.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
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAuthSortChange('email')}
|
||||
className="flex items-center gap-1 w-full text-left"
|
||||
>
|
||||
Email
|
||||
<span className="text-[10px]">{getAuthSortIndicator('email')}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAuthSortChange('name')}
|
||||
className="flex items-center gap-1 w-full text-left"
|
||||
>
|
||||
Name
|
||||
<span className="text-[10px]">{getAuthSortIndicator('name')}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Role
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAuthSortChange('is_admin')}
|
||||
className="flex items-center gap-1 w-full text-left"
|
||||
>
|
||||
Role
|
||||
<span className="text-[10px]">{getAuthSortIndicator('is_admin')}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Write Access
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAuthSortChange('has_write_access')}
|
||||
className="flex items-center gap-1 w-full text-left"
|
||||
>
|
||||
Write Access
|
||||
<span className="text-[10px]">
|
||||
{getAuthSortIndicator('has_write_access')}
|
||||
</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAuthSortChange('created_at')}
|
||||
className="flex items-center gap-1 w-full text-left"
|
||||
>
|
||||
Created
|
||||
<span className="text-[10px]">{getAuthSortIndicator('created_at')}</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('updated_at')}
|
||||
className="flex items-center gap-1 w-full text-left"
|
||||
>
|
||||
Updated
|
||||
<span className="text-[10px]">{getAuthSortIndicator('updated_at')}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
@ -603,7 +913,7 @@ export default function ManageUsers() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{authUsers.map((user) => (
|
||||
{sortedAuthUsers.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 || '-'}
|
||||
@ -636,6 +946,9 @@ export default function ManageUsers() {
|
||||
<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-sm text-gray-500">
|
||||
{formatDate(user.updated_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => startAuthEdit(user)}
|
||||
@ -884,10 +1197,32 @@ export default function ManageUsers() {
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
{canGrantFrontendPermission && (
|
||||
<div className="flex flex-col">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={grantFrontendPermission}
|
||||
onChange={(e) => setGrantFrontendPermission(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Give the user Frontend permission
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Creates a matching account in the auth database so this user can log into the
|
||||
web frontend.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setEditingUser(null)}
|
||||
onClick={() => {
|
||||
setEditingUser(null)
|
||||
setGrantFrontendPermission(false)
|
||||
}}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Cancel
|
||||
|
||||
@ -28,7 +28,7 @@ security = HTTPBearer()
|
||||
# Placeholder secrets - replace with env vars in production
|
||||
SECRET_KEY = "dev-secret-key-change-in-production"
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 360
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
||||
|
||||
# Single user mode placeholder
|
||||
|
||||
@ -31,6 +31,84 @@ def get_auth_db_optional() -> Session | None:
|
||||
return None
|
||||
|
||||
|
||||
def create_auth_user_if_missing(
|
||||
email: str,
|
||||
full_name: str,
|
||||
password_hash: str,
|
||||
is_admin: bool,
|
||||
) -> None:
|
||||
"""Create matching auth user if one does not already exist."""
|
||||
if not email:
|
||||
return
|
||||
|
||||
auth_db = get_auth_db_optional()
|
||||
if auth_db is None:
|
||||
return
|
||||
|
||||
try:
|
||||
check_result = auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id FROM users
|
||||
WHERE email = :email
|
||||
"""
|
||||
),
|
||||
{"email": email},
|
||||
)
|
||||
|
||||
existing_auth = check_result.first()
|
||||
if existing_auth:
|
||||
return
|
||||
|
||||
dialect = auth_db.bind.dialect.name if auth_db.bind else "postgresql"
|
||||
supports_returning = dialect == "postgresql"
|
||||
has_write_access = is_admin
|
||||
|
||||
if supports_returning:
|
||||
auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
|
||||
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"email": email,
|
||||
"name": full_name,
|
||||
"password_hash": password_hash,
|
||||
"is_admin": is_admin,
|
||||
"has_write_access": has_write_access,
|
||||
},
|
||||
)
|
||||
auth_db.commit()
|
||||
else:
|
||||
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": email,
|
||||
"name": full_name,
|
||||
"password_hash": password_hash,
|
||||
"is_admin": is_admin,
|
||||
"has_write_access": has_write_access,
|
||||
},
|
||||
)
|
||||
auth_db.commit()
|
||||
except Exception as e: # pragma: no cover - logging helper
|
||||
auth_db.rollback()
|
||||
import traceback
|
||||
|
||||
print(
|
||||
f"Warning: Failed to create auth user: {str(e)}\n{traceback.format_exc()}"
|
||||
)
|
||||
finally:
|
||||
auth_db.close()
|
||||
|
||||
|
||||
def get_current_admin_user(
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
@ -151,71 +229,13 @@ def create_user(
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
# If frontend permission is requested, create user in auth database
|
||||
if request.give_frontend_permission:
|
||||
auth_db = get_auth_db_optional()
|
||||
if auth_db is None:
|
||||
# Auth database not configured - this is okay, just continue
|
||||
# The backend user was created successfully
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
# Check if user with same email already exists in auth db
|
||||
check_result = auth_db.execute(text("""
|
||||
SELECT id FROM users
|
||||
WHERE email = :email
|
||||
"""), {"email": request.email})
|
||||
|
||||
existing_auth = check_result.first()
|
||||
if existing_auth:
|
||||
# User already exists in auth db, skip creation
|
||||
# This is not an error - user might have been created separately
|
||||
pass
|
||||
else:
|
||||
# Insert new user in auth database
|
||||
# Check database dialect for RETURNING support
|
||||
dialect = auth_db.bind.dialect.name if auth_db.bind else 'postgresql'
|
||||
supports_returning = dialect == 'postgresql'
|
||||
|
||||
# Set has_write_access based on admin status
|
||||
# Admins get write access by default, regular users don't
|
||||
has_write_access = request.is_admin
|
||||
|
||||
# Use the same password hash
|
||||
if supports_returning:
|
||||
auth_db.execute(text("""
|
||||
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
|
||||
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
|
||||
"""), {
|
||||
"email": request.email,
|
||||
"name": request.full_name,
|
||||
"password_hash": password_hash,
|
||||
"is_admin": request.is_admin,
|
||||
"has_write_access": has_write_access,
|
||||
})
|
||||
auth_db.commit()
|
||||
else:
|
||||
# SQLite - insert then select
|
||||
auth_db.execute(text("""
|
||||
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
|
||||
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
|
||||
"""), {
|
||||
"email": request.email,
|
||||
"name": request.full_name,
|
||||
"password_hash": password_hash,
|
||||
"is_admin": request.is_admin,
|
||||
"has_write_access": has_write_access,
|
||||
})
|
||||
auth_db.commit()
|
||||
except Exception as e:
|
||||
# If auth user creation fails, rollback and log but don't fail the whole request
|
||||
# The backend user was already created successfully
|
||||
auth_db.rollback()
|
||||
# In production, you might want to log this to a proper logging system
|
||||
import traceback
|
||||
print(f"Warning: Failed to create auth user: {str(e)}\n{traceback.format_exc()}")
|
||||
finally:
|
||||
auth_db.close()
|
||||
create_auth_user_if_missing(
|
||||
email=request.email,
|
||||
full_name=request.full_name,
|
||||
password_hash=password_hash,
|
||||
is_admin=request.is_admin,
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
@ -286,6 +306,14 @@ def update_user(
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
if request.give_frontend_permission:
|
||||
create_auth_user_if_missing(
|
||||
email=user.email,
|
||||
full_name=user.full_name or user.username,
|
||||
password_hash=user.password_hash,
|
||||
is_admin=user.is_admin,
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@ -48,6 +48,10 @@ class UserUpdateRequest(BaseModel):
|
||||
full_name: str = Field(..., min_length=1, max_length=200, description="Full name (required)")
|
||||
is_active: Optional[bool] = None
|
||||
is_admin: Optional[bool] = None
|
||||
give_frontend_permission: Optional[bool] = Field(
|
||||
None,
|
||||
description="Create user in auth database for frontend access if True",
|
||||
)
|
||||
|
||||
|
||||
class UsersListResponse(BaseModel):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user