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:
tanyar09
2025-11-20 13:18:58 -05:00
parent 926e738a13
commit 87146b1356
23 changed files with 2148 additions and 71 deletions
+55 -3
View File
@@ -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>
+23 -1
View File
@@ -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
},
}
+58
View File
@@ -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
},
}
+72
View File
@@ -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}`)
},
}
+25
View File
@@ -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}</>
}
+18 -13
View File
@@ -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>
)
}
+24 -3
View File
@@ -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>
)
+32 -29
View File
@@ -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>
+2 -2
View File
@@ -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>
+473
View File
@@ -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>
)
}
+368
View File
@@ -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>
)
}