diff --git a/apps/app-frontend/src/shell/AppShell.tsx b/apps/app-frontend/src/shell/AppShell.tsx index 7ca0f7e5..efe4d439 100644 --- a/apps/app-frontend/src/shell/AppShell.tsx +++ b/apps/app-frontend/src/shell/AppShell.tsx @@ -3,6 +3,7 @@ import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; import { Page } from '@patternfly/react-core'; import ErrorBoundary from '@osac/ui-components/components/ErrorBoundary/ErrorBoundary'; +import IdentityProviderListPage from '@osac/ui-components/components/IdentityProvider/IdentityProviderListPage'; 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'; @@ -46,6 +47,14 @@ export const AppShell = ({ logout }: { logout: () => Promise }) => { } /> + + + + } + /> navRowsForRole(role, tIdentity).find((row) => row.sectionId === sectionId); @@ -42,4 +42,11 @@ describe('navRowsForRole', () => { expect(findSection(role, 'nav-administration')).toBeUndefined(); } }); + + 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(); + } + }); }); diff --git a/apps/app-frontend/src/shell/shellNav.ts b/apps/app-frontend/src/shell/shellNav.ts index 6e0c13ce..95eec73b 100644 --- a/apps/app-frontend/src/shell/shellNav.ts +++ b/apps/app-frontend/src/shell/shellNav.ts @@ -12,6 +12,15 @@ 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 getAdminNav = (t: TFunction): NavSection[] => [ { kind: 'section', @@ -58,5 +67,9 @@ export const navRowsForRole = (role: UserRole, t: TFunction): NavSection[] => { return getAdminNav(t); } + if (role === 'tenant-idp-manager') { + return getIdpManagerNav(t); + } + return getBaseNav(t); }; diff --git a/apps/app-frontend/src/shell/shellRoutes.test.ts b/apps/app-frontend/src/shell/shellRoutes.test.ts index 7e85f05c..1726e8c6 100644 --- a/apps/app-frontend/src/shell/shellRoutes.test.ts +++ b/apps/app-frontend/src/shell/shellRoutes.test.ts @@ -6,7 +6,7 @@ describe('defaultRouteForRole', () => { it('lands *role* on default page', () => { expect(defaultRouteForRole('tenant-user')).toBe('/catalog'); expect(defaultRouteForRole('tenant-admin')).toBe('/catalog'); - expect(defaultRouteForRole('tenant-idp-manager')).toBe('/catalog'); + expect(defaultRouteForRole('tenant-idp-manager')).toBe('/tenant/identity-provider'); expect(defaultRouteForRole('admin')).toBe('/admin/tenants'); }); }); diff --git a/apps/app-frontend/src/shell/shellRoutes.ts b/apps/app-frontend/src/shell/shellRoutes.ts index 2ea53252..4b31cdf9 100644 --- a/apps/app-frontend/src/shell/shellRoutes.ts +++ b/apps/app-frontend/src/shell/shellRoutes.ts @@ -4,5 +4,9 @@ export const defaultRouteForRole = (role: UserRole): string => { if (role === 'admin') { return '/admin/tenants'; } + if (role === 'tenant-idp-manager') { + return '/tenant/identity-provider'; + } + return '/catalog'; }; diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 2029b466..9ed7f3c1 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -112,6 +112,7 @@ "Create": "Create", "Create cluster": "Create cluster", "Create cluster wizard": "Create cluster wizard", + "Create identity provider": "Create identity provider", "Create security group": "Create security group", "Create subnet": "Create subnet", "Create virtual machine": "Create virtual machine", @@ -173,6 +174,7 @@ "Host type": "Host type", "Host type is required": "Host type is required", "ICMP": "ICMP", + "Identity providers": "Identity providers", "IdP manager": "IdP manager", "Inbound Rules": "Inbound Rules", "Instance type": "Instance type", @@ -191,6 +193,7 @@ "Loading security groups...": "Loading security groups...", "Loading subnets...": "Loading subnets...", "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 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.", "Message": "Message", @@ -207,6 +210,8 @@ "No catalog items found": "No catalog items found", "No catalog items match your search.": "No catalog items match your search.", "No external IP pools available": "No external IP pools available", + "No identity providers match your search.": "No identity providers match your search.", + "No identity providers yet. Create one to get started.": "No identity providers yet. Create one to get started.", "No inbound rules yet. Add one to allow incoming traffic.": "No inbound rules yet. Add one to allow incoming traffic.", "No node sets added yet.": "No node sets added yet.", "No node sets configured.": "No node sets configured.", @@ -224,6 +229,7 @@ "Node sets": "Node sets", "Node Sets": "Node Sets", "Nodes": "Nodes", + "OIDC": "OIDC", "Open catalog item details for {{title}}": "Open catalog item details for {{title}}", "Optional cloud-init user data (max 64 KB).": "Optional cloud-init user data (max 64 KB).", "Outbound Rules": "Outbound Rules", @@ -255,6 +261,7 @@ "Pull secret": "Pull secret", "Pull secret is required": "Pull secret is required", "Pull secrets download OpenShift components and connect clusters to your Red Hat account. Copy the full JSON from OpenShift Cluster Manager (console.redhat.com/openshift/install/pull-secret).": "Pull secrets download OpenShift components and connect clusters to your Red Hat account. Copy the full JSON from OpenShift Cluster Manager (console.redhat.com/openshift/install/pull-secret).", + "Ready": "Ready", "Registered": "Registered", "Release image": "Release image", "Release image is required": "Release image is required", @@ -265,6 +272,8 @@ "Save": "Save", "Search by name": "Search by name", "Search catalog items": "Search catalog items", + "Search identity providers": "Search identity providers", + "Search identity providers by name…": "Search identity providers by name…", "Search security groups by name…": "Search security groups by name…", "Search tenants": "Search tenants", "Search tenants by name…": "Search tenants by name…", @@ -309,6 +318,7 @@ "This will permanently delete the rule. This action cannot be undone. Traffic matching this rule will be blocked.": "This will permanently delete the rule. This action cannot be undone. Traffic matching this rule will be blocked.", "This will permanently delete the security group and all its rules. This action cannot be undone.": "This will permanently delete the security group and all its rules. This action cannot be undone.", "Timed out waiting for the graphical console to finish connecting": "Timed out waiting for the graphical console to finish connecting", + "Type": "Type", "UDP": "UDP", "Unauthorized": "Unauthorized", "Unknown": "Unknown", diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 0133105f..56ff7bb0 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -56,3 +56,6 @@ export * from './osac/public/v1/baremetal_instance_type_pb.js'; export * from './osac/public/v1/baremetal_instances_service_pb.js'; export * from './osac/public/v1/baremetal_instance_catalog_item_type_pb.js'; 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'; diff --git a/libs/ui-components/src/api/types.ts b/libs/ui-components/src/api/types.ts index fc86f400..79a68330 100644 --- a/libs/ui-components/src/api/types.ts +++ b/libs/ui-components/src/api/types.ts @@ -27,7 +27,8 @@ export type ApiRoute = | 'v1/external_ip_attachments' | 'v1/external_ip_pools' | 'v1/console_sessions' - | 'v1/private/tenants'; + | 'v1/private/tenants' + | 'v1/identity_providers'; /** * Strict 3-part tuple that encodes an API address. diff --git a/libs/ui-components/src/api/v1/identity-provider.test.ts b/libs/ui-components/src/api/v1/identity-provider.test.ts new file mode 100644 index 00000000..da9ba924 --- /dev/null +++ b/libs/ui-components/src/api/v1/identity-provider.test.ts @@ -0,0 +1,54 @@ +import React, { type ReactNode, createElement } from 'react'; +import { createRouterTransport } from '@connectrpc/connect'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { IdentityProviderPhase, IdentityProviders } from '@osac/types'; + +import { useIdentityProviders } from './identity-provider'; +import { ApiProvider } from '../api-context'; + +const makeIdentityProvider = (id: string, title: string, phase?: IdentityProviderPhase) => ({ + id, + spec: { title, enabled: true, config: { case: 'oidc' as const, value: {} } }, + status: phase !== undefined ? { phase, message: '', conditions: [] } : undefined, +}); + +const makeWrapper = (transport: ReturnType) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => + createElement( + ApiProvider, + { transport } as React.ComponentProps, + createElement(QueryClientProvider, { client: queryClient }, children), + ); + return { wrapper, queryClient }; +}; + +describe('useIdentityProviders', () => { + it('returns identity provider items from the list response', async () => { + const transport = createRouterTransport((router) => { + router.service(IdentityProviders, { + list: () => ({ + items: [ + makeIdentityProvider('idp-1', 'Corporate OIDC', IdentityProviderPhase.READY), + makeIdentityProvider('idp-2', 'GitHub SSO', IdentityProviderPhase.ERROR), + ], + size: 2, + total: 2, + }), + }); + }); + + const { wrapper } = makeWrapper(transport); + const { result } = renderHook(() => useIdentityProviders(), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(2); + expect(result.current.data?.[0].id).toBe('idp-1'); + expect(result.current.data?.[1].id).toBe('idp-2'); + }); +}); diff --git a/libs/ui-components/src/api/v1/identity-provider.ts b/libs/ui-components/src/api/v1/identity-provider.ts new file mode 100644 index 00000000..33687e69 --- /dev/null +++ b/libs/ui-components/src/api/v1/identity-provider.ts @@ -0,0 +1,14 @@ +import { IdentityProviders } from '@osac/types'; + +import { useApiFetch } from '../api-context'; +import { type ListParams, apiQueryKey } from '../types'; +import { useApiQuery } from '../use-api-query'; + +export const useIdentityProviders = (params: ListParams = {}) => { + const client = useApiFetch(IdentityProviders); + return useApiQuery({ + queryKey: apiQueryKey('v1/identity_providers', undefined, params), + queryFn: () => client.list(params), + select: (data) => data.items, + }); +}; diff --git a/libs/ui-components/src/components/IdentityProvider/IdentityProviderListPage.test.tsx b/libs/ui-components/src/components/IdentityProvider/IdentityProviderListPage.test.tsx new file mode 100644 index 00000000..0abe06d7 --- /dev/null +++ b/libs/ui-components/src/components/IdentityProvider/IdentityProviderListPage.test.tsx @@ -0,0 +1,111 @@ +import { screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { IdentityProvider } from '@osac/types'; +import { IdentityProviderPhase } from '@osac/types'; + +import IdentityProviderListPage from './IdentityProviderListPage'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +const makeIdentityProvider = (id: string, title: string, phase?: IdentityProviderPhase) => + ({ + id, + metadata: { + creationTimestamp: { seconds: BigInt(1717000000), nanos: 0 }, + }, + spec: { + title, + enabled: true, + config: { case: 'oidc' as const, value: {} }, + }, + status: phase !== undefined ? { phase, message: '', conditions: [] } : undefined, + }) as IdentityProvider; + +const defaultIdentityProviders = [ + makeIdentityProvider('idp-1', 'Corporate OIDC', IdentityProviderPhase.READY), + makeIdentityProvider('idp-2', 'GitHub SSO', IdentityProviderPhase.ERROR), +]; + +const renderPage = (identityProviders: IdentityProvider[] = defaultIdentityProviders) => + renderWithProviders(, { + apiFixtures: { identityProviders }, + }); + +describe('IdentityProviderListPage', () => { + it('renders the page title', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Identity providers' })).toBeInTheDocument(); + }); + }); + + it('renders identity provider rows with titles', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Corporate OIDC')).toBeInTheDocument(); + }); + expect(screen.getByText('GitHub SSO')).toBeInTheDocument(); + }); + + it('renders status labels for each identity provider', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Ready')).toBeInTheDocument(); + }); + expect(screen.getByText('Error')).toBeInTheDocument(); + }); + + it('renders type column showing OIDC', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getAllByText('OIDC')).toHaveLength(2); + }); + }); + + it('shows empty state when there are no identity providers', async () => { + renderPage([]); + + await waitFor(() => { + expect( + screen.getByText('No identity providers yet. Create one to get started.'), + ).toBeInTheDocument(); + }); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + + it('shows filtered empty state when search matches nothing', async () => { + const { user } = renderPage(); + + await waitFor(() => { + expect(screen.getByText('Corporate OIDC')).toBeInTheDocument(); + }); + + const searchInput = screen.getByRole('textbox', { + name: 'Search identity providers', + }); + await user.type(searchInput, 'nonexistent'); + + expect(screen.getByText('No identity providers match your search.')).toBeInTheDocument(); + expect(screen.queryByText('Corporate OIDC')).not.toBeInTheDocument(); + }); + + it('filters identity providers by title', async () => { + const { user } = renderPage(); + + await waitFor(() => { + expect(screen.getByText('Corporate OIDC')).toBeInTheDocument(); + }); + + const searchInput = screen.getByRole('textbox', { + name: 'Search identity providers', + }); + await user.type(searchInput, 'Corporate'); + + expect(screen.getByText('Corporate OIDC')).toBeInTheDocument(); + expect(screen.queryByText('GitHub SSO')).not.toBeInTheDocument(); + }); +}); diff --git a/libs/ui-components/src/components/IdentityProvider/IdentityProviderListPage.tsx b/libs/ui-components/src/components/IdentityProvider/IdentityProviderListPage.tsx new file mode 100644 index 00000000..549adc4a --- /dev/null +++ b/libs/ui-components/src/components/IdentityProvider/IdentityProviderListPage.tsx @@ -0,0 +1,111 @@ +import { useMemo, useState } from 'react'; +import { + Button, + SearchInput, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, +} from '@patternfly/react-core'; +import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import { TFunction } from 'i18next'; + +import { useIdentityProviders } from '@osac/ui-components/api/v1/identity-provider'; +import IdentityProviderStatusLabel from '@osac/ui-components/components/IdentityProvider/IdentityProviderStatusLabel'; +import ListPage from '@osac/ui-components/components/Page/ListPage'; +import ListPageBody from '@osac/ui-components/components/Page/ListPageBody'; +import { Timestamp } from '@osac/ui-components/components/Primitives/Timestamp'; +import { SubtleContent } from '@osac/ui-components/components/SubtleContent/SubtleContent'; +import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; + +const resolveIdpType = (t: TFunction, configCase: string | undefined): string => { + switch (configCase) { + case 'oidc': + return t('OIDC'); + default: + return '-'; + } +}; + +const IdentityProviderListPage = () => { + const { t } = useTranslation(); + const [search, setSearch] = useState(''); + + const { data: identityProviders = [], isLoading, error } = useIdentityProviders(); + + const filteredProviders = useMemo(() => { + if (!search) { + return identityProviders; + } + const lowerSearch = search.toLowerCase(); + return identityProviders.filter((idp) => { + const title = idp.spec?.title || idp.metadata?.name || idp.id; + return title.toLowerCase().includes(lowerSearch); + }); + }, [search, identityProviders]); + + return ( + + {t('Create identity provider')} + + } + > + + + + + + setSearch(v)} + onClear={() => setSearch('')} + aria-label={t('Search identity providers')} + /> + + + + + {filteredProviders.length === 0 ? ( + + {search + ? t('No identity providers match your search.') + : t('No identity providers yet. Create one to get started.')} + + ) : ( + + + + + + + + + + + {filteredProviders.map((idp) => ( + + + + + + + ))} + +
{t('Name')}{t('Status')}{t('Type')}{t('Created')}
{idp.spec?.title ?? idp.metadata?.name ?? idp.id} + + {resolveIdpType(t, idp.spec?.config.case)} + +
+ )} +
+
+ ); +}; + +export default IdentityProviderListPage; diff --git a/libs/ui-components/src/components/IdentityProvider/IdentityProviderStatusLabel.tsx b/libs/ui-components/src/components/IdentityProvider/IdentityProviderStatusLabel.tsx new file mode 100644 index 00000000..50adedc7 --- /dev/null +++ b/libs/ui-components/src/components/IdentityProvider/IdentityProviderStatusLabel.tsx @@ -0,0 +1,47 @@ +import type { TFunction } from 'i18next'; + +import { IdentityProviderPhase } from '@osac/types'; + +import { + ResourceStatusLabel, + StatusLabelProps, +} from '../../components/Resource/ResourceStatusLabel'; +import { useTranslation } from '../../hooks/useTranslation'; + +interface IdentityProviderStatusLabelProps { + phase?: IdentityProviderPhase; +} + +const identityProviderPhaseMap = ( + t: TFunction, +): Record => ({ + [IdentityProviderPhase.READY]: { + status: 'ready', + text: t('Ready'), + }, + [IdentityProviderPhase.ERROR]: { + status: 'failed', + text: t('Error'), + }, + [IdentityProviderPhase.UNKNOWN]: { + status: 'unspecified', + text: t('Unknown'), + }, + [IdentityProviderPhase.UNSPECIFIED]: { + status: 'unspecified', + text: t('Unspecified'), + }, +}); + +const IdentityProviderStatusLabel = ({ phase }: IdentityProviderStatusLabelProps) => { + const { t } = useTranslation(); + + const phaseMap = identityProviderPhaseMap(t); + + const status = + phase !== undefined ? phaseMap[phase] : phaseMap[IdentityProviderPhase.UNSPECIFIED]; + + return ; +}; + +export default IdentityProviderStatusLabel; diff --git a/libs/ui-components/src/test-utils/createMockConnectTransport.ts b/libs/ui-components/src/test-utils/createMockConnectTransport.ts index 1bc0448c..3b4db12c 100644 --- a/libs/ui-components/src/test-utils/createMockConnectTransport.ts +++ b/libs/ui-components/src/test-utils/createMockConnectTransport.ts @@ -7,6 +7,7 @@ import type { ClustersCreateResponse, ComputeInstanceCatalogItem, HostType, + IdentityProvider, InstanceType, SecurityGroup, Subnet, @@ -18,6 +19,7 @@ import { Clusters, ComputeInstanceCatalogItems, HostTypes, + IdentityProviders, InstanceTypeState, InstanceTypes, SecurityGroups, @@ -39,6 +41,7 @@ export type MockApiFixtures = { virtualNetworks?: VirtualNetwork[]; subnets?: Subnet[]; securityGroups?: SecurityGroup[]; + identityProviders?: IdentityProvider[]; instanceTypes?: InstanceType[]; }; @@ -106,6 +109,7 @@ export const createMockConnectTransport = ( const clusterTemplates = fixtures.clusterTemplates ?? []; const hostTypes = fixtures.hostTypes ?? []; const tenants = fixtures.tenants ?? []; + const identityProviders = fixtures.identityProviders ?? []; const virtualNetworks = fixtures.virtualNetworks ?? []; const subnets = fixtures.subnets ?? []; const securityGroups = fixtures.securityGroups ?? []; @@ -205,6 +209,21 @@ export const createMockConnectTransport = ( }), }); + router.service(IdentityProviders, { + list: () => ({ + items: identityProviders, + size: identityProviders.length, + total: identityProviders.length, + }), + get: (req) => ({ + object: identityProviders.find((idp) => idp.id === req.id), + }), + create: (req) => ({ + object: { id: 'new-idp-1', ...req.object }, + }), + delete: () => ({}), + }); + router.service(Clusters, { create: (req) => { if (overrides.onClusterCreate) {