@@ -22,9 +22,13 @@ import {
2222} from 'lucide-react'
2323import { adminAPI } from '@/lib/api/client'
2424import { useAuth , ROLES } from '@/hooks/useAuth'
25+ import { PERMISSIONS , roleLabel } from '@/lib/permissions'
26+ import RoleManager from './RoleManager'
2527
2628const ROLE_BADGE_CLASSES = {
2729 [ ROLES . ADMIN ] : 'bg-red-100 text-red-700 border-red-200' ,
30+ [ ROLES . DBA ] : 'bg-purple-100 text-purple-700 border-purple-200' ,
31+ [ ROLES . DATA_ENGINEER ] : 'bg-teal-100 text-teal-700 border-teal-200' ,
2832 [ ROLES . DEVELOPER ] : 'bg-blue-100 text-blue-700 border-blue-200' ,
2933}
3034
@@ -35,18 +39,28 @@ const STATUS_BADGE_CLASSES = {
3539 DISABLED : 'bg-gray-100 text-gray-700 border-gray-200' ,
3640}
3741
38- const ACCESS_MATRIX = [
39- { area : 'Chat' , developer : 'Own + Assigned' , admin : 'Full' } ,
40- { area : 'Editor' , developer : 'Own + Assigned' , admin : 'Full' } ,
41- { area : 'Brain' , developer : 'Own + Full Access' , admin : 'Full' } ,
42- { area : 'Schema Docs' , developer : 'Own + Full Access' , admin : 'Full' } ,
43- { area : 'Company Knowledge' , developer : 'Own + Full Access' , admin : 'Full' } ,
44- { area : 'Performance' , developer : '—' , admin : 'Full' } ,
42+ /**
43+ * The sidebar sections, and the permission that opens each. Rendered as a live matrix
44+ * against whatever roles the backend reports, so a new custom role appears here without
45+ * a code change — the previous hardcoded two-column table silently went stale the moment
46+ * a third role existed.
47+ */
48+ const SECTION_MATRIX = [
49+ { area : 'Agent' , permission : PERMISSIONS . VIEW_AGENT } ,
50+ { area : 'Dashboards' , permission : PERMISSIONS . VIEW_DASHBOARDS } ,
51+ { area : 'Digest' , permission : PERMISSIONS . VIEW_DIGEST } ,
52+ { area : 'Brain' , permission : PERMISSIONS . VIEW_BRAIN } ,
53+ { area : 'Performance' , permission : PERMISSIONS . VIEW_PERFORMANCE } ,
54+ { area : 'Editor' , permission : PERMISSIONS . VIEW_EDITOR } ,
55+ { area : 'Connection settings' , permission : PERMISSIONS . MANAGE_CONNECTIONS } ,
56+ { area : 'User management' , permission : PERMISSIONS . MANAGE_USERS } ,
4557]
4658
4759const FALLBACK_ROLES = [
48- { name : ROLES . DEVELOPER , description : 'Access to Chat and the SQL Editor' } ,
49- { name : ROLES . ADMIN , description : 'Access to all product areas and administrative controls' } ,
60+ { name : ROLES . ADMIN , description : 'Full access to all product areas and administrative controls' } ,
61+ { name : ROLES . DBA , description : 'All product areas and connection settings, except user management' } ,
62+ { name : ROLES . DATA_ENGINEER , description : 'Agent, Dashboards, and the SQL Editor' } ,
63+ { name : ROLES . DEVELOPER , description : 'Agent, Digest, Dashboards, Performance, and the SQL Editor' } ,
5064]
5165
5266function badgeClassForRole ( role ) {
@@ -552,26 +566,48 @@ export default function UsersTab() {
552566 < div className = "grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_420px] gap-6" >
553567 < div >
554568 < h3 className = "text-sm font-medium text-gray-700 mb-4" > Role Permissions</ h3 >
555- < div className = "border border-gray-200 rounded-lg overflow-hidden " >
569+ < div className = "border border-gray-200 rounded-lg overflow-x-auto " >
556570 < table className = "w-full" >
557571 < thead className = "bg-gray-50" >
558572 < tr >
559573 < th className = "px-4 py-2.5 text-left text-xs font-medium text-gray-500 uppercase" > Area</ th >
560- < th className = "px-4 py-2.5 text-center text-xs font-medium text-gray-500 uppercase" > Developer</ th >
561- < th className = "px-4 py-2.5 text-center text-xs font-medium text-gray-500 uppercase" > Admin</ th >
574+ { availableRoles . map ( ( role ) => (
575+ < th key = { role . name } className = "px-3 py-2.5 text-center text-xs font-medium text-gray-500 uppercase whitespace-nowrap" >
576+ { role . displayName || roleLabel ( role . name ) }
577+ </ th >
578+ ) ) }
562579 </ tr >
563580 </ thead >
564581 < tbody className = "divide-y divide-gray-200" >
565- { ACCESS_MATRIX . map ( ( row ) => (
582+ { SECTION_MATRIX . map ( ( row ) => (
566583 < tr key = { row . area } >
567- < td className = "px-4 py-2 text-sm text-gray-700 font-medium" > { row . area } </ td >
568- < td className = "px-4 py-2 text-center text-xs text-gray-600" > { row . developer } </ td >
569- < td className = "px-4 py-2 text-center text-xs text-green-600 font-medium" > { row . admin } </ td >
584+ < td className = "px-4 py-2 text-sm text-gray-700 font-medium whitespace-nowrap" > { row . area } </ td >
585+ { availableRoles . map ( ( role ) => {
586+ const granted = ( role . permissions || [ ] ) . some (
587+ ( p ) => ( typeof p === 'string' ? p : p ?. name ) === row . permission ,
588+ )
589+ return (
590+ < td key = { role . name } className = "px-3 py-2 text-center" >
591+ { granted ? (
592+ < CheckCircle size = { 14 } className = "inline text-green-600" aria-label = "Allowed" />
593+ ) : (
594+ < span className = "text-gray-300" aria-label = "Not allowed" > —</ span >
595+ ) }
596+ </ td >
597+ )
598+ } ) }
570599 </ tr >
571600 ) ) }
572601 </ tbody >
573602 </ table >
574603 </ div >
604+ < p className = "mt-2 text-xs text-gray-500" >
605+ Built-in roles are fixed. Create a custom role below to define your own combination.
606+ </ p >
607+
608+ < div className = "mt-6" >
609+ < RoleManager roles = { availableRoles } onChanged = { loadData } />
610+ </ div >
575611 </ div >
576612
577613 < div className = "border border-gray-200 rounded-lg p-4" >
@@ -668,7 +704,7 @@ function RoleSelector({ user, roles, onRoleChange, loading, disabled }) {
668704 >
669705 { roles . map ( ( role ) => (
670706 < option key = { role . name } value = { role . name } >
671- { role . name }
707+ { role . displayName || roleLabel ( role . name ) }
672708 </ option >
673709 ) ) }
674710 </ select >
@@ -921,7 +957,6 @@ function ChangePasswordModal({ user, loading, onClose, onSubmit }) {
921957}
922958
923959function ConnectionAccessModal ( { user, data, loading, onClose, onSave, onRevoke, onSavePolicy, onPreviewPolicy } ) {
924- const [ drafts , setDrafts ] = useState ( { } )
925960 const [ policyDrafts , setPolicyDrafts ] = useState ( { } )
926961 const [ policyPreviews , setPolicyPreviews ] = useState ( { } )
927962 const [ previewLoading , setPreviewLoading ] = useState ( { } )
@@ -960,15 +995,14 @@ function ConnectionAccessModal({ user, data, loading, onClose, onSave, onRevoke,
960995 < div className = "space-y-3" >
961996 { assignableConnections . map ( ( connection ) => {
962997 const assignment = assignmentsByConnection . get ( connection . connectionId )
963- const draft = drafts [ connection . connectionId ] || assignment ?. accessLevel || 'CHAT_EDITOR'
998+ // Assignment implies full access now, so there is no level to choose.
999+ const draft = 'FULL_CONTENT'
9641000 const policyDraft = policyDrafts [ connection . connectionId ]
9651001 ?? assignment ?. chatAccessPolicy ?. plainEnglishPolicy
9661002 ?? ''
9671003 const preview = policyPreviews [ connection . connectionId ] || assignment ?. chatAccessPolicy
9681004
9691005 const isAssigned = Boolean ( assignment )
970- const isFull = assignment ?. accessLevel === 'FULL_CONTENT'
971- const isDirty = isAssigned && draft !== assignment . accessLevel
9721006
9731007 return (
9741008 < div
@@ -984,13 +1018,9 @@ function ConnectionAccessModal({ user, data, loading, onClose, onSave, onRevoke,
9841018 < div className = "flex items-center gap-2 flex-wrap" >
9851019 < span className = "font-medium text-gray-900" > { connection . connectionName } </ span >
9861020 { isAssigned ? (
987- < span className = { `inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-semibold border ${
988- isFull
989- ? 'border-green-300 bg-green-100 text-green-800'
990- : 'border-emerald-300 bg-emerald-100 text-emerald-800'
991- } `} >
1021+ < span className = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-semibold border border-green-300 bg-green-100 text-green-800" >
9921022 < CheckCircle size = { 12 } />
993- { isFull ? 'Full Access' : 'Chat + Editor' }
1023+ Assigned
9941024 </ span >
9951025 ) : (
9961026 < span className = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-semibold border border-gray-300 bg-gray-100 text-gray-500" >
@@ -1003,22 +1033,13 @@ function ConnectionAccessModal({ user, data, loading, onClose, onSave, onRevoke,
10031033 </ div >
10041034
10051035 < div className = "flex items-center gap-2 shrink-0" >
1006- < select
1007- value = { draft }
1008- onChange = { ( event ) => setDrafts ( ( prev ) => ( { ...prev , [ connection . connectionId ] : event . target . value } ) ) }
1009- className = "px-3 py-2 border border-gray-300 rounded-md text-sm bg-white focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent"
1010- disabled = { loading }
1011- >
1012- < option value = "CHAT_EDITOR" > Chat + Editor</ option >
1013- < option value = "FULL_CONTENT" > Full Access</ option >
1014- </ select >
10151036 < button
10161037 onClick = { ( ) => onSave ( user . id , connection . connectionId , draft ) }
1017- disabled = { loading || ( isAssigned && ! isDirty ) }
1038+ disabled = { loading || isAssigned }
10181039 className = "inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-white bg-gray-900 hover:bg-gray-800 rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
10191040 >
10201041 < CheckCircle size = { 14 } />
1021- { isAssigned ? ( isDirty ? 'Update' : 'Saved' ) : 'Assign' }
1042+ { isAssigned ? 'Assigned' : 'Assign' }
10221043 </ button >
10231044 < button
10241045 onClick = { ( ) => onRevoke ( user . id , connection . connectionId ) }
0 commit comments