From 87146b1356fc9de49567154e9efd24db3ff56bcd Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Thu, 20 Nov 2025 13:18:58 -0500 Subject: [PATCH] feat: Add user management features with password change and reported photos handling This commit introduces several user management functionalities, including the ability to create, update, and delete users through a new API. The frontend has been updated to include a Manage Users page, allowing admins to manage user accounts effectively. Additionally, a password change feature has been implemented, requiring users to change their passwords upon first login. The reported photos functionality has been added, enabling admins to review and manage reported content. Documentation has been updated to reflect these changes. --- frontend/src/App.tsx | 58 ++- frontend/src/api/auth.ts | 24 +- frontend/src/api/reportedPhotos.ts | 58 +++ frontend/src/api/users.ts | 72 +++ frontend/src/components/AdminRoute.tsx | 25 + frontend/src/components/Layout.tsx | 31 +- .../src/components/PasswordChangeModal.tsx | 129 +++++ frontend/src/context/AuthContext.tsx | 27 +- frontend/src/pages/ApproveIdentified.tsx | 61 +-- frontend/src/pages/AutoMatch.tsx | 4 +- frontend/src/pages/ManageUsers.tsx | 473 ++++++++++++++++++ frontend/src/pages/ReportedPhotos.tsx | 368 ++++++++++++++ requirements.txt | 1 + src/web/api/auth.py | 117 ++++- src/web/api/pending_identifications.py | 8 +- src/web/api/reported_photos.py | 281 +++++++++++ src/web/api/users.py | 224 +++++++++ src/web/app.py | 94 +++- src/web/db/models.py | 23 + src/web/schemas/auth.py | 44 +- src/web/schemas/users.py | 59 +++ src/web/utils/__init__.py | 2 + src/web/utils/password.py | 36 ++ 23 files changed, 2148 insertions(+), 71 deletions(-) create mode 100644 frontend/src/api/reportedPhotos.ts create mode 100644 frontend/src/api/users.ts create mode 100644 frontend/src/components/AdminRoute.tsx create mode 100644 frontend/src/components/PasswordChangeModal.tsx create mode 100644 frontend/src/pages/ManageUsers.tsx create mode 100644 frontend/src/pages/ReportedPhotos.tsx create mode 100644 src/web/api/reported_photos.py create mode 100644 src/web/api/users.py create mode 100644 src/web/schemas/users.py create mode 100644 src/web/utils/__init__.py create mode 100644 src/web/utils/password.py diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fac9322..0a1710c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { AuthProvider, useAuth } from './context/AuthContext' import { DeveloperModeProvider } from './context/DeveloperModeContext' @@ -12,16 +13,44 @@ import Modify from './pages/Modify' import Tags from './pages/Tags' import FacesMaintenance from './pages/FacesMaintenance' import ApproveIdentified from './pages/ApproveIdentified' +import ManageUsers from './pages/ManageUsers' +import ReportedPhotos from './pages/ReportedPhotos' import Settings from './pages/Settings' import Help from './pages/Help' import Layout from './components/Layout' +import PasswordChangeModal from './components/PasswordChangeModal' +import AdminRoute from './components/AdminRoute' function PrivateRoute({ children }: { children: React.ReactNode }) { - const { isAuthenticated, isLoading } = useAuth() + const { isAuthenticated, isLoading, passwordChangeRequired } = useAuth() + const [showPasswordModal, setShowPasswordModal] = useState(false) + + useEffect(() => { + if (isAuthenticated && passwordChangeRequired) { + setShowPasswordModal(true) + } + }, [isAuthenticated, passwordChangeRequired]) + if (isLoading) { return
Loading...
} - return isAuthenticated ? <>{children} : + + if (!isAuthenticated) { + return + } + + return ( + <> + {showPasswordModal && ( + { + setShowPasswordModal(false) + }} + /> + )} + {children} + + ) } function AppRoutes() { @@ -45,7 +74,30 @@ function AppRoutes() { } /> } /> } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> } /> } /> diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 15eda0d..6a21436 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -8,11 +8,23 @@ export interface LoginRequest { export interface TokenResponse { access_token: string refresh_token: string - token_type: string + token_type?: string + password_change_required?: boolean +} + +export interface PasswordChangeRequest { + current_password: string + new_password: string +} + +export interface PasswordChangeResponse { + success: boolean + message: string } export interface UserResponse { username: string + is_admin?: boolean } export const authApi = { @@ -36,5 +48,15 @@ export const authApi = { const { data } = await apiClient.get('/api/v1/auth/me') return data }, + + changePassword: async ( + request: PasswordChangeRequest + ): Promise => { + const { data } = await apiClient.post( + '/api/v1/auth/change-password', + request + ) + return data + }, } diff --git a/frontend/src/api/reportedPhotos.ts b/frontend/src/api/reportedPhotos.ts new file mode 100644 index 0000000..d4252e2 --- /dev/null +++ b/frontend/src/api/reportedPhotos.ts @@ -0,0 +1,58 @@ +import apiClient from './client' + +export interface ReportedPhotoResponse { + id: number + photo_id: number + user_id: number + user_name: string | null + user_email: string | null + status: string + reported_at: string + reviewed_at: string | null + reviewed_by: number | null + review_notes: string | null + photo_path: string | null + photo_filename: string | null +} + +export interface ReportedPhotosListResponse { + items: ReportedPhotoResponse[] + total: number +} + +export interface ReviewDecision { + id: number + decision: 'keep' | 'remove' + review_notes?: string | null +} + +export interface ReviewRequest { + decisions: ReviewDecision[] +} + +export interface ReviewResponse { + kept: number + removed: number + errors: string[] +} + +export const reportedPhotosApi = { + listReportedPhotos: async (statusFilter?: string): Promise => { + const { data } = await apiClient.get( + '/api/v1/reported-photos', + { + params: statusFilter ? { status_filter: statusFilter } : undefined, + } + ) + return data + }, + + reviewReportedPhotos: async (request: ReviewRequest): Promise => { + const { data } = await apiClient.post( + '/api/v1/reported-photos/review', + request + ) + return data + }, +} + diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..98cbc1a --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,72 @@ +import apiClient from './client' + +export interface UserResponse { + id: number + username: string + email: string | null + full_name: string | null + is_active: boolean + is_admin: boolean + created_date: string + last_login: string | null +} + +export interface UserCreateRequest { + username: string + password: string + email?: string | null + full_name?: string | null + is_active?: boolean + is_admin?: boolean +} + +export interface UserUpdateRequest { + password?: string | null + email?: string | null + full_name?: string | null + is_active?: boolean + is_admin?: boolean +} + +export interface UsersListResponse { + items: UserResponse[] + total: number +} + +export const usersApi = { + listUsers: async (params?: { + is_active?: boolean + is_admin?: boolean + }): Promise => { + const { data } = await apiClient.get('/api/v1/users', { + params, + }) + return data + }, + + getUser: async (userId: number): Promise => { + const { data } = await apiClient.get(`/api/v1/users/${userId}`) + return data + }, + + createUser: async (request: UserCreateRequest): Promise => { + const { data } = await apiClient.post('/api/v1/users', request) + return data + }, + + updateUser: async ( + userId: number, + request: UserUpdateRequest + ): Promise => { + const { data } = await apiClient.put( + `/api/v1/users/${userId}`, + request + ) + return data + }, + + deleteUser: async (userId: number): Promise => { + await apiClient.delete(`/api/v1/users/${userId}`) + }, +} + diff --git a/frontend/src/components/AdminRoute.tsx b/frontend/src/components/AdminRoute.tsx new file mode 100644 index 0000000..5628fb3 --- /dev/null +++ b/frontend/src/components/AdminRoute.tsx @@ -0,0 +1,25 @@ +import { Navigate } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' + +interface AdminRouteProps { + children: React.ReactNode +} + +export default function AdminRoute({ children }: AdminRouteProps) { + const { isAuthenticated, isLoading, isAdmin } = useAuth() + + if (isLoading) { + return
Loading...
+ } + + if (!isAuthenticated) { + return + } + + if (!isAdmin) { + return + } + + return <>{children} +} + diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 47fca52..f92aef2 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -3,22 +3,27 @@ import { useAuth } from '../context/AuthContext' export default function Layout() { const location = useLocation() - const { username, logout } = useAuth() + const { username, logout, isAdmin } = useAuth() - const navItems = [ - { path: '/scan', label: 'Scan', icon: 'đŸ—‚ī¸' }, - { path: '/process', label: 'Process', icon: 'âš™ī¸' }, - { path: '/search', label: 'Search', icon: '🔍' }, - { path: '/identify', label: 'Identify', icon: '👤' }, - { path: '/auto-match', label: 'Auto-Match', icon: '🤖' }, - { path: '/modify', label: 'Modify', icon: 'âœī¸' }, - { path: '/tags', label: 'Tag', icon: 'đŸˇī¸' }, - { path: '/faces-maintenance', label: 'Faces Maintenance', icon: '🔧' }, - { path: '/approve-identified', label: 'Approve identified', icon: '✅' }, - { path: '/settings', label: 'Settings', icon: 'âš™ī¸' }, - { path: '/help', label: 'Help', icon: '📚' }, + const allNavItems = [ + { path: '/scan', label: 'Scan', icon: 'đŸ—‚ī¸', adminOnly: false }, + { path: '/process', label: 'Process', icon: 'âš™ī¸', adminOnly: false }, + { path: '/search', label: 'Search', icon: '🔍', adminOnly: false }, + { path: '/identify', label: 'Identify', icon: '👤', adminOnly: false }, + { path: '/auto-match', label: 'Auto-Match', icon: '🤖', adminOnly: false }, + { path: '/modify', label: 'Modify', icon: 'âœī¸', adminOnly: false }, + { path: '/tags', label: 'Tag', 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 }, + { path: '/reported-photos', label: 'Reported photos', icon: '🚩', adminOnly: true }, + { path: '/settings', label: 'Settings', icon: 'âš™ī¸', adminOnly: false }, + { path: '/help', label: 'Help', icon: '📚', adminOnly: false }, ] + // Filter nav items based on admin status + const navItems = allNavItems.filter((item) => !item.adminOnly || isAdmin) + return (
{/* Top bar */} diff --git a/frontend/src/components/PasswordChangeModal.tsx b/frontend/src/components/PasswordChangeModal.tsx new file mode 100644 index 0000000..2102310 --- /dev/null +++ b/frontend/src/components/PasswordChangeModal.tsx @@ -0,0 +1,129 @@ +import { useState } from 'react' +import { authApi } from '../api/auth' +import { useAuth } from '../context/AuthContext' + +interface PasswordChangeModalProps { + onSuccess: () => void +} + +export default function PasswordChangeModal({ onSuccess }: PasswordChangeModalProps) { + const { clearPasswordChangeRequired } = useAuth() + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + // Validation + if (!currentPassword || !newPassword || !confirmPassword) { + setError('All fields are required') + return + } + + if (newPassword.length < 6) { + setError('New password must be at least 6 characters') + return + } + + if (newPassword !== confirmPassword) { + setError('New passwords do not match') + return + } + + if (currentPassword === newPassword) { + setError('New password must be different from current password') + return + } + + try { + setLoading(true) + await authApi.changePassword({ + current_password: currentPassword, + new_password: newPassword, + }) + clearPasswordChangeRequired() + onSuccess() + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to change password') + } finally { + setLoading(false) + } + } + + return ( +
+
+

Change Password Required

+

+ You must change your password before continuing. Please enter your current password + (provided by your administrator) and choose a new password. +

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setCurrentPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md" + required + autoFocus + /> +
+ +
+ + setNewPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md" + required + minLength={6} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md" + required + minLength={6} + /> +
+ +
+ +
+
+
+
+ ) +} + diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 9ad80c5..d0e67da 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -5,11 +5,14 @@ interface AuthState { isAuthenticated: boolean username: string | null isLoading: boolean + passwordChangeRequired: boolean + isAdmin: boolean } interface AuthContextType extends AuthState { - login: (username: string, password: string) => Promise<{ success: boolean; error?: string }> + login: (username: string, password: string) => Promise<{ success: boolean; error?: string; passwordChangeRequired?: boolean }> logout: () => void + clearPasswordChangeRequired: () => void } const AuthContext = createContext(undefined) @@ -19,6 +22,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAuthenticated: false, username: null, isLoading: true, + passwordChangeRequired: false, + isAdmin: false, }) useEffect(() => { @@ -31,6 +36,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAuthenticated: true, username: user.username, isLoading: false, + passwordChangeRequired: false, + isAdmin: user.is_admin || false, }) }) .catch(() => { @@ -40,6 +47,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAuthenticated: false, username: null, isLoading: false, + passwordChangeRequired: false, + isAdmin: false, }) }) } else { @@ -47,6 +56,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAuthenticated: false, username: null, isLoading: false, + passwordChangeRequired: false, + isAdmin: false, }) } }, []) @@ -57,12 +68,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { localStorage.setItem('access_token', tokens.access_token) localStorage.setItem('refresh_token', tokens.refresh_token) const user = await authApi.me() + const passwordChangeRequired = tokens.password_change_required || false setAuthState({ isAuthenticated: true, username: user.username, isLoading: false, + passwordChangeRequired, + isAdmin: user.is_admin || false, }) - return { success: true } + return { success: true, passwordChangeRequired } } catch (error: any) { return { success: false, @@ -71,6 +85,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { } } + const clearPasswordChangeRequired = () => { + setAuthState((prev) => ({ + ...prev, + passwordChangeRequired: false, + })) + } + const logout = () => { localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') @@ -84,7 +105,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } return ( - + {children} ) diff --git a/frontend/src/pages/ApproveIdentified.tsx b/frontend/src/pages/ApproveIdentified.tsx index e87ce78..64be17f 100644 --- a/frontend/src/pages/ApproveIdentified.tsx +++ b/frontend/src/pages/ApproveIdentified.tsx @@ -58,11 +58,11 @@ export default function ApproveIdentified() { } const handleSubmit = async () => { - // Get all decisions that have been made, but only for pending items + // Get all decisions that have been made, for pending or denied items (not approved) const decisionsList = Object.entries(decisions) .filter(([id, decision]) => { const pending = pendingIdentifications.find(p => p.id === parseInt(id)) - return decision !== null && pending && pending.status === 'pending' + return decision !== null && pending && pending.status !== 'approved' }) .map(([id, decision]) => ({ id: parseInt(id), @@ -193,7 +193,7 @@ export default function ApproveIdentified() { const isDenied = pending.status === 'denied' const isApproved = pending.status === 'approved' return ( - +
{formatName(pending)} @@ -265,34 +265,37 @@ export default function ApproveIdentified() {
- {isDenied ? ( -
Denied
- ) : isApproved ? ( + {isApproved ? (
Approved
) : ( -
- - +
+ {isDenied && ( + (Denied) + )} +
+ + +
)} diff --git a/frontend/src/pages/AutoMatch.tsx b/frontend/src/pages/AutoMatch.tsx index 6aa2709..a071964 100644 --- a/frontend/src/pages/AutoMatch.tsx +++ b/frontend/src/pages/AutoMatch.tsx @@ -624,7 +624,7 @@ export default function AutoMatch() {
- Person {currentIndex + 1} of {activePeople.length} + Person {currentIndex + 1}
- Person {currentIndex + 1} of {activePeople.length} + Person {currentIndex + 1} {currentPerson && ` â€ĸ ${currentPerson.total_matches} matches`}
diff --git a/frontend/src/pages/ManageUsers.tsx b/frontend/src/pages/ManageUsers.tsx new file mode 100644 index 0000000..2b490ff --- /dev/null +++ b/frontend/src/pages/ManageUsers.tsx @@ -0,0 +1,473 @@ +import { useState, useEffect } from 'react' +import { usersApi, UserResponse, UserCreateRequest, UserUpdateRequest } from '../api/users' + +export default function ManageUsers() { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showCreateModal, setShowCreateModal] = useState(false) + const [editingUser, setEditingUser] = useState(null) + const [filterActive, setFilterActive] = useState(null) + const [filterAdmin, setFilterAdmin] = useState(null) + + const [createForm, setCreateForm] = useState({ + username: '', + password: '', + email: '', + full_name: '', + is_active: true, + is_admin: false, + }) + + const [editForm, setEditForm] = useState({ + password: '', + email: '', + full_name: '', + is_active: true, + is_admin: false, + }) + + useEffect(() => { + loadUsers() + }, [filterActive, filterAdmin]) + + const loadUsers = async () => { + try { + setLoading(true) + setError(null) + const params: { is_active?: boolean; is_admin?: boolean } = {} + if (filterActive !== null) params.is_active = filterActive + if (filterAdmin !== null) params.is_admin = filterAdmin + const response = await usersApi.listUsers(params) + setUsers(response.items) + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to load users') + } finally { + setLoading(false) + } + } + + const handleCreate = async () => { + try { + setError(null) + await usersApi.createUser(createForm) + setShowCreateModal(false) + setCreateForm({ + username: '', + password: '', + email: '', + full_name: '', + is_active: true, + is_admin: false, + }) + loadUsers() + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to create 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() !== '' + ? editForm.password + : undefined, + } + await usersApi.updateUser(editingUser.id, updateData) + setEditingUser(null) + setEditForm({ + password: '', + email: '', + full_name: '', + is_active: true, + is_admin: false, + }) + loadUsers() + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to update user') + } + } + + const handleDelete = async (userId: number, username: string) => { + if (!confirm(`Are you sure you want to delete user "${username}"?`)) { + return + } + try { + setError(null) + await usersApi.deleteUser(userId) + loadUsers() + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to delete user') + } + } + + const startEdit = (user: UserResponse) => { + setEditingUser(user) + setEditForm({ + password: '', + email: user.email || '', + full_name: user.full_name || '', + is_active: user.is_active, + is_admin: user.is_admin, + }) + } + + const formatDate = (dateString: string | null) => { + if (!dateString) return 'Never' + return new Date(dateString).toLocaleString() + } + + return ( +
+
+

Manage Users

+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Filters */} +
+
+ + + +
+
+ + {/* Users Table */} +
+ {loading ? ( +
Loading users...
+ ) : users.length === 0 ? ( +
No users found
+ ) : ( + + + + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + + + ))} + +
+ Username + + Email + + Full Name + + Status + + Role + + Created + + Last Login + + Actions +
+ {user.username} + + {user.email || '-'} + + {user.full_name || '-'} + + + {user.is_active ? 'Active' : 'Inactive'} + + + + {user.is_admin ? 'Admin' : 'User'} + + + {formatDate(user.created_date)} + + {formatDate(user.last_login)} + + + +
+ )} +
+ + {/* Create Modal */} + {showCreateModal && ( +
+
+

Create New User

+
+
+ + + setCreateForm({ ...createForm, username: e.target.value }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-md" + required + /> +
+
+ + + setCreateForm({ ...createForm, password: e.target.value }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-md" + required + minLength={6} + /> +
+
+ + + setCreateForm({ ...createForm, email: e.target.value }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-md" + /> +
+
+ + + setCreateForm({ ...createForm, full_name: e.target.value }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-md" + /> +
+
+ + +
+
+
+ + +
+
+
+ )} + + {/* Edit Modal */} + {editingUser && ( +
+
+

+ Edit User: {editingUser.username} +

+
+
+ + + setEditForm({ ...editForm, password: e.target.value || null }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-md" + minLength={6} + placeholder="Leave empty to keep current password" + /> +

+ Minimum 6 characters. Leave empty to keep the current password. +

+
+
+ + + setEditForm({ ...editForm, email: e.target.value }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-md" + /> +
+
+ + + setEditForm({ ...editForm, full_name: e.target.value }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-md" + /> +
+
+ + +
+
+
+ + +
+
+
+ )} +
+ ) +} + diff --git a/frontend/src/pages/ReportedPhotos.tsx b/frontend/src/pages/ReportedPhotos.tsx new file mode 100644 index 0000000..8766b0a --- /dev/null +++ b/frontend/src/pages/ReportedPhotos.tsx @@ -0,0 +1,368 @@ +import { useEffect, useState, useCallback } from 'react' +import { + reportedPhotosApi, + ReportedPhotoResponse, + ReviewDecision, +} from '../api/reportedPhotos' +import { apiClient } from '../api/client' + +export default function ReportedPhotos() { + const [reportedPhotos, setReportedPhotos] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [decisions, setDecisions] = useState>({}) + const [reviewNotes, setReviewNotes] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [statusFilter, setStatusFilter] = useState('pending') + + const loadReportedPhotos = useCallback(async () => { + setLoading(true) + setError(null) + try { + const response = await reportedPhotosApi.listReportedPhotos( + statusFilter || undefined + ) + setReportedPhotos(response.items) + + // Initialize review notes from existing data + const existingNotes: Record = {} + response.items.forEach((item) => { + if (item.review_notes) { + existingNotes[item.id] = item.review_notes + } + }) + setReviewNotes(existingNotes) + } catch (err: any) { + setError(err.response?.data?.detail || err.message || 'Failed to load reported photos') + console.error('Error loading reported photos:', err) + } finally { + setLoading(false) + } + }, [statusFilter]) + + useEffect(() => { + loadReportedPhotos() + }, [loadReportedPhotos]) + + const formatDate = (dateString: string | null | undefined): string => { + if (!dateString) return '-' + try { + const date = new Date(dateString) + return date.toLocaleString() + } catch { + return dateString + } + } + + const handleDecisionChange = (id: number, decision: 'keep' | 'remove') => { + setDecisions((prev) => ({ + ...prev, + [id]: decision, + })) + } + + const handleReviewNotesChange = (id: number, notes: string) => { + setReviewNotes((prev) => ({ + ...prev, + [id]: notes, + })) + } + + const handleSubmit = async () => { + // Get all decisions that have been made for pending or reviewed items + const decisionsList: ReviewDecision[] = Object.entries(decisions) + .filter(([id, decision]) => { + const reported = reportedPhotos.find((p) => p.id === parseInt(id)) + return decision !== null && reported && (reported.status === 'pending' || reported.status === 'reviewed') + }) + .map(([id, decision]) => ({ + id: parseInt(id), + decision: decision!, + review_notes: reviewNotes[parseInt(id)] || null, + })) + + if (decisionsList.length === 0) { + alert('Please select Keep or Remove for at least one reported photo.') + return + } + + if ( + !confirm( + `Submit ${decisionsList.length} decision(s)?\n\nThis will ${ + decisionsList.filter((d) => d.decision === 'remove').length + } remove photo(s) and ${ + decisionsList.filter((d) => d.decision === 'keep').length + } keep photo(s).` + ) + ) { + return + } + + setSubmitting(true) + try { + const response = await reportedPhotosApi.reviewReportedPhotos({ + decisions: decisionsList, + }) + + const message = [ + `✅ Kept: ${response.kept}`, + `❌ Removed: ${response.removed}`, + response.errors.length > 0 ? `âš ī¸ Errors: ${response.errors.length}` : '', + ] + .filter(Boolean) + .join('\n') + + alert(message) + + if (response.errors.length > 0) { + console.error('Errors:', response.errors) + } + + // Reload the list to show updated status + await loadReportedPhotos() + // Clear decisions and notes + setDecisions({}) + setReviewNotes({}) + } catch (err: any) { + const errorMessage = + err.response?.data?.detail || err.message || 'Failed to submit decisions' + alert(`Error: ${errorMessage}`) + console.error('Error submitting decisions:', err) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+

Reported Photos

+ + {loading && ( +
+

Loading reported photos...

+
+ )} + + {error && ( +
+

Error loading data

+

{error}

+ +
+ )} + + {!loading && !error && ( + <> +
+
+
+ Total reported photos: {reportedPhotos.length} +
+ +
+ +
+ + {reportedPhotos.length === 0 ? ( +
+

No reported photos found.

+
+ ) : ( +
+ + + + + + + + + + + + + {reportedPhotos.map((reported) => { + const isReviewed = reported.status === 'reviewed' + const isDismissed = reported.status === 'dismissed' + return ( + + + + + + +
+ Photo + + Reported By + + Reported At + + Status + + Decision + + Review Notes +
+
+ {reported.photo_id ? ( +
{ + const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${reported.photo_id}/image` + window.open(photoUrl, '_blank') + }} + title="Click to open full photo" + > + {`Photo { + const target = e.target as HTMLImageElement + target.style.display = 'none' + const parent = target.parentElement + if (parent && !parent.querySelector('.error-fallback')) { + const fallback = document.createElement('div') + fallback.className = + 'text-gray-400 text-xs error-fallback' + fallback.textContent = `#${reported.photo_id}` + parent.appendChild(fallback) + } + }} + /> +
+ ) : ( +
Photo not found
+ )} +
+
+ {reported.photo_filename || `Photo #${reported.photo_id}`} +
+
+
+ {reported.user_name || 'Unknown'} +
+
+ {reported.user_email || '-'} +
+
+
+ {formatDate(reported.reported_at)} +
+
+ + {reported.status} + + +
+ + +
+
+ {isReviewed || isDismissed ? ( +
+ {reported.review_notes ? ( +
+ {reported.review_notes} +
+ ) : ( + - + )} +
+ ) : ( +
+ {reported.review_notes && ( +
+
+ Existing notes: +
+
+ {reported.review_notes} +
+
+ )} +