diff --git a/src/shared/GlobalTopBanners/OktaBanners/OktaBanners.spec.tsx b/src/shared/GlobalTopBanners/OktaBanners/OktaBanners.spec.tsx index 357ebf8348..5ff1a7d3c0 100644 --- a/src/shared/GlobalTopBanners/OktaBanners/OktaBanners.spec.tsx +++ b/src/shared/GlobalTopBanners/OktaBanners/OktaBanners.spec.tsx @@ -9,6 +9,7 @@ import OktaBanners from './OktaBanners' jest.mock('../OktaEnabledBanner', () => () => 'OktaEnabledBanner') jest.mock('../OktaEnforcedBanner', () => () => 'OktaEnforcedBanner') +jest.mock('../OktaErrorBanners', () => () => 'OktaErrorBanners') const wrapper = (initialEntries = ['/gh/owner']): React.FC => @@ -145,6 +146,27 @@ describe('OktaBanners', () => { const banner = await screen.findByText(/OktaEnabledBanner/) expect(banner).toBeInTheDocument() }) + + it('should render OktaErrorBanners', async () => { + setup({ + owner: { + isUserOktaAuthenticated: false, + account: { + oktaConfig: { + enabled: true, + enforced: false, + url: 'https://okta.com', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + }, + }, + }) + + render(, { wrapper: wrapper() }) + const banner = await screen.findByText(/OktaErrorBanners/) + expect(banner).toBeInTheDocument() + }) }) describe('when Okta is enforced', () => { @@ -169,6 +191,27 @@ describe('OktaBanners', () => { const banner = await screen.findByText(/OktaEnforcedBanner/) expect(banner).toBeInTheDocument() }) + + it('should render OktaErrorBanners', async () => { + setup({ + owner: { + isUserOktaAuthenticated: false, + account: { + oktaConfig: { + enabled: true, + enforced: true, + url: 'https://okta.com', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + }, + }, + }) + + render(, { wrapper: wrapper() }) + const banner = await screen.findByText(/OktaErrorBanners/) + expect(banner).toBeInTheDocument() + }) }) }) }) diff --git a/src/shared/GlobalTopBanners/OktaBanners/OktaBanners.tsx b/src/shared/GlobalTopBanners/OktaBanners/OktaBanners.tsx index 6681dd1746..dfb20057c9 100644 --- a/src/shared/GlobalTopBanners/OktaBanners/OktaBanners.tsx +++ b/src/shared/GlobalTopBanners/OktaBanners/OktaBanners.tsx @@ -10,6 +10,7 @@ interface URLParams { const OktaEnabledBanner = lazy(() => import('../OktaEnabledBanner')) const OktaEnforcedBanner = lazy(() => import('../OktaEnforcedBanner')) +const OktaErrorBanners = lazy(() => import('../OktaErrorBanners')) function OktaBanners() { const { provider, owner } = useParams() @@ -25,7 +26,12 @@ function OktaBanners() { if (!owner || !oktaConfig?.enabled || data?.owner?.isUserOktaAuthenticated) return null - return oktaConfig?.enforced ? : + return ( +
+ {oktaConfig?.enforced ? : } + +
+ ) } export default OktaBanners diff --git a/src/shared/GlobalTopBanners/OktaErrorBanners/OktaErrorBanners.spec.tsx b/src/shared/GlobalTopBanners/OktaErrorBanners/OktaErrorBanners.spec.tsx new file mode 100644 index 0000000000..f7cd89fbf6 --- /dev/null +++ b/src/shared/GlobalTopBanners/OktaErrorBanners/OktaErrorBanners.spec.tsx @@ -0,0 +1,111 @@ +import * as Sentry from '@sentry/react' +import { render, screen } from '@testing-library/react' +import { MemoryRouter, Route } from 'react-router-dom' + +import OktaErrorBanners from './OktaErrorBanners' + +jest.mock('@sentry/react', () => { + const originalModule = jest.requireActual('@sentry/react') + return { + ...originalModule, + captureMessage: jest.fn(), + } +}) + +const wrapper = + ( + initialEntries = ['/gh/codecov'], + path = '/:provider/:owner' + ): React.FC => + ({ children }) => ( + + {children} + + ) + +describe('OktaErrorBanners', () => { + it('should return null if owner is not provided', () => { + const { container } = render(, { + wrapper: wrapper(['/gh/'], '/:provider'), + }) + + expect(container).toBeEmptyDOMElement() + }) + + it('should return null if error is not provided', () => { + const { container } = render(, { + wrapper: wrapper(['/gh/codecov']), + }) + + expect(container).toBeEmptyDOMElement() + }) + + it('should render error message for invalid_request', () => { + render(, { + wrapper: wrapper(['/gh/codecov?error=invalid_request']), + }) + + const content = screen.getByText( + /Invalid request: The request parameters aren't valid. Please try again or contact support./ + ) + expect(content).toBeInTheDocument() + }) + + it('should render error message for unauthorized_client', () => { + render(, { + wrapper: wrapper(['/gh/codecov?error=unauthorized_client']), + }) + + const content = screen.getByText( + /Unauthorized client: The client isn't authorized to request an authorization code using this method. Please reach out to your administrator./ + ) + expect(content).toBeInTheDocument() + }) + + it('should render error message for access_denied', () => { + render(, { + wrapper: wrapper(['/gh/codecov?error=access_denied']), + }) + + const content = screen.getByText( + /The resource owner or authorization server denied the request/ + ) + expect(content).toBeInTheDocument() + }) + + it('should render default error message for unknown error', () => { + render(, { + wrapper: wrapper(['/gh/codecov?error=unknown']), + }) + + const content = screen.getByText( + /An unknown error occurred. Please try again or contact support./ + ) + expect(content).toBeInTheDocument() + }) + + it('should capture unknown error message', () => { + render(, { + wrapper: wrapper(['/gh/codecov?error=unknown']), + }) + + expect(Sentry.captureMessage).toHaveBeenCalledWith( + 'Unknown Okta error: unknown', + { + fingerprint: ['unknown-okta-error'], + tags: { + error: 'unknown', + }, + } + ) + }) + + it('should render dismiss button', () => { + render(, { + wrapper: wrapper(['/gh/codecov?error=invalid_request']), + }) + + const dismissButton = screen.getByRole('button', { name: /Dismiss/ }) + expect(dismissButton).toBeInTheDocument() + }) +}) diff --git a/src/shared/GlobalTopBanners/OktaErrorBanners/OktaErrorBanners.tsx b/src/shared/GlobalTopBanners/OktaErrorBanners/OktaErrorBanners.tsx new file mode 100644 index 0000000000..17a1885b71 --- /dev/null +++ b/src/shared/GlobalTopBanners/OktaErrorBanners/OktaErrorBanners.tsx @@ -0,0 +1,41 @@ +import { useLocation, useParams } from 'react-router-dom' + +import Icon from 'ui/Icon' +import TopBanner from 'ui/TopBanner' + +import { getOktaErrorMessage } from './enums' + +interface URLParams { + owner?: string +} + +const OktaErrorBanners = () => { + const { owner } = useParams() + const location = useLocation() + + const searchParams = new URLSearchParams(location.search) + const error = searchParams.get('error') + + if (!owner || !error) return null + + const errorMessage = getOktaErrorMessage(error) + + return ( + + +

+ + + Okta Authentication Error + + {errorMessage} +

+
+ + Dismiss + +
+ ) +} + +export default OktaErrorBanners diff --git a/src/shared/GlobalTopBanners/OktaErrorBanners/enums.ts b/src/shared/GlobalTopBanners/OktaErrorBanners/enums.ts new file mode 100644 index 0000000000..aaf8c9fb91 --- /dev/null +++ b/src/shared/GlobalTopBanners/OktaErrorBanners/enums.ts @@ -0,0 +1,55 @@ +import * as Sentry from '@sentry/react' + +const ErrorType = { + UnauthorizedClient: 'unauthorized_client', + AccessDenied: 'access_denied', + UnsupportedResponseType: 'unsupported_response_type', + UnsupportedResponseMode: 'unsupported_response_mode', + InvalidScope: 'invalid_scope', + ServerError: 'server_error', + TemporarilyUnavailable: 'temporarily_unavailable', + InvalidClient: 'invalid_client', + LoginRequired: 'login_required', + InvalidRequest: 'invalid_request', + UserCanceledRequest: 'user_canceled_request', +} as const + +const errorMessages = { + [ErrorType.UnauthorizedClient]: + "Unauthorized client: The client isn't authorized to request an authorization code using this method. Please reach out to your administrator.", + [ErrorType.AccessDenied]: + 'Access denied: The resource owner or authorization server denied the request. Please reach out to your administrator.', + [ErrorType.UnsupportedResponseType]: + "Unsupported response type: The authorization server doesn't support obtaining an authorization code using this method. Please reach out to your administrator.", + [ErrorType.UnsupportedResponseMode]: + "Unsupported response mode: The authorization server doesn't support the requested response mode. Please reach out to your administrator.", + [ErrorType.InvalidScope]: + 'Invalid scope: The requested scope is invalid, unknown, or malformed. Please reach out to your administrator.', + [ErrorType.ServerError]: + 'Server error: The authorization server encountered an unexpected condition that prevented it from fulfilling the request. Please try again later or contact support.', + [ErrorType.TemporarilyUnavailable]: + 'Temporarily unavailable: The authorization server is currently unable to handle the request due to temporary overloading or maintenance. Please try again later.', + [ErrorType.InvalidClient]: + "Invalid client: The specified client isn't valid. Please reach out to your administrator.", + [ErrorType.LoginRequired]: + "Login required: The client specified not to prompt, but the user isn't signed in. Please sign in and try again.", + [ErrorType.InvalidRequest]: + "Invalid request: The request parameters aren't valid. Please try again or contact support.", + [ErrorType.UserCanceledRequest]: + 'Request canceled: User canceled the social sign-in request. Please try again if this was unintentional.', +} as const + +export const getOktaErrorMessage = (error: string): string => { + if (error in errorMessages) { + return errorMessages[error as keyof typeof errorMessages] + } + + Sentry.captureMessage(`Unknown Okta error: ${error}`, { + fingerprint: ['unknown-okta-error'], + tags: { + error: error, + }, + }) + + return 'An unknown error occurred. Please try again or contact support.' +} diff --git a/src/shared/GlobalTopBanners/OktaErrorBanners/index.ts b/src/shared/GlobalTopBanners/OktaErrorBanners/index.ts new file mode 100644 index 0000000000..e292af552d --- /dev/null +++ b/src/shared/GlobalTopBanners/OktaErrorBanners/index.ts @@ -0,0 +1 @@ +export { default } from './OktaErrorBanners' diff --git a/src/ui/TopBanner/TopBanner.stories.tsx b/src/ui/TopBanner/TopBanner.stories.tsx index 3574b047a3..49d43632c7 100644 --- a/src/ui/TopBanner/TopBanner.stories.tsx +++ b/src/ui/TopBanner/TopBanner.stories.tsx @@ -13,7 +13,7 @@ const meta: Meta = { }, }, variant: { - options: ['default', 'warning'], + options: ['default', 'warning', 'error'], control: 'radio', description: 'This prop controls the variation of top banner that is displayed', @@ -35,7 +35,7 @@ export const TopBanner: Story = { }, argTypes: { variant: { - options: ['default', 'warning'], + options: ['default', 'warning', 'error'], control: 'radio', }, children: { diff --git a/src/ui/TopBanner/TopBanner.tsx b/src/ui/TopBanner/TopBanner.tsx index 8d177b9af2..2d3cb21633 100644 --- a/src/ui/TopBanner/TopBanner.tsx +++ b/src/ui/TopBanner/TopBanner.tsx @@ -19,12 +19,21 @@ const variants = { iconColor: 'text-ds-primary-yellow', bgColor: 'bg-orange-100', }, + error: { + icon: 'exclamationCircle', + iconColor: '', + bgColor: 'bg-ds-primary-red', + }, } as const type Variants = keyof typeof variants const topBannerContext = z.object({ - variant: z.union([z.literal('default'), z.literal('warning')]), + variant: z.union([ + z.literal('default'), + z.literal('warning'), + z.literal('error'), + ]), localStorageKey: z.string().optional(), setHideBanner: z.function().args(z.boolean()).returns(z.void()), })