diff --git a/apps/app-frontend/src/shell/AppShell.tsx b/apps/app-frontend/src/shell/AppShell.tsx index 119954b2..9b923bf8 100644 --- a/apps/app-frontend/src/shell/AppShell.tsx +++ b/apps/app-frontend/src/shell/AppShell.tsx @@ -4,6 +4,7 @@ import { Page } from '@patternfly/react-core'; import ErrorBoundary from '@osac/ui-components/components/ErrorBoundary/ErrorBoundary'; import IdentityProviderRoutes from '@osac/ui-components/components/IdentityProvider/IdentityProviderRoutes'; +import RoleBindingRoutes from '@osac/ui-components/components/RoleBinding/RoleBindingRoutes'; import { VmDetailsPage } from '@osac/ui-components/components/vm/VmDetailsPage'; import { useSession } from '@osac/ui-components/hooks/use-session'; import { SecurityGroupDetailPage } from '@osac/ui-components/pages/networking/SecurityGroupDetailPage'; @@ -73,6 +74,14 @@ export const AppShell = ({ logout }: { logout: () => Promise }) => { } /> + + + + } + /> { } }); - it('IDP administration shows up only for idp manager', () => { - expect(findSection('tenant-idp-manager', 'nav-tenant-administration')).toBeDefined(); - for (const role of ['tenant-user', 'tenant-admin', 'admin'] as UserRole[]) { - expect(findSection(role, 'nav-tenant-administration')).toBeUndefined(); + it('Tenant section shows up for admin, tenant-admin, and tenant-idp-manager', () => { + for (const role of ['admin', 'tenant-admin', 'tenant-idp-manager'] as UserRole[]) { + const section = findSection(role, 'nav-tenant-administration'); + expect(section).toBeDefined(); + expect(section?.children.map((c) => c.id)).toContain('idp'); + expect(section?.children.map((c) => c.id)).toContain('role-bindings'); } + expect(findSection('tenant-user', 'nav-tenant-administration')).toBeUndefined(); }); }); diff --git a/apps/app-frontend/src/shell/shellNav.ts b/apps/app-frontend/src/shell/shellNav.ts index 6ab8e212..e3040ed9 100644 --- a/apps/app-frontend/src/shell/shellNav.ts +++ b/apps/app-frontend/src/shell/shellNav.ts @@ -12,14 +12,17 @@ type NavSection = { children: NavLink[]; }; -const getIdpManagerNav = (t: TFunction): NavSection[] => [ - { - kind: 'section', - sectionId: 'nav-tenant-administration', - label: t('Tenant'), - children: [{ id: 'idp', label: t('Identity providers'), path: '/tenant/identity-provider' }], - }, -]; +const getTenantAdminSection = (t: TFunction): NavSection => ({ + kind: 'section', + sectionId: 'nav-tenant-administration', + label: t('Tenant'), + children: [ + { id: 'idp', label: t('Identity providers'), path: '/tenant/identity-provider' }, + { id: 'role-bindings', label: t('Role Bindings'), path: '/tenant/role-binding' }, + ], +}); + +const getIdpManagerNav = (t: TFunction): NavSection[] => [getTenantAdminSection(t)]; const getAdminNav = (t: TFunction): NavSection[] => [ { @@ -45,6 +48,12 @@ const getAdminNav = (t: TFunction): NavSection[] => [ }, ], }, + getTenantAdminSection(t), + ...getBaseNav(t), +]; + +const getTenantAdminNav = (t: TFunction): NavSection[] => [ + getTenantAdminSection(t), ...getBaseNav(t), ]; @@ -88,5 +97,9 @@ export const navRowsForRole = (role: UserRole, t: TFunction): NavSection[] => { return getIdpManagerNav(t); } + if (role === 'tenant-admin') { + return getTenantAdminNav(t); + } + return getBaseNav(t); }; diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index d2595ed3..8ebfe198 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -1,4 +1,6 @@ { + "{{count}} user_one": "{{count}} user", + "{{count}} user_other": "{{count}} users", "Actions": "Actions", "Actions for {{name}}": "Actions for {{name}}", "Active": "Active", @@ -15,6 +17,7 @@ "Are you sure you want to enable Identity provider {{idpName}}": "Are you sure you want to enable Identity provider {{idpName}}", "At least one CIDR (IPv4 or IPv6) is required": "At least one CIDR (IPv4 or IPv6) is required", "At least one node set is required": "At least one node set is required", + "At least one user is required": "At least one user is required", "Attach": "Attach", "Attach external IP": "Attach external IP", "Authorization URL": "Authorization URL", @@ -133,6 +136,7 @@ "Create Identity provider": "Create Identity provider", "Create Identity provider steps": "Create Identity provider steps", "Create instance type": "Create instance type", + "Create role binding": "Create role binding", "Create security group": "Create security group", "Create storage backend": "Create storage backend", "Create storage tier": "Create storage tier", @@ -147,6 +151,7 @@ "Defined": "Defined", "Delete": "Delete", "Delete {{name}}?": "Delete {{name}}?", + "Delete role binding?": "Delete role binding?", "Delete rule": "Delete rule", "Delete rule?": "Delete rule?", "Delete security group": "Delete security group", @@ -198,6 +203,7 @@ "Failed to attach external IP": "Failed to attach external IP", "Failed to connect to the console.": "Failed to connect to the console.", "Failed to create resource": "Failed to create resource", + "Failed to create role binding": "Failed to create role binding", "Failed to create storage tier": "Failed to create storage tier", "Failed to create subnet": "Failed to create subnet", "Failed to create tenant": "Failed to create tenant", @@ -207,6 +213,7 @@ "Failed to delete compute instance": "Failed to delete compute instance", "Failed to delete Identity provider": "Failed to delete Identity provider", "Failed to delete instance type": "Failed to delete instance type", + "Failed to delete role binding": "Failed to delete role binding", "Failed to delete storage backend": "Failed to delete storage backend", "Failed to delete storage tier": "Failed to delete storage tier", "Failed to delete tenant": "Failed to delete tenant", @@ -218,6 +225,7 @@ "Failed to fetch host types": "Failed to fetch host types", "Failed to fetch Identity provider": "Failed to fetch Identity provider", "Failed to fetch instance type": "Failed to fetch instance type", + "Failed to fetch role binding": "Failed to fetch role binding", "Failed to fetch security groups": "Failed to fetch security groups", "Failed to fetch subnet": "Failed to fetch subnet", "Failed to fetch virtual network": "Failed to fetch virtual network", @@ -231,6 +239,7 @@ "Failed to retrieve break-glass credentials.": "Failed to retrieve break-glass credentials.", "Failed to start virtual machine": "Failed to start virtual machine", "Failed to stop virtual machine": "Failed to stop virtual machine", + "Failed to update role binding": "Failed to update role binding", "Filter bare metal instances by name": "Filter bare metal instances by name", "Filter catalog by keyword": "Filter catalog by keyword", "Filter catalog by resource type": "Filter catalog by resource type", @@ -276,6 +285,7 @@ "Manage firewall rules for your virtual networks.": "Manage firewall rules for your virtual networks.", "Manage identity providers for your tenant.": "Manage identity providers for your tenant.", "Manage provider-defined instance types for this cloud platform.": "Manage provider-defined instance types for this cloud platform.", + "Manage role bindings for users.": "Manage role bindings for users.", "Manage storage backends and tiers for this cloud platform.": "Manage storage backends and tiers for this cloud platform.", "Manage tenants for this cloud platform.": "Manage tenants for this cloud platform.", "Manage virtual networks for your compute instances.": "Manage virtual networks for your compute instances.", @@ -313,6 +323,7 @@ "No node sets configured.": "No node sets configured.", "No outbound rules yet. Add one to allow outgoing traffic.": "No outbound rules yet. Add one to allow outgoing traffic.", "No published catalog items are available yet.": "No published catalog items are available yet.", + "No role bindings available.": "No role bindings available.", "No security groups match your search.": "No security groups match your search.", "No security groups yet. Create one to get started.": "No security groups yet. Create one to get started.", "No storage backends yet. Create one to get started.": "No storage backends yet. Create one to get started.", @@ -376,6 +387,9 @@ "Restart": "Restart", "Retry": "Retry", "Review": "Review", + "Role": "Role", + "Role Bindings": "Role Bindings", + "Role is required": "Role is required", "Running": "Running", "Save": "Save", "Save these credentials now — they cannot be retrieved later.": "Save these credentials now — they cannot be retrieved later.", @@ -420,6 +434,7 @@ "TCP": "TCP", "Tenant": "Tenant", "Tenant admin": "Tenant admin", + "Tenant is required": "Tenant is required", "Tenant user": "Tenant user", "Tenant: {{ tenantId }}": "Tenant: {{ tenantId }}", "Tenants": "Tenants", @@ -433,6 +448,7 @@ "This permanently deletes the compute instance. This action cannot be undone.": "This permanently deletes the compute instance. This action cannot be undone.", "This permanently deletes the Identity provider and all its resources. This action cannot be undone.": "This permanently deletes the Identity provider and all its resources. This action cannot be undone.", "This permanently deletes the instance type. This action cannot be undone.": "This permanently deletes the instance type. This action cannot be undone.", + "This permanently deletes the role binding. Users will lose the permissions granted by this binding. This action cannot be undone.": "This permanently deletes the role binding. Users will lose the permissions granted by this binding. This action cannot be undone.", "This permanently deletes the storage backend. This action cannot be undone.": "This permanently deletes the storage backend. This action cannot be undone.", "This permanently deletes the storage tier. This action cannot be undone.": "This permanently deletes the storage tier. This action cannot be undone.", "This permanently deletes the tenant and all its resources. This action cannot be undone.": "This permanently deletes the tenant and all its resources. This action cannot be undone.", @@ -449,6 +465,8 @@ "Unauthorized": "Unauthorized", "Unknown": "Unknown", "Unspecified": "Unspecified", + "Update": "Update", + "Update role binding": "Update role binding", "Use IPv4 CIDR notation (for example 10.128.0.0/14).": "Use IPv4 CIDR notation (for example 10.128.0.0/14).", "Use IPv4 CIDR notation (for example 172.30.0.0/16).": "Use IPv4 CIDR notation (for example 172.30.0.0/16).", "User data": "User data", @@ -457,6 +475,7 @@ "User data must not exceed 64 KB.": "User data must not exceed 64 KB.", "User info URL": "User info URL", "Username": "Username", + "Users": "Users", "View and manage your bare metal instances.": "View and manage your bare metal instances.", "View password": "View password", "Virtual machine conditions": "Virtual machine conditions", diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index b386db17..72c6e31f 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -60,3 +60,8 @@ export * from './osac/public/v1/baremetal_instance_catalog_items_service_pb.js'; export * from './osac/public/v1/identity_provider_type_pb.js'; export * from './osac/public/v1/identity_providers_service_pb.js'; + +export * from './osac/public/v1/role_type_pb.js'; +export * from './osac/public/v1/roles_service_pb.js'; +export * from './osac/public/v1/role_binding_type_pb.js'; +export * from './osac/public/v1/role_bindings_service_pb.js'; diff --git a/libs/ui-components/src/api/types.ts b/libs/ui-components/src/api/types.ts index 2bf0463c..ee9a5afc 100644 --- a/libs/ui-components/src/api/types.ts +++ b/libs/ui-components/src/api/types.ts @@ -31,7 +31,9 @@ export type ApiRoute = | 'v1/private/tenants' | 'v1/private/storage_backends' | 'v1/private/storage_tiers' - | 'v1/identity_providers'; + | 'v1/identity_providers' + | 'v1/roles' + | 'v1/role_bindings'; /** * Strict 3-part tuple that encodes an API address. diff --git a/libs/ui-components/src/api/v1/private/tenant.ts b/libs/ui-components/src/api/v1/private/tenant.ts index 26ca1fef..6704cd60 100644 --- a/libs/ui-components/src/api/v1/private/tenant.ts +++ b/libs/ui-components/src/api/v1/private/tenant.ts @@ -10,12 +10,13 @@ import { type ApiQueryClient, useApiQuery, useApiQueryClient } from '../../use-a const invalidateTenantsQueries = (qc: ApiQueryClient) => qc.invalidateQueries({ queryKey: apiQueryKey('v1/private/tenants') }); -export const useTenants = (params: ListParams = {}) => { +export const useTenants = (params: ListParams = {}, disabled?: boolean) => { const client = useApiFetch(Tenants); return useApiQuery({ queryKey: apiQueryKey('v1/private/tenants', undefined, params), queryFn: () => client.list(params), select: (data) => data.items, + enabled: !disabled, }); }; diff --git a/libs/ui-components/src/api/v1/role-binding.ts b/libs/ui-components/src/api/v1/role-binding.ts new file mode 100644 index 00000000..0f36eafa --- /dev/null +++ b/libs/ui-components/src/api/v1/role-binding.ts @@ -0,0 +1,84 @@ +import { type MessageInitShape } from '@bufbuild/protobuf'; +import { useMutation } from '@tanstack/react-query'; + +import { type RoleBindingSchema, RoleBindings } from '@osac/types'; + +import { useApiFetch } from '../api-context'; +import { type ListParams, apiQueryKey } from '../types'; +import { type ApiQueryClient, useApiQuery, useApiQueryClient } from '../use-api-query'; +import { buildUpdateMaskPaths } from './update-mask'; + +const invalidateRoleBindingQueries = (qc: ApiQueryClient) => + qc.invalidateQueries({ queryKey: apiQueryKey('v1/role_bindings') }); + +export const useRoleBindings = (params: ListParams = {}) => { + const client = useApiFetch(RoleBindings); + return useApiQuery({ + queryKey: apiQueryKey('v1/role_bindings', undefined, params), + queryFn: () => client.list(params), + select: (data) => data.items, + }); +}; + +export const useRoleBinding = (id?: string) => { + const client = useApiFetch(RoleBindings); + return useApiQuery({ + queryKey: apiQueryKey('v1/role_bindings', [id]), + queryFn: () => client.get({ id }), + select: (data) => data.object, + enabled: !!id, + }); +}; + +export const useCreateRoleBinding = () => { + const client = useApiFetch(RoleBindings); + const qc = useApiQueryClient(); + return useMutation({ + mutationFn: async (body: MessageInitShape) => { + const resp = await client.create({ object: body }); + if (!resp.object) { + throw new Error('Create response missing role binding object'); + } + return resp.object; + }, + onSuccess: () => invalidateRoleBindingQueries(qc), + }); +}; + +export const useUpdateRoleBinding = () => { + const client = useApiFetch(RoleBindings); + const qc = useApiQueryClient(); + return useMutation({ + mutationFn: async ({ + id, + body, + }: { + id: string; + body: MessageInitShape; + }) => { + const resp = await client.update({ + object: { + id, + ...body, + }, + updateMask: { + paths: buildUpdateMaskPaths(body), + }, + }); + if (!resp.object) { + throw new Error('Update response missing role binding object'); + } + return resp.object; + }, + onSuccess: () => invalidateRoleBindingQueries(qc), + }); +}; + +export const useDeleteRoleBinding = () => { + const client = useApiFetch(RoleBindings); + const qc = useApiQueryClient(); + return useMutation({ + mutationFn: (id: string) => client.delete({ id }), + onSuccess: () => invalidateRoleBindingQueries(qc), + }); +}; diff --git a/libs/ui-components/src/api/v1/role.ts b/libs/ui-components/src/api/v1/role.ts new file mode 100644 index 00000000..c4964fdc --- /dev/null +++ b/libs/ui-components/src/api/v1/role.ts @@ -0,0 +1,14 @@ +import { Roles } from '@osac/types'; + +import { useApiFetch } from '../api-context'; +import { type ListParams, apiQueryKey } from '../types'; +import { useApiQuery } from '../use-api-query'; + +export const useRoles = (params: ListParams = {}) => { + const client = useApiFetch(Roles); + return useApiQuery({ + queryKey: apiQueryKey('v1/roles', undefined, params), + queryFn: () => client.list(params), + select: (data) => data.items, + }); +}; diff --git a/libs/ui-components/src/api/v1/user.ts b/libs/ui-components/src/api/v1/user.ts index ba319ff5..43d73404 100644 --- a/libs/ui-components/src/api/v1/user.ts +++ b/libs/ui-components/src/api/v1/user.ts @@ -4,11 +4,14 @@ import { useApiFetch } from '../api-context'; import { type ListParams, apiQueryKey } from '../types'; import { useApiQuery } from '../use-api-query'; -export const useUsers = (params: ListParams = {}) => { +export const getTenantUsersFilter = (tenantId: string) => `this.metadata.tenant == "${tenantId}"`; + +export const useUsers = (params: ListParams = {}, disabled?: boolean) => { const client = useApiFetch(Users); return useApiQuery({ queryKey: apiQueryKey('v1/users', undefined, params), queryFn: () => client.list(params), select: (data) => data.items, + enabled: !disabled, }); }; diff --git a/libs/ui-components/src/components/IdentityProvider/CreateWizard/payload.ts b/libs/ui-components/src/components/IdentityProvider/CreateWizard/payload.ts index 876cfe5b..4e3c97cc 100644 --- a/libs/ui-components/src/components/IdentityProvider/CreateWizard/payload.ts +++ b/libs/ui-components/src/components/IdentityProvider/CreateWizard/payload.ts @@ -37,6 +37,7 @@ export const buildIdpCreatePayload = ( ...values.spec.config, }, }, + enabled: true, }, }; }; diff --git a/libs/ui-components/src/components/RoleBinding/CreatePage/RoleBindingCreatePage.tsx b/libs/ui-components/src/components/RoleBinding/CreatePage/RoleBindingCreatePage.tsx new file mode 100644 index 00000000..56700d81 --- /dev/null +++ b/libs/ui-components/src/components/RoleBinding/CreatePage/RoleBindingCreatePage.tsx @@ -0,0 +1,239 @@ +import { useNavigate, useParams } from 'react-router-dom'; +import { + ActionList, + ActionListGroup, + ActionListItem, + Alert, + Breadcrumb, + BreadcrumbItem, + Bullseye, + Button, + PageSection, + Spinner, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import { Formik, useFormikContext } from 'formik'; + +import { RoleBinding } from '@osac/types'; + +import { getRoleBindingSpec } from './payload'; +import { validationSchema } from './validation'; +import { RoleBindingCreateFormValues, getInitialValues } from './values'; +import { useTenants } from '../../../api/v1/private/tenant'; +import { useRoles } from '../../../api/v1/role'; +import { + useCreateRoleBinding, + useRoleBinding, + useUpdateRoleBinding, +} from '../../../api/v1/role-binding'; +import { getTenantUsersFilter, useUsers } from '../../../api/v1/user'; +import { useSession } from '../../../hooks/use-session'; +import { useTranslation } from '../../../hooks/useTranslation'; +import { getErrorMessage } from '../../../utils/error'; +import NameField from '../../catalogProvision/wizard/fields/NameField'; +import LeaveFormConfirmation from '../../Form/LeaveFormConfirmation'; +import { MultiSelectField } from '../../Form/MultiSelectField'; +import OsacForm from '../../Form/OsacForm'; +import { SelectField } from '../../Form/SelectField'; + +const RoleBindingCreateForm = ({ isEdit }: { isEdit: boolean }) => { + const { t } = useTranslation(); + const { values } = useFormikContext(); + const { role: sessionRole } = useSession(); + + const isAdmin = sessionRole === 'admin'; + + const { data: tenants = [], isLoading: tenantsLoading } = useTenants({}, !isAdmin); + + const { data: users = [], isLoading: usersLoading } = useUsers( + { filter: getTenantUsersFilter(values.metadata.tenant) }, + !values.metadata.tenant, + ); + + const { data: roles = [], isLoading: rolesLoading } = useRoles(); + + return ( + + {isAdmin && ( + ({ + label: tenant.metadata?.name || tenant.id, + value: tenant.id, + }))} + isDisabled={isEdit} + /> + )} + + ({ + label: user.spec?.username || user.metadata?.name || user.id, + value: user.metadata?.name || user.id, + }))} + /> + ({ + label: role.spec?.title || role.metadata?.name || role.id, + value: role.metadata?.name || role.id, + }))} + /> + + ); +}; + +interface RoleBindingCreatePageProps { + roleBinding?: RoleBinding; +} + +const RoleBindingCreatePageInner = ({ roleBinding }: RoleBindingCreatePageProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { tenantId } = useSession(); + const { mutateAsync: create, error: createErr } = useCreateRoleBinding(); + const { mutateAsync: update, error: updateErr } = useUpdateRoleBinding(); + + const initialValues = getInitialValues(roleBinding, tenantId); + + const navigateToList = () => navigate('/tenant/role-binding'); + + const onSubmit = async (values: RoleBindingCreateFormValues) => { + try { + if (roleBinding) { + await update({ + id: roleBinding.id, + body: { + spec: getRoleBindingSpec(values), + }, + }); + } else { + await create({ + metadata: values.metadata, + spec: getRoleBindingSpec(values), + }); + } + + navigateToList(); + } catch { + // tanstack handles the err + } + }; + + return ( + <> + + + + + + + {roleBinding && ( + {roleBinding.metadata?.name || roleBinding.id} + )} + {roleBinding ? t('Edit') : t('Create')} + + + {roleBinding ? t('Update role binding') : t('Create role binding')} + + + + + + initialValues={initialValues} + validationSchema={validationSchema(t)} + onSubmit={onSubmit} + > + {({ isSubmitting, submitForm }) => ( + <> + + + + + + {createErr && ( + + + {getErrorMessage(createErr)} + + + )} + {updateErr && ( + + + {getErrorMessage(updateErr)} + + + )} + + + + + + + + + + + + + + + )} + + + + ); +}; + +const RoleBindingCreatePage = () => { + const { t } = useTranslation(); + const { id } = useParams<{ id: string }>(); + + const { data, isLoading, error } = useRoleBinding(id); + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return ( + + {getErrorMessage(error)} + + ); + } + + return ; +}; + +export default RoleBindingCreatePage; diff --git a/libs/ui-components/src/components/RoleBinding/CreatePage/payload.ts b/libs/ui-components/src/components/RoleBinding/CreatePage/payload.ts new file mode 100644 index 00000000..ae013430 --- /dev/null +++ b/libs/ui-components/src/components/RoleBinding/CreatePage/payload.ts @@ -0,0 +1,14 @@ +import { MessageInitShape } from '@bufbuild/protobuf'; + +import { RoleBindingSpecSchema } from '@osac/types'; + +import { RoleBindingCreateFormValues } from './values'; + +export const getRoleBindingSpec = ( + values: RoleBindingCreateFormValues, +): MessageInitShape => ({ + role: { + name: values.role, + }, + users: values.users.map((u) => ({ name: u })), +}); diff --git a/libs/ui-components/src/components/RoleBinding/CreatePage/validation.ts b/libs/ui-components/src/components/RoleBinding/CreatePage/validation.ts new file mode 100644 index 00000000..3198d582 --- /dev/null +++ b/libs/ui-components/src/components/RoleBinding/CreatePage/validation.ts @@ -0,0 +1,14 @@ +import { TFunction } from 'i18next'; +import * as Yup from 'yup'; + +import { resourceNameSchema } from '@osac/ui-components/validation/resource-name'; + +export const validationSchema = (t: TFunction) => + Yup.object({ + metadata: Yup.object({ + name: resourceNameSchema(t), + tenant: Yup.string().required(t('Tenant is required')), + }), + users: Yup.array().min(1, t('At least one user is required')), + role: Yup.string().required(t('Role is required')), + }); diff --git a/libs/ui-components/src/components/RoleBinding/CreatePage/values.ts b/libs/ui-components/src/components/RoleBinding/CreatePage/values.ts new file mode 100644 index 00000000..2b11e5e9 --- /dev/null +++ b/libs/ui-components/src/components/RoleBinding/CreatePage/values.ts @@ -0,0 +1,35 @@ +import { RoleBinding } from '@osac/types'; + +export interface RoleBindingCreateFormValues { + metadata: { + name: string; + tenant: string; + }; + users: string[]; + role: string; +} + +export const getInitialValues = ( + roleBinding: RoleBinding | undefined, + tenant: string, +): RoleBindingCreateFormValues => { + if (roleBinding) { + return { + metadata: { + name: roleBinding.metadata?.name || '', + tenant: roleBinding.metadata?.tenant || '', + }, + users: roleBinding.spec?.users.map((u) => u.name) || [], + role: roleBinding.spec?.role?.name || '', + }; + } + + return { + metadata: { + name: '', + tenant, + }, + users: [], + role: '', + }; +}; diff --git a/libs/ui-components/src/components/RoleBinding/RoleBindingActionsMenu.tsx b/libs/ui-components/src/components/RoleBinding/RoleBindingActionsMenu.tsx new file mode 100644 index 00000000..bb480419 --- /dev/null +++ b/libs/ui-components/src/components/RoleBinding/RoleBindingActionsMenu.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Dropdown, DropdownItem, DropdownList, MenuToggle } from '@patternfly/react-core'; +import { EllipsisVIcon } from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon'; + +import type { RoleBinding } from '@osac/types'; + +import RoleBindingDeleteModal from './RoleBindingDeleteModal'; +import { useTranslation } from '../../hooks/useTranslation'; + +interface RoleBindingActionsMenuProps { + roleBinding: RoleBinding; +} + +const RoleBindingActionsMenu = ({ roleBinding }: RoleBindingActionsMenuProps) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const navigate = useNavigate(); + + return ( + <> + {deleteOpen && ( + setDeleteOpen(false)} + onSuccess={() => setDeleteOpen(false)} + /> + )} + ( + setOpen((o) => !o)} + aria-label={t('Actions')} + > + + + )} + popperProps={{ position: 'right' }} + > + + navigate(`${roleBinding.id}/edit`)}> + {t('Edit')} + + { + setDeleteOpen(true); + setOpen(false); + }} + > + {t('Delete')} + + + + + ); +}; + +export default RoleBindingActionsMenu; diff --git a/libs/ui-components/src/components/RoleBinding/RoleBindingDeleteModal.tsx b/libs/ui-components/src/components/RoleBinding/RoleBindingDeleteModal.tsx new file mode 100644 index 00000000..87bbc074 --- /dev/null +++ b/libs/ui-components/src/components/RoleBinding/RoleBindingDeleteModal.tsx @@ -0,0 +1,77 @@ +import { + Alert, + Button, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Stack, + StackItem, +} from '@patternfly/react-core'; + +import type { RoleBinding } from '@osac/types'; + +import { useDeleteRoleBinding } from '../../api/v1/role-binding'; +import { useTranslation } from '../../hooks/useTranslation'; +import { getErrorMessage } from '../../utils/error'; + +interface RoleBindingDeleteModalProps { + roleBinding: RoleBinding; + onClose: () => void; + onSuccess: () => void; +} + +const RoleBindingDeleteModal = ({ + roleBinding, + onClose, + onSuccess, +}: RoleBindingDeleteModalProps) => { + const { t } = useTranslation(); + const { mutate, isPending, error } = useDeleteRoleBinding(); + + return ( + + + + + + {t( + 'This permanently deletes the role binding. Users will lose the permissions granted by this binding. This action cannot be undone.', + )} + + {error && ( + + + {getErrorMessage(error)} + + + )} + + + + + + + + ); +}; + +export default RoleBindingDeleteModal; diff --git a/libs/ui-components/src/components/RoleBinding/RoleBindingRoutes.tsx b/libs/ui-components/src/components/RoleBinding/RoleBindingRoutes.tsx new file mode 100644 index 00000000..b5cd49b5 --- /dev/null +++ b/libs/ui-components/src/components/RoleBinding/RoleBindingRoutes.tsx @@ -0,0 +1,16 @@ +import { Route, Routes } from 'react-router-dom'; + +import RoleBindingCreatePage from './CreatePage/RoleBindingCreatePage'; +import RoleBindingsPage from './RoleBindingsPage'; + +const RoleBindingRoutes = () => { + return ( + + } /> + } /> + } /> + + ); +}; + +export default RoleBindingRoutes; diff --git a/libs/ui-components/src/components/RoleBinding/RoleBindingStatusLabel.tsx b/libs/ui-components/src/components/RoleBinding/RoleBindingStatusLabel.tsx new file mode 100644 index 00000000..3c6f8889 --- /dev/null +++ b/libs/ui-components/src/components/RoleBinding/RoleBindingStatusLabel.tsx @@ -0,0 +1,51 @@ +import type { TFunction } from 'i18next'; + +import { RoleBinding, RoleBindingState } from '@osac/types'; + +import { + ResourceStatusLabel, + StatusLabelProps, +} from '../../components/Resource/ResourceStatusLabel'; +import { useTranslation } from '../../hooks/useTranslation'; + +interface RoleBindingStatusLabelProps { + rb: RoleBinding; +} + +const roleBindingStatusMap = (t: TFunction): Record => ({ + [RoleBindingState.READY]: { + status: 'ready', + text: t('Ready'), + }, + [RoleBindingState.FAILED]: { + status: 'failed', + text: t('Failed'), + }, + [RoleBindingState.PENDING]: { + status: 'progressing', + text: t('Pending'), + }, + [RoleBindingState.UNSPECIFIED]: { + status: 'unspecified', + text: t('Unspecified'), + }, +}); + +const RoleBindingStatusLabel = ({ rb }: RoleBindingStatusLabelProps) => { + const { t } = useTranslation(); + + if (rb.metadata?.deletionTimestamp) { + return ; + } + + const statusMap = roleBindingStatusMap(t); + + const status = + rb.status?.state !== undefined + ? statusMap[rb.status.state] || statusMap[RoleBindingState.UNSPECIFIED] + : statusMap[RoleBindingState.UNSPECIFIED]; + + return ; +}; + +export default RoleBindingStatusLabel; diff --git a/libs/ui-components/src/components/RoleBinding/RoleBindingsPage.test.tsx b/libs/ui-components/src/components/RoleBinding/RoleBindingsPage.test.tsx new file mode 100644 index 00000000..476e060d --- /dev/null +++ b/libs/ui-components/src/components/RoleBinding/RoleBindingsPage.test.tsx @@ -0,0 +1,163 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Role, RoleBinding, User } from '@osac/types'; +import { RoleBindingState } from '@osac/types'; + +import RoleBindingsPage from './RoleBindingsPage'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +vi.mock('../../hooks/use-session', () => ({ + useSession: vi.fn(() => ({ + role: 'tenant-admin', + username: 'testuser', + tenantId: 'tenant-1', + })), +})); + +const { useSession } = await import('../../hooks/use-session'); + +const makeRole = (id: string, title: string, description = ''): Role => + ({ + id, + metadata: { + name: id, + }, + spec: { title, description }, + status: { state: 0, message: '' }, + }) as Role; + +const makeRoleBinding = ( + id: string, + roleId: string, + userIds: string[], + state = RoleBindingState.READY, +): RoleBinding => + ({ + id, + metadata: { + name: id, + }, + spec: { + role: { + id: roleId, + name: roleId, + }, + users: userIds.map((uid) => ({ id: uid, name: uid })), + }, + status: { state, message: '' }, + }) as RoleBinding; + +const makeUser = (id: string, username: string, email: string): User => + ({ + id, + metadata: { name: id }, + spec: { username, email }, + status: {}, + }) as User; + +const defaultRoles = [ + makeRole('role-viewer', 'Viewer', 'Read-only access'), + makeRole('role-editor', 'Editor', 'Edit resources'), +]; + +const defaultRoleBindings = [ + makeRoleBinding('rb-1', 'role-viewer', ['user-1', 'user-2']), + makeRoleBinding('rb-2', 'role-editor', ['user-3'], RoleBindingState.PENDING), +]; + +const defaultUsers = [ + makeUser('user-1', 'alice', 'alice@example.com'), + makeUser('user-2', 'bob', 'bob@example.com'), + makeUser('user-3', 'charlie', 'charlie@example.com'), +]; + +const renderPage = ({ + roles = defaultRoles, + roleBindings = defaultRoleBindings, + users = defaultUsers, +}: { + roles?: Role[]; + roleBindings?: RoleBinding[]; + users?: User[]; +} = {}) => + renderWithProviders(, { + apiFixtures: { roles, roleBindings, users }, + }); + +describe('RoleBindingsPage', () => { + beforeEach(() => { + vi.mocked(useSession).mockReturnValue({ + role: 'tenant-admin', + username: 'testuser', + tenantId: 'tenant-1', + userTheme: 'system', + resolvedTheme: 'light', + setUserTheme: vi.fn(), + }); + }); + + it('renders the page title', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Role Bindings' })).toBeInTheDocument(); + }); + }); + + it('renders role binding rows with resolved role and user count', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Viewer')).toBeInTheDocument(); + expect(screen.getByText('2 users')).toBeInTheDocument(); + }); + expect(screen.getByText('1 user')).toBeInTheDocument(); + expect(screen.getByText('Editor')).toBeInTheDocument(); + }); + + it('displays status column', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Viewer')).toBeInTheDocument(); + }); + + const rows = screen.getAllByRole('row'); + const viewerRow = rows.find((row) => within(row).queryByText('Viewer')); + expect(viewerRow).toBeDefined(); + expect(within(viewerRow as HTMLElement).getByText('Ready')).toBeInTheDocument(); + + const editorRow = rows.find((row) => within(row).queryByText('Editor')); + expect(editorRow).toBeDefined(); + expect(within(editorRow as HTMLElement).getByText('Pending')).toBeInTheDocument(); + }); + + it('shows empty state when there are no role bindings', async () => { + renderPage({ roleBindings: [] }); + + await waitFor(() => { + expect(screen.getByText('No role bindings available.')).toBeInTheDocument(); + }); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + + it('renders the create role binding button', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Create role binding' })).toBeInTheDocument(); + }); + }); + + it('renders actions menu for each row', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Viewer')).toBeInTheDocument(); + }); + + const actionButtons = screen.getAllByRole('button', { name: 'Actions' }); + expect(actionButtons).toHaveLength(2); + }); +}); diff --git a/libs/ui-components/src/components/RoleBinding/RoleBindingsPage.tsx b/libs/ui-components/src/components/RoleBinding/RoleBindingsPage.tsx new file mode 100644 index 00000000..429eafc9 --- /dev/null +++ b/libs/ui-components/src/components/RoleBinding/RoleBindingsPage.tsx @@ -0,0 +1,81 @@ +import { useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button } from '@patternfly/react-core'; +import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; + +import RoleBindingActionsMenu from './RoleBindingActionsMenu'; +import RoleBindingStatusLabel from './RoleBindingStatusLabel'; +import { useRoles } from '../../api/v1/role'; +import { useRoleBindings } from '../../api/v1/role-binding'; +import ListPage from '../../components/Page/ListPage'; +import ListPageBody from '../../components/Page/ListPageBody'; +import { SubtleContent } from '../../components/SubtleContent/SubtleContent'; +import { useTranslation } from '../../hooks/useTranslation'; + +const RoleBindingsPage = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const { data: roleBindings = [], isLoading, error } = useRoleBindings(); + const { data: roles = [] } = useRoles(); + + const rolesById = useMemo(() => { + const map = new Map(); + for (const role of roles) { + map.set(role.id, role.spec?.title || role.metadata?.name || role.id); + } + return map; + }, [roles]); + + return ( + navigate('create')}> + {t('Create role binding')} + + } + > + + {roleBindings.length === 0 ? ( + {t('No role bindings available.')} + ) : ( + + + + + + + + + + + {roleBindings.map((rb) => ( + + + + + + + + ))} + +
{t('Name')}{t('Status')}{t('Role')}{t('Users')} +
{rb.metadata?.name || rb.id} + + + {rb.spec?.role?.name ? rolesById.get(rb.spec.role.id) : '-'} + + {rb.spec ? t('{{count}} user', { count: rb.spec.users.length }) : '-'} + + +
+ )} +
+
+ ); +}; + +export default RoleBindingsPage; diff --git a/libs/ui-components/src/test-utils/createMockConnectTransport.ts b/libs/ui-components/src/test-utils/createMockConnectTransport.ts index 22116b4e..c7ed45fa 100644 --- a/libs/ui-components/src/test-utils/createMockConnectTransport.ts +++ b/libs/ui-components/src/test-utils/createMockConnectTransport.ts @@ -14,8 +14,11 @@ import type { IdentityProvidersUpdateRequest, IdentityProvidersUpdateResponse, InstanceType, + Role, + RoleBinding, SecurityGroup, Subnet, + User, VirtualNetwork, } from '@osac/types'; import { @@ -27,8 +30,11 @@ import { IdentityProviders, InstanceTypeState, InstanceTypes, + RoleBindings, + Roles, SecurityGroups, Subnets, + Users, VirtualNetworkState, VirtualNetworks, } from '@osac/types'; @@ -85,6 +91,9 @@ export type MockApiFixtures = { privateInstanceTypes?: PrivateInstanceType[]; storageBackends?: StorageBackend[]; storageTiers?: StorageTier[]; + roles?: Role[]; + roleBindings?: RoleBinding[]; + users?: User[]; }; export const wrapWithAuthInterceptor = (transport: Transport): Transport => { @@ -195,6 +204,9 @@ export const createMockConnectTransport = ( const privateInstanceTypes = fixtures.privateInstanceTypes ?? []; const storageBackends = [...(fixtures.storageBackends ?? [])]; const storageTiers = fixtures.storageTiers ?? []; + const roles = fixtures.roles ?? []; + const roleBindingsFixtures = fixtures.roleBindings ?? []; + const usersFixtures = fixtures.users ?? []; return wrapWithAuthInterceptor( createRouterTransport((router) => { @@ -464,6 +476,43 @@ export const createMockConnectTransport = ( return { object: { id: 'cluster-1', ...req.object } }; }, }); + + router.service(Roles, { + list: () => ({ + items: roles, + size: roles.length, + total: roles.length, + }), + get: (req) => ({ + object: roles.find((r) => r.id === req.id), + }), + }); + + router.service(RoleBindings, { + list: () => ({ + items: roleBindingsFixtures, + size: roleBindingsFixtures.length, + total: roleBindingsFixtures.length, + }), + get: (req) => ({ + object: roleBindingsFixtures.find((rb) => rb.id === req.id), + }), + create: (req) => ({ + object: { id: 'new-rb-1', ...req.object }, + }), + delete: () => ({}), + }); + + router.service(Users, { + list: () => ({ + items: usersFixtures, + size: usersFixtures.length, + total: usersFixtures.length, + }), + get: (req) => ({ + object: usersFixtures.find((u) => u.id === req.id), + }), + }); }), ); };