feat: implement role-based access for settings route and add focus trap hook
- Wrapped the Settings component in RequireRole to enforce access control for admin and user roles. - Introduced a new custom hook `useFocusTrap` to manage focus within modal dialogs, enhancing accessibility. - Applied the focus trap in InviteModal, PermissionsModal, and UserDetailModal to prevent focus from leaving the dialog. - Updated PassthroughLanding to focus on the heading when the component mounts.
This commit is contained in:
@@ -199,10 +199,19 @@ func (h *UserHandler) Setup(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// rejectPassthrough aborts with 403 if the caller is a passthrough user.
|
||||
// Returns true if the request was rejected (caller should return).
|
||||
func rejectPassthrough(c *gin.Context, action string) bool {
|
||||
if c.GetString("role") == string(models.RolePassthrough) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Passthrough users cannot " + action})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RegenerateAPIKey generates a new API key for the authenticated user.
|
||||
func (h *UserHandler) RegenerateAPIKey(c *gin.Context) {
|
||||
if c.GetString("role") == string(models.RolePassthrough) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Passthrough users cannot manage API keys"})
|
||||
if rejectPassthrough(c, "manage API keys") {
|
||||
return
|
||||
}
|
||||
userID, exists := c.Get("userID")
|
||||
@@ -228,8 +237,7 @@ func (h *UserHandler) RegenerateAPIKey(c *gin.Context) {
|
||||
|
||||
// GetProfile returns the current user's profile including API key.
|
||||
func (h *UserHandler) GetProfile(c *gin.Context) {
|
||||
if c.GetString("role") == string(models.RolePassthrough) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Passthrough users cannot access profile"})
|
||||
if rejectPassthrough(c, "access profile") {
|
||||
return
|
||||
}
|
||||
userID, exists := c.Get("userID")
|
||||
@@ -262,8 +270,7 @@ type UpdateProfileRequest struct {
|
||||
|
||||
// UpdateProfile updates the authenticated user's profile.
|
||||
func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
||||
if c.GetString("role") == string(models.RolePassthrough) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Passthrough users cannot update profile"})
|
||||
if rejectPassthrough(c, "update profile") {
|
||||
return
|
||||
}
|
||||
userID, exists := c.Get("userID")
|
||||
@@ -734,8 +741,8 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req UpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -763,7 +770,7 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
if req.Email != "" {
|
||||
email := strings.ToLower(req.Email)
|
||||
var count int64
|
||||
if err := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", email, id).Count(&count).Error; err == nil && count > 0 {
|
||||
if dbErr := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", email, id).Count(&count).Error; dbErr == nil && count > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"})
|
||||
return
|
||||
}
|
||||
@@ -786,23 +793,13 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Last-admin protection
|
||||
if user.Role == models.RoleAdmin && newRole != models.RoleAdmin {
|
||||
var adminCount int64
|
||||
h.DB.Model(&models.User{}).Where("role = ? AND enabled = ?", models.RoleAdmin, true).Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot demote the last admin"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updates["role"] = string(newRole)
|
||||
needsSessionInvalidation = true
|
||||
}
|
||||
}
|
||||
|
||||
if req.Password != nil {
|
||||
if err := user.SetPassword(*req.Password); err != nil {
|
||||
if hashErr := user.SetPassword(*req.Password); hashErr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
||||
return
|
||||
}
|
||||
@@ -818,28 +815,70 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Last-admin protection for disabling
|
||||
if user.Role == models.RoleAdmin && !*req.Enabled {
|
||||
var adminCount int64
|
||||
h.DB.Model(&models.User{}).Where("role = ? AND enabled = ?", models.RoleAdmin, true).Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot disable the last admin"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updates["enabled"] = *req.Enabled
|
||||
if !*req.Enabled {
|
||||
needsSessionInvalidation = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := h.DB.Model(&user).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
|
||||
return
|
||||
// Wrap the last-admin checks and the actual update in a transaction to prevent
|
||||
// race conditions: two concurrent requests could both read adminCount==2
|
||||
// and both proceed, leaving zero admins.
|
||||
err = h.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// Re-fetch user inside transaction for consistent state
|
||||
if txErr := tx.First(&user, id).Error; txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
|
||||
// Last-admin protection for role demotion
|
||||
if newRoleStr, ok := updates["role"]; ok {
|
||||
newRole := models.UserRole(newRoleStr.(string))
|
||||
if user.Role == models.RoleAdmin && newRole != models.RoleAdmin {
|
||||
var adminCount int64
|
||||
// Policy: count only enabled admins. This is stricter than "WHERE role = ?"
|
||||
// because a disabled admin cannot act; treating them as non-existent
|
||||
// prevents leaving the system with only disabled admins.
|
||||
tx.Model(&models.User{}).Where("role = ? AND enabled = ?", models.RoleAdmin, true).Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
return fmt.Errorf("cannot demote the last admin")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last-admin protection for disabling
|
||||
if enabledVal, ok := updates["enabled"]; ok {
|
||||
if enabled, isBool := enabledVal.(bool); isBool && !enabled {
|
||||
if user.Role == models.RoleAdmin {
|
||||
var adminCount int64
|
||||
// Policy: count only enabled admins (same rationale as above).
|
||||
tx.Model(&models.User{}).Where("role = ? AND enabled = ?", models.RoleAdmin, true).Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
return fmt.Errorf("cannot disable the last admin")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if txErr := tx.Model(&user).Updates(updates).Error; txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if errMsg == "cannot demote the last admin" || errMsg == "cannot disable the last admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot" + errMsg[len("cannot"):]})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if needsSessionInvalidation && h.AuthService != nil {
|
||||
if invErr := h.AuthService.InvalidateSessions(user.ID); invErr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to invalidate sessions"})
|
||||
|
||||
@@ -117,7 +117,7 @@ func RequireManagementAccess() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role := c.GetString("role")
|
||||
if role == string(models.RolePassthrough) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Pass-through users cannot access management features"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -101,7 +101,7 @@ export default function App() {
|
||||
<Route path="import" element={<Navigate to="/tasks/import/caddyfile" replace />} />
|
||||
|
||||
{/* Settings Routes */}
|
||||
<Route path="settings" element={<Settings />}>
|
||||
<Route path="settings" element={<RequireRole allowed={['admin', 'user']}><Settings /></RequireRole>}>
|
||||
<Route index element={<SystemSettings />} />
|
||||
<Route path="system" element={<SystemSettings />} />
|
||||
<Route path="notifications" element={<Notifications />} />
|
||||
|
||||
47
frontend/src/hooks/useFocusTrap.ts
Normal file
47
frontend/src/hooks/useFocusTrap.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
export function useFocusTrap(
|
||||
dialogRef: RefObject<HTMLElement | null>,
|
||||
isOpen: boolean,
|
||||
onEscape?: () => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && onEscape) {
|
||||
onEscape()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Tab' && dialogRef.current) {
|
||||
const focusable =
|
||||
dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||
if (focusable.length === 0) return
|
||||
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const first = dialogRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||
first?.focus()
|
||||
})
|
||||
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, onEscape, dialogRef])
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { Button } from '../components/ui/Button'
|
||||
@@ -7,6 +8,11 @@ import { Shield, LogOut } from 'lucide-react'
|
||||
export default function PassthroughLanding() {
|
||||
const { t } = useTranslation()
|
||||
const { user, logout } = useAuth()
|
||||
const headingRef = useRef<HTMLHeadingElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
headingRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-light-bg dark:bg-dark-bg flex items-center justify-center p-4">
|
||||
@@ -19,7 +25,12 @@ export default function PassthroughLanding() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 id="passthrough-heading" className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
<h1
|
||||
id="passthrough-heading"
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
className="text-2xl font-bold text-gray-900 dark:text-white outline-none"
|
||||
>
|
||||
{t('passthrough.title')}
|
||||
</h1>
|
||||
{user?.name && (
|
||||
@@ -37,16 +48,14 @@ export default function PassthroughLanding() {
|
||||
{t('passthrough.noAccessToManagement')}
|
||||
</p>
|
||||
|
||||
<nav aria-label={t('passthrough.title')}>
|
||||
<Button
|
||||
onClick={logout}
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" aria-hidden="true" />
|
||||
{t('auth.logout')}
|
||||
</Button>
|
||||
</nav>
|
||||
<Button
|
||||
onClick={logout}
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" aria-hidden="true" />
|
||||
{t('auth.logout')}
|
||||
</Button>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
@@ -55,6 +56,7 @@ interface InviteModalProps {
|
||||
function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailError, setEmailError] = useState<string | null>(null)
|
||||
const [role, setRole] = useState<'user' | 'admin' | 'passthrough'>('user')
|
||||
@@ -92,19 +94,7 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Keyboard navigation - close on Escape
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
useFocusTrap(dialogRef, isOpen, onClose)
|
||||
|
||||
// Fetch preview when email changes
|
||||
useEffect(() => {
|
||||
@@ -204,6 +194,7 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
|
||||
{/* Layer 3: Form content (pointer-events-auto) */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto pointer-events-auto"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -425,6 +416,7 @@ interface PermissionsModalProps {
|
||||
function PermissionsModal({ isOpen, onClose, user, proxyHosts }: PermissionsModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('allow_all')
|
||||
const [selectedHosts, setSelectedHosts] = useState<number[]>([])
|
||||
|
||||
@@ -436,23 +428,11 @@ function PermissionsModal({ isOpen, onClose, user, proxyHosts }: PermissionsModa
|
||||
}
|
||||
}, [user])
|
||||
|
||||
// Keyboard navigation - close on Escape
|
||||
const handleClose = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, handleClose])
|
||||
useFocusTrap(dialogRef, isOpen, handleClose)
|
||||
|
||||
const updatePermissionsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -492,6 +472,7 @@ function PermissionsModal({ isOpen, onClose, user, proxyHosts }: PermissionsModa
|
||||
|
||||
{/* Layer 3: Form content (pointer-events-auto) */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto pointer-events-auto"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -624,44 +605,7 @@ function UserDetailModal({ isOpen, onClose, user, isSelf }: UserDetailModalProps
|
||||
}
|
||||
}, [profile])
|
||||
|
||||
// Focus trap and Escape handling
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
// Focus trap
|
||||
if (e.key === 'Tab' && dialogRef.current) {
|
||||
const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
if (focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus first focusable element on open
|
||||
requestAnimationFrame(() => {
|
||||
const first = dialogRef.current?.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
first?.focus()
|
||||
})
|
||||
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, onClose])
|
||||
useFocusTrap(dialogRef, isOpen, onClose)
|
||||
|
||||
const profileMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
|
||||
Reference in New Issue
Block a user