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.
This commit is contained in:
+55
-3
@@ -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 <div className="min-h-screen flex items-center justify-center">Loading...</div>
|
||||
}
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPasswordModal && (
|
||||
<PasswordChangeModal
|
||||
onSuccess={() => {
|
||||
setShowPasswordModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
@@ -45,7 +74,30 @@ function AppRoutes() {
|
||||
<Route path="modify" element={<Modify />} />
|
||||
<Route path="tags" element={<Tags />} />
|
||||
<Route path="faces-maintenance" element={<FacesMaintenance />} />
|
||||
<Route path="approve-identified" element={<ApproveIdentified />} />
|
||||
<Route
|
||||
path="approve-identified"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<ApproveIdentified />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="manage-users"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<ManageUsers />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="reported-photos"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<ReportedPhotos />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="help" element={<Help />} />
|
||||
</Route>
|
||||
|
||||
@@ -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<UserResponse>('/api/v1/auth/me')
|
||||
return data
|
||||
},
|
||||
|
||||
changePassword: async (
|
||||
request: PasswordChangeRequest
|
||||
): Promise<PasswordChangeResponse> => {
|
||||
const { data } = await apiClient.post<PasswordChangeResponse>(
|
||||
'/api/v1/auth/change-password',
|
||||
request
|
||||
)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ReportedPhotosListResponse> => {
|
||||
const { data } = await apiClient.get<ReportedPhotosListResponse>(
|
||||
'/api/v1/reported-photos',
|
||||
{
|
||||
params: statusFilter ? { status_filter: statusFilter } : undefined,
|
||||
}
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
reviewReportedPhotos: async (request: ReviewRequest): Promise<ReviewResponse> => {
|
||||
const { data } = await apiClient.post<ReviewResponse>(
|
||||
'/api/v1/reported-photos/review',
|
||||
request
|
||||
)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<UsersListResponse> => {
|
||||
const { data } = await apiClient.get<UsersListResponse>('/api/v1/users', {
|
||||
params,
|
||||
})
|
||||
return data
|
||||
},
|
||||
|
||||
getUser: async (userId: number): Promise<UserResponse> => {
|
||||
const { data } = await apiClient.get<UserResponse>(`/api/v1/users/${userId}`)
|
||||
return data
|
||||
},
|
||||
|
||||
createUser: async (request: UserCreateRequest): Promise<UserResponse> => {
|
||||
const { data } = await apiClient.post<UserResponse>('/api/v1/users', request)
|
||||
return data
|
||||
},
|
||||
|
||||
updateUser: async (
|
||||
userId: number,
|
||||
request: UserUpdateRequest
|
||||
): Promise<UserResponse> => {
|
||||
const { data } = await apiClient.put<UserResponse>(
|
||||
`/api/v1/users/${userId}`,
|
||||
request
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
deleteUser: async (userId: number): Promise<void> => {
|
||||
await apiClient.delete(`/api/v1/users/${userId}`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 <div className="min-h-screen flex items-center justify-center">Loading...</div>
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Top bar */}
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<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">Change Password Required</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
You must change your password before continuing. Please enter your current password
|
||||
(provided by your administrator) and choose a new password.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Current Password *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
New Password * (min 6 characters)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(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">
|
||||
Confirm New Password *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<AuthContextType | undefined>(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 (
|
||||
<AuthContext.Provider value={{ ...authState, login, logout }}>
|
||||
<AuthContext.Provider value={{ ...authState, login, logout, clearPasswordChangeRequired }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<tr key={pending.id} className={`hover:bg-gray-50 ${isDenied ? 'opacity-60 bg-gray-50' : ''} ${isApproved ? 'opacity-60 bg-green-50' : ''}`}>
|
||||
<tr key={pending.id} className={`hover:bg-gray-50 ${isDenied ? 'opacity-60 bg-gray-50' : ''}`}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{formatName(pending)}
|
||||
@@ -265,34 +265,37 @@ export default function ApproveIdentified() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{isDenied ? (
|
||||
<div className="text-sm text-red-600 font-medium">Denied</div>
|
||||
) : isApproved ? (
|
||||
{isApproved ? (
|
||||
<div className="text-sm text-green-600 font-medium">Approved</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`decision-${pending.id}`}
|
||||
value="approve"
|
||||
checked={decisions[pending.id] === 'approve'}
|
||||
onChange={() => handleDecisionChange(pending.id, 'approve')}
|
||||
className="w-4 h-4 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Approve</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`decision-${pending.id}`}
|
||||
value="deny"
|
||||
checked={decisions[pending.id] === 'deny'}
|
||||
onChange={() => handleDecisionChange(pending.id, 'deny')}
|
||||
className="w-4 h-4 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Deny</span>
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{isDenied && (
|
||||
<span className="text-xs text-red-600 font-medium">(Denied)</span>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`decision-${pending.id}`}
|
||||
value="approve"
|
||||
checked={decisions[pending.id] === 'approve'}
|
||||
onChange={() => handleDecisionChange(pending.id, 'approve')}
|
||||
className="w-4 h-4 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Approve</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`decision-${pending.id}`}
|
||||
value="deny"
|
||||
checked={decisions[pending.id] === 'deny'}
|
||||
onChange={() => handleDecisionChange(pending.id, 'deny')}
|
||||
className="w-4 h-4 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Deny</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -624,7 +624,7 @@ export default function AutoMatch() {
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm text-gray-600">
|
||||
Person {currentIndex + 1} of {activePeople.length}
|
||||
Person {currentIndex + 1}
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<button
|
||||
@@ -781,7 +781,7 @@ export default function AutoMatch() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
Person {currentIndex + 1} of {activePeople.length}
|
||||
Person {currentIndex + 1}
|
||||
{currentPerson && ` • ${currentPerson.total_matches} matches`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<UserResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
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 [filterAdmin, setFilterAdmin] = useState<boolean | null>(null)
|
||||
|
||||
const [createForm, setCreateForm] = useState<UserCreateRequest>({
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
full_name: '',
|
||||
is_active: true,
|
||||
is_admin: false,
|
||||
})
|
||||
|
||||
const [editForm, setEditForm] = useState<UserUpdateRequest>({
|
||||
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 (
|
||||
<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)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
+ Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* 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">
|
||||
<h2 className="text-xl font-bold mb-4">Create New User</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.username}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, username: 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">
|
||||
Password * (min 6 characters)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={createForm.password}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, 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">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={createForm.email || ''}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, email: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.full_name || ''}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, full_name: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createForm.is_active}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, is_active: e.target.checked })
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<h2 className="text-xl font-bold mb-4">
|
||||
Edit User: {editingUser.username}
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
New Password (leave empty to keep current)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={editForm.password || ''}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Minimum 6 characters. Leave empty to keep the current password.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={editForm.email || ''}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, email: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.full_name || ''}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, full_name: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.is_active ?? false}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, is_active: e.target.checked })
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setEditingUser(null)}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpdate}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<ReportedPhotoResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [decisions, setDecisions] = useState<Record<number, 'keep' | 'remove' | null>>({})
|
||||
const [reviewNotes, setReviewNotes] = useState<Record<number, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [statusFilter, setStatusFilter] = useState<string>('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<number, string> = {}
|
||||
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 (
|
||||
<div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Reported Photos</h1>
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">Loading reported photos...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-700">
|
||||
<p className="font-semibold">Error loading data</p>
|
||||
<p className="text-sm mt-1">{error}</p>
|
||||
<button
|
||||
onClick={loadReportedPhotos}
|
||||
className="mt-3 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
Total reported photos: <span className="font-semibold">{reportedPhotos.length}</span>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="reviewed">Reviewed</option>
|
||||
<option value="dismissed">Dismissed</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
submitting ||
|
||||
Object.values(decisions).filter((d) => d !== null).length === 0
|
||||
}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Decisions'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{reportedPhotos.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>No reported photos found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<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">
|
||||
Photo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Reported By
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Reported At
|
||||
</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">
|
||||
Decision
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Review Notes
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{reportedPhotos.map((reported) => {
|
||||
const isReviewed = reported.status === 'reviewed'
|
||||
const isDismissed = reported.status === 'dismissed'
|
||||
return (
|
||||
<tr
|
||||
key={reported.id}
|
||||
className={`hover:bg-gray-50 ${
|
||||
isReviewed || isDismissed ? 'opacity-60 bg-gray-50' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
{reported.photo_id ? (
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => {
|
||||
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${reported.photo_id}/image`
|
||||
window.open(photoUrl, '_blank')
|
||||
}}
|
||||
title="Click to open full photo"
|
||||
>
|
||||
<img
|
||||
src={`/api/v1/photos/${reported.photo_id}/image`}
|
||||
alt={`Photo ${reported.photo_id}`}
|
||||
className="w-24 h-24 object-cover rounded border border-gray-300"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-400 text-xs">Photo not found</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{reported.photo_filename || `Photo #${reported.photo_id}`}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{reported.user_name || 'Unknown'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{reported.user_email || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{formatDate(reported.reported_at)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
reported.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: reported.status === 'reviewed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{reported.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`decision-${reported.id}`}
|
||||
value="keep"
|
||||
checked={decisions[reported.id] === 'keep'}
|
||||
onChange={() => handleDecisionChange(reported.id, 'keep')}
|
||||
className="w-4 h-4 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Keep</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`decision-${reported.id}`}
|
||||
value="remove"
|
||||
checked={decisions[reported.id] === 'remove'}
|
||||
onChange={() => handleDecisionChange(reported.id, 'remove')}
|
||||
className="w-4 h-4 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Remove</span>
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{isReviewed || isDismissed ? (
|
||||
<div className="text-sm text-gray-700 whitespace-pre-wrap">
|
||||
{reported.review_notes ? (
|
||||
<div className="bg-gray-50 p-2 rounded border border-gray-200">
|
||||
{reported.review_notes}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">-</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{reported.review_notes && (
|
||||
<div className="bg-blue-50 p-2 rounded border border-blue-200 text-sm text-gray-700">
|
||||
<div className="text-xs text-blue-600 font-medium mb-1">
|
||||
Existing notes:
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap">
|
||||
{reported.review_notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={reviewNotes[reported.id] || ''}
|
||||
onChange={(e) =>
|
||||
handleReviewNotesChange(reported.id, e.target.value)
|
||||
}
|
||||
placeholder="Optional review notes..."
|
||||
className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user