Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/app-frontend/src/shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -46,6 +47,14 @@ export const AppShell = ({ logout }: { logout: () => Promise<void> }) => {
</ShellRoute>
}
/>
<Route
path="/tenant/identity-provider"
element={
<ShellRoute>
<IdentityProviderListPage />
</ShellRoute>
}
/>
<Route
path="/vms"
element={
Expand Down
9 changes: 8 additions & 1 deletion apps/app-frontend/src/shell/shellNav.test.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, new persona should not be added at this stage (avoid complexity at init point)
please use tenant admin

Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { tIdentity } from '@osac/ui-components/test-utils/i18n';

import { navRowsForRole } from './shellNav';

const roles: UserRole[] = ['tenant-user', 'tenant-admin', 'tenant-idp-manager', 'admin'];
const roles: UserRole[] = ['tenant-user', 'tenant-admin', 'admin'];

const findSection = (role: UserRole, sectionId: string) =>
navRowsForRole(role, tIdentity).find((row) => row.sectionId === sectionId);
Expand Down Expand Up @@ -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();
}
});
});
13 changes: 13 additions & 0 deletions apps/app-frontend/src/shell/shellNav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
};
2 changes: 1 addition & 1 deletion apps/app-frontend/src/shell/shellRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
4 changes: 4 additions & 0 deletions apps/app-frontend/src/shell/shellRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
};
10 changes: 10 additions & 0 deletions libs/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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.",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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…",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions libs/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
3 changes: 2 additions & 1 deletion libs/ui-components/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions libs/ui-components/src/api/v1/identity-provider.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createRouterTransport>) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const wrapper = ({ children }: { children: ReactNode }) =>
createElement(
ApiProvider,
{ transport } as React.ComponentProps<typeof ApiProvider>,
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');
});
});
14 changes: 14 additions & 0 deletions libs/ui-components/src/api/v1/identity-provider.ts
Original file line number Diff line number Diff line change
@@ -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,
Comment on lines +7 to +12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 4 'export type ListParams|useIdentityProviders\s*\(' libs apps
rg -n -C 4 'IdentityProviders.*list|metadata\.tenant|IdentityProvidersList' libs

Repository: osac-project/osac-ui

Length of output: 17403


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## Candidate files"
git ls-files | rg 'libs/ui-components/src/(api/v1/identity-provider|api/types|components/IdentityProvider/IdentityProviderListPage|test-utils/createMockConnectTransport).*\.(ts|tsx)$' || true

echo "## identity-provider.ts"
cat -n libs/ui-components/src/api/v1/identity-provider.ts

echo "## api/types relevant"
cat -n libs/ui-components/src/api/types.ts | sed -n '70,115p'

echo "## IdentityProviderListPage"
cat -n libs/ui-components/src/components/IdentityProvider/IdentityProviderListPage.tsx

echo "## createMockConnectTransport relevant"
cat -n libs/ui-components/src/test-utils/createMockConnectTransport.ts | sed -n '190,230p'

echo "## identity-provider tests relevant"
cat -n libs/ui-components/src/api/v1/identity-provider.test.ts | sed -n '1,90p'

Repository: osac-project/osac-ui

Length of output: 10281


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## IdentityProviders usage across ui-components"
rg -n -C 3 'IdentityProviders|identityProviders|metadata\.tenant|useActive|activeTenant|useTenant|tenant' libs/ui-components/src --glob '*.ts' --glob '*.tsx' || true

echo "## createMockConnectTransport setup"
cat -n libs/ui-components/src/test-utils/createMockConnectTransport.ts | sed -n '1,140p'
cat -n libs/ui-components/src/test-utils/createMockConnectTransport.ts | sed -n '230,340p'

Repository: osac-project/osac-ui

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## IdentityProviderListPage.test.tsx"
cat -n libs/ui-components/src/components/IdentityProvider/IdentityProviderListPage.test.tsx | sed -n '1,120p'

echo "## createMockConnectTransport header/defaults"
cat -n libs/ui-components/src/test-utils/createMockConnectTransport.ts | sed -n '1,90p'

Repository: osac-project/osac-ui

Length of output: 7376


Preserve tenant scope for identity-provider lists.

IdentityProviders.List uses metadata.tenant to scope identity providers, but IdentityProviderListPage calls useIdentityProviders() with no filters, so the hook sends an unscoped list request.

  • libs/ui-components/src/api/v1/identity-provider.ts#L7-L12: pass filter: 'metadata.tenant == "<tenant>"' from the active tenant context before calling client.list(params), unless public.v1.IdentityProviders already derives scope from authentication context and always returns only the caller’s tenant.
  • libs/ui-components/src/test-utils/createMockConnectTransport.ts#L212-L217: apply the same req.filter/tenant scope to fixture results so tests catch tenant-scope regressions.
📍 Affects 2 files
  • libs/ui-components/src/api/v1/identity-provider.ts#L7-L12 (this comment)
  • libs/ui-components/src/test-utils/createMockConnectTransport.ts#L212-L217
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/ui-components/src/api/v1/identity-provider.ts` around lines 7 - 12,
Update libs/ui-components/src/api/v1/identity-provider.ts (lines 7-12) in the
useIdentityProviders hook to extract the active tenant context and pass a
tenant-scoped filter in the params before calling client.list(params), ensuring
IdentityProviders.List receives the metadata.tenant constraint (unless
public.v1.IdentityProviders already derives scope from authentication context).
Then update libs/ui-components/src/test-utils/createMockConnectTransport.ts
(lines 212-217) to apply the same req.filter tenant-scope validation to the
fixture results so tests verify tenant-scope filtering is working correctly.

});
};
Original file line number Diff line number Diff line change
@@ -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(<IdentityProviderListPage />, {
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();
});
});
Loading
Loading