2026-02-21 14:26:56 +05:30
"use client" ;
import { useQuery } from "@tanstack/react-query" ;
import { useAtomValue } from "jotai" ;
import {
Bot ,
2026-04-25 15:12:22 +05:30
ChevronRight ,
Earth ,
2026-04-27 14:04:50 -07:00
FileText ,
2026-04-25 15:12:22 +05:30
Image ,
2026-02-25 01:50:28 -08:00
Logs ,
2026-02-21 14:26:56 +05:30
type LucideIcon ,
2026-04-25 15:12:22 +05:30
MessageCircleReply ,
2026-02-21 14:26:56 +05:30
MessageSquare ,
Mic ,
MoreHorizontal ,
2026-04-27 14:04:50 -07:00
Pencil ,
ScanEye ,
2026-02-21 14:26:56 +05:30
Settings ,
Shield ,
2026-04-25 15:12:22 +05:30
SlidersHorizontal ,
2026-02-21 14:26:56 +05:30
Trash2 ,
2026-04-27 14:04:50 -07:00
Unplug ,
2026-02-21 14:26:56 +05:30
Users ,
2026-04-25 15:12:22 +05:30
Video ,
2026-02-21 14:26:56 +05:30
} from "lucide-react" ;
2026-02-21 22:30:41 +05:30
import { useCallback , useEffect , useMemo , useState } from "react" ;
2026-02-21 14:26:56 +05:30
import { toast } from "sonner" ;
2026-02-25 01:50:28 -08:00
import { myAccessAtom } from "@/atoms/members/members-query.atoms" ;
import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms" ;
2026-02-21 14:26:56 +05:30
import {
createRoleMutationAtom ,
deleteRoleMutationAtom ,
updateRoleMutationAtom ,
} from "@/atoms/roles/roles-mutation.atoms" ;
import {
AlertDialog ,
AlertDialogAction ,
AlertDialogCancel ,
AlertDialogContent ,
AlertDialogDescription ,
AlertDialogFooter ,
AlertDialogHeader ,
AlertDialogTitle ,
AlertDialogTrigger ,
} from "@/components/ui/alert-dialog" ;
import { Button } from "@/components/ui/button" ;
import { Checkbox } from "@/components/ui/checkbox" ;
import {
Dialog ,
DialogContent ,
DialogDescription ,
DialogHeader ,
DialogTitle ,
} from "@/components/ui/dialog" ;
import {
DropdownMenu ,
DropdownMenuContent ,
DropdownMenuItem ,
DropdownMenuSeparator ,
DropdownMenuTrigger ,
} from "@/components/ui/dropdown-menu" ;
import { Input } from "@/components/ui/input" ;
import { Label } from "@/components/ui/label" ;
import { Spinner } from "@/components/ui/spinner" ;
import type { PermissionInfo } from "@/contracts/types/permissions.types" ;
import type {
CreateRoleRequest ,
DeleteRoleRequest ,
Role ,
UpdateRoleRequest ,
} from "@/contracts/types/roles.types" ;
import { rolesApiService } from "@/lib/apis/roles-api.service" ;
import { cacheKeys } from "@/lib/query-client/cache-keys" ;
import { cn } from "@/lib/utils" ;
const CATEGORY_CONFIG : Record <
string ,
{ label : string ; icon : LucideIcon ; description : string ; order : number }
> = {
documents : {
label : "Documents" ,
icon : FileText ,
description : "Manage files, notes, and content" ,
order : 1 ,
} ,
chats : {
label : "AI Chats" ,
icon : MessageSquare ,
description : "Create and manage AI conversations" ,
order : 2 ,
} ,
comments : {
label : "Comments" ,
2026-04-25 15:12:22 +05:30
icon : MessageCircleReply ,
2026-02-21 14:26:56 +05:30
description : "Add annotations to documents" ,
order : 3 ,
} ,
llm_configs : {
label : "AI Models" ,
icon : Bot ,
description : "Configure AI model settings" ,
order : 4 ,
} ,
2026-04-25 15:12:22 +05:30
image_generations : {
label : "Image Models" ,
icon : Image ,
description : "Configure image generation model settings" ,
order : 4.1 ,
} ,
vision_configs : {
label : "Vision Models" ,
icon : ScanEye ,
description : "Configure vision model settings" ,
order : 4.2 ,
} ,
video_presentations : {
label : "Video Presentations" ,
icon : Video ,
description : "Generate and manage video presentations" ,
order : 4.3 ,
} ,
2026-02-21 14:26:56 +05:30
podcasts : {
label : "Podcasts" ,
icon : Mic ,
description : "Generate AI podcasts from content" ,
order : 5 ,
} ,
connectors : {
2026-04-25 15:12:22 +05:30
label : "Connectors" ,
icon : Unplug ,
2026-02-21 14:26:56 +05:30
description : "Connect external data sources" ,
order : 6 ,
} ,
logs : {
label : "Activity Logs" ,
icon : Logs ,
description : "View and manage audit trail" ,
order : 7 ,
} ,
members : {
label : "Team Members" ,
icon : Users ,
description : "Manage team membership" ,
order : 8 ,
} ,
roles : {
label : "Roles" ,
icon : Shield ,
description : "Configure role permissions" ,
order : 9 ,
} ,
settings : {
label : "Settings" ,
icon : Settings ,
description : "Manage search space settings" ,
order : 10 ,
} ,
public_sharing : {
label : "Public Chat Sharing" ,
2026-04-25 15:12:22 +05:30
icon : Earth ,
2026-02-21 14:26:56 +05:30
description : "Share chats publicly via links" ,
order : 11 ,
} ,
2026-04-25 15:12:22 +05:30
general : {
label : "General" ,
icon : SlidersHorizontal ,
description : "General search space permissions" ,
order : 12 ,
} ,
2026-02-21 14:26:56 +05:30
} ;
const ACTION_LABELS : Record < string , string > = {
create : "Create" ,
read : "Read" ,
update : "Update" ,
delete : "Delete" ,
invite : "Invite" ,
view : "View" ,
remove : "Remove" ,
manage_roles : "Manage Roles" ,
} ;
const ROLE_PRESETS = {
editor : {
name : "Editor" ,
2026-02-21 22:30:41 +05:30
description : "Create, read, and edit content. No delete or admin access." ,
2026-02-21 14:26:56 +05:30
permissions : [
"documents:create" ,
"documents:read" ,
"documents:update" ,
"chats:create" ,
"chats:read" ,
"chats:update" ,
"comments:create" ,
"comments:read" ,
"llm_configs:create" ,
"llm_configs:read" ,
"llm_configs:update" ,
"podcasts:create" ,
"podcasts:read" ,
"podcasts:update" ,
"connectors:create" ,
"connectors:read" ,
"connectors:update" ,
"logs:read" ,
"members:invite" ,
"members:view" ,
"roles:read" ,
"settings:view" ,
] ,
} ,
viewer : {
name : "Viewer" ,
description : "Read-only access with ability to add comments" ,
permissions : [
"documents:read" ,
"chats:read" ,
"comments:create" ,
"comments:read" ,
"llm_configs:read" ,
"podcasts:read" ,
"connectors:read" ,
"logs:read" ,
"members:view" ,
"roles:read" ,
"settings:view" ,
] ,
} ,
contributor : {
name : "Contributor" ,
description : "Can add and manage their own content" ,
permissions : [
"documents:create" ,
"documents:read" ,
"documents:update" ,
"chats:create" ,
"chats:read" ,
"comments:create" ,
"comments:read" ,
"llm_configs:read" ,
"podcasts:read" ,
"connectors:read" ,
"logs:read" ,
"members:view" ,
"roles:read" ,
"settings:view" ,
] ,
} ,
} ;
type PermissionWithDescription = PermissionInfo ;
// ============ Roles Manager (for Settings page) ============
export function RolesManager ( { searchSpaceId } : { searchSpaceId : number } ) {
const { data : access = null } = useAtomValue ( myAccessAtom ) ;
const hasPermission = useCallback (
( permission : string ) = > {
if ( ! access ) return false ;
if ( access . is_owner ) return true ;
return access . permissions ? . includes ( permission ) ? ? false ;
} ,
[ access ]
) ;
2026-02-22 04:41:56 +05:30
const { data : roles = [ ] , isLoading : rolesLoading } = useQuery ( {
2026-02-21 14:26:56 +05:30
queryKey : cacheKeys.roles.all ( searchSpaceId . toString ( ) ) ,
2026-02-22 04:41:56 +05:30
queryFn : ( ) = > rolesApiService . getRoles ( { search_space_id : searchSpaceId } ) ,
2026-02-21 14:26:56 +05:30
enabled : ! ! searchSpaceId ,
} ) ;
const { data : permissionsData } = useAtomValue ( permissionsAtom ) ;
const permissions = permissionsData ? . permissions || [ ] ;
const groupedPermissions = useMemo ( ( ) = > {
const groups : Record < string , typeof permissions > = { } ;
for ( const perm of permissions ) {
if ( ! groups [ perm . category ] ) {
groups [ perm . category ] = [ ] ;
}
groups [ perm . category ] . push ( perm ) ;
}
return groups ;
} , [ permissions ] ) ;
const { mutateAsync : createRole } = useAtomValue ( createRoleMutationAtom ) ;
const { mutateAsync : updateRole } = useAtomValue ( updateRoleMutationAtom ) ;
const { mutateAsync : deleteRole } = useAtomValue ( deleteRoleMutationAtom ) ;
const handleUpdateRole = useCallback (
async (
roleId : number ,
data : {
name? : string ;
description? : string | null ;
permissions? : string [ ] ;
is_default? : boolean ;
}
) : Promise < Role > = > {
const request : UpdateRoleRequest = {
search_space_id : searchSpaceId ,
role_id : roleId ,
data : data ,
} ;
return await updateRole ( request ) ;
} ,
[ updateRole , searchSpaceId ]
) ;
const handleDeleteRole = useCallback (
async ( roleId : number ) : Promise < boolean > = > {
const request : DeleteRoleRequest = {
search_space_id : searchSpaceId ,
role_id : roleId ,
} ;
await deleteRole ( request ) ;
return true ;
} ,
[ deleteRole , searchSpaceId ]
) ;
const handleCreateRole = useCallback (
async ( roleData : CreateRoleRequest [ "data" ] ) : Promise < Role > = > {
const request : CreateRoleRequest = {
search_space_id : searchSpaceId ,
data : roleData ,
} ;
return await createRole ( request ) ;
} ,
[ createRole , searchSpaceId ]
) ;
return (
< RolesContent
roles = { roles }
groupedPermissions = { groupedPermissions }
loading = { rolesLoading }
onUpdateRole = { handleUpdateRole }
onDeleteRole = { handleDeleteRole }
onCreateRole = { handleCreateRole }
canUpdate = { hasPermission ( "roles:update" ) }
canDelete = { hasPermission ( "roles:delete" ) }
canCreate = { hasPermission ( "roles:create" ) }
/ >
) ;
}
2026-02-21 22:30:41 +05:30
function PermissionsBadge ( { permissions } : { permissions : string [ ] } ) {
if ( permissions . includes ( "*" ) ) {
return (
< div className = "px-2.5 py-1 rounded-md bg-muted/50 border border-border/60 text-muted-foreground" >
< span className = "text-xs font-medium whitespace-nowrap" > Full access < / span >
< / div >
) ;
}
return (
< div className = "px-2.5 py-1 rounded-md border border-border/60 bg-muted/50 text-muted-foreground" >
2026-02-22 04:41:56 +05:30
< span className = "text-xs font-medium whitespace-nowrap" >
{ permissions . length } permissions
< / span >
2026-02-21 22:30:41 +05:30
< / div >
) ;
}
2026-02-21 14:26:56 +05:30
// ============ Roles Content ============
function RolesContent ( {
roles ,
groupedPermissions ,
loading ,
onUpdateRole ,
onDeleteRole ,
onCreateRole ,
canUpdate ,
canDelete ,
canCreate ,
} : {
roles : Role [ ] ;
groupedPermissions : Record < string , PermissionWithDescription [ ] > ;
loading : boolean ;
onUpdateRole : (
roleId : number ,
data : {
name? : string ;
description? : string | null ;
permissions? : string [ ] ;
is_default? : boolean ;
}
) = > Promise < Role > ;
onDeleteRole : ( roleId : number ) = > Promise < boolean > ;
onCreateRole : ( data : CreateRoleRequest [ "data" ] ) = > Promise < Role > ;
canUpdate : boolean ;
canDelete : boolean ;
canCreate : boolean ;
} ) {
const [ showCreateRole , setShowCreateRole ] = useState ( false ) ;
const [ editingRoleId , setEditingRoleId ] = useState < number | null > ( null ) ;
2026-04-13 21:45:53 +05:30
const [ expandedRoleId , setExpandedRoleId ] = useState < number | null > ( null ) ;
2026-02-21 14:26:56 +05:30
if ( loading ) {
return (
< div className = "flex items-center justify-center py-12" >
< Spinner size = "md" className = "text-primary" / >
< / div >
) ;
}
2026-02-21 22:30:41 +05:30
const editingRole = editingRoleId !== null ? roles . find ( ( r ) = > r . id === editingRoleId ) : null ;
2026-02-21 14:26:56 +05:30
return (
2026-03-16 21:10:46 +05:30
< div className = "space-y-6" >
2026-02-21 22:30:41 +05:30
{ canCreate && (
< div className = "flex justify-end" >
< Button
variant = "outline"
onClick = { ( ) = > setShowCreateRole ( true ) }
className = "gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
2026-02-21 14:26:56 +05:30
Create Custom Role
< / Button >
2026-02-21 22:30:41 +05:30
< / div >
2026-02-21 14:26:56 +05:30
) }
2026-02-21 22:30:41 +05:30
< CreateRoleDialog
open = { showCreateRole }
onOpenChange = { setShowCreateRole }
groupedPermissions = { groupedPermissions }
onCreateRole = { onCreateRole }
/ >
{ editingRole && (
< EditRoleDialog
open = { ! ! editingRole }
2026-02-22 04:41:56 +05:30
onOpenChange = { ( open ) = > {
if ( ! open ) setEditingRoleId ( null ) ;
} }
2026-02-21 22:30:41 +05:30
role = { editingRole }
2026-02-21 14:26:56 +05:30
groupedPermissions = { groupedPermissions }
2026-02-21 22:30:41 +05:30
onUpdateRole = { onUpdateRole }
2026-02-21 14:26:56 +05:30
/ >
) }
2026-02-22 04:41:56 +05:30
< div className = "space-y-3" >
2026-04-13 21:45:53 +05:30
{ roles . map ( ( role ) = > {
const isExpanded = expandedRoleId === role . id ;
const isFullAccess = role . permissions . includes ( "*" ) ;
const grouped : Record < string , string [ ] > = { } ;
if ( ! isFullAccess ) {
for ( const perm of role . permissions ) {
const [ category , action ] = perm . split ( ":" ) ;
if ( ! grouped [ category ] ) grouped [ category ] = [ ] ;
grouped [ category ] . push ( action ) ;
}
}
const sortedCategories = Object . keys ( grouped ) . sort ( ( a , b ) = > {
const orderA = CATEGORY_CONFIG [ a ] ? . order ? ? 99 ;
const orderB = CATEGORY_CONFIG [ b ] ? . order ? ? 99 ;
return orderA - orderB ;
} ) ;
return (
< div key = { role . id } className = "rounded-lg border border-border/60 overflow-hidden" >
2026-04-27 14:04:50 -07:00
{ /* biome-ignore lint/a11y/useSemanticElements: row contains nested interactive elements (DropdownMenu); using a <button> would produce invalid nested-button markup */ }
2026-04-25 15:12:22 +05:30
< div
2026-04-27 14:04:50 -07:00
role = "button"
tabIndex = { 0 }
aria - expanded = { isExpanded }
className = "flex items-center gap-4 p-4 transition-colors hover:bg-muted/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
2026-04-25 15:12:22 +05:30
onClick = { ( ) = > setExpandedRoleId ( isExpanded ? null : role . id ) }
2026-04-27 14:04:50 -07:00
onKeyDown = { ( e ) = > {
if ( e . key === "Enter" || e . key === " " ) {
e . preventDefault ( ) ;
setExpandedRoleId ( isExpanded ? null : role . id ) ;
}
} }
2026-04-25 15:12:22 +05:30
>
< div className = "flex-1 min-w-0 text-left" >
2026-04-13 21:45:53 +05:30
< div className = "flex items-center gap-2" >
< span className = "font-medium text-sm" > { role . name } < / span >
{ role . is_system_role && (
< span className = "text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium" >
System
< / span >
2026-02-21 22:30:41 +05:30
) }
2026-04-13 21:45:53 +05:30
{ role . is_default && (
< span className = "text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium" >
Default
< / span >
) }
< / div >
{ role . description && (
< p className = "text-xs text-muted-foreground mt-0.5 truncate" >
{ role . description }
< / p >
) }
2026-04-25 15:12:22 +05:30
< / div >
2026-04-13 21:45:53 +05:30
< div className = "shrink-0" >
< PermissionsBadge permissions = { role . permissions } / >
< / div >
2026-03-24 03:11:30 -07:00
2026-04-13 21:45:53 +05:30
{ ! role . is_system_role && (
2026-04-25 15:12:22 +05:30
< div className = "shrink-0" role = "none" onClick = { ( e ) = > e . stopPropagation ( ) } >
2026-04-13 21:45:53 +05:30
< DropdownMenu >
< DropdownMenuTrigger asChild >
< Button variant = "ghost" size = "icon" className = "h-8 w-8" >
< MoreHorizontal className = "h-4 w-4" / >
< / Button >
< / DropdownMenuTrigger >
< DropdownMenuContent align = "end" onCloseAutoFocus = { ( e ) = > e . preventDefault ( ) } >
{ canUpdate && (
< DropdownMenuItem onClick = { ( ) = > setEditingRoleId ( role . id ) } >
2026-04-25 15:12:22 +05:30
< Pencil className = "h-4 w-4 mr-2" / >
2026-04-13 21:45:53 +05:30
Edit Role
< / DropdownMenuItem >
) }
{ canDelete && (
< >
< DropdownMenuSeparator / >
< AlertDialog >
< AlertDialogTrigger asChild >
< DropdownMenuItem onSelect = { ( e ) = > e . preventDefault ( ) } >
< Trash2 className = "h-4 w-4 mr-2" / >
Delete Role
< / DropdownMenuItem >
< / AlertDialogTrigger >
< AlertDialogContent >
< AlertDialogHeader >
< AlertDialogTitle > Delete role ? < / AlertDialogTitle >
< AlertDialogDescription >
This will permanently delete the & quot ; { role . name } & quot ; role .
Members with this role will lose their permissions .
< / AlertDialogDescription >
< / AlertDialogHeader >
< AlertDialogFooter >
< AlertDialogCancel > Cancel < / AlertDialogCancel >
< AlertDialogAction
onClick = { ( ) = > onDeleteRole ( role . id ) }
className = "bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
< / AlertDialogAction >
< / AlertDialogFooter >
< / AlertDialogContent >
< / AlertDialog >
< / >
) }
< / DropdownMenuContent >
< / DropdownMenu >
< / div >
) }
2026-04-25 15:12:22 +05:30
< div className = "shrink-0 p-1" >
< ChevronRight
2026-04-13 21:45:53 +05:30
className = { cn (
"h-4 w-4 text-muted-foreground transition-transform duration-200" ,
2026-04-25 15:12:22 +05:30
isExpanded && "rotate-90"
2026-04-13 21:45:53 +05:30
) }
/ >
2026-04-25 15:12:22 +05:30
< / div >
2026-03-24 03:11:30 -07:00
< / div >
2026-04-13 21:45:53 +05:30
{ isExpanded && (
< div className = "border-t border-border/40 px-4 py-3" >
{ isFullAccess ? (
< div className = "flex items-center gap-3 py-2" >
< Shield className = "h-4 w-4 text-muted-foreground shrink-0" / >
< p className = "text-sm text-muted-foreground" >
Full access — all permissions granted across every category
< / p >
< / div >
) : (
< div className = "divide-y divide-border/30" >
{ sortedCategories . map ( ( category ) = > {
const actions = grouped [ category ] ;
const config = CATEGORY_CONFIG [ category ] || {
label : category ,
icon : FileText ,
} ;
const IconComponent = config . icon ;
return (
< div
key = { category }
className = "flex items-center justify-between gap-3 py-2.5"
>
< div className = "flex items-center gap-2 shrink-0" >
< IconComponent className = "h-3.5 w-3.5 text-muted-foreground" / >
2026-04-14 21:26:00 -07:00
< span className = "text-sm text-muted-foreground" > { config . label } < / span >
2026-04-13 21:45:53 +05:30
< / div >
< div className = "flex flex-wrap justify-end gap-1" >
{ actions . map ( ( action ) = > (
< span
key = { action }
className = "px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[11px] font-medium"
2026-03-24 03:11:30 -07:00
>
2026-04-13 21:45:53 +05:30
{ ACTION_LABELS [ action ] || action . replace ( /_/g , " " ) }
< / span >
) ) }
< / div >
< / div >
) ;
} ) }
< / div >
) }
2026-02-21 22:30:41 +05:30
< / div >
2026-03-24 03:11:30 -07:00
) }
< / div >
2026-04-13 21:45:53 +05:30
) ;
} ) }
2026-02-21 14:26:56 +05:30
< / div >
2026-03-16 21:10:46 +05:30
< / div >
2026-02-21 14:26:56 +05:30
) ;
}
// ============ Permissions Editor (shared by Create and Edit) ============
function PermissionsEditor ( {
groupedPermissions ,
selectedPermissions ,
onTogglePermission ,
onToggleCategory ,
} : {
groupedPermissions : Record < string , PermissionWithDescription [ ] > ;
selectedPermissions : string [ ] ;
onTogglePermission : ( perm : string ) = > void ;
onToggleCategory : ( category : string ) = > void ;
} ) {
const [ expandedCategories , setExpandedCategories ] = useState < string [ ] > ( [ ] ) ;
const sortedCategories = useMemo ( ( ) = > {
return Object . keys ( groupedPermissions ) . sort ( ( a , b ) = > {
const orderA = CATEGORY_CONFIG [ a ] ? . order ? ? 99 ;
const orderB = CATEGORY_CONFIG [ b ] ? . order ? ? 99 ;
return orderA - orderB ;
} ) ;
} , [ groupedPermissions ] ) ;
const toggleCategoryExpanded = useCallback ( ( category : string ) = > {
setExpandedCategories ( ( prev ) = >
2026-02-22 04:41:56 +05:30
prev . includes ( category ) ? prev . filter ( ( c ) = > c !== category ) : [ . . . prev , category ]
2026-02-21 14:26:56 +05:30
) ;
} , [ ] ) ;
const getCategoryStats = useCallback (
( category : string ) = > {
const perms = groupedPermissions [ category ] || [ ] ;
2026-02-22 04:41:56 +05:30
const selected = perms . filter ( ( p ) = > selectedPermissions . includes ( p . value ) ) . length ;
2026-02-21 14:26:56 +05:30
return {
selected ,
total : perms.length ,
allSelected : selected === perms . length ,
} ;
} ,
[ groupedPermissions , selectedPermissions ]
) ;
return (
< div className = "space-y-3" >
< div className = "flex items-center justify-between" >
< Label className = "text-sm font-medium" >
Permissions ( { selectedPermissions . length } selected )
< / Label >
< Button
type = "button"
variant = "ghost"
size = "sm"
className = "text-xs h-7"
onClick = { ( ) = >
setExpandedCategories (
2026-02-22 04:41:56 +05:30
expandedCategories . length === sortedCategories . length ? [ ] : sortedCategories
2026-02-21 14:26:56 +05:30
)
}
>
2026-02-22 04:41:56 +05:30
{ expandedCategories . length === sortedCategories . length ? "Collapse All" : "Expand All" }
2026-02-21 14:26:56 +05:30
< / Button >
< / div >
2026-02-21 22:30:41 +05:30
< div className = "space-y-1.5" >
2026-02-21 14:26:56 +05:30
{ sortedCategories . map ( ( category ) = > {
const config = CATEGORY_CONFIG [ category ] || {
label : category ,
icon : FileText ,
description : "" ,
order : 99 ,
} ;
const IconComponent = config . icon ;
const stats = getCategoryStats ( category ) ;
const isExpanded = expandedCategories . includes ( category ) ;
const perms = groupedPermissions [ category ] || [ ] ;
return (
2026-02-22 04:41:56 +05:30
< div key = { category } className = "rounded-lg border border-border/60 overflow-hidden" >
2026-04-27 14:04:50 -07:00
{ /* biome-ignore lint/a11y/useSemanticElements: row contains a nested interactive Checkbox; using a <button> would produce invalid nested-button markup */ }
2026-04-25 15:12:22 +05:30
< div
2026-04-27 14:04:50 -07:00
role = "button"
tabIndex = { 0 }
aria - expanded = { isExpanded }
className = "flex items-center justify-between px-3 py-2.5 hover:bg-muted/40 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
2026-04-25 15:12:22 +05:30
onClick = { ( ) = > toggleCategoryExpanded ( category ) }
2026-04-27 14:04:50 -07:00
onKeyDown = { ( e ) = > {
if ( e . key === "Enter" || e . key === " " ) {
e . preventDefault ( ) ;
toggleCategoryExpanded ( category ) ;
}
} }
2026-04-25 15:12:22 +05:30
>
< div className = "flex-1 flex items-center gap-2.5" >
2026-02-21 22:30:41 +05:30
< IconComponent className = "h-4 w-4 text-muted-foreground shrink-0" / >
< span className = "font-medium text-sm" > { config . label } < / span >
< span className = "text-[11px] text-muted-foreground tabular-nums" >
{ stats . selected } / { stats . total }
< / span >
2026-04-25 15:12:22 +05:30
< / div >
2026-02-21 14:26:56 +05:30
< div className = "flex items-center gap-2" >
< Checkbox
checked = { stats . allSelected }
2026-04-25 15:12:22 +05:30
onClick = { ( e ) = > e . stopPropagation ( ) }
2026-02-21 22:30:41 +05:30
onCheckedChange = { ( ) = > onToggleCategory ( category ) }
2026-02-21 14:26:56 +05:30
aria - label = { ` Select all ${ config . label } permissions ` }
/ >
2026-04-25 15:12:22 +05:30
< ChevronRight
className = { cn (
"h-4 w-4 text-muted-foreground transition-transform duration-200" ,
isExpanded && "rotate-90"
) }
/ >
2026-02-21 14:26:56 +05:30
< / div >
2026-04-13 21:45:53 +05:30
< / div >
2026-02-21 14:26:56 +05:30
{ isExpanded && (
2026-03-16 21:10:46 +05:30
< div className = "border-t border-border/60" >
2026-02-21 22:30:41 +05:30
< div className = "p-2 space-y-0.5" >
2026-02-21 14:26:56 +05:30
{ perms . map ( ( perm ) = > {
2026-02-21 22:30:41 +05:30
const action = perm . value . split ( ":" ) [ 1 ] ;
const actionLabel = ACTION_LABELS [ action ] || action . replace ( /_/g , " " ) ;
const isSelected = selectedPermissions . includes ( perm . value ) ;
2026-02-21 14:26:56 +05:30
return (
2026-04-13 21:45:53 +05:30
< div
2026-02-21 14:26:56 +05:30
key = { perm . value }
className = { cn (
2026-04-13 21:45:53 +05:30
"flex items-center justify-between gap-3 px-2.5 py-2 rounded-md transition-colors" ,
2026-02-22 04:41:56 +05:30
isSelected ? "bg-muted/60 hover:bg-muted/80" : "hover:bg-muted/40"
2026-02-21 14:26:56 +05:30
) }
>
2026-04-13 21:45:53 +05:30
< button
type = "button"
2026-04-25 15:12:22 +05:30
className = "flex-1 min-w-0 text-left cursor-pointer focus:outline-none focus-visible:outline-none"
2026-04-13 21:45:53 +05:30
onClick = { ( ) = > onTogglePermission ( perm . value ) }
>
2026-02-21 22:30:41 +05:30
< span className = "text-sm font-medium" > { actionLabel } < / span >
< p className = "text-xs text-muted-foreground truncate" >
{ perm . description }
< / p >
2026-04-13 21:45:53 +05:30
< / button >
2026-02-21 22:30:41 +05:30
< Checkbox
checked = { isSelected }
onCheckedChange = { ( ) = > onTogglePermission ( perm . value ) }
className = "shrink-0"
/ >
2026-04-13 21:45:53 +05:30
< / div >
2026-02-21 14:26:56 +05:30
) ;
} ) }
< / div >
2026-03-16 21:10:46 +05:30
< / div >
2026-02-21 14:26:56 +05:30
) }
< / div >
) ;
} ) }
< / div >
< / div >
) ;
}
2026-02-21 22:30:41 +05:30
// ============ Create Role Dialog ============
2026-02-21 14:26:56 +05:30
2026-02-21 22:30:41 +05:30
function CreateRoleDialog ( {
open ,
onOpenChange ,
2026-02-21 14:26:56 +05:30
groupedPermissions ,
onCreateRole ,
} : {
2026-02-21 22:30:41 +05:30
open : boolean ;
onOpenChange : ( open : boolean ) = > void ;
2026-02-21 14:26:56 +05:30
groupedPermissions : Record < string , PermissionWithDescription [ ] > ;
onCreateRole : ( data : CreateRoleRequest [ "data" ] ) = > Promise < Role > ;
} ) {
const [ creating , setCreating ] = useState ( false ) ;
const [ name , setName ] = useState ( "" ) ;
const [ description , setDescription ] = useState ( "" ) ;
const [ selectedPermissions , setSelectedPermissions ] = useState < string [ ] > ( [ ] ) ;
const [ isDefault , setIsDefault ] = useState ( false ) ;
2026-02-21 22:30:41 +05:30
const handleClose = ( ) = > {
onOpenChange ( false ) ;
setName ( "" ) ;
setDescription ( "" ) ;
setSelectedPermissions ( [ ] ) ;
setIsDefault ( false ) ;
} ;
2026-02-21 14:26:56 +05:30
const handleCreate = async ( ) = > {
if ( ! name . trim ( ) ) {
toast . error ( "Please enter a role name" ) ;
return ;
}
setCreating ( true ) ;
try {
await onCreateRole ( {
name : name.trim ( ) ,
description : description.trim ( ) || null ,
permissions : selectedPermissions ,
is_default : isDefault ,
} ) ;
2026-02-21 22:30:41 +05:30
handleClose ( ) ;
2026-02-21 14:26:56 +05:30
} catch ( error ) {
console . error ( "Failed to create role:" , error ) ;
} finally {
setCreating ( false ) ;
}
} ;
const togglePermission = useCallback ( ( perm : string ) = > {
setSelectedPermissions ( ( prev ) = >
2026-02-22 04:41:56 +05:30
prev . includes ( perm ) ? prev . filter ( ( p ) = > p !== perm ) : [ . . . prev , perm ]
2026-02-21 14:26:56 +05:30
) ;
} , [ ] ) ;
const toggleCategory = useCallback (
( category : string ) = > {
2026-02-22 04:41:56 +05:30
const categoryPerms = groupedPermissions [ category ] ? . map ( ( p ) = > p . value ) || [ ] ;
const allSelected = categoryPerms . every ( ( p ) = > selectedPermissions . includes ( p ) ) ;
2026-02-21 14:26:56 +05:30
if ( allSelected ) {
2026-02-22 04:41:56 +05:30
setSelectedPermissions ( ( prev ) = > prev . filter ( ( p ) = > ! categoryPerms . includes ( p ) ) ) ;
2026-02-21 14:26:56 +05:30
} else {
2026-02-22 04:41:56 +05:30
setSelectedPermissions ( ( prev ) = > [ . . . new Set ( [ . . . prev , . . . categoryPerms ] ) ] ) ;
2026-02-21 14:26:56 +05:30
}
} ,
[ groupedPermissions , selectedPermissions ]
) ;
const applyPreset = useCallback (
( presetKey : keyof typeof ROLE_PRESETS ) = > {
const preset = ROLE_PRESETS [ presetKey ] ;
setSelectedPermissions ( preset . permissions ) ;
if ( ! name . trim ( ) ) {
setName ( preset . name ) ;
setDescription ( preset . description ) ;
}
toast . success ( ` Applied ${ preset . name } preset ` ) ;
} ,
[ name ]
) ;
return (
2026-02-21 22:30:41 +05:30
< Dialog open = { open } onOpenChange = { ( v ) = > ( v ? onOpenChange ( true ) : handleClose ( ) ) } >
< DialogContent className = "!flex !flex-col w-[92vw] max-w-[92vw] sm:max-w-2xl p-0 gap-0 max-h-[85vh] overflow-hidden" >
< DialogHeader className = "px-5 pt-5 pb-4 shrink-0" >
< DialogTitle className = "text-lg" > Create Custom Role < / DialogTitle >
< DialogDescription className = "text-sm text-muted-foreground" >
Define permissions for a new role in this search space
< / DialogDescription >
< / DialogHeader >
< div className = "flex-1 min-h-0 overflow-y-auto" >
< div className = "px-5 py-5 space-y-5" >
< div className = "space-y-2" >
< Label className = "text-sm font-medium" > Start from a template < / Label >
< div className = "grid grid-cols-3 gap-2" >
{ Object . entries ( ROLE_PRESETS ) . map ( ( [ key , preset ] ) = > (
2026-02-21 14:26:56 +05:30
< button
key = { key }
type = "button"
2026-02-21 22:30:41 +05:30
onClick = { ( ) = > applyPreset ( key as keyof typeof ROLE_PRESETS ) }
2026-02-21 14:26:56 +05:30
className = { cn (
2026-04-25 15:12:22 +05:30
"p-3 rounded-lg border transition-colors hover:bg-muted/40" ,
"flex items-center justify-center text-center sm:block sm:text-left" ,
2026-02-21 14:26:56 +05:30
selectedPermissions . length > 0 &&
2026-02-21 22:30:41 +05:30
preset . permissions . every ( ( p ) = > selectedPermissions . includes ( p ) )
? "border-foreground/30 bg-muted/40"
: "border-border/60"
2026-02-21 14:26:56 +05:30
) }
>
2026-02-21 22:30:41 +05:30
< span className = "font-medium text-sm" > { preset . name } < / span >
2026-04-25 15:12:22 +05:30
< p className = "hidden sm:block text-xs text-muted-foreground mt-0.5 line-clamp-2" >
2026-02-21 14:26:56 +05:30
{ preset . description }
< / p >
< / button >
2026-02-21 22:30:41 +05:30
) ) }
< / div >
2026-02-21 14:26:56 +05:30
< / div >
2026-02-21 22:30:41 +05:30
< div className = "grid grid-cols-2 gap-3" >
< div className = "space-y-1.5" >
< Label htmlFor = "role-name" > Role Name * < / Label >
< Input
id = "role-name"
2026-04-16 22:33:01 +08:00
maxLength = { 100 }
2026-02-21 22:30:41 +05:30
placeholder = "e.g., Content Manager"
value = { name }
onChange = { ( e ) = > setName ( e . target . value ) }
/ >
< / div >
< div className = "space-y-1.5" >
< Label htmlFor = "role-description" > Description < / Label >
< Input
id = "role-description"
2026-04-16 22:33:01 +08:00
maxLength = { 500 }
2026-02-21 22:30:41 +05:30
placeholder = "Brief description of this role"
value = { description }
onChange = { ( e ) = > setDescription ( e . target . value ) }
/ >
< / div >
2026-02-21 14:26:56 +05:30
< / div >
2026-02-21 22:30:41 +05:30
< div className = "flex items-center gap-3 p-3 rounded-lg bg-muted/40" >
< Checkbox
id = "is-default"
checked = { isDefault }
onCheckedChange = { ( checked ) = > setIsDefault ( checked === true ) }
2026-02-21 14:26:56 +05:30
/ >
2026-02-21 22:30:41 +05:30
< div className = "flex-1" >
< Label htmlFor = "is-default" className = "cursor-pointer font-medium text-sm" >
Set as default role
< / Label >
< p className = "text-xs text-muted-foreground" >
New members without a specific role will be assigned this role
< / p >
< / div >
2026-02-21 14:26:56 +05:30
< / div >
2026-02-21 22:30:41 +05:30
< PermissionsEditor
groupedPermissions = { groupedPermissions }
selectedPermissions = { selectedPermissions }
onTogglePermission = { togglePermission }
onToggleCategory = { toggleCategory }
2026-02-21 14:26:56 +05:30
/ >
< / div >
2026-02-21 22:30:41 +05:30
< / div >
2026-03-17 04:40:46 +05:30
< div className = "flex items-center justify-end gap-3 px-5 py-3 shrink-0" >
< Button variant = "secondary" onClick = { handleClose } >
Cancel
< / Button >
< Button onClick = { handleCreate } disabled = { creating || ! name . trim ( ) } >
2026-02-21 22:30:41 +05:30
{ creating ? (
< >
< Spinner size = "sm" className = "mr-2" / >
Creating
< / >
) : (
"Create Role"
) }
< / Button >
< / div >
< / DialogContent >
< / Dialog >
2026-02-21 14:26:56 +05:30
) ;
}
2026-02-21 22:30:41 +05:30
// ============ Edit Role Dialog ============
2026-02-21 14:26:56 +05:30
2026-02-21 22:30:41 +05:30
function EditRoleDialog ( {
open ,
onOpenChange ,
2026-02-21 14:26:56 +05:30
role ,
groupedPermissions ,
onUpdateRole ,
} : {
2026-02-21 22:30:41 +05:30
open : boolean ;
onOpenChange : ( open : boolean ) = > void ;
2026-02-21 14:26:56 +05:30
role : Role ;
groupedPermissions : Record < string , PermissionWithDescription [ ] > ;
onUpdateRole : (
roleId : number ,
data : {
name? : string ;
description? : string | null ;
permissions? : string [ ] ;
is_default? : boolean ;
}
) = > Promise < Role > ;
} ) {
const [ saving , setSaving ] = useState ( false ) ;
const [ name , setName ] = useState ( role . name ) ;
const [ description , setDescription ] = useState ( role . description || "" ) ;
2026-02-21 22:30:41 +05:30
const [ selectedPermissions , setSelectedPermissions ] = useState < string [ ] > ( role . permissions ) ;
2026-02-21 14:26:56 +05:30
const [ isDefault , setIsDefault ] = useState ( role . is_default ) ;
2026-02-21 22:30:41 +05:30
useEffect ( ( ) = > {
if ( open ) {
setName ( role . name ) ;
setDescription ( role . description || "" ) ;
setSelectedPermissions ( role . permissions ) ;
setIsDefault ( role . is_default ) ;
}
} , [ open , role ] ) ;
2026-02-21 14:26:56 +05:30
const handleSave = async ( ) = > {
if ( ! name . trim ( ) ) {
toast . error ( "Please enter a role name" ) ;
return ;
}
setSaving ( true ) ;
try {
await onUpdateRole ( role . id , {
name : name.trim ( ) ,
description : description.trim ( ) || null ,
permissions : selectedPermissions ,
is_default : isDefault ,
} ) ;
toast . success ( "Role updated successfully" ) ;
2026-02-21 22:30:41 +05:30
onOpenChange ( false ) ;
2026-02-21 14:26:56 +05:30
} catch ( error ) {
console . error ( "Failed to update role:" , error ) ;
toast . error ( "Failed to update role" ) ;
} finally {
setSaving ( false ) ;
}
} ;
const togglePermission = useCallback ( ( perm : string ) = > {
setSelectedPermissions ( ( prev ) = >
2026-02-22 04:41:56 +05:30
prev . includes ( perm ) ? prev . filter ( ( p ) = > p !== perm ) : [ . . . prev , perm ]
2026-02-21 14:26:56 +05:30
) ;
} , [ ] ) ;
const toggleCategory = useCallback (
( category : string ) = > {
2026-02-22 04:41:56 +05:30
const categoryPerms = groupedPermissions [ category ] ? . map ( ( p ) = > p . value ) || [ ] ;
const allSelected = categoryPerms . every ( ( p ) = > selectedPermissions . includes ( p ) ) ;
2026-02-21 14:26:56 +05:30
if ( allSelected ) {
2026-02-22 04:41:56 +05:30
setSelectedPermissions ( ( prev ) = > prev . filter ( ( p ) = > ! categoryPerms . includes ( p ) ) ) ;
2026-02-21 14:26:56 +05:30
} else {
2026-02-22 04:41:56 +05:30
setSelectedPermissions ( ( prev ) = > [ . . . new Set ( [ . . . prev , . . . categoryPerms ] ) ] ) ;
2026-02-21 14:26:56 +05:30
}
} ,
[ groupedPermissions , selectedPermissions ]
) ;
return (
2026-02-21 22:30:41 +05:30
< Dialog open = { open } onOpenChange = { onOpenChange } >
< DialogContent className = "!flex !flex-col w-[92vw] max-w-[92vw] sm:max-w-2xl p-0 gap-0 max-h-[85vh] overflow-hidden" >
< DialogHeader className = "px-5 py-4 shrink-0" >
< DialogTitle className = "text-base" > Edit Role < / DialogTitle >
< DialogDescription className = "text-xs" >
Modify permissions for & quot ; { role . name } & quot ;
< / DialogDescription >
< / DialogHeader >
< div className = "flex-1 min-h-0 overflow-y-auto" >
< div className = "px-5 py-5 space-y-5" >
< div className = "grid grid-cols-2 gap-3" >
< div className = "space-y-1.5" >
< Label htmlFor = "edit-role-name" > Role Name * < / Label >
< Input
id = "edit-role-name"
2026-04-16 22:33:01 +08:00
maxLength = { 100 }
2026-02-21 22:30:41 +05:30
placeholder = "e.g., Content Manager"
value = { name }
onChange = { ( e ) = > setName ( e . target . value ) }
/ >
2026-02-21 14:26:56 +05:30
< / div >
2026-02-21 22:30:41 +05:30
< div className = "space-y-1.5" >
< Label htmlFor = "edit-role-description" > Description < / Label >
< Input
id = "edit-role-description"
2026-04-16 22:33:01 +08:00
maxLength = { 500 }
2026-02-21 22:30:41 +05:30
placeholder = "Brief description of this role"
value = { description }
onChange = { ( e ) = > setDescription ( e . target . value ) }
/ >
2026-02-21 14:26:56 +05:30
< / div >
< / div >
2026-02-21 22:30:41 +05:30
< div className = "flex items-center gap-3 p-3 rounded-lg bg-muted/40" >
< Checkbox
id = "edit-is-default"
checked = { isDefault }
onCheckedChange = { ( checked ) = > setIsDefault ( checked === true ) }
2026-02-21 14:26:56 +05:30
/ >
2026-02-21 22:30:41 +05:30
< div className = "flex-1" >
< Label htmlFor = "edit-is-default" className = "cursor-pointer font-medium text-sm" >
Set as default role
< / Label >
< p className = "text-xs text-muted-foreground" >
New members without a specific role will be assigned this role
< / p >
< / div >
2026-02-21 14:26:56 +05:30
< / div >
2026-02-21 22:30:41 +05:30
< PermissionsEditor
groupedPermissions = { groupedPermissions }
selectedPermissions = { selectedPermissions }
onTogglePermission = { togglePermission }
onToggleCategory = { toggleCategory }
2026-02-21 14:26:56 +05:30
/ >
< / div >
2026-02-21 22:30:41 +05:30
< / div >
2026-03-17 04:40:46 +05:30
< div className = "flex items-center justify-end gap-3 px-5 py-3 border-t shrink-0" >
< Button variant = "secondary" onClick = { ( ) = > onOpenChange ( false ) } >
Cancel
< / Button >
2026-02-21 22:30:41 +05:30
< Button onClick = { handleSave } disabled = { saving || ! name . trim ( ) } >
{ saving ? (
< >
< Spinner size = "sm" className = "mr-2" / >
Saving . . .
< / >
) : (
"Save Changes"
) }
< / Button >
< / div >
< / DialogContent >
< / Dialog >
2026-02-21 14:26:56 +05:30
) ;
}