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:
parent
eed3b36dad
commit
7c35e4d8ec
6
.gitignore
vendored
6
.gitignore
vendored
@ -67,4 +67,8 @@ photos/
|
||||
*.webp
|
||||
dlib/
|
||||
*.dat
|
||||
*.model
|
||||
*.model
|
||||
# Node.js
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
frontend/.parcel-cache/
|
||||
|
||||
BIN
data/uploads/9dc697df-1ad3-4dd6-b734-841f07f7a1e6.JPG
Normal file
BIN
data/uploads/9dc697df-1ad3-4dd6-b734-841f07f7a1e6.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
49
frontend/.eslintrc.cjs
Normal file
49
frontend/.eslintrc.cjs
Normal file
@ -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: '^_' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
1717
frontend/package-lock.json
generated
1717
frontend/package-lock.json
generated
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 = {
|
||||
|
||||
36
frontend/src/api/rolePermissions.ts
Normal file
36
frontend/src/api/rolePermissions.ts
Normal file
@ -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>
|
||||
|
||||
@ -10,6 +10,11 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.web.constants.roles import (
|
||||
DEFAULT_ADMIN_ROLE,
|
||||
DEFAULT_USER_ROLE,
|
||||
ROLE_VALUES,
|
||||
)
|
||||
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
|
||||
@ -21,6 +26,7 @@ from src.web.schemas.auth import (
|
||||
PasswordChangeRequest,
|
||||
PasswordChangeResponse,
|
||||
)
|
||||
from src.web.services.role_permissions import fetch_role_permissions_map
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
security = HTTPBearer()
|
||||
@ -110,6 +116,7 @@ def get_current_user_with_id(
|
||||
full_name=username,
|
||||
is_active=True,
|
||||
is_admin=False,
|
||||
role=DEFAULT_USER_ROLE,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
@ -118,6 +125,13 @@ def get_current_user_with_id(
|
||||
return {"username": username, "user_id": user.id}
|
||||
|
||||
|
||||
def _resolve_user_role(user: User | None, is_admin_flag: bool) -> str:
|
||||
"""Determine the role value for a user, ensuring it is valid."""
|
||||
if user and user.role in ROLE_VALUES:
|
||||
return user.role
|
||||
return DEFAULT_ADMIN_ROLE if is_admin_flag else DEFAULT_USER_ROLE
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(credentials: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
|
||||
"""Authenticate user and return tokens.
|
||||
@ -262,6 +276,7 @@ def get_current_user_info(
|
||||
full_name=username,
|
||||
is_active=True,
|
||||
is_admin=True,
|
||||
role=DEFAULT_ADMIN_ROLE,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
@ -275,6 +290,7 @@ def get_current_user_info(
|
||||
# Update existing user to be admin if no admins exist
|
||||
if not user.is_admin:
|
||||
user.is_admin = True
|
||||
user.role = DEFAULT_ADMIN_ROLE
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
is_admin = user.is_admin
|
||||
@ -285,7 +301,16 @@ def get_current_user_info(
|
||||
else:
|
||||
is_admin = user.is_admin if user else False
|
||||
|
||||
return UserResponse(username=username, is_admin=is_admin)
|
||||
role_value = _resolve_user_role(user, is_admin)
|
||||
permissions_map = fetch_role_permissions_map(db)
|
||||
permissions = permissions_map.get(role_value, {})
|
||||
|
||||
return UserResponse(
|
||||
username=username,
|
||||
is_admin=is_admin,
|
||||
role=role_value,
|
||||
permissions=permissions,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-password", response_model=PasswordChangeResponse)
|
||||
|
||||
@ -10,9 +10,10 @@ from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import text, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.web.constants.roles import DEFAULT_USER_ROLE
|
||||
from src.web.db.session import get_auth_db, get_db
|
||||
from src.web.db.models import Face, Person, PersonEncoding, User
|
||||
from src.web.api.users import get_current_admin_user
|
||||
from src.web.api.users import get_current_admin_user, require_feature_permission
|
||||
from src.web.utils.password import hash_password
|
||||
|
||||
router = APIRouter(prefix="/pending-identifications", tags=["pending-identifications"])
|
||||
@ -43,6 +44,7 @@ def get_or_create_frontend_user(db: Session) -> User:
|
||||
full_name="Frontend System User",
|
||||
is_active=False, # Not an active user, just a system marker
|
||||
is_admin=False,
|
||||
role=DEFAULT_USER_ROLE,
|
||||
password_change_required=False,
|
||||
)
|
||||
db.add(user)
|
||||
@ -144,7 +146,9 @@ class ClearDatabaseResponse(BaseModel):
|
||||
|
||||
@router.get("", response_model=PendingIdentificationsListResponse)
|
||||
def list_pending_identifications(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_identified"))
|
||||
],
|
||||
include_denied: bool = False,
|
||||
db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
@ -240,7 +244,9 @@ 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)],
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_identified"))
|
||||
],
|
||||
request: ApproveDenyRequest,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
@ -401,7 +407,9 @@ def approve_deny_pending_identifications(
|
||||
|
||||
@router.get("/report", response_model=IdentificationReportResponse)
|
||||
def get_identification_report(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_identified"))
|
||||
],
|
||||
date_from: Optional[str] = Query(None, description="Filter by identification date (from) - YYYY-MM-DD"),
|
||||
date_to: Optional[str] = Query(None, description="Filter by identification date (to) - YYYY-MM-DD"),
|
||||
main_db: Session = Depends(get_db),
|
||||
@ -484,7 +492,7 @@ def get_identification_report(
|
||||
|
||||
@router.post("/clear-denied", response_model=ClearDatabaseResponse)
|
||||
def clear_denied_identifications(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
current_admin: dict = Depends(get_current_admin_user),
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> ClearDatabaseResponse:
|
||||
"""Delete all denied pending identifications from the database.
|
||||
|
||||
@ -14,7 +14,7 @@ from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.web.db.session import get_auth_db, get_db
|
||||
from src.web.api.users import get_current_admin_user
|
||||
from src.web.api.users import get_current_admin_user, require_feature_permission
|
||||
from src.web.api.auth import get_current_user
|
||||
from src.web.services.photo_service import import_photo_from_path, calculate_file_hash
|
||||
from src.web.settings import PHOTO_STORAGE_DIR
|
||||
@ -83,7 +83,9 @@ class ReviewResponse(BaseModel):
|
||||
|
||||
@router.get("", response_model=PendingPhotosListResponse)
|
||||
def list_pending_photos(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_uploaded"))
|
||||
],
|
||||
status_filter: Optional[str] = None,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> PendingPhotosListResponse:
|
||||
@ -244,7 +246,9 @@ def get_pending_photo_image(
|
||||
|
||||
@router.post("/review", response_model=ReviewResponse)
|
||||
def review_pending_photos(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_uploaded"))
|
||||
],
|
||||
request: ReviewRequest,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
@ -267,7 +271,7 @@ def review_pending_photos(
|
||||
rejected_count = 0
|
||||
duplicate_count = 0
|
||||
errors = []
|
||||
admin_user_id = current_admin.get("user_id")
|
||||
admin_user_id = current_user.get("user_id")
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Base directories
|
||||
@ -453,7 +457,7 @@ class CleanupResponse(BaseModel):
|
||||
|
||||
@router.post("/cleanup-files", response_model=CleanupResponse)
|
||||
def cleanup_shared_files(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
current_admin: dict = Depends(get_current_admin_user),
|
||||
status_filter: Optional[str] = Query(None, description="Filter by status: 'approved', 'rejected', or None for both"),
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> CleanupResponse:
|
||||
@ -526,7 +530,7 @@ def cleanup_shared_files(
|
||||
|
||||
@router.post("/cleanup-database", response_model=CleanupResponse)
|
||||
def cleanup_pending_photos_database(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
current_admin: dict = Depends(get_current_admin_user),
|
||||
status_filter: Optional[str] = Query(None, description="Filter by status: 'approved', 'rejected', or None for all"),
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> CleanupResponse:
|
||||
|
||||
@ -12,7 +12,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from src.web.db.session import get_auth_db, get_db
|
||||
from src.web.db.models import Photo, PhotoTagLinkage
|
||||
from src.web.api.users import get_current_admin_user
|
||||
from src.web.api.users import get_current_admin_user, require_feature_permission
|
||||
|
||||
router = APIRouter(prefix="/reported-photos", tags=["reported-photos"])
|
||||
|
||||
@ -87,7 +87,9 @@ class CleanupResponse(BaseModel):
|
||||
|
||||
@router.get("", response_model=ReportedPhotosListResponse)
|
||||
def list_reported_photos(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_reported"))
|
||||
],
|
||||
status_filter: Optional[str] = None,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
@ -176,7 +178,9 @@ def list_reported_photos(
|
||||
|
||||
@router.post("/review", response_model=ReviewResponse)
|
||||
def review_reported_photos(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_reported"))
|
||||
],
|
||||
request: ReviewRequest,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
@ -194,7 +198,7 @@ def review_reported_photos(
|
||||
kept_count = 0
|
||||
removed_count = 0
|
||||
errors = []
|
||||
admin_user_id = current_admin.get("user_id")
|
||||
admin_user_id = current_user.get("user_id")
|
||||
now = datetime.utcnow()
|
||||
|
||||
for decision in request.decisions:
|
||||
@ -299,7 +303,7 @@ def review_reported_photos(
|
||||
|
||||
@router.post("/cleanup", response_model=CleanupResponse)
|
||||
def cleanup_reported_photos(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
current_admin: dict = Depends(get_current_admin_user),
|
||||
status_filter: Annotated[
|
||||
Optional[str],
|
||||
Query(description="Use 'keep' to clear reviewed or 'remove' to clear dismissed records.")
|
||||
|
||||
68
src/web/api/role_permissions.py
Normal file
68
src/web/api/role_permissions.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""Manage role-to-feature permissions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.web.api.users import get_current_admin_user
|
||||
from src.web.constants.role_features import ROLE_FEATURES, ROLE_FEATURE_KEYS
|
||||
from src.web.constants.roles import ROLE_VALUES
|
||||
from src.web.db.session import get_db
|
||||
from src.web.schemas.role_permissions import (
|
||||
RoleFeatureSchema,
|
||||
RolePermissionsResponse,
|
||||
RolePermissionsUpdateRequest,
|
||||
)
|
||||
from src.web.services.role_permissions import (
|
||||
ensure_role_permissions_initialized,
|
||||
fetch_role_permissions_map,
|
||||
set_role_permissions,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/role-permissions", tags=["role-permissions"])
|
||||
|
||||
|
||||
@router.get("", response_model=RolePermissionsResponse)
|
||||
def list_role_permissions(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> RolePermissionsResponse:
|
||||
"""Return the current role/feature permission matrix."""
|
||||
|
||||
ensure_role_permissions_initialized(db)
|
||||
permissions = fetch_role_permissions_map(db)
|
||||
features = [RoleFeatureSchema(**feature) for feature in ROLE_FEATURES]
|
||||
return RolePermissionsResponse(features=features, permissions=permissions)
|
||||
|
||||
|
||||
@router.put("", response_model=RolePermissionsResponse)
|
||||
def update_role_permissions(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
request: RolePermissionsUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> RolePermissionsResponse:
|
||||
"""Update permissions for the provided matrix."""
|
||||
|
||||
invalid_roles = set(request.permissions.keys()) - set(ROLE_VALUES)
|
||||
if invalid_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid role(s): {', '.join(sorted(invalid_roles))}",
|
||||
)
|
||||
|
||||
for feature_map in request.permissions.values():
|
||||
invalid_features = set(feature_map.keys()) - set(ROLE_FEATURE_KEYS)
|
||||
if invalid_features:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid feature(s): {', '.join(sorted(invalid_features))}",
|
||||
)
|
||||
|
||||
set_role_permissions(db, request.permissions)
|
||||
permissions = fetch_role_permissions_map(db)
|
||||
features = [RoleFeatureSchema(**feature) for feature in ROLE_FEATURES]
|
||||
return RolePermissionsResponse(features=features, permissions=permissions)
|
||||
|
||||
@ -2,13 +2,22 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.web.api.auth import get_current_user
|
||||
from src.web.constants.roles import (
|
||||
DEFAULT_ADMIN_ROLE,
|
||||
DEFAULT_USER_ROLE,
|
||||
ROLE_VALUES,
|
||||
UserRole,
|
||||
is_admin_role,
|
||||
)
|
||||
from src.web.db.session import get_auth_db, get_db
|
||||
from src.web.db.models import User
|
||||
from src.web.schemas.users import (
|
||||
@ -18,8 +27,38 @@ from src.web.schemas.users import (
|
||||
UsersListResponse,
|
||||
)
|
||||
from src.web.utils.password import hash_password
|
||||
from src.web.services.role_permissions import fetch_role_permissions_map
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_role_and_admin(
|
||||
role: str | None,
|
||||
is_admin_flag: bool | None,
|
||||
) -> tuple[str, bool]:
|
||||
"""Normalize requested role/is_admin values into a consistent pair."""
|
||||
selected_role = role or (DEFAULT_ADMIN_ROLE if is_admin_flag else DEFAULT_USER_ROLE)
|
||||
if selected_role not in ROLE_VALUES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid role '{selected_role}'",
|
||||
)
|
||||
derived_is_admin = is_admin_role(selected_role)
|
||||
if is_admin_flag is not None and is_admin_flag != derived_is_admin:
|
||||
logger.warning(
|
||||
"Role/is_admin mismatch detected. Using role-derived admin flag.",
|
||||
extra={"role": selected_role, "is_admin_flag": is_admin_flag},
|
||||
)
|
||||
return selected_role, derived_is_admin
|
||||
|
||||
|
||||
def _ensure_role_set(user: User) -> None:
|
||||
"""Guarantee that a User instance has a valid role value."""
|
||||
if user.role in ROLE_VALUES:
|
||||
return
|
||||
fallback_role = DEFAULT_ADMIN_ROLE if user.is_admin else DEFAULT_USER_ROLE
|
||||
user.role = fallback_role
|
||||
|
||||
|
||||
def get_auth_db_optional() -> Session | None:
|
||||
@ -137,6 +176,7 @@ def get_current_admin_user(
|
||||
password_hash=default_password_hash,
|
||||
is_active=True,
|
||||
is_admin=True,
|
||||
role=DEFAULT_ADMIN_ROLE,
|
||||
)
|
||||
db.add(main_user)
|
||||
db.commit()
|
||||
@ -144,6 +184,7 @@ def get_current_admin_user(
|
||||
elif not main_user.is_admin:
|
||||
# User exists but is not admin - make them admin for bootstrap
|
||||
main_user.is_admin = True
|
||||
main_user.role = DEFAULT_ADMIN_ROLE
|
||||
db.add(main_user)
|
||||
db.commit()
|
||||
db.refresh(main_user)
|
||||
@ -162,6 +203,53 @@ def get_current_admin_user(
|
||||
return {"username": username, "user_id": main_user.id}
|
||||
|
||||
|
||||
def require_feature_permission(feature_key: str):
|
||||
"""Return a dependency that enforces feature-level access via role permissions."""
|
||||
|
||||
def dependency(
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
username = current_user["username"]
|
||||
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
default_password_hash = hash_password("changeme")
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=default_password_hash,
|
||||
is_active=True,
|
||||
is_admin=False,
|
||||
role=DEFAULT_USER_ROLE,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
_ensure_role_set(user)
|
||||
|
||||
has_access = user.is_admin or is_admin_role(user.role)
|
||||
if not has_access:
|
||||
permissions_map = fetch_role_permissions_map(db)
|
||||
role_permissions = permissions_map.get(user.role, {})
|
||||
has_access = bool(role_permissions.get(feature_key))
|
||||
|
||||
if not has_access:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied for this feature",
|
||||
)
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"user_id": user.id,
|
||||
"role": user.role,
|
||||
"is_admin": user.is_admin,
|
||||
}
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
@router.get("", response_model=UsersListResponse)
|
||||
def list_users(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
@ -182,6 +270,8 @@ def list_users(
|
||||
query = query.filter(User.is_admin == is_admin)
|
||||
|
||||
users = query.order_by(User.username.asc()).all()
|
||||
for user in users:
|
||||
_ensure_role_set(user)
|
||||
items = [UserResponse.model_validate(u) for u in users]
|
||||
return UsersListResponse(items=items, total=len(items))
|
||||
|
||||
@ -215,6 +305,16 @@ def create_user(
|
||||
|
||||
# Hash the password before storing
|
||||
password_hash = hash_password(request.password)
|
||||
if request.role is None:
|
||||
requested_role = None
|
||||
elif isinstance(request.role, UserRole):
|
||||
requested_role = request.role.value
|
||||
else:
|
||||
requested_role = str(request.role)
|
||||
normalized_role, normalized_is_admin = _normalize_role_and_admin(
|
||||
requested_role,
|
||||
request.is_admin,
|
||||
)
|
||||
|
||||
user = User(
|
||||
username=request.username,
|
||||
@ -222,7 +322,8 @@ def create_user(
|
||||
email=request.email,
|
||||
full_name=request.full_name,
|
||||
is_active=request.is_active,
|
||||
is_admin=request.is_admin,
|
||||
is_admin=normalized_is_admin,
|
||||
role=normalized_role,
|
||||
password_change_required=True, # Force password change on first login
|
||||
)
|
||||
db.add(user)
|
||||
@ -234,7 +335,7 @@ def create_user(
|
||||
email=request.email,
|
||||
full_name=request.full_name,
|
||||
password_hash=password_hash,
|
||||
is_admin=request.is_admin,
|
||||
is_admin=normalized_is_admin,
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(user)
|
||||
@ -253,6 +354,7 @@ def get_user(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with ID {user_id} not found",
|
||||
)
|
||||
_ensure_role_set(user)
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@ -271,12 +373,26 @@ def update_user(
|
||||
detail=f"User with ID {user_id} not found",
|
||||
)
|
||||
|
||||
if request.role is None:
|
||||
desired_role = None
|
||||
elif isinstance(request.role, UserRole):
|
||||
desired_role = request.role.value
|
||||
else:
|
||||
desired_role = str(request.role)
|
||||
if desired_role is None:
|
||||
if request.is_admin is not None:
|
||||
desired_role = DEFAULT_ADMIN_ROLE if request.is_admin else DEFAULT_USER_ROLE
|
||||
elif user.role:
|
||||
desired_role = user.role
|
||||
else:
|
||||
desired_role = DEFAULT_ADMIN_ROLE if user.is_admin else DEFAULT_USER_ROLE
|
||||
normalized_role, normalized_is_admin = _normalize_role_and_admin(
|
||||
desired_role,
|
||||
request.is_admin,
|
||||
)
|
||||
|
||||
# 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
|
||||
):
|
||||
if current_admin["username"] == user.username and not normalized_is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot remove your own admin status",
|
||||
@ -300,8 +416,8 @@ def update_user(
|
||||
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
|
||||
user.is_admin = normalized_is_admin
|
||||
user.role = normalized_role
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
@ -342,8 +458,21 @@ def delete_user(
|
||||
detail="Cannot delete your own account",
|
||||
)
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
try:
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
constraint_name = "faces_identified_by_user_id_fkey"
|
||||
if exc.orig and constraint_name in str(exc.orig):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"This user has identified faces and cannot be deleted. "
|
||||
"Set the user inactive instead."
|
||||
),
|
||||
) from exc
|
||||
raise
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@ -23,12 +23,15 @@ from src.web.api.pending_photos import router as pending_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.auth_users import router as auth_users_router
|
||||
from src.web.api.role_permissions import router as role_permissions_router
|
||||
from src.web.api.version import router as version_router
|
||||
from src.web.settings import APP_TITLE, APP_VERSION
|
||||
from src.web.constants.roles import DEFAULT_ADMIN_ROLE, DEFAULT_USER_ROLE, ROLE_VALUES
|
||||
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.db.models import RolePermission
|
||||
from src.web.utils.password import hash_password
|
||||
|
||||
# Global worker process (will be set in lifespan)
|
||||
@ -262,6 +265,77 @@ def ensure_face_identified_by_user_id_column(inspector) -> None:
|
||||
print("✅ Added identified_by_user_id column to faces table")
|
||||
|
||||
|
||||
def ensure_user_role_column(inspector) -> None:
|
||||
"""Ensure users table has a role column with valid values."""
|
||||
if "users" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("users")}
|
||||
dialect = engine.dialect.name
|
||||
role_values = sorted(ROLE_VALUES)
|
||||
placeholder_parts = ", ".join(
|
||||
f":role_value_{index}" for index, _ in enumerate(role_values)
|
||||
)
|
||||
where_clause = (
|
||||
"role IS NULL OR role = ''"
|
||||
if not placeholder_parts
|
||||
else f"role IS NULL OR role = '' OR role NOT IN ({placeholder_parts})"
|
||||
)
|
||||
params = {
|
||||
f"role_value_{index}": value for index, value in enumerate(role_values)
|
||||
}
|
||||
params["admin_role"] = DEFAULT_ADMIN_ROLE
|
||||
params["default_role"] = DEFAULT_USER_ROLE
|
||||
|
||||
with engine.connect() as connection:
|
||||
with connection.begin():
|
||||
if "role" not in columns:
|
||||
if dialect == "postgresql":
|
||||
connection.execute(
|
||||
text(
|
||||
f"ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT "
|
||||
f"NOT NULL DEFAULT '{DEFAULT_USER_ROLE}'"
|
||||
)
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
text(
|
||||
f"ALTER TABLE users ADD COLUMN role TEXT "
|
||||
f"DEFAULT '{DEFAULT_USER_ROLE}'"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
f"""
|
||||
UPDATE users
|
||||
SET role = CASE
|
||||
WHEN is_admin THEN :admin_role
|
||||
ELSE :default_role
|
||||
END
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
connection.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_users_role ON users(role)")
|
||||
)
|
||||
print("✅ Ensured users.role column exists and is populated")
|
||||
|
||||
|
||||
def ensure_role_permissions_table(inspector) -> None:
|
||||
"""Ensure the role_permissions table exists for permission matrix."""
|
||||
if "role_permissions" in inspector.get_table_names():
|
||||
return
|
||||
|
||||
try:
|
||||
print("🔄 Creating role_permissions table...")
|
||||
RolePermission.__table__.create(bind=engine, checkfirst=True)
|
||||
print("✅ Created role_permissions table")
|
||||
except Exception as exc:
|
||||
print(f"⚠️ Failed to create role_permissions table: {exc}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Lifespan context manager for startup and shutdown events."""
|
||||
@ -297,6 +371,8 @@ async def lifespan(app: FastAPI):
|
||||
ensure_user_password_change_required_column(inspector)
|
||||
ensure_user_email_unique_constraint(inspector)
|
||||
ensure_face_identified_by_user_id_column(inspector)
|
||||
ensure_user_role_column(inspector)
|
||||
ensure_role_permissions_table(inspector)
|
||||
except Exception as exc:
|
||||
print(f"❌ Database initialization failed: {exc}")
|
||||
raise
|
||||
@ -337,6 +413,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(tags_router, prefix="/api/v1")
|
||||
app.include_router(users_router, prefix="/api/v1")
|
||||
app.include_router(auth_users_router, prefix="/api/v1")
|
||||
app.include_router(role_permissions_router, prefix="/api/v1")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
42
src/web/constants/role_features.py
Normal file
42
src/web/constants/role_features.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Feature definitions and default role permissions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Final, List, Set
|
||||
|
||||
from src.web.constants.roles import UserRole
|
||||
|
||||
ROLE_FEATURES: Final[List[dict[str, str]]] = [
|
||||
{"key": "scan", "label": "Scan"},
|
||||
{"key": "process", "label": "Process"},
|
||||
{"key": "search_photos", "label": "Search Photos"},
|
||||
{"key": "identify_people", "label": "Identify People"},
|
||||
{"key": "auto_match", "label": "Auto-Match"},
|
||||
{"key": "modify_people", "label": "Modify People"},
|
||||
{"key": "tag_photos", "label": "Tag Photos"},
|
||||
{"key": "faces_maintenance", "label": "Faces Maintenance"},
|
||||
{"key": "user_identified", "label": "User Identified"},
|
||||
{"key": "user_reported", "label": "User Reported"},
|
||||
{"key": "user_uploaded", "label": "User Uploaded"},
|
||||
{"key": "manage_users", "label": "Manage Users"},
|
||||
{"key": "manage_roles", "label": "Manage Roles"},
|
||||
]
|
||||
|
||||
ROLE_FEATURE_KEYS: Final[List[str]] = [feature["key"] for feature in ROLE_FEATURES]
|
||||
|
||||
DEFAULT_ROLE_FEATURE_MATRIX: Final[Dict[str, Set[str]]] = {
|
||||
UserRole.ADMIN.value: set(ROLE_FEATURE_KEYS),
|
||||
UserRole.MANAGER.value: set(ROLE_FEATURE_KEYS),
|
||||
UserRole.MODERATOR.value: {"scan", "process", "manage_users"},
|
||||
UserRole.REVIEWER.value: {"user_identified", "user_reported", "user_uploaded"},
|
||||
UserRole.EDITOR.value: {"user_identified", "user_uploaded", "manage_users"},
|
||||
UserRole.IMPORTER.value: {"user_uploaded"},
|
||||
UserRole.VIEWER.value: {"user_identified", "user_reported"},
|
||||
}
|
||||
|
||||
|
||||
def get_default_permission(role: str, feature_key: str) -> bool:
|
||||
"""Return the default allowed value for a role/feature pair."""
|
||||
allowed_features = DEFAULT_ROLE_FEATURE_MATRIX.get(role, set())
|
||||
return feature_key in allowed_features
|
||||
|
||||
32
src/web/constants/roles.py
Normal file
32
src/web/constants/roles.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""Shared role definitions for backend user management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Final, Set
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
"""Enumerated set of supported user roles."""
|
||||
|
||||
ADMIN = "admin"
|
||||
MANAGER = "manager"
|
||||
MODERATOR = "moderator"
|
||||
REVIEWER = "reviewer"
|
||||
EDITOR = "editor"
|
||||
IMPORTER = "importer"
|
||||
VIEWER = "viewer"
|
||||
|
||||
|
||||
ROLE_VALUES: Final[Set[str]] = {role.value for role in UserRole}
|
||||
ADMIN_ROLE_VALUES: Final[Set[str]] = {
|
||||
UserRole.ADMIN.value,
|
||||
}
|
||||
DEFAULT_ADMIN_ROLE: Final[str] = UserRole.ADMIN.value
|
||||
DEFAULT_USER_ROLE: Final[str] = UserRole.VIEWER.value
|
||||
|
||||
|
||||
def is_admin_role(role: str) -> bool:
|
||||
"""Return True when the provided role is considered an admin role."""
|
||||
return role in ADMIN_ROLE_VALUES
|
||||
|
||||
@ -21,6 +21,8 @@ from sqlalchemy import (
|
||||
)
|
||||
from sqlalchemy.orm import declarative_base, relationship
|
||||
|
||||
from src.web.constants.roles import DEFAULT_USER_ROLE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
@ -212,6 +214,13 @@ class User(Base):
|
||||
full_name = Column(Text, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
is_admin = Column(Boolean, default=False, nullable=False, index=True)
|
||||
role = Column(
|
||||
Text,
|
||||
nullable=False,
|
||||
default=DEFAULT_USER_ROLE,
|
||||
server_default=DEFAULT_USER_ROLE,
|
||||
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)
|
||||
@ -221,5 +230,22 @@ class User(Base):
|
||||
Index("idx_users_email", "email"),
|
||||
Index("idx_users_is_admin", "is_admin"),
|
||||
Index("idx_users_password_change_required", "password_change_required"),
|
||||
Index("idx_users_role", "role"),
|
||||
)
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
"""Role-to-feature permission matrix."""
|
||||
|
||||
__tablename__ = "role_permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
role = Column(Text, nullable=False, index=True)
|
||||
feature_key = Column(Text, nullable=False, index=True)
|
||||
allowed = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("role", "feature_key", name="uq_role_feature"),
|
||||
Index("idx_role_permissions_role_feature", "role", "feature_key"),
|
||||
)
|
||||
|
||||
|
||||
@ -2,8 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from src.web.constants.roles import DEFAULT_USER_ROLE, UserRole
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Login request payload."""
|
||||
@ -39,6 +43,8 @@ class UserResponse(BaseModel):
|
||||
|
||||
username: str
|
||||
is_admin: bool = False
|
||||
role: UserRole = DEFAULT_USER_ROLE
|
||||
permissions: Dict[str, bool] = {}
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
|
||||
42
src/web/schemas/role_permissions.py
Normal file
42
src/web/schemas/role_permissions.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Schemas for role permissions management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from src.web.constants.role_features import ROLE_FEATURES
|
||||
from src.web.constants.roles import UserRole
|
||||
|
||||
|
||||
class RoleFeatureSchema(BaseModel):
|
||||
"""Feature metadata visible in the UI."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
|
||||
|
||||
class RolePermissionsResponse(BaseModel):
|
||||
"""Payload returned when listing role permissions."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
features: list[RoleFeatureSchema]
|
||||
permissions: Dict[UserRole, Dict[str, bool]]
|
||||
|
||||
|
||||
class RolePermissionsUpdateRequest(BaseModel):
|
||||
"""Payload for updating role permissions."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
permissions: Dict[UserRole, Dict[str, bool]] = Field(
|
||||
...,
|
||||
description="Map of role -> {feature_key: allowed}",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_feature_list() -> list[RoleFeatureSchema]:
|
||||
return [RoleFeatureSchema(**feature) for feature in ROLE_FEATURES]
|
||||
|
||||
@ -7,6 +7,8 @@ from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
from src.web.constants.roles import DEFAULT_USER_ROLE, UserRole
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""User DTO returned from API."""
|
||||
@ -19,6 +21,7 @@ class UserResponse(BaseModel):
|
||||
full_name: Optional[str] = None
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
role: UserRole
|
||||
password_change_required: bool
|
||||
created_date: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
@ -35,6 +38,10 @@ class UserCreateRequest(BaseModel):
|
||||
full_name: str = Field(..., min_length=1, max_length=200, description="Full name (required)")
|
||||
is_active: bool = True
|
||||
is_admin: bool = False
|
||||
role: UserRole = Field(
|
||||
DEFAULT_USER_ROLE,
|
||||
description="Role for feature-level access; also controls admin status where applicable",
|
||||
)
|
||||
give_frontend_permission: bool = Field(False, description="Create user in auth database for frontend access")
|
||||
|
||||
|
||||
@ -48,6 +55,10 @@ class UserUpdateRequest(BaseModel):
|
||||
full_name: str = Field(..., min_length=1, max_length=200, description="Full name (required)")
|
||||
is_active: Optional[bool] = None
|
||||
is_admin: Optional[bool] = None
|
||||
role: Optional[UserRole] = Field(
|
||||
None,
|
||||
description="Updated role; determines admin status when provided",
|
||||
)
|
||||
give_frontend_permission: Optional[bool] = Field(
|
||||
None,
|
||||
description="Create user in auth database for frontend access if True",
|
||||
|
||||
@ -1248,11 +1248,11 @@ def list_unidentified_faces(
|
||||
)
|
||||
else:
|
||||
# Photos that have ANY of the specified tags
|
||||
query = (
|
||||
query.join(PhotoTagLinkage, Photo.id == PhotoTagLinkage.photo_id)
|
||||
tagged_photo_ids_subquery = (
|
||||
db.query(PhotoTagLinkage.photo_id)
|
||||
.filter(PhotoTagLinkage.tag_id.in_(tag_ids))
|
||||
.distinct()
|
||||
)
|
||||
query = query.filter(Face.photo_id.in_(tagged_photo_ids_subquery))
|
||||
else:
|
||||
# No matching tags found - return empty result
|
||||
return [], 0
|
||||
|
||||
73
src/web/services/role_permissions.py
Normal file
73
src/web/services/role_permissions.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""Role permission helpers for ensuring and updating access matrix."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Dict
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.web.constants.role_features import (
|
||||
ROLE_FEATURE_KEYS,
|
||||
get_default_permission,
|
||||
)
|
||||
from src.web.constants.roles import ROLE_VALUES
|
||||
from src.web.db.models import RolePermission
|
||||
|
||||
|
||||
def ensure_role_permissions_initialized(session: Session) -> None:
|
||||
"""Seed permissions table once using default matrix if table is empty."""
|
||||
|
||||
has_permissions = session.execute(select(RolePermission.id)).first()
|
||||
if has_permissions:
|
||||
return
|
||||
|
||||
for role in ROLE_VALUES:
|
||||
for feature_key in ROLE_FEATURE_KEYS:
|
||||
permission = RolePermission(
|
||||
role=role,
|
||||
feature_key=feature_key,
|
||||
allowed=get_default_permission(role, feature_key),
|
||||
)
|
||||
session.add(permission)
|
||||
|
||||
session.commit()
|
||||
|
||||
|
||||
def fetch_role_permissions_map(session: Session) -> Dict[str, Dict[str, bool]]:
|
||||
"""Return permissions map keyed by role then feature."""
|
||||
|
||||
ensure_role_permissions_initialized(session)
|
||||
permissions = defaultdict(dict)
|
||||
results = session.execute(select(RolePermission)).scalars().all()
|
||||
for perm in results:
|
||||
permissions[perm.role][perm.feature_key] = bool(perm.allowed)
|
||||
return dict(permissions)
|
||||
|
||||
|
||||
def set_role_permissions(session: Session, permissions: Dict[str, Dict[str, bool]]) -> None:
|
||||
"""Update permissions based on provided map."""
|
||||
|
||||
ensure_role_permissions_initialized(session)
|
||||
existing = {
|
||||
(perm.role, perm.feature_key): perm
|
||||
for perm in session.execute(select(RolePermission)).scalars().all()
|
||||
}
|
||||
|
||||
updated = False
|
||||
for role, feature_map in permissions.items():
|
||||
for feature_key, allowed in feature_map.items():
|
||||
key = (role, feature_key)
|
||||
perm = existing.get(key)
|
||||
if perm is None:
|
||||
perm = RolePermission(role=role, feature_key=feature_key)
|
||||
session.add(perm)
|
||||
existing[key] = perm
|
||||
if perm.allowed != bool(allowed):
|
||||
perm.allowed = bool(allowed)
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
session.commit()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user