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

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>

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
},
}

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
frontend/src/api/users.ts Normal file
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}`)
},
}

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}</>
}

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 */}

View File

@ -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>
)
}

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>
)

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>

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>

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>
)
}

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>
)
}

View File

@ -8,6 +8,7 @@ rq==1.16.2
python-jose[cryptography]==3.3.0
python-multipart==0.0.9
python-dotenv==1.0.0
bcrypt==4.1.2
# PunimTag Dependencies - DeepFace Implementation
# Core Dependencies
numpy>=1.21.0

View File

@ -8,12 +8,18 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from src.web.db.session import get_db
from src.web.db.models import User
from src.web.utils.password import verify_password, hash_password
from src.web.schemas.auth import (
LoginRequest,
RefreshRequest,
TokenResponse,
UserResponse,
PasswordChangeRequest,
PasswordChangeResponse,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@ -69,8 +75,55 @@ def get_current_user(
@router.post("/login", response_model=TokenResponse)
def login(credentials: LoginRequest) -> TokenResponse:
"""Authenticate user and return tokens."""
def login(credentials: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
"""Authenticate user and return tokens.
First checks main database for users, falls back to hardcoded admin/admin
for backward compatibility.
"""
# First, try to find user in main database
user = db.query(User).filter(User.username == credentials.username).first()
if user:
# User exists in main database - verify password
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Account is inactive",
)
# Check if password_hash exists (migration might not have run)
if not user.password_hash:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Password not set. Please contact administrator to set your password.",
)
if not verify_password(credentials.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
# Update last login
user.last_login = datetime.utcnow()
db.add(user)
db.commit()
# Generate tokens
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": credentials.username},
expires_delta=access_token_expires,
)
refresh_token = create_refresh_token(data={"sub": credentials.username})
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
password_change_required=user.password_change_required,
)
# Fallback to hardcoded admin/admin for backward compatibility
if (
credentials.username == SINGLE_USER_USERNAME
and credentials.password == SINGLE_USER_PASSWORD
@ -82,8 +135,11 @@ def login(credentials: LoginRequest) -> TokenResponse:
)
refresh_token = create_refresh_token(data={"sub": credentials.username})
return TokenResponse(
access_token=access_token, refresh_token=refresh_token
access_token=access_token,
refresh_token=refresh_token,
password_change_required=False, # Hardcoded admin doesn't require password change
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
@ -125,8 +181,57 @@ def refresh_token(request: RefreshRequest) -> TokenResponse:
@router.get("/me", response_model=UserResponse)
def get_current_user_info(
current_user: Annotated[dict, Depends(get_current_user)]
current_user: Annotated[dict, Depends(get_current_user)],
db: Session = Depends(get_db),
) -> UserResponse:
"""Get current user information."""
return UserResponse(username=current_user["username"])
"""Get current user information including admin status."""
username = current_user["username"]
# Check if user exists in main database to get admin status
user = db.query(User).filter(User.username == username).first()
is_admin = user.is_admin if user else False
return UserResponse(username=username, is_admin=is_admin)
@router.post("/change-password", response_model=PasswordChangeResponse)
def change_password(
request: PasswordChangeRequest,
current_user: Annotated[dict, Depends(get_current_user)],
db: Session = Depends(get_db),
) -> PasswordChangeResponse:
"""Change user password.
Requires current password verification.
After successful change, clears password_change_required flag.
"""
username = current_user["username"]
# Find user in main database
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Verify current password
if not verify_password(request.current_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Current password is incorrect",
)
# Update password
user.password_hash = hash_password(request.new_password)
user.password_change_required = False # Clear the flag after password change
db.add(user)
db.commit()
return PasswordChangeResponse(
success=True,
message="Password changed successfully",
)

View File

@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Optional
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict
@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
from src.web.db.session import get_auth_db, get_db
from src.web.db.models import Face, Person, PersonEncoding
from src.web.api.users import get_current_admin_user
router = APIRouter(prefix="/pending-identifications", tags=["pending-identifications"])
@ -75,6 +76,7 @@ class ApproveDenyResponse(BaseModel):
@router.get("", response_model=PendingIdentificationsListResponse)
def list_pending_identifications(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
include_denied: bool = False,
db: Session = Depends(get_auth_db),
main_db: Session = Depends(get_db),
@ -170,6 +172,7 @@ def list_pending_identifications(
@router.post("/approve-deny", response_model=ApproveDenyResponse)
def approve_deny_pending_identifications(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
request: ApproveDenyRequest,
auth_db: Session = Depends(get_auth_db),
main_db: Session = Depends(get_db),
@ -191,6 +194,7 @@ def approve_deny_pending_identifications(
for decision in request.decisions:
try:
# Get pending identification from auth database
# Allow processing of both 'pending' and 'denied' status (to allow re-approval)
result = auth_db.execute(text("""
SELECT
pi.id,
@ -201,7 +205,7 @@ def approve_deny_pending_identifications(
pi.maiden_name,
pi.date_of_birth
FROM pending_identifications pi
WHERE pi.id = :id AND pi.status = 'pending'
WHERE pi.id = :id AND pi.status IN ('pending', 'denied')
"""), {"id": decision.id})
row = result.fetchone()

View File

@ -0,0 +1,281 @@
"""Reported photos endpoints - admin only."""
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import text
from sqlalchemy.orm import Session
from src.web.db.session import get_auth_db, get_db
from src.web.db.models import Photo
from src.web.api.users import get_current_admin_user
router = APIRouter(prefix="/reported-photos", tags=["reported-photos"])
class ReportedPhotoResponse(BaseModel):
"""Reported photo DTO returned from API."""
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: int
photo_id: int
user_id: int
user_name: Optional[str] = None
user_email: Optional[str] = None
status: str
reported_at: str
reviewed_at: Optional[str] = None
reviewed_by: Optional[int] = None
review_notes: Optional[str] = None
# Photo details from main database
photo_path: Optional[str] = None
photo_filename: Optional[str] = None
class ReportedPhotosListResponse(BaseModel):
"""List of reported photos."""
model_config = ConfigDict(protected_namespaces=())
items: list[ReportedPhotoResponse]
total: int
class ReviewDecision(BaseModel):
"""Decision for a single reported photo."""
model_config = ConfigDict(protected_namespaces=())
id: int
decision: str # 'keep' or 'remove'
review_notes: Optional[str] = None
class ReviewRequest(BaseModel):
"""Request to review multiple reported photos."""
model_config = ConfigDict(protected_namespaces=())
decisions: list[ReviewDecision]
class ReviewResponse(BaseModel):
"""Response from review operation."""
model_config = ConfigDict(protected_namespaces=())
kept: int
removed: int
errors: list[str]
@router.get("", response_model=ReportedPhotosListResponse)
def list_reported_photos(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
status_filter: Optional[str] = None,
auth_db: Session = Depends(get_auth_db),
main_db: Session = Depends(get_db),
) -> ReportedPhotosListResponse:
"""List all reported photos from the auth database.
This endpoint reads from the separate auth database (DATABASE_URL_AUTH)
and returns all reported photos from the inappropriate_photo_reports table.
Optionally filter by status: 'pending', 'reviewed', or 'dismissed'.
"""
try:
# Query inappropriate_photo_reports from auth database using raw SQL
# Join with users table to get user name/email
if status_filter:
result = auth_db.execute(text("""
SELECT
ipr.id,
ipr.photo_id,
ipr.user_id,
u.name as user_name,
u.email as user_email,
ipr.status,
ipr.reported_at,
ipr.reviewed_at,
ipr.reviewed_by,
ipr.review_notes
FROM inappropriate_photo_reports ipr
LEFT JOIN users u ON ipr.user_id = u.id
WHERE ipr.status = :status_filter
ORDER BY ipr.reported_at DESC
"""), {"status_filter": status_filter})
else:
result = auth_db.execute(text("""
SELECT
ipr.id,
ipr.photo_id,
ipr.user_id,
u.name as user_name,
u.email as user_email,
ipr.status,
ipr.reported_at,
ipr.reviewed_at,
ipr.reviewed_by,
ipr.review_notes
FROM inappropriate_photo_reports ipr
LEFT JOIN users u ON ipr.user_id = u.id
ORDER BY ipr.reported_at DESC
"""))
rows = result.fetchall()
items = []
for row in rows:
# Get photo details from main database
photo_path = None
photo_filename = None
photo = main_db.query(Photo).filter(Photo.id == row.photo_id).first()
if photo:
photo_path = photo.path
photo_filename = photo.filename
items.append(ReportedPhotoResponse(
id=row.id,
photo_id=row.photo_id,
user_id=row.user_id,
user_name=row.user_name,
user_email=row.user_email,
status=row.status,
reported_at=str(row.reported_at) if row.reported_at else '',
reviewed_at=str(row.reviewed_at) if row.reviewed_at else None,
reviewed_by=row.reviewed_by,
review_notes=row.review_notes,
photo_path=photo_path,
photo_filename=photo_filename,
))
return ReportedPhotosListResponse(items=items, total=len(items))
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error reading from auth database: {str(e)}"
)
@router.post("/review", response_model=ReviewResponse)
def review_reported_photos(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
request: ReviewRequest,
auth_db: Session = Depends(get_auth_db),
main_db: Session = Depends(get_db),
) -> ReviewResponse:
"""Review reported photos - keep or remove them.
For 'keep' decision:
- Updates status in auth database to 'reviewed'
- Photo remains in main database
For 'remove' decision:
- Updates status in auth database to 'reviewed'
- Deletes photo from main database (cascade deletes faces, tags, etc.)
"""
kept_count = 0
removed_count = 0
errors = []
admin_user_id = current_admin.get("user_id")
now = datetime.utcnow()
for decision in request.decisions:
try:
# Get reported photo from auth database
# Allow processing 'pending' and 'reviewed' status reports (to allow changing decisions)
result = auth_db.execute(text("""
SELECT
ipr.id,
ipr.photo_id,
ipr.status
FROM inappropriate_photo_reports ipr
WHERE ipr.id = :id AND ipr.status IN ('pending', 'reviewed')
"""), {"id": decision.id})
row = result.fetchone()
if not row:
errors.append(f"Reported photo {decision.id} not found or cannot be reviewed (status: dismissed)")
continue
if decision.decision == 'remove':
# Delete photo from main database (cascade will handle related records)
photo = main_db.query(Photo).filter(Photo.id == row.photo_id).first()
if not photo:
errors.append(f"Photo {row.photo_id} not found in main database")
# Still update status to reviewed since we can't process it
auth_db.execute(text("""
UPDATE inappropriate_photo_reports
SET status = 'reviewed',
reviewed_at = :reviewed_at,
reviewed_by = :reviewed_by,
review_notes = :review_notes
WHERE id = :id
"""), {
"id": decision.id,
"reviewed_at": now,
"reviewed_by": admin_user_id,
"review_notes": decision.review_notes or "Photo not found in database"
})
auth_db.commit()
kept_count += 1 # Count as kept since we couldn't remove it
continue
# Delete the photo (cascade will delete faces, tags, etc.)
main_db.delete(photo)
main_db.commit()
# Update status in auth database
auth_db.execute(text("""
UPDATE inappropriate_photo_reports
SET status = 'reviewed',
reviewed_at = :reviewed_at,
reviewed_by = :reviewed_by,
review_notes = :review_notes
WHERE id = :id
"""), {
"id": decision.id,
"reviewed_at": now,
"reviewed_by": admin_user_id,
"review_notes": decision.review_notes or "Photo removed"
})
auth_db.commit()
removed_count += 1
elif decision.decision == 'keep':
# Update status to reviewed (photo stays in database)
auth_db.execute(text("""
UPDATE inappropriate_photo_reports
SET status = 'reviewed',
reviewed_at = :reviewed_at,
reviewed_by = :reviewed_by,
review_notes = :review_notes
WHERE id = :id
"""), {
"id": decision.id,
"reviewed_at": now,
"reviewed_by": admin_user_id,
"review_notes": decision.review_notes or "Photo kept"
})
auth_db.commit()
kept_count += 1
else:
errors.append(f"Invalid decision '{decision.decision}' for reported photo {decision.id}")
except Exception as e:
errors.append(f"Error processing reported photo {decision.id}: {str(e)}")
# Rollback any partial changes
main_db.rollback()
auth_db.rollback()
return ReviewResponse(
kept=kept_count,
removed=removed_count,
errors=errors
)

224
src/web/api/users.py Normal file
View File

@ -0,0 +1,224 @@
"""User management endpoints - admin only."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from src.web.api.auth import get_current_user
from src.web.db.session import get_db
from src.web.db.models import User
from src.web.schemas.users import (
UserCreateRequest,
UserResponse,
UserUpdateRequest,
UsersListResponse,
)
from src.web.utils.password import hash_password
router = APIRouter(prefix="/users", tags=["users"])
def get_current_admin_user(
current_user: Annotated[dict, Depends(get_current_user)],
db: Session = Depends(get_db),
) -> dict:
"""Get current user and verify admin status from main database.
Raises HTTPException if user is not an admin.
If no admin users exist, allows the current user to bootstrap as admin.
"""
username = current_user["username"]
# Check if any admin users exist
admin_count = db.query(User).filter(User.is_admin == True).count()
# If no admins exist, allow current user to bootstrap as admin
if admin_count == 0:
# Check if user already exists in main database
main_user = db.query(User).filter(User.username == username).first()
if not main_user:
# Create the user as admin for bootstrap
# Use a default password hash (user should change password after first login)
# In production, this should be handled differently
default_password_hash = hash_password("changeme")
main_user = User(
username=username,
password_hash=default_password_hash,
is_active=True,
is_admin=True,
)
db.add(main_user)
db.commit()
db.refresh(main_user)
elif not main_user.is_admin:
# User exists but is not admin - make them admin for bootstrap
main_user.is_admin = True
db.add(main_user)
db.commit()
db.refresh(main_user)
return {"username": username, "user_id": main_user.id}
# Normal admin check - user must exist and be admin
main_user = db.query(User).filter(User.username == username).first()
if not main_user or not main_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return {"username": username, "user_id": main_user.id}
@router.get("", response_model=UsersListResponse)
def list_users(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
is_active: bool | None = Query(None, description="Filter by active status"),
is_admin: bool | None = Query(None, description="Filter by admin status"),
db: Session = Depends(get_db),
) -> UsersListResponse:
"""List all users - admin only.
Optionally filter by is_active and/or is_admin status.
"""
query = db.query(User)
if is_active is not None:
query = query.filter(User.is_active == is_active)
if is_admin is not None:
query = query.filter(User.is_admin == is_admin)
users = query.order_by(User.username.asc()).all()
items = [UserResponse.model_validate(u) for u in users]
return UsersListResponse(items=items, total=len(items))
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
request: UserCreateRequest,
db: Session = Depends(get_db),
) -> UserResponse:
"""Create a new user - admin only."""
# Check if username already exists
existing_user = db.query(User).filter(User.username == request.username).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Username '{request.username}' already exists",
)
# Hash the password before storing
password_hash = hash_password(request.password)
user = User(
username=request.username,
password_hash=password_hash,
email=request.email,
full_name=request.full_name,
is_active=request.is_active,
is_admin=request.is_admin,
password_change_required=True, # Force password change on first login
)
db.add(user)
db.commit()
db.refresh(user)
return UserResponse.model_validate(user)
@router.get("/{user_id}", response_model=UserResponse)
def get_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
user_id: int,
db: Session = Depends(get_db),
) -> UserResponse:
"""Get a specific user by ID - admin only."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {user_id} not found",
)
return UserResponse.model_validate(user)
@router.put("/{user_id}", response_model=UserResponse)
def update_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
user_id: int,
request: UserUpdateRequest,
db: Session = Depends(get_db),
) -> UserResponse:
"""Update a user - admin only."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {user_id} not found",
)
# Prevent admin from removing their own admin status
if (
current_admin["username"] == user.username
and request.is_admin is not None
and not request.is_admin
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot remove your own admin status",
)
# Update fields if provided
if request.password is not None:
user.password_hash = hash_password(request.password)
if request.email is not None:
user.email = request.email
if request.full_name is not None:
user.full_name = request.full_name
if request.is_active is not None:
user.is_active = request.is_active
if request.is_admin is not None:
user.is_admin = request.is_admin
db.add(user)
db.commit()
db.refresh(user)
return UserResponse.model_validate(user)
@router.delete("/{user_id}")
def delete_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
user_id: int,
db: Session = Depends(get_db),
) -> Response:
"""Delete a user - admin only.
Prevents admin from deleting themselves.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {user_id} not found",
)
# Prevent admin from deleting themselves
if current_admin["username"] == user.username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete your own account",
)
db.delete(user)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)

View File

@ -8,6 +8,7 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import inspect, text
from src.web.api.auth import router as auth_router
from src.web.api.faces import router as faces_router
@ -17,13 +18,16 @@ from src.web.api.metrics import router as metrics_router
from src.web.api.people import router as people_router
from src.web.api.pending_identifications import router as pending_identifications_router
from src.web.api.photos import router as photos_router
from src.web.api.reported_photos import router as reported_photos_router
from src.web.api.tags import router as tags_router
from src.web.api.users import router as users_router
from src.web.api.version import router as version_router
from src.web.settings import APP_TITLE, APP_VERSION
from src.web.db.base import Base, engine
from src.web.db.session import database_url
# Import models to ensure they're registered with Base.metadata
from src.web.db import models # noqa: F401
from src.web.utils.password import hash_password
# Global worker process (will be set in lifespan)
_worker_process: subprocess.Popen | None = None
@ -88,6 +92,85 @@ def stop_worker() -> None:
pass
def ensure_user_password_hash_column(inspector) -> None:
"""Ensure users table contains password_hash column."""
if "users" not in inspector.get_table_names():
print(" Users table does not exist yet - will be created with password_hash column")
return
columns = {column["name"] for column in inspector.get_columns("users")}
if "password_hash" in columns:
print(" password_hash column already exists in users table")
return
print("🔄 Adding password_hash column to users table...")
default_hash = hash_password("changeme")
dialect = engine.dialect.name
with engine.connect() as connection:
with connection.begin():
if dialect == "postgresql":
# PostgreSQL: Add column as nullable first, then update, then set NOT NULL
connection.execute(
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT")
)
connection.execute(
text(
"UPDATE users SET password_hash = :default_hash "
"WHERE password_hash IS NULL OR password_hash = ''"
),
{"default_hash": default_hash},
)
# Set NOT NULL constraint
connection.execute(
text("ALTER TABLE users ALTER COLUMN password_hash SET NOT NULL")
)
else:
# SQLite
connection.execute(
text("ALTER TABLE users ADD COLUMN password_hash TEXT")
)
connection.execute(
text(
"UPDATE users SET password_hash = :default_hash "
"WHERE password_hash IS NULL OR password_hash = ''"
),
{"default_hash": default_hash},
)
print("✅ Added password_hash column to users table (default password: changeme)")
def ensure_user_password_change_required_column(inspector) -> None:
"""Ensure users table contains password_change_required column."""
if "users" not in inspector.get_table_names():
return
columns = {column["name"] for column in inspector.get_columns("users")}
if "password_change_required" in columns:
print(" password_change_required column already exists in users table")
return
print("🔄 Adding password_change_required column to users table...")
dialect = engine.dialect.name
with engine.connect() as connection:
with connection.begin():
if dialect == "postgresql":
connection.execute(
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS password_change_required BOOLEAN NOT NULL DEFAULT true")
)
else:
# SQLite
connection.execute(
text("ALTER TABLE users ADD COLUMN password_change_required BOOLEAN DEFAULT 1")
)
connection.execute(
text("UPDATE users SET password_change_required = 1 WHERE password_change_required IS NULL")
)
print("✅ Added password_change_required column to users table")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown events."""
@ -99,12 +182,11 @@ async def lifespan(app: FastAPI):
db_file.parent.mkdir(parents=True, exist_ok=True)
# Only create tables if they don't already exist (safety check)
from sqlalchemy import inspect
inspector = inspect(engine)
existing_tables = set(inspector.get_table_names())
# Check if required application tables exist (not just alembic_version)
required_tables = {"photos", "people", "faces", "tags", "phototaglinkage", "person_encodings", "photo_favorites"}
required_tables = {"photos", "people", "faces", "tags", "phototaglinkage", "person_encodings", "photo_favorites", "users"}
missing_tables = required_tables - existing_tables
if missing_tables:
@ -118,6 +200,10 @@ async def lifespan(app: FastAPI):
else:
# All required tables exist - don't recreate (prevents data loss)
print(f"✅ Database already initialized ({len(existing_tables)} tables exist)")
# Ensure new columns exist (backward compatibility without migrations)
ensure_user_password_hash_column(inspector)
ensure_user_password_change_required_column(inspector)
except Exception as exc:
print(f"❌ Database initialization failed: {exc}")
raise
@ -153,7 +239,9 @@ def create_app() -> FastAPI:
app.include_router(faces_router, prefix="/api/v1")
app.include_router(people_router, prefix="/api/v1")
app.include_router(pending_identifications_router, prefix="/api/v1")
app.include_router(tags_router, prefix="/api/v1")
app.include_router(reported_photos_router, prefix="/api/v1")
app.include_router(tags_router, prefix="/api/v1")
app.include_router(users_router, prefix="/api/v1")
return app

View File

@ -195,3 +195,26 @@ class PhotoFavorite(Base):
Index("idx_favorites_photo", "photo_id"),
)
class User(Base):
"""User model for main database - separate from auth database users."""
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
username = Column(Text, unique=True, nullable=False, index=True)
password_hash = Column(Text, nullable=False) # Hashed password
email = Column(Text, nullable=True)
full_name = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False, index=True)
password_change_required = Column(Boolean, default=True, nullable=False, index=True)
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
last_login = Column(DateTime, nullable=True)
__table_args__ = (
Index("idx_users_username", "username"),
Index("idx_users_is_admin", "is_admin"),
Index("idx_users_password_change_required", "password_change_required"),
)

View File

@ -1,33 +1,59 @@
"""Authentication schemas."""
"""Authentication schemas for web API."""
from __future__ import annotations
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
class LoginRequest(BaseModel):
"""Login request schema."""
"""Login request payload."""
model_config = ConfigDict(protected_namespaces=())
username: str
password: str
class RefreshRequest(BaseModel):
"""Refresh token request payload."""
model_config = ConfigDict(protected_namespaces=())
refresh_token: str
class TokenResponse(BaseModel):
"""Token response schema."""
"""Token response payload."""
model_config = ConfigDict(protected_namespaces=())
access_token: str
refresh_token: str
token_type: str = "bearer"
password_change_required: bool = False
class UserResponse(BaseModel):
"""User response schema."""
"""User response payload."""
model_config = ConfigDict(protected_namespaces=())
username: str
is_admin: bool = False
class RefreshRequest(BaseModel):
"""Refresh token request schema."""
class PasswordChangeRequest(BaseModel):
"""Password change request payload."""
refresh_token: str
model_config = ConfigDict(protected_namespaces=())
current_password: str
new_password: str
class PasswordChangeResponse(BaseModel):
"""Password change response payload."""
model_config = ConfigDict(protected_namespaces=())
success: bool
message: str

59
src/web/schemas/users.py Normal file
View File

@ -0,0 +1,59 @@
"""User management schemas for web API."""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class UserResponse(BaseModel):
"""User DTO returned from API."""
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: int
username: str
email: Optional[str] = None
full_name: Optional[str] = None
is_active: bool
is_admin: bool
password_change_required: bool
created_date: datetime
last_login: Optional[datetime] = None
class UserCreateRequest(BaseModel):
"""Request payload to create a new user."""
model_config = ConfigDict(protected_namespaces=())
username: str = Field(..., min_length=1, max_length=100)
password: str = Field(..., min_length=6, description="Password (minimum 6 characters)")
email: Optional[EmailStr] = None
full_name: Optional[str] = Field(None, max_length=200)
is_active: bool = True
is_admin: bool = False
class UserUpdateRequest(BaseModel):
"""Request payload to update a user."""
model_config = ConfigDict(protected_namespaces=())
password: Optional[str] = Field(None, min_length=6, description="New password (minimum 6 characters, leave empty to keep current)")
email: Optional[EmailStr] = None
full_name: Optional[str] = Field(None, max_length=200)
is_active: Optional[bool] = None
is_admin: Optional[bool] = None
class UsersListResponse(BaseModel):
"""List of users."""
model_config = ConfigDict(protected_namespaces=())
items: list[UserResponse]
total: int

View File

@ -0,0 +1,2 @@
"""Utility functions for PunimTag Web."""

36
src/web/utils/password.py Normal file
View File

@ -0,0 +1,36 @@
"""Password hashing utilities."""
from __future__ import annotations
import bcrypt
def hash_password(password: str) -> str:
"""Hash a password using bcrypt.
Args:
password: Plain text password
Returns:
Hashed password as string
"""
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
return hashed.decode('utf-8')
def verify_password(password: str, password_hash: str) -> bool:
"""Verify a password against a hash.
Args:
password: Plain text password to verify
password_hash: Hashed password to compare against
Returns:
True if password matches, False otherwise
"""
return bcrypt.checkpw(
password.encode('utf-8'),
password_hash.encode('utf-8')
)