diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx index 9874500d..f7e926b2 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx @@ -1,4 +1,4 @@ -import React, { FormEvent } from 'react'; +import React, { type FormEvent, useState } from 'react'; import { useId } from '../../hooks/hookPolyfills'; import { type CoderAuthStatus, @@ -6,22 +6,15 @@ import { useCoderAuth, } from '../CoderProvider'; -import { Theme, makeStyles } from '@material-ui/core'; -import TextField from '@material-ui/core/TextField'; import { CoderLogo } from '../CoderLogo'; import { Link, LinkButton } from '@backstage/core-components'; import { VisuallyHidden } from '../VisuallyHidden'; +import { makeStyles } from '@material-ui/core'; +import TextField from '@material-ui/core/TextField'; +import ErrorIcon from '@material-ui/icons/ErrorOutline'; +import SyncIcon from '@material-ui/icons/Sync'; -type UseStyleInput = Readonly<{ status: CoderAuthStatus }>; -type StyleKeys = - | 'formContainer' - | 'authInputFieldset' - | 'coderLogo' - | 'authButton' - | 'warningBanner' - | 'warningBannerContainer'; - -const useStyles = makeStyles(theme => ({ +const useStyles = makeStyles(theme => ({ formContainer: { maxWidth: '30em', marginLeft: 'auto', @@ -50,41 +43,13 @@ const useStyles = makeStyles(theme => ({ marginLeft: 'auto', marginRight: 'auto', }, - - warningBannerContainer: { - paddingTop: theme.spacing(4), - paddingLeft: theme.spacing(6), - paddingRight: theme.spacing(6), - }, - - warningBanner: ({ status }) => { - let color: string; - let backgroundColor: string; - - if (status === 'invalid') { - color = theme.palette.error.contrastText; - backgroundColor = theme.palette.banner.error; - } else { - color = theme.palette.text.primary; - backgroundColor = theme.palette.background.default; - } - - return { - color, - backgroundColor, - borderRadius: theme.shape.borderRadius, - textAlign: 'center', - paddingTop: theme.spacing(0.5), - paddingBottom: theme.spacing(0.5), - }; - }, })); export const CoderAuthInputForm = () => { const hookId = useId(); + const styles = useStyles(); const appConfig = useCoderAppConfig(); const { status, registerNewToken } = useCoderAuth(); - const styles = useStyles({ status }); const onSubmit = (event: FormEvent) => { event.preventDefault(); @@ -161,13 +126,122 @@ export const CoderAuthInputForm = () => { {(status === 'invalid' || status === 'authenticating') && ( -
-
- {status === 'invalid' && 'Invalid token'} - {status === 'authenticating' && <>Authenticating…} -
-
+ )} ); }; + +const useInvalidStatusStyles = makeStyles(theme => ({ + warningBannerSpacer: { + paddingTop: theme.spacing(2), + }, + + warningBanner: { + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + color: theme.palette.text.primary, + backgroundColor: theme.palette.background.default, + borderRadius: theme.shape.borderRadius, + border: `1.5px solid ${theme.palette.background.default}`, + padding: 0, + }, + + errorContent: { + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + columnGap: theme.spacing(1), + marginRight: 'auto', + + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(0.5), + paddingLeft: theme.spacing(2), + paddingRight: 0, + }, + + icon: { + fontSize: '16px', + }, + + syncIcon: { + color: theme.palette.text.primary, + opacity: 0.6, + }, + + errorIcon: { + color: theme.palette.error.main, + fontSize: '16px', + }, + + dismissButton: { + border: 'none', + alignSelf: 'stretch', + padding: `0 ${theme.spacing(1.5)}px 0 ${theme.spacing(2)}px`, + color: theme.palette.text.primary, + backgroundColor: 'inherit', + lineHeight: 1, + cursor: 'pointer', + + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + }, + + '@keyframes spin': { + '100%': { + transform: 'rotate(360deg)', + }, + }, +})); + +type InvalidStatusProps = Readonly<{ + authStatus: CoderAuthStatus; + bannerId: string; +}>; + +function InvalidStatusNotifier({ authStatus, bannerId }: InvalidStatusProps) { + const [showNotification, setShowNotification] = useState(true); + const styles = useInvalidStatusStyles(); + + if (!showNotification) { + return null; + } + + return ( +
+
+ + {authStatus === 'authenticating' && ( + <> + + Authenticating… + + )} + + {authStatus === 'invalid' && ( + <> + + Invalid token + + )} + + + +
+
+ ); +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx index de33394a..bf27a634 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { CoderProviderWithMockAuth } from '../../testHelpers/setup'; import type { CoderAuth, CoderAuthStatus } from '../CoderProvider'; @@ -12,13 +12,13 @@ import { CoderAuthWrapper } from './CoderAuthWrapper'; import { renderInTestApp } from '@backstage/test-utils'; type RenderInputs = Readonly<{ - childButtonText: string; authStatus: CoderAuthStatus; + childButtonText?: string; }>; async function renderAuthWrapper({ authStatus, - childButtonText, + childButtonText = 'Default button text', }: RenderInputs) { const ejectToken = jest.fn(); const registerNewToken = jest.fn(); @@ -108,7 +108,6 @@ describe(`${CoderAuthWrapper.name}`, () => { it('Lets the user eject the current token', async () => { const { ejectToken } = await renderAuthWrapper({ authStatus: 'distrusted', - childButtonText: "I don't matter", }); const user = userEvent.setup(); @@ -174,7 +173,6 @@ describe(`${CoderAuthWrapper.name}`, () => { it('Lets the user submit a new token', async () => { const { registerNewToken } = await renderAuthWrapper({ authStatus: 'tokenMissing', - childButtonText: "I don't matter", }); /** @@ -194,5 +192,24 @@ describe(`${CoderAuthWrapper.name}`, () => { expect(registerNewToken).toHaveBeenCalledWith(mockCoderAuthToken); }); + + it('Lets the user dismiss any notifications for invalid/authenticating states', async () => { + const authStatuses: readonly CoderAuthStatus[] = [ + 'invalid', + 'authenticating', + ]; + + const user = userEvent.setup(); + for (const authStatus of authStatuses) { + const { unmount } = await renderAuthWrapper({ authStatus }); + const dismissButton = await screen.findByRole('button', { + name: 'Dismiss', + }); + + await user.click(dismissButton); + await waitFor(() => expect(dismissButton).not.toBeInTheDocument()); + unmount(); + } + }); }); });