diff --git a/apps/app-frontend/src/hooks/oidc-login.test.ts b/apps/app-frontend/src/hooks/oidc-login.test.ts new file mode 100644 index 00000000..8c3e3528 --- /dev/null +++ b/apps/app-frontend/src/hooks/oidc-login.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { roleFromRoles } from './oidc-login'; + +describe('roleFromRoles', () => { + it('returns admin when groups include admins', () => { + expect(roleFromRoles(['tenant-admin'], ['admins'])).toBe('admin'); + }); + + it('returns admin when groups include admins even with tenant-idp-manager role', () => { + expect(roleFromRoles(['tenant-idp-manager'], ['admins'])).toBe('admin'); + }); + + it('returns tenant-admin when roles include tenant-admin and not in admins group', () => { + expect(roleFromRoles(['tenant-admin'], [])).toBe('tenant-admin'); + }); + + it('returns tenant-admin over tenant-idp-manager when both roles present', () => { + expect(roleFromRoles(['tenant-admin', 'tenant-idp-manager'], [])).toBe('tenant-admin'); + }); + + it('returns tenant-idp-manager when roles include tenant-idp-manager and not tenant-admin', () => { + expect(roleFromRoles(['tenant-idp-manager'], [])).toBe('tenant-idp-manager'); + }); + + it('returns tenant-user when no recognized group or role is present', () => { + expect(roleFromRoles([], [])).toBe('tenant-user'); + }); + + it('returns tenant-user for unrecognized roles and groups', () => { + expect(roleFromRoles(['some-other-role'], ['some-group'])).toBe('tenant-user'); + }); +}); diff --git a/apps/app-frontend/src/hooks/oidc-login.tsx b/apps/app-frontend/src/hooks/oidc-login.tsx index 86f53809..68c50da8 100644 --- a/apps/app-frontend/src/hooks/oidc-login.tsx +++ b/apps/app-frontend/src/hooks/oidc-login.tsx @@ -1,16 +1,19 @@ import * as React from 'react'; -import { DemoShellRole } from '@osac/ui-components/shellTypes'; +import type { UserRole } from '@osac/ui-components/shellTypes'; import { getErrorMessage } from '@osac/ui-components/utils/error'; -const roleFromRoles = (roles: string[], groups: string[]): DemoShellRole => { +export const roleFromRoles = (roles: string[] = [], groups: string[] = []): UserRole => { if (groups.includes('admins')) { - return 'providerAdmin'; + return 'admin'; } if (roles.includes('tenant-admin')) { - return 'tenantAdmin'; + return 'tenant-admin'; } - return 'tenantUser'; + if (roles.includes('tenant-idp-manager')) { + return 'tenant-idp-manager'; + } + return 'tenant-user'; }; const fetchLoginInfo = async (): Promise<{ @@ -41,7 +44,7 @@ const maxTimeout = 2 ** 31 - 1; export const useOIDCLogin = (): [ string, - DemoShellRole, + UserRole, boolean, string | undefined, () => Promise, @@ -49,7 +52,7 @@ export const useOIDCLogin = (): [ const [triggerRelogin, setTriggerRelogin] = React.useState(0); const [isLoading, setIsLoading] = React.useState(true); const [username, setUsername] = React.useState(''); - const [role, setRole] = React.useState('tenantUser'); + const [role, setRole] = React.useState('tenant-user'); const [error, setError] = React.useState(); const refreshTimerRef = React.useRef | null>(null); @@ -133,7 +136,7 @@ export const useOIDCLogin = (): [ if (result) { setError(undefined); setUsername(result.username); - setRole(roleFromRoles(result.roles ?? [], result.groups ?? [])); + setRole(roleFromRoles(result.roles, result.groups)); setIsLoading(false); scheduleRefresh(); } else { diff --git a/apps/app-frontend/src/shell/AppShell.tsx b/apps/app-frontend/src/shell/AppShell.tsx index d6375dcd..57c702cf 100644 --- a/apps/app-frontend/src/shell/AppShell.tsx +++ b/apps/app-frontend/src/shell/AppShell.tsx @@ -117,7 +117,6 @@ export const AppShell = ({ logout }: { logout: () => Promise }) => { } /> - } /> diff --git a/apps/app-frontend/src/shell/ShellMasthead.tsx b/apps/app-frontend/src/shell/ShellMasthead.tsx index c1eb8e34..3f2454b0 100644 --- a/apps/app-frontend/src/shell/ShellMasthead.tsx +++ b/apps/app-frontend/src/shell/ShellMasthead.tsx @@ -30,15 +30,16 @@ import { UserIcon } from '@patternfly/react-icons/dist/esm/icons/user-icon'; import UserPreferencesModal from '@osac/ui-components/components/UserPreferences/UserPreferencesModal'; import { useSession } from '@osac/ui-components/hooks/use-session'; +import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; +import { userRoleLabels } from '@osac/ui-components/shellTypes'; import { getErrorMessage } from '@osac/ui-components/utils/error'; -import { operatingModeLabel } from './shellLabels'; - interface ShellMastheadProps { onLogout: () => Promise; } export const ShellMasthead = ({ onLogout }: ShellMastheadProps) => { + const { t } = useTranslation(); const [isUserMenuOpen, setIsUserMenuOpen] = React.useState(false); const [isPreferencesOpen, setPreferencesOpen] = React.useState(false); const [logoutError, setLogoutError] = React.useState(); @@ -102,7 +103,7 @@ export const ShellMasthead = ({ onLogout }: ShellMastheadProps) => { > {displayName}{' '} )} diff --git a/apps/app-frontend/src/shell/shellLabels.ts b/apps/app-frontend/src/shell/shellLabels.ts deleted file mode 100644 index 5707b84c..00000000 --- a/apps/app-frontend/src/shell/shellLabels.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { DemoShellRole } from '@osac/ui-components/shellTypes'; - -const OPERATING_MODE_LABELS: Record = { - providerAdmin: 'Cloud provider admin', - tenantAdmin: 'Tenant admin', - tenantUser: 'Tenant user', -}; - -/** Masthead operating-mode label for the signed-in shell role. */ -export const operatingModeLabel = (role: DemoShellRole): string => - OPERATING_MODE_LABELS[role] ?? role; diff --git a/apps/app-frontend/src/shell/shellNav.test.ts b/apps/app-frontend/src/shell/shellNav.test.ts index 0b20b0ed..275858fa 100644 --- a/apps/app-frontend/src/shell/shellNav.test.ts +++ b/apps/app-frontend/src/shell/shellNav.test.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from 'vitest'; -import type { DemoShellRole } from '@osac/ui-components/shellTypes'; +import type { UserRole } from '@osac/ui-components/shellTypes'; import { tIdentity } from '@osac/ui-components/test-utils/i18n'; import { navRowsForRole } from './shellNav'; -const roles: DemoShellRole[] = ['tenantUser', 'tenantAdmin', 'providerAdmin']; +const roles: UserRole[] = ['tenant-user', 'tenant-admin', 'tenant-idp-manager', 'admin']; -const findSection = (role: DemoShellRole, sectionId: string) => +const findSection = (role: UserRole, sectionId: string) => navRowsForRole(role, tIdentity).find((row) => row.sectionId === sectionId); -const servicesChildren = (role: DemoShellRole) => +const servicesChildren = (role: UserRole) => findSection(role, 'nav-tenant-services')?.children ?? []; describe('navRowsForRole', () => { diff --git a/apps/app-frontend/src/shell/shellNav.ts b/apps/app-frontend/src/shell/shellNav.ts index ab03c83f..9072170d 100644 --- a/apps/app-frontend/src/shell/shellNav.ts +++ b/apps/app-frontend/src/shell/shellNav.ts @@ -1,7 +1,7 @@ /** Role-based sidebar navigation (sectioned NavGroup layout). Nav icons: shellNavIcon in @osac/ui-components/icons */ import type { TFunction } from 'i18next'; -import type { DemoShellRole } from '@osac/ui-components/shellTypes'; +import type { UserRole } from '@osac/ui-components/shellTypes'; export type NavLink = { id: string; label: string; path: string }; @@ -45,4 +45,4 @@ const getBaseNav = (t: TFunction): NavRow[] => [ }, ]; -export const navRowsForRole = (_role: DemoShellRole, t: TFunction): NavRow[] => getBaseNav(t); +export const navRowsForRole = (_role: UserRole, t: TFunction): NavRow[] => getBaseNav(t); diff --git a/apps/app-frontend/src/shell/shellRoutes.test.ts b/apps/app-frontend/src/shell/shellRoutes.test.ts index 6a455b45..b39e5d62 100644 --- a/apps/app-frontend/src/shell/shellRoutes.test.ts +++ b/apps/app-frontend/src/shell/shellRoutes.test.ts @@ -4,8 +4,9 @@ import { defaultRouteForRole } from './shellRoutes'; describe('defaultRouteForRole', () => { it('lands every role on /catalog', () => { - expect(defaultRouteForRole('tenantUser')).toBe('/catalog'); - expect(defaultRouteForRole('tenantAdmin')).toBe('/catalog'); - expect(defaultRouteForRole('providerAdmin')).toBe('/catalog'); + expect(defaultRouteForRole('tenant-user')).toBe('/catalog'); + expect(defaultRouteForRole('tenant-admin')).toBe('/catalog'); + expect(defaultRouteForRole('tenant-idp-manager')).toBe('/catalog'); + expect(defaultRouteForRole('admin')).toBe('/catalog'); }); }); diff --git a/apps/app-frontend/src/shell/shellRoutes.ts b/apps/app-frontend/src/shell/shellRoutes.ts index cf5b1d11..d1839afc 100644 --- a/apps/app-frontend/src/shell/shellRoutes.ts +++ b/apps/app-frontend/src/shell/shellRoutes.ts @@ -1,3 +1,3 @@ -import type { DemoShellRole } from '@osac/ui-components/shellTypes'; +import type { UserRole } from '@osac/ui-components/shellTypes'; -export const defaultRouteForRole = (_role: DemoShellRole): string => '/catalog'; +export const defaultRouteForRole = (_role: UserRole): string => '/catalog'; diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index fc315b88..69b4757c 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -84,6 +84,7 @@ "CIDR overlaps with existing subnet \"{{name}}\" ({{cidr}})": "CIDR overlaps with existing subnet \"{{name}}\" ({{cidr}})", "Close": "Close", "Cloud Init User Data": "Cloud Init User Data", + "Cloud provider admin": "Cloud provider admin", "Cluster conditions": "Cluster conditions", "Cluster node sets": "Cluster node sets", "Cluster password": "Cluster password", @@ -167,6 +168,7 @@ "Host type": "Host type", "Host type is required": "Host type is required", "ICMP": "ICMP", + "IdP manager": "IdP manager", "Inbound Rules": "Inbound Rules", "Instance type": "Instance type", "Internal IP": "Internal IP", @@ -278,6 +280,8 @@ "Subnets": "Subnets", "Take over": "Take over", "TCP": "TCP", + "Tenant admin": "Tenant admin", + "Tenant user": "Tenant user", "The console is available when the virtual machine is running.": "The console is available when the virtual machine is running.", "This console is already open in another tab in this browser. Take over to continue here, or switch to that tab.": "This console is already open in another tab in this browser. Take over to continue here, or switch to that tab.", "This field is required": "This field is required", diff --git a/libs/ui-components/src/hooks/use-session.tsx b/libs/ui-components/src/hooks/use-session.tsx index dc59a3d1..458a83c3 100644 --- a/libs/ui-components/src/hooks/use-session.tsx +++ b/libs/ui-components/src/hooks/use-session.tsx @@ -1,10 +1,10 @@ import { createContext, useContext } from 'react'; -import type { DemoShellRole } from '../shellTypes'; +import type { UserRole } from '../shellTypes'; import { type ResolvedTheme, type Theme, useTheme } from './use-theme'; interface SessionContextValue { - role: DemoShellRole; + role: UserRole; username: string; userTheme: Theme; resolvedTheme: ResolvedTheme; @@ -15,7 +15,7 @@ const SessionContext = createContext(null); interface SessionProviderProps { children: React.ReactNode; - role: DemoShellRole; + role: UserRole; username: string; } diff --git a/libs/ui-components/src/shellTypes.ts b/libs/ui-components/src/shellTypes.ts index 51ebb5ec..27060039 100644 --- a/libs/ui-components/src/shellTypes.ts +++ b/libs/ui-components/src/shellTypes.ts @@ -1,2 +1,11 @@ -/** Demo / OIDC shell roles mapped from Keycloak realm roles. */ -export type DemoShellRole = 'providerAdmin' | 'tenantAdmin' | 'tenantUser'; +import { TFunction } from 'i18next'; + +/** OIDC shell roles mapped from Keycloak realm roles and groups. */ +export type UserRole = 'admin' | 'tenant-idp-manager' | 'tenant-admin' | 'tenant-user'; + +export const userRoleLabels = (t: TFunction): Record => ({ + admin: t('Cloud provider admin'), + 'tenant-idp-manager': t('IdP manager'), + 'tenant-admin': t('Tenant admin'), + 'tenant-user': t('Tenant user'), +});