From cef7bda487ee493e2103748fb32b40d42b655f5e Mon Sep 17 00:00:00 2001 From: David C Date: Tue, 13 Oct 2026 16:08:44 -0400 Subject: [PATCH] feat(organization): manage service accounts and API tokens, and show everything a role allows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Authentication gains an admin-only API access section: the organization's Allow API tokens switch (off until an admin turns it on), and service accounts with their tokens (create and revoke tokens, change an account's role, disable or enable it, delete it), for central manager's service accounts (HarperFast/central-manager#576). The role dialog shows everything a role allows in central manager's terms: organization update and delete, a View/Create/Update/Delete matrix for Roles and Clusters, cluster access (all or selected clusters, exactly as clusters.resources expresses it, anything else shown as custom), and an Advanced JSON tab that shares the form's value and is validated before it can be saved or left. The Roles list stays on the shared table, with a heading. Refs #1791 Co-Authored-By: Claude Opus 5.5 --- src/components/SimpleBrowseDataTable.tsx | 4 +- .../mutations/OrganizationRoleFormSchema.ts | 6 +- .../components/RolePermissionsEditor.tsx | 316 ++++++++++++++++ .../organization/roles/index.test.tsx | 73 ++++ src/features/organization/roles/index.tsx | 4 + .../modals/AddOrganizationRoleModal.test.tsx | 215 +++++++++-- .../roles/modals/AddOrganizationRoleModal.tsx | 234 ++++-------- .../modals/EditOrganizationRoleModal.test.tsx | 218 +++++++++-- .../modals/EditOrganizationRoleModal.tsx | 338 +++++------------ .../roles/rolePermissions.test.ts | 136 +++++++ .../organization/roles/rolePermissions.ts | 165 ++++++++ .../ServiceAccountsSection.tsx | 254 +++++++++++++ .../__tests__/ServiceAccountsSection.test.tsx | 357 ++++++++++++++++++ .../serviceAccounts/__tests__/selectStub.tsx | 31 ++ .../components/RevokeApiTokenButton.test.tsx | 63 ++++ .../components/RevokeApiTokenButton.tsx | 55 +++ .../components/ServiceAccountTokens.tsx | 152 ++++++++ .../serviceAccounts/components/Timestamp.tsx | 26 ++ .../serviceAccounts/lib/timeText.test.ts | 23 ++ .../serviceAccounts/lib/timeText.ts | 24 ++ .../modals/ChangeServiceAccountRoleModal.tsx | 62 +++ .../modals/CreateApiTokenModal.test.tsx | 255 +++++++++++++ .../modals/CreateApiTokenModal.tsx | 254 +++++++++++++ .../modals/CreateServiceAccountModal.test.tsx | 115 ++++++ .../modals/CreateServiceAccountModal.tsx | 159 ++++++++ .../createOrganizationApiToken.test.ts | 62 +++ .../mutations/createOrganizationApiToken.ts | 24 ++ .../mutations/revokeOrganizationApiToken.ts | 25 ++ .../mutations/serviceAccounts.test.ts | 123 ++++++ .../mutations/serviceAccounts.ts | 77 ++++ .../queries/serviceAccounts.ts | 29 ++ .../settings/ApiAccessSettings.test.tsx | 39 ++ .../settings/ApiAccessSettings.tsx | 20 + .../settings/ApiTokensSetting.test.tsx | 65 ++++ .../settings/ApiTokensSetting.tsx | 47 +++ .../organization/settings/SettingsSection.tsx | 22 ++ .../organization/settings/index.test.tsx | 3 +- src/features/organization/settings/index.tsx | 49 ++- src/hooks/usePermissions.test.ts | 36 ++ src/hooks/usePermissions.ts | 21 ++ src/integrations/api/api.patch.d.ts | 33 ++ 41 files changed, 3735 insertions(+), 479 deletions(-) create mode 100644 src/features/organization/roles/components/RolePermissionsEditor.tsx create mode 100644 src/features/organization/roles/index.test.tsx create mode 100644 src/features/organization/roles/rolePermissions.test.ts create mode 100644 src/features/organization/roles/rolePermissions.ts create mode 100644 src/features/organization/serviceAccounts/ServiceAccountsSection.tsx create mode 100644 src/features/organization/serviceAccounts/__tests__/ServiceAccountsSection.test.tsx create mode 100644 src/features/organization/serviceAccounts/__tests__/selectStub.tsx create mode 100644 src/features/organization/serviceAccounts/components/RevokeApiTokenButton.test.tsx create mode 100644 src/features/organization/serviceAccounts/components/RevokeApiTokenButton.tsx create mode 100644 src/features/organization/serviceAccounts/components/ServiceAccountTokens.tsx create mode 100644 src/features/organization/serviceAccounts/components/Timestamp.tsx create mode 100644 src/features/organization/serviceAccounts/lib/timeText.test.ts create mode 100644 src/features/organization/serviceAccounts/lib/timeText.ts create mode 100644 src/features/organization/serviceAccounts/modals/ChangeServiceAccountRoleModal.tsx create mode 100644 src/features/organization/serviceAccounts/modals/CreateApiTokenModal.test.tsx create mode 100644 src/features/organization/serviceAccounts/modals/CreateApiTokenModal.tsx create mode 100644 src/features/organization/serviceAccounts/modals/CreateServiceAccountModal.test.tsx create mode 100644 src/features/organization/serviceAccounts/modals/CreateServiceAccountModal.tsx create mode 100644 src/features/organization/serviceAccounts/mutations/createOrganizationApiToken.test.ts create mode 100644 src/features/organization/serviceAccounts/mutations/createOrganizationApiToken.ts create mode 100644 src/features/organization/serviceAccounts/mutations/revokeOrganizationApiToken.ts create mode 100644 src/features/organization/serviceAccounts/mutations/serviceAccounts.test.ts create mode 100644 src/features/organization/serviceAccounts/mutations/serviceAccounts.ts create mode 100644 src/features/organization/serviceAccounts/queries/serviceAccounts.ts create mode 100644 src/features/organization/settings/ApiAccessSettings.test.tsx create mode 100644 src/features/organization/settings/ApiAccessSettings.tsx create mode 100644 src/features/organization/settings/ApiTokensSetting.test.tsx create mode 100644 src/features/organization/settings/ApiTokensSetting.tsx create mode 100644 src/features/organization/settings/SettingsSection.tsx 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)} + /> + + ))} + + +
+
    +
  • + Role permissions also control membership: Create allows adding members and inviting people; Delete allows + removing members. +
  • +
  • Cluster Update also allows running operations.
  • +
+ + +
+

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.

+ : ( +
    + {listedClusters.map((cluster) => ( +
  • + +
  • + ))} +
+ ) + )} + + )} +
+ + ); +} + +/** + * 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 }) => ( +