diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1f39dd8..a06bf35 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> { + 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 }, diff --git a/frontend/src/hooks/useInactivityTimeout.ts b/frontend/src/hooks/useInactivityTimeout.ts new file mode 100644 index 0000000..966ccb1 --- /dev/null +++ b/frontend/src/hooks/useInactivityTimeout.ts @@ -0,0 +1,68 @@ +import { useEffect, useRef } from 'react' + +interface UseInactivityTimeoutOptions { + timeoutMs: number + onTimeout: () => void + isEnabled?: boolean +} + +const ACTIVITY_EVENTS: Array = [ + 'mousemove', + 'mousedown', + 'keydown', + 'scroll', + 'touchstart', + 'focus', +] + +export function useInactivityTimeout({ + timeoutMs, + onTimeout, + isEnabled = true, +}: UseInactivityTimeoutOptions) { + const timeoutRef = useRef(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]) +} + + diff --git a/frontend/src/pages/ManagePhotos.tsx b/frontend/src/pages/ManagePhotos.tsx new file mode 100644 index 0000000..c2506a3 --- /dev/null +++ b/frontend/src/pages/ManagePhotos.tsx @@ -0,0 +1,12 @@ +export default function ManagePhotos() { + return ( +
+

Manage Photos

+ +
+

Photo management functionality coming soon...

+
+
+ ) +} + diff --git a/frontend/src/pages/ManageUsers.tsx b/frontend/src/pages/ManageUsers.tsx index f42134d..160c69e 100644 --- a/frontend/src/pages/ManageUsers.tsx +++ b/frontend/src/pages/ManageUsers.tsx @@ -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([]) const [authLoading, setAuthLoading] = useState(true) + const [authUsersLoaded, setAuthUsersLoaded] = useState(false) const [authError, setAuthError] = useState(null) const [showAuthCreateModal, setShowAuthCreateModal] = useState(false) const [editingAuthUser, setEditingAuthUser] = useState(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() {
{loading ? (
Loading users...
- ) : users.length === 0 ? ( + ) : sortedUsers.length === 0 ? (
No users found
) : ( - {users.map((user) => ( + {sortedUsers.map((user) => (
- Username + - Email + - Full Name + - Status + - Role + - Created + - Last Login + Actions @@ -509,7 +772,7 @@ export default function ManageUsers() {
{user.username} @@ -576,26 +839,73 @@ export default function ManageUsers() {
{authLoading ? (
Loading users...
- ) : authUsers.length === 0 ? ( + ) : sortedAuthUsers.length === 0 ? (
No users found
) : ( + - {authUsers.map((user) => ( + {sortedAuthUsers.map((user) => ( +
- Email + - Name + - Role + - Write Access + - Created + + + Actions @@ -603,7 +913,7 @@ export default function ManageUsers() {
{user.email || '-'} @@ -636,6 +946,9 @@ export default function ManageUsers() { {formatDate(user.created_at)} + {formatDate(user.updated_at)} +