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:
parent
926e738a13
commit
87146b1356
@ -1,3 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import { DeveloperModeProvider } from './context/DeveloperModeContext'
|
||||
@ -12,16 +13,44 @@ import Modify from './pages/Modify'
|
||||
import Tags from './pages/Tags'
|
||||
import FacesMaintenance from './pages/FacesMaintenance'
|
||||
import ApproveIdentified from './pages/ApproveIdentified'
|
||||
import ManageUsers from './pages/ManageUsers'
|
||||
import ReportedPhotos from './pages/ReportedPhotos'
|
||||
import Settings from './pages/Settings'
|
||||
import Help from './pages/Help'
|
||||
import Layout from './components/Layout'
|
||||
import PasswordChangeModal from './components/PasswordChangeModal'
|
||||
import AdminRoute from './components/AdminRoute'
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
const { isAuthenticated, isLoading, passwordChangeRequired } = useAuth()
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && passwordChangeRequired) {
|
||||
setShowPasswordModal(true)
|
||||
}
|
||||
}, [isAuthenticated, passwordChangeRequired])
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>
|
||||
}
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPasswordModal && (
|
||||
<PasswordChangeModal
|
||||
onSuccess={() => {
|
||||
setShowPasswordModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
@ -45,7 +74,30 @@ function AppRoutes() {
|
||||
<Route path="modify" element={<Modify />} />
|
||||
<Route path="tags" element={<Tags />} />
|
||||
<Route path="faces-maintenance" element={<FacesMaintenance />} />
|
||||
<Route path="approve-identified" element={<ApproveIdentified />} />
|
||||
<Route
|
||||
path="approve-identified"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<ApproveIdentified />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="manage-users"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<ManageUsers />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="reported-photos"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<ReportedPhotos />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="help" element={<Help />} />
|
||||
</Route>
|
||||
|
||||
@ -8,11 +8,23 @@ export interface LoginRequest {
|
||||
export interface TokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
token_type: string
|
||||
token_type?: string
|
||||
password_change_required?: boolean
|
||||
}
|
||||
|
||||
export interface PasswordChangeRequest {
|
||||
current_password: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
export interface PasswordChangeResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
username: string
|
||||
is_admin?: boolean
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
@ -36,5 +48,15 @@ export const authApi = {
|
||||
const { data } = await apiClient.get<UserResponse>('/api/v1/auth/me')
|
||||
return data
|
||||
},
|
||||
|
||||
changePassword: async (
|
||||
request: PasswordChangeRequest
|
||||
): Promise<PasswordChangeResponse> => {
|
||||
const { data } = await apiClient.post<PasswordChangeResponse>(
|
||||
'/api/v1/auth/change-password',
|
||||
request
|
||||
)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
58
frontend/src/api/reportedPhotos.ts
Normal file
58
frontend/src/api/reportedPhotos.ts
Normal 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
72
frontend/src/api/users.ts
Normal 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
frontend/src/components/AdminRoute.tsx
Normal file
25
frontend/src/components/AdminRoute.tsx
Normal 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}</>
|
||||
}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
129
frontend/src/components/PasswordChangeModal.tsx
Normal file
129
frontend/src/components/PasswordChangeModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,11 +5,14 @@ interface AuthState {
|
||||
isAuthenticated: boolean
|
||||
username: string | null
|
||||
isLoading: boolean
|
||||
passwordChangeRequired: boolean
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>
|
||||
login: (username: string, password: string) => Promise<{ success: boolean; error?: string; passwordChangeRequired?: boolean }>
|
||||
logout: () => void
|
||||
clearPasswordChangeRequired: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
@ -19,6 +22,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
isAuthenticated: false,
|
||||
username: null,
|
||||
isLoading: true,
|
||||
passwordChangeRequired: false,
|
||||
isAdmin: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@ -31,6 +36,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
isAuthenticated: true,
|
||||
username: user.username,
|
||||
isLoading: false,
|
||||
passwordChangeRequired: false,
|
||||
isAdmin: user.is_admin || false,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
@ -40,6 +47,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
isAuthenticated: false,
|
||||
username: null,
|
||||
isLoading: false,
|
||||
passwordChangeRequired: false,
|
||||
isAdmin: false,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
@ -47,6 +56,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
isAuthenticated: false,
|
||||
username: null,
|
||||
isLoading: false,
|
||||
passwordChangeRequired: false,
|
||||
isAdmin: false,
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
@ -57,12 +68,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
localStorage.setItem('access_token', tokens.access_token)
|
||||
localStorage.setItem('refresh_token', tokens.refresh_token)
|
||||
const user = await authApi.me()
|
||||
const passwordChangeRequired = tokens.password_change_required || false
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
username: user.username,
|
||||
isLoading: false,
|
||||
passwordChangeRequired,
|
||||
isAdmin: user.is_admin || false,
|
||||
})
|
||||
return { success: true }
|
||||
return { success: true, passwordChangeRequired }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
@ -71,6 +85,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
const clearPasswordChangeRequired = () => {
|
||||
setAuthState((prev) => ({
|
||||
...prev,
|
||||
passwordChangeRequired: false,
|
||||
}))
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
@ -84,7 +105,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ ...authState, login, logout }}>
|
||||
<AuthContext.Provider value={{ ...authState, login, logout, clearPasswordChangeRequired }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
@ -58,11 +58,11 @@ export default function ApproveIdentified() {
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Get all decisions that have been made, but only for pending items
|
||||
// Get all decisions that have been made, for pending or denied items (not approved)
|
||||
const decisionsList = Object.entries(decisions)
|
||||
.filter(([id, decision]) => {
|
||||
const pending = pendingIdentifications.find(p => p.id === parseInt(id))
|
||||
return decision !== null && pending && pending.status === 'pending'
|
||||
return decision !== null && pending && pending.status !== 'approved'
|
||||
})
|
||||
.map(([id, decision]) => ({
|
||||
id: parseInt(id),
|
||||
@ -193,7 +193,7 @@ export default function ApproveIdentified() {
|
||||
const isDenied = pending.status === 'denied'
|
||||
const isApproved = pending.status === 'approved'
|
||||
return (
|
||||
<tr key={pending.id} className={`hover:bg-gray-50 ${isDenied ? 'opacity-60 bg-gray-50' : ''} ${isApproved ? 'opacity-60 bg-green-50' : ''}`}>
|
||||
<tr key={pending.id} className={`hover:bg-gray-50 ${isDenied ? 'opacity-60 bg-gray-50' : ''}`}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{formatName(pending)}
|
||||
@ -265,34 +265,37 @@ export default function ApproveIdentified() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{isDenied ? (
|
||||
<div className="text-sm text-red-600 font-medium">Denied</div>
|
||||
) : isApproved ? (
|
||||
{isApproved ? (
|
||||
<div className="text-sm text-green-600 font-medium">Approved</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`decision-${pending.id}`}
|
||||
value="approve"
|
||||
checked={decisions[pending.id] === 'approve'}
|
||||
onChange={() => handleDecisionChange(pending.id, 'approve')}
|
||||
className="w-4 h-4 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Approve</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`decision-${pending.id}`}
|
||||
value="deny"
|
||||
checked={decisions[pending.id] === 'deny'}
|
||||
onChange={() => handleDecisionChange(pending.id, 'deny')}
|
||||
className="w-4 h-4 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Deny</span>
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{isDenied && (
|
||||
<span className="text-xs text-red-600 font-medium">(Denied)</span>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`decision-${pending.id}`}
|
||||
value="approve"
|
||||
checked={decisions[pending.id] === 'approve'}
|
||||
onChange={() => handleDecisionChange(pending.id, 'approve')}
|
||||
className="w-4 h-4 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Approve</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`decision-${pending.id}`}
|
||||
value="deny"
|
||||
checked={decisions[pending.id] === 'deny'}
|
||||
onChange={() => handleDecisionChange(pending.id, 'deny')}
|
||||
className="w-4 h-4 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Deny</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@ -624,7 +624,7 @@ export default function AutoMatch() {
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm text-gray-600">
|
||||
Person {currentIndex + 1} of {activePeople.length}
|
||||
Person {currentIndex + 1}
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<button
|
||||
@ -781,7 +781,7 @@ export default function AutoMatch() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
Person {currentIndex + 1} of {activePeople.length}
|
||||
Person {currentIndex + 1}
|
||||
{currentPerson && ` • ${currentPerson.total_matches} matches`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
473
frontend/src/pages/ManageUsers.tsx
Normal file
473
frontend/src/pages/ManageUsers.tsx
Normal 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
frontend/src/pages/ReportedPhotos.tsx
Normal file
368
frontend/src/pages/ReportedPhotos.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
281
src/web/api/reported_photos.py
Normal file
281
src/web/api/reported_photos.py
Normal 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
224
src/web/api/users.py
Normal 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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"),
|
||||
)
|
||||
|
||||
|
||||
@ -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
59
src/web/schemas/users.py
Normal 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
|
||||
|
||||
2
src/web/utils/__init__.py
Normal file
2
src/web/utils/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Utility functions for PunimTag Web."""
|
||||
|
||||
36
src/web/utils/password.py
Normal file
36
src/web/utils/password.py
Normal 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')
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user