chore: Update .gitignore and add role permissions management API
This commit updates the .gitignore file to include Node.js related directories and files. Additionally, it introduces a new API for managing role-to-feature permissions, allowing for better control over user access levels. The API includes endpoints for listing and updating role permissions, ensuring that the permissions matrix is initialized and maintained. Documentation has been updated to reflect these changes.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json'],
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'max-len': [
|
||||
'error',
|
||||
{
|
||||
code: 100,
|
||||
tabWidth: 2,
|
||||
ignoreUrls: true,
|
||||
ignoreStrings: true,
|
||||
ignoreTemplateLiterals: true,
|
||||
},
|
||||
],
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
Generated
+1717
File diff suppressed because it is too large
Load Diff
@@ -10,11 +10,11 @@
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.8.4",
|
||||
"axios": "^1.6.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"@tanstack/react-query": "^5.8.4",
|
||||
"axios": "^1.6.2"
|
||||
"react-router-dom": "^6.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
@@ -24,6 +24,7 @@
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"postcss": "^8.4.31",
|
||||
@@ -32,4 +33,3 @@
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ function AppRoutes() {
|
||||
<Route
|
||||
path="approve-identified"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminRoute featureKey="user_identified">
|
||||
<ApproveIdentified />
|
||||
</AdminRoute>
|
||||
}
|
||||
@@ -88,7 +88,7 @@ function AppRoutes() {
|
||||
<Route
|
||||
path="manage-users"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminRoute featureKey="manage_users">
|
||||
<ManageUsers />
|
||||
</AdminRoute>
|
||||
}
|
||||
@@ -96,7 +96,7 @@ function AppRoutes() {
|
||||
<Route
|
||||
path="reported-photos"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminRoute featureKey="user_reported">
|
||||
<ReportedPhotos />
|
||||
</AdminRoute>
|
||||
}
|
||||
@@ -104,7 +104,7 @@ function AppRoutes() {
|
||||
<Route
|
||||
path="pending-photos"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminRoute featureKey="user_uploaded">
|
||||
<PendingPhotos />
|
||||
</AdminRoute>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import apiClient from './client'
|
||||
import { UserRoleValue } from './users'
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
@@ -25,6 +26,8 @@ export interface PasswordChangeResponse {
|
||||
export interface UserResponse {
|
||||
username: string
|
||||
is_admin?: boolean
|
||||
role?: UserRoleValue
|
||||
permissions?: Record<string, boolean>
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import apiClient from './client'
|
||||
import { UserRoleValue } from './users'
|
||||
|
||||
export interface RoleFeature {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type RolePermissionsMap = Record<UserRoleValue, Record<string, boolean>>
|
||||
|
||||
export interface RolePermissionsResponse {
|
||||
features: RoleFeature[]
|
||||
permissions: RolePermissionsMap
|
||||
}
|
||||
|
||||
export interface RolePermissionsUpdateRequest {
|
||||
permissions: RolePermissionsMap
|
||||
}
|
||||
|
||||
export const rolePermissionsApi = {
|
||||
async listPermissions(): Promise<RolePermissionsResponse> {
|
||||
const { data } = await apiClient.get<RolePermissionsResponse>('/api/v1/role-permissions')
|
||||
return data
|
||||
},
|
||||
|
||||
async updatePermissions(
|
||||
request: RolePermissionsUpdateRequest
|
||||
): Promise<RolePermissionsResponse> {
|
||||
const { data } = await apiClient.put<RolePermissionsResponse>(
|
||||
'/api/v1/role-permissions',
|
||||
request
|
||||
)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export type UserRoleValue =
|
||||
| 'admin'
|
||||
| 'manager'
|
||||
| 'moderator'
|
||||
| 'reviewer'
|
||||
| 'editor'
|
||||
| 'importer'
|
||||
| 'viewer'
|
||||
|
||||
export interface UserResponse {
|
||||
id: number
|
||||
username: string
|
||||
@@ -7,6 +16,7 @@ export interface UserResponse {
|
||||
full_name: string | null
|
||||
is_active: boolean
|
||||
is_admin: boolean
|
||||
role?: UserRoleValue | null
|
||||
created_date: string
|
||||
last_login: string | null
|
||||
}
|
||||
@@ -18,6 +28,7 @@ export interface UserCreateRequest {
|
||||
full_name: string
|
||||
is_active?: boolean
|
||||
is_admin?: boolean
|
||||
role: UserRoleValue
|
||||
give_frontend_permission?: boolean
|
||||
}
|
||||
|
||||
@@ -27,6 +38,7 @@ export interface UserUpdateRequest {
|
||||
full_name: string
|
||||
is_active?: boolean
|
||||
is_admin?: boolean
|
||||
role?: UserRoleValue
|
||||
give_frontend_permission?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@ import { useAuth } from '../context/AuthContext'
|
||||
|
||||
interface AdminRouteProps {
|
||||
children: React.ReactNode
|
||||
featureKey?: string
|
||||
}
|
||||
|
||||
export default function AdminRoute({ children }: AdminRouteProps) {
|
||||
const { isAuthenticated, isLoading, isAdmin } = useAuth()
|
||||
export default function AdminRoute({ children, featureKey }: AdminRouteProps) {
|
||||
const { isAuthenticated, isLoading, isAdmin, hasPermission } = useAuth()
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>
|
||||
@@ -16,7 +17,11 @@ export default function AdminRoute({ children }: AdminRouteProps) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
if (featureKey) {
|
||||
if (!hasPermission(featureKey)) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
} else if (!isAdmin) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,16 @@ import { useInactivityTimeout } from '../hooks/useInactivityTimeout'
|
||||
|
||||
const INACTIVITY_TIMEOUT_MS = 30 * 60 * 1000
|
||||
|
||||
type NavItem = {
|
||||
path: string
|
||||
label: string
|
||||
icon: string
|
||||
featureKey?: string
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation()
|
||||
const { username, logout, isAdmin, isAuthenticated } = useAuth()
|
||||
const { username, logout, isAuthenticated, hasPermission } = useAuth()
|
||||
const [maintenanceExpanded, setMaintenanceExpanded] = useState(true)
|
||||
|
||||
const handleInactivityLogout = useCallback(() => {
|
||||
@@ -20,31 +27,31 @@ export default function Layout() {
|
||||
isEnabled: isAuthenticated,
|
||||
})
|
||||
|
||||
const primaryNavItems = [
|
||||
{ path: '/scan', label: 'Scan', icon: '🗂️', adminOnly: false },
|
||||
{ path: '/process', label: 'Process', icon: '⚙️', adminOnly: false },
|
||||
{ path: '/search', label: 'Search Photos', icon: '🔍', adminOnly: false },
|
||||
{ path: '/identify', label: 'Identify People', icon: '👤', adminOnly: false },
|
||||
{ path: '/auto-match', label: 'Auto-Match', icon: '🤖', adminOnly: false },
|
||||
{ path: '/modify', label: 'Modify People', icon: '✏️', adminOnly: false },
|
||||
{ path: '/tags', label: 'Tag Photos', icon: '🏷️', adminOnly: false },
|
||||
const primaryNavItems: NavItem[] = [
|
||||
{ path: '/scan', label: 'Scan', icon: '🗂️', featureKey: 'scan' },
|
||||
{ path: '/process', label: 'Process', icon: '⚙️', featureKey: 'process' },
|
||||
{ path: '/search', label: 'Search Photos', icon: '🔍', featureKey: 'search_photos' },
|
||||
{ path: '/identify', label: 'Identify People', icon: '👤', featureKey: 'identify_people' },
|
||||
{ path: '/auto-match', label: 'Auto-Match', icon: '🤖', featureKey: 'auto_match' },
|
||||
{ path: '/modify', label: 'Modify People', icon: '✏️', featureKey: 'modify_people' },
|
||||
{ path: '/tags', label: 'Tag Photos', icon: '🏷️', featureKey: 'tag_photos' },
|
||||
]
|
||||
|
||||
const maintenanceNavItems = [
|
||||
{ path: '/faces-maintenance', label: 'Faces', icon: '🔧', adminOnly: false },
|
||||
{ path: '/approve-identified', label: 'User Identified Faces', icon: '✅', adminOnly: true },
|
||||
{ path: '/reported-photos', label: 'User Reported Photos', icon: '🚩', adminOnly: true },
|
||||
{ path: '/pending-photos', label: 'User Uploaded Photos', icon: '📤', adminOnly: true },
|
||||
{ path: '/manage-users', label: 'Users', icon: '👥', adminOnly: true },
|
||||
const maintenanceNavItems: NavItem[] = [
|
||||
{ path: '/faces-maintenance', label: 'Faces', icon: '🔧', featureKey: 'faces_maintenance' },
|
||||
{ path: '/approve-identified', label: 'User Identified Faces', icon: '✅', featureKey: 'user_identified' },
|
||||
{ path: '/reported-photos', label: 'User Reported Photos', icon: '🚩', featureKey: 'user_reported' },
|
||||
{ path: '/pending-photos', label: 'User Uploaded Photos', icon: '📤', featureKey: 'user_uploaded' },
|
||||
{ path: '/manage-users', label: 'Users', icon: '👥', featureKey: 'manage_users' },
|
||||
]
|
||||
|
||||
const footerNavItems = [
|
||||
{ path: '/settings', label: 'Settings', icon: '⚙️', adminOnly: false },
|
||||
{ path: '/help', label: 'Help', icon: '📚', adminOnly: false },
|
||||
const footerNavItems: NavItem[] = [
|
||||
{ path: '/settings', label: 'Settings', icon: '⚙️' },
|
||||
{ path: '/help', label: 'Help', icon: '📚' },
|
||||
]
|
||||
|
||||
const filterNavItems = (items: typeof primaryNavItems) =>
|
||||
items.filter((item) => !item.adminOnly || isAdmin)
|
||||
const filterNavItems = (items: NavItem[]) =>
|
||||
items.filter((item) => !item.featureKey || hasPermission(item.featureKey))
|
||||
|
||||
const renderNavLink = (
|
||||
item: { path: string; label: string; icon: string },
|
||||
@@ -100,7 +107,7 @@ export default function Layout() {
|
||||
<nav className="p-4 space-y-1">
|
||||
{visiblePrimary.map((item) => renderNavLink(item))}
|
||||
|
||||
{isAdmin && visibleMaintenance.length > 0 && (
|
||||
{visibleMaintenance.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'
|
||||
import { authApi, TokenResponse } from '../api/auth'
|
||||
import { UserRoleValue } from '../api/users'
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean
|
||||
@@ -7,12 +8,15 @@ interface AuthState {
|
||||
isLoading: boolean
|
||||
passwordChangeRequired: boolean
|
||||
isAdmin: boolean
|
||||
role: UserRoleValue | null
|
||||
permissions: Record<string, boolean>
|
||||
}
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
login: (username: string, password: string) => Promise<{ success: boolean; error?: string; passwordChangeRequired?: boolean }>
|
||||
logout: () => void
|
||||
clearPasswordChangeRequired: () => void
|
||||
hasPermission: (featureKey: string) => boolean
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
@@ -24,6 +28,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
isLoading: true,
|
||||
passwordChangeRequired: false,
|
||||
isAdmin: false,
|
||||
role: null,
|
||||
permissions: {},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,6 +44,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
isLoading: false,
|
||||
passwordChangeRequired: false,
|
||||
isAdmin: user.is_admin || false,
|
||||
role: (user.role as UserRoleValue) || null,
|
||||
permissions: user.permissions || {},
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -49,6 +57,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
isLoading: false,
|
||||
passwordChangeRequired: false,
|
||||
isAdmin: false,
|
||||
role: null,
|
||||
permissions: {},
|
||||
})
|
||||
})
|
||||
} else {
|
||||
@@ -58,6 +68,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
isLoading: false,
|
||||
passwordChangeRequired: false,
|
||||
isAdmin: false,
|
||||
role: null,
|
||||
permissions: {},
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
@@ -75,6 +87,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
isLoading: false,
|
||||
passwordChangeRequired,
|
||||
isAdmin: user.is_admin || false,
|
||||
role: (user.role as UserRoleValue) || null,
|
||||
permissions: user.permissions || {},
|
||||
})
|
||||
return { success: true, passwordChangeRequired }
|
||||
} catch (error: any) {
|
||||
@@ -101,11 +115,28 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
isAuthenticated: false,
|
||||
username: null,
|
||||
isLoading: false,
|
||||
passwordChangeRequired: false,
|
||||
isAdmin: false,
|
||||
role: null,
|
||||
permissions: {},
|
||||
})
|
||||
}
|
||||
|
||||
const hasPermission = useCallback(
|
||||
(featureKey: string): boolean => {
|
||||
if (!featureKey) {
|
||||
return authState.isAdmin
|
||||
}
|
||||
if (authState.isAdmin) {
|
||||
return true
|
||||
}
|
||||
return Boolean(authState.permissions[featureKey])
|
||||
},
|
||||
[authState.isAdmin, authState.permissions]
|
||||
)
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ ...authState, login, logout, clearPasswordChangeRequired }}>
|
||||
<AuthContext.Provider value={{ ...authState, login, logout, clearPasswordChangeRequired, hasPermission }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
@@ -40,3 +40,26 @@ body {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.role-permissions-scroll {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: #1d4ed8 #e5e7eb;
|
||||
}
|
||||
|
||||
.role-permissions-scroll::-webkit-scrollbar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.role-permissions-scroll::-webkit-scrollbar-track {
|
||||
background: #bfdbfe;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.role-permissions-scroll::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%);
|
||||
border-radius: 8px;
|
||||
border: 3px solid #bfdbfe;
|
||||
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ import pendingIdentificationsApi, {
|
||||
UserIdentificationStats
|
||||
} from '../api/pendingIdentifications'
|
||||
import { apiClient } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function ApproveIdentified() {
|
||||
const { isAdmin } = useAuth()
|
||||
const [pendingIdentifications, setPendingIdentifications] = useState<PendingIdentification[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -240,10 +242,19 @@ export default function ApproveIdentified() {
|
||||
📊 Statistics
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearDenied}
|
||||
disabled={clearing}
|
||||
onClick={() => {
|
||||
if (!isAdmin) {
|
||||
return
|
||||
}
|
||||
handleClearDenied()
|
||||
}}
|
||||
disabled={clearing || !isAdmin}
|
||||
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
|
||||
title="Delete all denied records from the database"
|
||||
title={
|
||||
isAdmin
|
||||
? 'Delete all denied records from the database'
|
||||
: 'Only admins can clear denied records'
|
||||
}
|
||||
>
|
||||
{clearing ? 'Clearing...' : '🗑️ Clear Database'}
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from '../context/AuthContext'
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login, isAuthenticated, isLoading } = useAuth()
|
||||
@@ -86,15 +87,25 @@ export default function Login() {
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="admin"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="admin"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute inset-y-0 right-2 flex items-center text-gray-500 hover:text-gray-700 focus:outline-none"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? '🙈' : '👁️'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { usersApi, UserResponse, UserCreateRequest, UserUpdateRequest } from '../api/users'
|
||||
import { authUsersApi, AuthUserResponse, AuthUserCreateRequest, AuthUserUpdateRequest } from '../api/authUsers'
|
||||
import {
|
||||
usersApi,
|
||||
UserResponse,
|
||||
UserCreateRequest,
|
||||
UserUpdateRequest,
|
||||
UserRoleValue,
|
||||
} from '../api/users'
|
||||
import {
|
||||
authUsersApi,
|
||||
AuthUserResponse,
|
||||
AuthUserCreateRequest,
|
||||
AuthUserUpdateRequest,
|
||||
} from '../api/authUsers'
|
||||
import {
|
||||
rolePermissionsApi,
|
||||
RoleFeature,
|
||||
RolePermissionsMap,
|
||||
} from '../api/rolePermissions'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
type TabType = 'backend' | 'frontend'
|
||||
type TabType = 'backend' | 'frontend' | 'roles'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
type UserSortKey =
|
||||
| 'username'
|
||||
@@ -19,6 +36,18 @@ type AuthUserSortKey =
|
||||
| 'has_write_access'
|
||||
| 'created_at'
|
||||
| 'updated_at'
|
||||
const DEFAULT_ADMIN_ROLE: UserRoleValue = 'admin'
|
||||
const DEFAULT_USER_ROLE: UserRoleValue = 'viewer'
|
||||
|
||||
const ROLE_OPTIONS: Array<{ value: UserRoleValue; label: string; isAdmin: boolean }> = [
|
||||
{ value: 'admin', label: 'Admin', isAdmin: true },
|
||||
{ value: 'manager', label: 'Manager', isAdmin: true },
|
||||
{ value: 'moderator', label: 'Moderator', isAdmin: false },
|
||||
{ value: 'reviewer', label: 'Reviewer', isAdmin: false },
|
||||
{ value: 'editor', label: 'Editor', isAdmin: false },
|
||||
{ value: 'importer', label: 'Importer', isAdmin: false },
|
||||
{ value: 'viewer', label: 'Viewer', isAdmin: false },
|
||||
]
|
||||
|
||||
/**
|
||||
* Format Pydantic validation errors into user-friendly messages
|
||||
@@ -76,6 +105,8 @@ function formatValidationError(error: any): string {
|
||||
|
||||
export default function ManageUsers() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('backend')
|
||||
const { hasPermission } = useAuth()
|
||||
const canManageRoles = hasPermission('manage_roles')
|
||||
|
||||
// Backend users state
|
||||
const [users, setUsers] = useState<UserResponse[]>([])
|
||||
@@ -84,7 +115,7 @@ export default function ManageUsers() {
|
||||
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 [filterRole, setFilterRole] = useState<UserRoleValue | null>(null)
|
||||
|
||||
const [createForm, setCreateForm] = useState<UserCreateRequest>({
|
||||
username: '',
|
||||
@@ -93,6 +124,7 @@ export default function ManageUsers() {
|
||||
full_name: '',
|
||||
is_active: true,
|
||||
is_admin: false,
|
||||
role: DEFAULT_USER_ROLE,
|
||||
give_frontend_permission: false,
|
||||
})
|
||||
|
||||
@@ -102,7 +134,10 @@ export default function ManageUsers() {
|
||||
full_name: '',
|
||||
is_active: true,
|
||||
is_admin: false,
|
||||
role: DEFAULT_USER_ROLE,
|
||||
})
|
||||
const [createRole, setCreateRole] = useState<UserRoleValue>(DEFAULT_USER_ROLE)
|
||||
const [editRole, setEditRole] = useState<UserRoleValue>(DEFAULT_USER_ROLE)
|
||||
|
||||
// Frontend users state
|
||||
const [authUsers, setAuthUsers] = useState<AuthUserResponse[]>([])
|
||||
@@ -136,6 +171,56 @@ export default function ManageUsers() {
|
||||
key: 'email',
|
||||
direction: 'asc',
|
||||
})
|
||||
const [roleFeatures, setRoleFeatures] = useState<RoleFeature[]>([])
|
||||
const [rolePermissions, setRolePermissions] = useState<RolePermissionsMap>({} as RolePermissionsMap)
|
||||
const [rolePermissionsLoading, setRolePermissionsLoading] = useState(true)
|
||||
const [rolePermissionsError, setRolePermissionsError] = useState<string | null>(null)
|
||||
const [rolePermissionsDirty, setRolePermissionsDirty] = useState(false)
|
||||
const [rolePermissionsSaving, setRolePermissionsSaving] = useState(false)
|
||||
|
||||
const getRoleOption = (role: UserRoleValue) =>
|
||||
ROLE_OPTIONS.find((option) => option.value === role) ?? ROLE_OPTIONS[ROLE_OPTIONS.length - 1]
|
||||
|
||||
const getRoleFromAdminFlag = (isAdmin: boolean | undefined | null): UserRoleValue =>
|
||||
isAdmin ? DEFAULT_ADMIN_ROLE : DEFAULT_USER_ROLE
|
||||
|
||||
const isValidRoleValue = (role: string | null | undefined): role is UserRoleValue =>
|
||||
ROLE_OPTIONS.some((option) => option.value === role)
|
||||
|
||||
const normalizeRoleValue = (
|
||||
role: string | null | undefined,
|
||||
isAdmin: boolean | undefined | null
|
||||
): UserRoleValue => {
|
||||
if (role && isValidRoleValue(role)) {
|
||||
return role
|
||||
}
|
||||
return getRoleFromAdminFlag(isAdmin)
|
||||
}
|
||||
|
||||
const getDisplayRoleLabel = (user: UserResponse): string => {
|
||||
const roleValue = normalizeRoleValue(user.role ?? null, user.is_admin)
|
||||
return getRoleOption(roleValue).label
|
||||
}
|
||||
|
||||
const handleCreateRoleChange = (role: UserRoleValue) => {
|
||||
const option = getRoleOption(role)
|
||||
setCreateRole(role)
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
is_admin: option.isAdmin,
|
||||
role,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleEditRoleChange = (role: UserRoleValue) => {
|
||||
const option = getRoleOption(role)
|
||||
setEditRole(role)
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
is_admin: option.isAdmin,
|
||||
role,
|
||||
}))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'backend') {
|
||||
@@ -143,19 +228,34 @@ export default function ManageUsers() {
|
||||
} else {
|
||||
loadAuthUsers()
|
||||
}
|
||||
}, [activeTab, filterActive, filterAdmin])
|
||||
}, [activeTab, filterActive, filterRole])
|
||||
|
||||
useEffect(() => {
|
||||
loadAuthUsers()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'roles' && canManageRoles) {
|
||||
loadRolePermissions()
|
||||
}
|
||||
}, [activeTab, canManageRoles])
|
||||
|
||||
useEffect(() => {
|
||||
if (!canManageRoles && activeTab === 'roles') {
|
||||
setActiveTab('backend')
|
||||
}
|
||||
}, [canManageRoles, activeTab])
|
||||
|
||||
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
|
||||
if (filterRole !== null) {
|
||||
const roleOption = getRoleOption(filterRole)
|
||||
params.is_admin = roleOption.isAdmin
|
||||
}
|
||||
const response = await usersApi.listUsers(params)
|
||||
setUsers(response.items)
|
||||
} catch (err: any) {
|
||||
@@ -179,6 +279,52 @@ export default function ManageUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadRolePermissions = async () => {
|
||||
try {
|
||||
setRolePermissionsLoading(true)
|
||||
setRolePermissionsError(null)
|
||||
const response = await rolePermissionsApi.listPermissions()
|
||||
setRoleFeatures(response.features)
|
||||
setRolePermissions(response.permissions)
|
||||
setRolePermissionsDirty(false)
|
||||
} catch (err: any) {
|
||||
setRolePermissionsError(err.response?.data?.detail || 'Failed to load role permissions')
|
||||
} finally {
|
||||
setRolePermissionsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRolePermission = (role: UserRoleValue, featureKey: string) => {
|
||||
setRolePermissions((prev) => {
|
||||
const roleEntry = prev[role] ?? {}
|
||||
return {
|
||||
...prev,
|
||||
[role]: {
|
||||
...roleEntry,
|
||||
[featureKey]: !roleEntry[featureKey],
|
||||
},
|
||||
}
|
||||
})
|
||||
setRolePermissionsDirty(true)
|
||||
}
|
||||
|
||||
const handleRolePermissionsSave = async () => {
|
||||
try {
|
||||
setRolePermissionsSaving(true)
|
||||
setRolePermissionsError(null)
|
||||
const response = await rolePermissionsApi.updatePermissions({
|
||||
permissions: rolePermissions,
|
||||
})
|
||||
setRoleFeatures(response.features)
|
||||
setRolePermissions(response.permissions)
|
||||
setRolePermissionsDirty(false)
|
||||
} catch (err: any) {
|
||||
setRolePermissionsError(err.response?.data?.detail || 'Failed to update role permissions')
|
||||
} finally {
|
||||
setRolePermissionsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const usernameExists = (username: string): boolean => {
|
||||
const normalized = username.trim().toLowerCase()
|
||||
return users.some((user) => user.username?.toLowerCase() === normalized)
|
||||
@@ -244,6 +390,7 @@ export default function ManageUsers() {
|
||||
...createForm,
|
||||
username: trimmedUsername,
|
||||
email: trimmedEmail,
|
||||
role: createRole,
|
||||
})
|
||||
setShowCreateModal(false)
|
||||
setCreateForm({
|
||||
@@ -253,8 +400,10 @@ export default function ManageUsers() {
|
||||
full_name: '',
|
||||
is_active: true,
|
||||
is_admin: false,
|
||||
role: DEFAULT_USER_ROLE,
|
||||
give_frontend_permission: false,
|
||||
})
|
||||
setCreateRole(DEFAULT_USER_ROLE)
|
||||
loadUsers()
|
||||
} catch (err: any) {
|
||||
// Handle validation errors (422) - Pydantic returns array of errors
|
||||
@@ -363,10 +512,11 @@ export default function ManageUsers() {
|
||||
|
||||
const updateData: UserUpdateRequest = {
|
||||
...editForm,
|
||||
email: trimmedEmail || undefined,
|
||||
email: trimmedEmail,
|
||||
password: editForm.password && editForm.password.trim() !== ''
|
||||
? editForm.password
|
||||
: undefined,
|
||||
role: editRole,
|
||||
give_frontend_permission: grantFrontendPermission || undefined,
|
||||
}
|
||||
await usersApi.updateUser(editingUser.id, updateData)
|
||||
@@ -377,7 +527,9 @@ export default function ManageUsers() {
|
||||
full_name: '',
|
||||
is_active: true,
|
||||
is_admin: false,
|
||||
role: DEFAULT_USER_ROLE,
|
||||
})
|
||||
setEditRole(DEFAULT_USER_ROLE)
|
||||
setGrantFrontendPermission(false)
|
||||
loadUsers()
|
||||
} catch (err: any) {
|
||||
@@ -455,8 +607,17 @@ export default function ManageUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
if (filterRole === null) {
|
||||
return users
|
||||
}
|
||||
return users.filter(
|
||||
(user) => normalizeRoleValue(user.role ?? null, user.is_admin) === filterRole
|
||||
)
|
||||
}, [users, filterRole])
|
||||
|
||||
const sortedUsers = useMemo(() => {
|
||||
const cloned = [...users]
|
||||
const cloned = [...filteredUsers]
|
||||
cloned.sort((a, b) => {
|
||||
const valueA = getUserSortValue(a, userSort.key)
|
||||
const valueB = getUserSortValue(b, userSort.key)
|
||||
@@ -467,7 +628,7 @@ export default function ManageUsers() {
|
||||
return userSort.direction === 'asc' ? comparison : -comparison
|
||||
})
|
||||
return cloned
|
||||
}, [users, userSort])
|
||||
}, [filteredUsers, userSort])
|
||||
|
||||
const sortedAuthUsers = useMemo(() => {
|
||||
const cloned = [...authUsers]
|
||||
@@ -528,7 +689,15 @@ export default function ManageUsers() {
|
||||
await usersApi.deleteUser(userId)
|
||||
loadUsers()
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to delete user')
|
||||
const responseDetail = err.response?.data?.detail
|
||||
if (err.response?.status === 409) {
|
||||
setError(
|
||||
responseDetail ||
|
||||
'This user identified faces and cannot be deleted. Set them inactive instead.'
|
||||
)
|
||||
return
|
||||
}
|
||||
setError(responseDetail || 'Failed to delete user')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,14 +716,17 @@ export default function ManageUsers() {
|
||||
|
||||
const startEdit = (user: UserResponse) => {
|
||||
setEditingUser(user)
|
||||
const roleValue = normalizeRoleValue(user.role ?? null, user.is_admin)
|
||||
setEditForm({
|
||||
password: '',
|
||||
email: user.email || '',
|
||||
full_name: user.full_name || '',
|
||||
is_active: user.is_active,
|
||||
is_admin: user.is_admin,
|
||||
role: roleValue,
|
||||
})
|
||||
setGrantFrontendPermission(false)
|
||||
setEditRole(roleValue)
|
||||
}
|
||||
|
||||
const startAuthEdit = (user: AuthUserResponse) => {
|
||||
@@ -582,46 +754,44 @@ export default function ManageUsers() {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
|
||||
const formatDateShort = (dateString: string | null) => {
|
||||
if (!dateString) return '-'
|
||||
const date = new Date(dateString)
|
||||
return date.toISOString().split('T')[0] // YYYY-MM-DD format
|
||||
}
|
||||
|
||||
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={() => {
|
||||
if (activeTab === 'backend') {
|
||||
setError(null)
|
||||
setCreateForm({
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
full_name: '',
|
||||
is_active: true,
|
||||
is_admin: false,
|
||||
give_frontend_permission: false,
|
||||
})
|
||||
setShowCreateModal(true)
|
||||
} else {
|
||||
setAuthError(null)
|
||||
setAuthCreateForm({
|
||||
email: '',
|
||||
name: '',
|
||||
password: '',
|
||||
is_admin: false,
|
||||
has_write_access: false,
|
||||
})
|
||||
setShowAuthCreateModal(true)
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
+ Add User
|
||||
</button>
|
||||
{activeTab !== 'roles' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (activeTab === 'backend') {
|
||||
setError(null)
|
||||
setCreateForm({
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
full_name: '',
|
||||
is_active: true,
|
||||
is_admin: false,
|
||||
role: DEFAULT_USER_ROLE,
|
||||
give_frontend_permission: false,
|
||||
})
|
||||
setCreateRole(DEFAULT_USER_ROLE)
|
||||
setShowCreateModal(true)
|
||||
} else {
|
||||
setAuthError(null)
|
||||
setAuthCreateForm({
|
||||
email: '',
|
||||
name: '',
|
||||
password: '',
|
||||
is_admin: false,
|
||||
has_write_access: false,
|
||||
})
|
||||
setShowAuthCreateModal(true)
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
+ Add User
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -647,6 +817,18 @@ export default function ManageUsers() {
|
||||
>
|
||||
Front End Users
|
||||
</button>
|
||||
{canManageRoles && (
|
||||
<button
|
||||
onClick={() => setActiveTab('roles')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'roles'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Manage Roles
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -671,21 +853,33 @@ export default function ManageUsers() {
|
||||
<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'
|
||||
)
|
||||
}
|
||||
value={filterRole ?? 'all'}
|
||||
onChange={(e) => {
|
||||
const { value } = e.target
|
||||
if (value === 'all') {
|
||||
setFilterRole(null)
|
||||
return
|
||||
}
|
||||
setFilterRole(value as UserRoleValue)
|
||||
}}
|
||||
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>
|
||||
{ROLE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{loading ? (
|
||||
@@ -802,7 +996,7 @@ export default function ManageUsers() {
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{user.is_admin ? 'Admin' : 'User'}
|
||||
{getDisplayRoleLabel(user)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
@@ -971,6 +1165,93 @@ export default function ManageUsers() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manage Roles Tab */}
|
||||
{activeTab === 'roles' && (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
{rolePermissionsError && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
|
||||
{rolePermissionsError}
|
||||
</div>
|
||||
)}
|
||||
{rolePermissionsLoading ? (
|
||||
<div className="p-8 text-center text-gray-500">Loading role permissions...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto role-permissions-scroll">
|
||||
<table className="min-w-max 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">
|
||||
Role
|
||||
</th>
|
||||
{roleFeatures.map((feature) => (
|
||||
<th
|
||||
key={feature.key}
|
||||
className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{feature.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<tr key={role.value} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{role.label}
|
||||
</td>
|
||||
{roleFeatures.map((feature) => {
|
||||
const allowed =
|
||||
rolePermissions[role.value]?.[feature.key] ?? false
|
||||
return (
|
||||
<td key={feature.key} className="px-6 py-4 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowed}
|
||||
onChange={() => toggleRolePermission(role.value, feature.key)}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-start gap-3">
|
||||
<button
|
||||
onClick={loadRolePermissions}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
rolePermissionsDirty && !rolePermissionsSaving
|
||||
? 'text-gray-700 bg-gray-100 hover:bg-gray-200'
|
||||
: 'text-gray-400 bg-gray-100 cursor-not-allowed'
|
||||
}`}
|
||||
disabled={
|
||||
rolePermissionsLoading ||
|
||||
rolePermissionsSaving ||
|
||||
!rolePermissionsDirty
|
||||
}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRolePermissionsSave}
|
||||
disabled={!rolePermissionsDirty || rolePermissionsSaving}
|
||||
className={`px-4 py-2 rounded-lg text-white ${
|
||||
rolePermissionsDirty && !rolePermissionsSaving
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'bg-blue-300 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{rolePermissionsSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backend Create Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
@@ -1058,15 +1339,16 @@ export default function ManageUsers() {
|
||||
Role *
|
||||
</label>
|
||||
<select
|
||||
value={createForm.is_admin ? 'admin' : 'user'}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, is_admin: e.target.value === 'admin' })
|
||||
}
|
||||
value={createRole}
|
||||
onChange={(e) => handleCreateRoleChange(e.target.value as UserRoleValue)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
{ROLE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
@@ -1094,8 +1376,10 @@ export default function ManageUsers() {
|
||||
full_name: '',
|
||||
is_active: true,
|
||||
is_admin: false,
|
||||
role: DEFAULT_USER_ROLE,
|
||||
give_frontend_permission: false,
|
||||
})
|
||||
setCreateRole(DEFAULT_USER_ROLE)
|
||||
setShowCreateModal(false)
|
||||
}}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
|
||||
@@ -1186,15 +1470,16 @@ export default function ManageUsers() {
|
||||
Role *
|
||||
</label>
|
||||
<select
|
||||
value={editForm.is_admin ?? false ? 'admin' : 'user'}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, is_admin: e.target.value === 'admin' })
|
||||
}
|
||||
value={editRole}
|
||||
onChange={(e) => handleEditRoleChange(e.target.value as UserRoleValue)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
{ROLE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{canGrantFrontendPermission && (
|
||||
@@ -1222,6 +1507,7 @@ export default function ManageUsers() {
|
||||
onClick={() => {
|
||||
setEditingUser(null)
|
||||
setGrantFrontendPermission(false)
|
||||
setEditRole(DEFAULT_USER_ROLE)
|
||||
}}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
|
||||
@@ -9,7 +9,9 @@ import { apiClient } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function PendingPhotos() {
|
||||
const { isAdmin } = useAuth()
|
||||
const { hasPermission, isAdmin } = useAuth()
|
||||
const canManageUploads = hasPermission('user_uploaded')
|
||||
const canRunCleanup = isAdmin
|
||||
const [pendingPhotos, setPendingPhotos] = useState<PendingPhotoResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -444,19 +446,39 @@ export default function PendingPhotos() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isAdmin && (
|
||||
{canManageUploads && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleCleanupFiles()}
|
||||
onClick={() => {
|
||||
if (!canRunCleanup) {
|
||||
return
|
||||
}
|
||||
handleCleanupFiles()
|
||||
}}
|
||||
disabled={!canRunCleanup}
|
||||
className="px-3 py-1.5 text-sm bg-orange-100 text-orange-700 rounded-md hover:bg-orange-200 font-medium"
|
||||
title="Delete files from shared space for approved/rejected photos"
|
||||
title={
|
||||
canRunCleanup
|
||||
? 'Delete files from shared space for approved/rejected photos'
|
||||
: 'Cleanup files is restricted to admins'
|
||||
}
|
||||
>
|
||||
🗑️ Cleanup Files
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCleanupDatabase()}
|
||||
onClick={() => {
|
||||
if (!canRunCleanup) {
|
||||
return
|
||||
}
|
||||
handleCleanupDatabase()
|
||||
}}
|
||||
disabled={!canRunCleanup}
|
||||
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 font-medium"
|
||||
title="Delete all records from pending_photos table"
|
||||
title={
|
||||
canRunCleanup
|
||||
? 'Delete all records from pending_photos table'
|
||||
: 'Clear database is restricted to admins'
|
||||
}
|
||||
>
|
||||
🗑️ Clear Database
|
||||
</button>
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
ReviewDecision,
|
||||
} from '../api/reportedPhotos'
|
||||
import { apiClient } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function ReportedPhotos() {
|
||||
const { isAdmin } = useAuth()
|
||||
const [reportedPhotos, setReportedPhotos] = useState<ReportedPhotoResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -263,9 +265,19 @@ export default function ReportedPhotos() {
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={handleClearDatabase}
|
||||
disabled={clearing}
|
||||
onClick={() => {
|
||||
if (!isAdmin) {
|
||||
return
|
||||
}
|
||||
handleClearDatabase()
|
||||
}}
|
||||
disabled={clearing || !isAdmin}
|
||||
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 disabled:bg-gray-200 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
|
||||
title={
|
||||
isAdmin
|
||||
? 'Delete kept/removed records'
|
||||
: 'Only admins can clear reported photos'
|
||||
}
|
||||
>
|
||||
{clearing ? 'Clearing...' : '🗑️ Clear Database'}
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { photosApi, PhotoSearchResult } from '../api/photos'
|
||||
import tagsApi, { TagResponse, PhotoTagItem } from '../api/tags'
|
||||
import { apiClient } from '../api/client'
|
||||
import PhotoViewer from '../components/PhotoViewer'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
type SearchType = 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed' | 'favorites'
|
||||
|
||||
@@ -21,10 +22,11 @@ type SortColumn = 'person' | 'tags' | 'processed' | 'path' | 'date_taken'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
export default function Search() {
|
||||
const { hasPermission } = useAuth()
|
||||
const canTagPhotos = hasPermission('tag_photos')
|
||||
const canDeletePhotos = hasPermission('faces_maintenance')
|
||||
const [searchType, setSearchType] = useState<SearchType>('name')
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(false)
|
||||
const [tagsExpanded, setTagsExpanded] = useState(true) // Default to expanded
|
||||
const [folderPath, setFolderPath] = useState('')
|
||||
|
||||
// Search inputs
|
||||
const [personName, setPersonName] = useState('')
|
||||
@@ -80,12 +82,11 @@ export default function Search() {
|
||||
loadTags()
|
||||
}, [])
|
||||
|
||||
const performSearch = async (pageNum: number = page, folderPathOverride?: string) => {
|
||||
const performSearch = async (pageNum: number = page) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: any = {
|
||||
search_type: searchType,
|
||||
folder_path: folderPathOverride || folderPath || undefined,
|
||||
page: pageNum,
|
||||
page_size: pageSize,
|
||||
}
|
||||
@@ -516,7 +517,6 @@ export default function Search() {
|
||||
// Build search params (same as current search)
|
||||
const baseParams: any = {
|
||||
search_type: searchType,
|
||||
folder_path: folderPath || undefined,
|
||||
page_size: maxPageSize,
|
||||
}
|
||||
|
||||
@@ -592,49 +592,7 @@ export default function Search() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow py-2 px-4 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-medium text-gray-700">Filters</h2>
|
||||
<button
|
||||
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||
className="text-lg text-gray-600 hover:text-gray-800"
|
||||
title={filtersExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{filtersExpanded ? '▼' : '▶'}
|
||||
</button>
|
||||
</div>
|
||||
{filtersExpanded && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Folder location:
|
||||
{folderPath && (
|
||||
<span className="ml-2 text-xs text-blue-600 font-normal">
|
||||
(filtering by: {folderPath})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
placeholder="(optional - filter by folder path)"
|
||||
className="flex-1 border rounded px-3 py-2"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setFolderPath('')}
|
||||
className="px-3 py-2 border rounded hover:bg-gray-50"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Filters removed: folder location filter is no longer supported */}
|
||||
|
||||
{/* Search Inputs */}
|
||||
{(searchType !== 'no_faces' && searchType !== 'no_tags' && searchType !== 'processed' && searchType !== 'unprocessed') && (
|
||||
@@ -777,17 +735,38 @@ export default function Search() {
|
||||
{loadingFavorites ? '...' : '⭐'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowTagModal(true)}
|
||||
disabled={selectedPhotos.size === 0}
|
||||
onClick={() => {
|
||||
if (!canTagPhotos) {
|
||||
return
|
||||
}
|
||||
setShowTagModal(true)
|
||||
}}
|
||||
disabled={selectedPhotos.size === 0 || !canTagPhotos}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
|
||||
title={
|
||||
canTagPhotos
|
||||
? undefined
|
||||
: 'Tagging requires permission for the Tag Photos feature'
|
||||
}
|
||||
>
|
||||
Tag selected photos
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteSelectedPhotos}
|
||||
disabled={selectedPhotos.size === 0 || deletingPhotos}
|
||||
onClick={() => {
|
||||
if (!canDeletePhotos) {
|
||||
return
|
||||
}
|
||||
handleDeleteSelectedPhotos()
|
||||
}}
|
||||
disabled={
|
||||
selectedPhotos.size === 0 || deletingPhotos || !canDeletePhotos
|
||||
}
|
||||
className="px-3 py-1 text-sm border rounded text-red-700 border-red-200 hover:bg-red-50 disabled:bg-gray-100 disabled:text-gray-400 disabled:border-gray-200"
|
||||
title="Permanently delete selected photos"
|
||||
title={
|
||||
canDeletePhotos
|
||||
? 'Permanently delete selected photos'
|
||||
: 'Deleting requires permission for the Faces Maintenance feature'
|
||||
}
|
||||
>
|
||||
{deletingPhotos ? 'Deleting...' : '🗑 Delete selected'}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user