diff --git a/src/components/SimpleBrowseDataTable.tsx b/src/components/SimpleBrowseDataTable.tsx index 80cba700e..269d9410d 100644 --- a/src/components/SimpleBrowseDataTable.tsx +++ b/src/components/SimpleBrowseDataTable.tsx @@ -101,7 +101,7 @@ export function SimpleBrowseDataTable({ {children}
- +
{table.getHeaderGroups().map((headerGroup) => ( @@ -111,7 +111,7 @@ export function SimpleBrowseDataTable({ ))} - + {table.getRowModel().rows?.length ? (table.getRowModel().rows.map((row) => ( & z.infer & { organizationId: string }; - -export type OrganizationRoleOverviewType = z.infer; -export type OrganizationRoleSpecificPermissionsType = z.infer; diff --git a/src/features/organization/roles/components/RolePermissionsEditor.tsx b/src/features/organization/roles/components/RolePermissionsEditor.tsx new file mode 100644 index 000000000..42165132d --- /dev/null +++ b/src/features/organization/roles/components/RolePermissionsEditor.tsx @@ -0,0 +1,316 @@ +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { deletedClusterStatuses } from '@/config/clusterStatuses'; +import { + ClusterAccess, + clusterRowFlags, + CrudFlags, + parsePermissionsJson, + readClusterAccess, + RolePermissions, + toJson, + writeClusters, +} from '@/features/organization/roles/rolePermissions'; +import { useMonacoTheme } from '@/hooks/useMonacoTheme'; +import { Editor } from '@/lib/monaco/MonacoEditor'; +import { useId, useRef, useState } from 'react'; + +const ACTIONS = ['view', 'create', 'update', 'delete'] as const; +const ACTION_LABELS: Record<(typeof ACTIONS)[number], string> = { + view: 'View', + create: 'Create', + update: 'Update', + delete: 'Delete', +}; + +function Checkbox( + { checked, disabled, onChange, label, id, describedBy }: { + checked: boolean; + disabled?: boolean; + onChange: (checked: boolean) => void; + label?: string; + id?: string; + describedBy?: string; + }, +) { + return ( + onChange(event.target.checked)} + /> + ); +} + +function OrganizationPermission( + { label, description, checked, disabled, onChange }: { + label: string; + description: string; + checked: boolean; + disabled: boolean; + onChange: (checked: boolean) => void; + }, +) { + const id = useId(); + return ( +
+
+ +
+
+ +

{description}

+
+
+ ); +} + +type EditableAccess = Exclude; + +function PermissionsForm( + { value, onChange, clusters, disabled }: { + value: RolePermissions; + onChange: (value: RolePermissions) => void; + clusters: Array<{ id: string; name?: string; status?: string }>; + disabled: boolean; + }, +) { + const [access, setAccess] = useState(() => readClusterAccess(value)); + // Kept here as well as in `value`: with "selected clusters" and none chosen, central manager's fields + // have nowhere to hold the row's flags. + const [clusterRow, setClusterRow] = useState(() => clusterRowFlags(value, readClusterAccess(value))); + const isCustom = access.scope === 'custom'; + const clustersLocked = disabled || isCustom; + const selectedIds = access.scope === 'selected' ? access.clusterIds : []; + // A deleted cluster stays listed while the role still names it, so unticking it is the only way it goes. + const listedClusters = clusters.filter((cluster) => + !deletedClusterStatuses.includes(cluster.status ?? '') || selectedIds.includes(cluster.id) + ); + + const updateClusters = (row: CrudFlags, nextAccess: EditableAccess) => { + setClusterRow(row); + setAccess(nextAccess); + onChange(writeClusters(value, row, nextAccess)); + }; + + return ( +
+
+

Organization

+
+ onChange({ ...value, update: checked })} + /> + onChange({ ...value, delete: checked })} + /> +
+
+ +
+

Resource permissions

+
+ + + Resource + {ACTIONS.map((action) => ( + {ACTION_LABELS[action]} + ))} + + + + + Roles + {ACTIONS.map((action) => ( + + onChange({ ...value, roles: { ...value.roles, [action]: checked } })} + /> + + ))} + + + Clusters + {ACTIONS.map((action) => ( + + + updateClusters({ ...clusterRow, [action]: checked }, access as EditableAccess)} + /> + + ))} + + +
+ + + +
+

Cluster access

+ {isCustom + ? ( +

+ This role sets permissions cluster by cluster in a way this form can’t show. Edit them in Advanced JSON. +

+ ) + : ( + <> +
+ +
+

+ {access.scope === 'selected' + ? 'View, Update and Delete apply only to the clusters chosen here. Create always applies to the whole organization.' + : 'The Clusters permissions apply to every cluster in the organization.'} +

+ {access.scope === 'selected' && ( + listedClusters.length === 0 + ?

This organization has no clusters yet.

+ : ( + + ) + )} + + )} +
+ + ); +} + +/** + * A role's permissions as a form, or as the JSON central manager stores. Both edit the same value; JSON that + * doesn't parse or describe a role keeps you on that tab and is reported through `onValidChange`. + */ +export function RolePermissionsEditor( + { value, onChange, clusters, disabled = false, onValidChange }: { + value: RolePermissions; + onChange: (value: RolePermissions) => void; + clusters: Array<{ id: string; name?: string; status?: string }>; + disabled?: boolean; + onValidChange?: (valid: boolean) => void; + }, +) { + const monacoTheme = useMonacoTheme(); + const [tab, setTab] = useState<'form' | 'json'>('form'); + const [jsonText, setJsonText] = useState(() => toJson(value)); + const [jsonError, setJsonError] = useState(null); + // Remounting the form re-reads cluster access from whatever the JSON tab left behind. + const [formVersion, setFormVersion] = useState(0); + const formPanelRef = useRef(null); + // The JSON tab takes the form's height, so switching tabs doesn't move the dialog's footer. + const [jsonPanelHeight, setJsonPanelHeight] = useState(); + + const onTabChange = (next: string) => { + if (next === 'json') { + setJsonPanelHeight(formPanelRef.current?.offsetHeight); + setJsonText(toJson(value)); + setJsonError(null); + setTab('json'); + } else if (!jsonError) { + setFormVersion((version) => version + 1); + setTab('form'); + } + }; + + const onJsonChange = (text: string | undefined) => { + const next = text ?? ''; + setJsonText(next); + const parsed = parsePermissionsJson(next); + setJsonError(parsed.ok ? null : parsed.error); + onValidChange?.(parsed.ok); + if (parsed.ok) { onChange(parsed.permissions); } + }; + + return ( + + + Permissions + Advanced JSON + + + + + +
+ +
+ {jsonError && ( +

+ {jsonError} Fix it to save or to go back to Permissions. +

+ )} +
+
+ ); +} diff --git a/src/features/organization/roles/index.test.tsx b/src/features/organization/roles/index.test.tsx new file mode 100644 index 000000000..e16cf347c --- /dev/null +++ b/src/features/organization/roles/index.test.tsx @@ -0,0 +1,73 @@ +/** + * @vitest-environment jsdom + */ +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { navigate, permissions } = vi.hoisted(() => ({ + navigate: vi.fn(), + permissions: { value: { create: true } }, +})); + +vi.mock('sonner', () => ({ toast: { loading: vi.fn(), success: vi.fn() } })); +vi.mock('@tanstack/react-router', () => ({ + useParams: () => ({ organizationId: 'org-1' }), + useNavigate: () => navigate, +})); +vi.mock('@tanstack/react-query', async (importOriginal) => ({ + ...(await importOriginal()), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useSuspenseQuery: () => ({ + data: [ + { id: 'role-admin', roleName: 'admin', userIds: ['u1', 'u2'] }, + { id: 'role-ci', roleName: 'deployers', userIds: [] }, + ], + refetch: vi.fn(), + isFetching: false, + isRefetching: false, + }), +})); +vi.mock('@/hooks/usePermissions', () => ({ useOrganizationRolePermissions: () => permissions.value })); +vi.mock('@/components/SubNavMenu', () => ({ SubNavMenu: () => null })); +vi.mock('@/features/organization/components/OrgPageLayout', () => ({ + OrgPageLayout: ({ children }: { children: ReactNode }) => <>{children}, +})); +vi.mock('./modals/AddOrganizationRoleModal', () => ({ AddOrganizationRoleModal: () => null })); +vi.mock('./modals/EditOrganizationRoleModal', () => ({ EditOrganizationRoleModal: () => null })); + +import { OrgConfigRolesIndex } from './index'; + +beforeEach(() => { + navigate.mockReset(); + permissions.value = { create: true }; +}); + +afterEach(cleanup); + +describe('OrgConfigRolesIndex', () => { + it('lists each role in the shared table, with its id and how many users have it', () => { + render(); + expect(screen.getByRole('heading', { name: 'Roles' })).toBeTruthy(); + const [, admin, deployers] = screen.getAllByRole('row'); + expect(within(admin).getAllByRole('cell').map((cell) => cell.textContent)).toEqual(['admin', 'role-admin', '2']); + expect(within(deployers).getAllByRole('cell')[2].textContent).toBe('No users assigned'); + }); + + it('opens a role from its row', async () => { + render(); + await act(async () => { + fireEvent.click(screen.getByText('deployers')); + }); + expect(navigate).toHaveBeenCalledWith({ to: 'role-ci' }); + }); + + it('offers to add a role only to someone who can create roles', () => { + render(); + expect(screen.getByRole('button', { name: 'Add' })).toBeTruthy(); + cleanup(); + permissions.value = { create: false }; + render(); + expect(screen.queryByRole('button', { name: 'Add' })).toBeNull(); + }); +}); diff --git a/src/features/organization/roles/index.tsx b/src/features/organization/roles/index.tsx index c9ba93bb6..599802e82 100644 --- a/src/features/organization/roles/index.tsx +++ b/src/features/organization/roles/index.tsx @@ -74,6 +74,10 @@ export function OrgConfigRolesIndex() { <> +
+

Roles

+

Manage access to this organization and its clusters.

+
}> ({ Editor: () => null })); -vi.mock('@/hooks/useMonacoTheme', () => ({ useMonacoTheme: () => 'light' })); +const { mutate } = vi.hoisted(() => ({ mutate: vi.fn() })); vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })); vi.mock('@tanstack/react-router', () => ({ useParams: () => ({ organizationId: 'org-1' }) })); vi.mock('@tanstack/react-query', async (importOriginal) => ({ ...(await importOriginal()), useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useQuery: () => ({ + data: { + clusters: [ + { id: 'clu-a', name: 'alpha', status: 'RUNNING' }, + { id: 'clu-b', name: 'beta', status: 'RUNNING' }, + { id: 'clu-c', name: 'gamma', status: 'TERMINATED' }, + ], + }, + }), })); -const { mutate } = vi.hoisted(() => ({ mutate: vi.fn() })); vi.mock('@/features/organization/mutations/addOrganizationRole', () => ({ useAddOrganizationRole: () => ({ mutate, isPending: false }), })); +vi.mock('@/components/ui/select', () => selectStub); +vi.mock('@/hooks/useMonacoTheme', () => ({ useMonacoTheme: () => 'light' })); +vi.mock('@/lib/monaco/MonacoEditor', () => ({ + Editor: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( +