diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json index 6dcc24a8..e21caf74 100644 --- a/plugins/backstage-plugin-coder/package.json +++ b/plugins/backstage-plugin-coder/package.json @@ -46,7 +46,8 @@ "valibot": "^0.28.1" }, "peerDependencies": { - "react": "^16.13.1 || ^17.0.0 || ^18.0.0" + "react": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0" }, "devDependencies": { "@backstage/cli": "^0.25.1", diff --git a/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx b/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx new file mode 100644 index 00000000..4c5959b9 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/A11yInfoCard/A11yInfoCard.tsx @@ -0,0 +1,63 @@ +/** + * @file A slightly different take on Backstage's official InfoCard component, + * with better support for accessibility. + * + * Does not support all of InfoCard's properties just yet. + */ +import React, { type HTMLAttributes, type ReactNode, forwardRef } from 'react'; +import { makeStyles } from '@material-ui/core'; + +export type A11yInfoCardProps = Readonly< + HTMLAttributes & { + headerContent?: ReactNode; + } +>; + +const useStyles = makeStyles(theme => ({ + root: { + color: theme.palette.type, + backgroundColor: theme.palette.background.paper, + padding: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + boxShadow: theme.shadows[1], + }, + + headerContent: { + // Ideally wouldn't be using hard-coded font sizes, but couldn't figure out + // how to use the theme.typography property, especially since not all + // sub-properties have font sizes defined + fontSize: '1.5rem', + color: theme.palette.text.primary, + fontWeight: 700, + borderBottom: `1px solid ${theme.palette.divider}`, + + // Margins and padding are a bit wonky to support full-bleed layouts + marginLeft: `-${theme.spacing(2)}px`, + marginRight: `-${theme.spacing(2)}px`, + padding: `0 ${theme.spacing(2)}px ${theme.spacing(2)}px`, + }, +})); + +// Card should be treated as equivalent to Backstage's official InfoCard +// component; had to make custom version so that it could forward properties for +// accessibility/screen reader support +export const A11yInfoCard = forwardRef( + (props, ref) => { + const { className, children, headerContent, ...delegatedProps } = props; + const styles = useStyles(); + + return ( +
+ {headerContent !== undefined && ( +
{headerContent}
+ )} + + {children} +
+ ); + }, +); diff --git a/plugins/backstage-plugin-coder/src/components/A11yInfoCard/index.ts b/plugins/backstage-plugin-coder/src/components/A11yInfoCard/index.ts new file mode 100644 index 00000000..5ef69f03 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/A11yInfoCard/index.ts @@ -0,0 +1 @@ +export * from './A11yInfoCard'; diff --git a/plugins/backstage-plugin-coder/src/components/Card/Card.tsx b/plugins/backstage-plugin-coder/src/components/Card/Card.tsx deleted file mode 100644 index 995b8e5c..00000000 --- a/plugins/backstage-plugin-coder/src/components/Card/Card.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React, { type HTMLAttributes, forwardRef } from 'react'; -import { makeStyles } from '@material-ui/core'; - -const useStyles = makeStyles(theme => ({ - root: { - color: theme.palette.type, - backgroundColor: theme.palette.background.paper, - padding: theme.spacing(2), - borderRadius: theme.shape.borderRadius, - boxShadow: theme.shadows[1], - }, -})); - -type CardProps = HTMLAttributes; - -export const Card = forwardRef((props, ref) => { - const { className, ...delegatedProps } = props; - const styles = useStyles(); - - return ( -
- ); -}); diff --git a/plugins/backstage-plugin-coder/src/components/Card/index.ts b/plugins/backstage-plugin-coder/src/components/Card/index.ts deleted file mode 100644 index ca0b0604..00000000 --- a/plugins/backstage-plugin-coder/src/components/Card/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Card'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx similarity index 69% rename from plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx rename to plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx index 1a63a24a..a37c1916 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthDistrustedForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { CoderLogo } from '../CoderLogo'; -import { LinkButton } from '@backstage/core-components'; import { makeStyles } from '@material-ui/core'; -import { useCoderAuth } from '../CoderProvider'; +import { UnlinkAccountButton } from './UnlinkAccountButton'; const useStyles = makeStyles(theme => ({ root: { @@ -31,8 +30,6 @@ const useStyles = makeStyles(theme => ({ export const CoderAuthDistrustedForm = () => { const styles = useStyles(); - const { ejectToken } = useCoderAuth(); - return (
@@ -43,18 +40,7 @@ export const CoderAuthDistrustedForm = () => {

- - Eject token - +
); }; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx similarity index 56% rename from plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx rename to plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx index 43199c04..95ce2993 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx @@ -8,18 +8,14 @@ import { mockAuthStates, mockCoderAuthToken, } from '../../testHelpers/mockBackstageData'; -import { CoderAuthWrapper } from './CoderAuthWrapper'; +import { CoderAuthForm } from './CoderAuthForm'; import { renderInTestApp } from '@backstage/test-utils'; type RenderInputs = Readonly<{ authStatus: CoderAuthStatus; - childButtonText?: string; }>; -async function renderAuthWrapper({ - authStatus, - childButtonText = 'Default button text', -}: RenderInputs) { +async function renderAuthWrapper({ authStatus }: RenderInputs) { const ejectToken = jest.fn(); const registerNewToken = jest.fn(); @@ -40,50 +36,24 @@ async function renderAuthWrapper({ */ const renderOutput = await renderInTestApp( - - - + , ); return { ...renderOutput, ejectToken, registerNewToken }; } -describe(`${CoderAuthWrapper.name}`, () => { - describe('Displaying main content', () => { - it('Displays the main children when the user is authenticated', async () => { - const buttonText = 'I have secret Coder content!'; - renderAuthWrapper({ - authStatus: 'authenticated', - childButtonText: buttonText, - }); - - const button = await screen.findByRole('button', { name: buttonText }); - - // This assertion isn't necessary because findByRole will throw an error - // if the button can't be found within the expected period of time. Doing - // this purely to make the Backstage linter happy - expect(button).toBeInTheDocument(); - }); - }); - +describe(`${CoderAuthForm.name}`, () => { describe('Loading UI', () => { it('Is displayed while the auth is initializing', async () => { - const buttonText = "You shouldn't be able to see me!"; - renderAuthWrapper({ - authStatus: 'initializing', - childButtonText: buttonText, - }); - - await screen.findByText(/Loading/); - const button = screen.queryByRole('button', { name: buttonText }); - expect(button).not.toBeInTheDocument(); + renderAuthWrapper({ authStatus: 'initializing' }); + const loadingIndicator = await screen.findByText(/Loading/); + expect(loadingIndicator).toBeInTheDocument(); }); }); describe('Token distrusted form', () => { it("Is displayed when the user's auth status cannot be verified", async () => { - const buttonText = 'Not sure if you should be able to see me'; const distrustedTextMatcher = /Unable to verify token authenticity/; const distrustedStatuses: readonly CoderAuthStatus[] = [ 'distrusted', @@ -91,16 +61,11 @@ describe(`${CoderAuthWrapper.name}`, () => { 'deploymentUnavailable', ]; - for (const status of distrustedStatuses) { - const { unmount } = await renderAuthWrapper({ - authStatus: status, - childButtonText: buttonText, - }); - - await screen.findByText(distrustedTextMatcher); - const button = screen.queryByRole('button', { name: buttonText }); - expect(button).not.toBeInTheDocument(); + for (const authStatus of distrustedStatuses) { + const { unmount } = await renderAuthWrapper({ authStatus }); + const message = await screen.findByText(distrustedTextMatcher); + expect(message).toBeInTheDocument(); unmount(); } }); @@ -112,58 +77,28 @@ describe(`${CoderAuthWrapper.name}`, () => { const user = userEvent.setup(); const ejectButton = await screen.findByRole('button', { - name: 'Eject token', + name: /Unlink Coder account/, }); await user.click(ejectButton); expect(ejectToken).toHaveBeenCalled(); }); - - it('Will appear if auth status changes during re-renders', async () => { - const buttonText = "Now you see me, now you don't"; - const { rerender } = await renderAuthWrapper({ - authStatus: 'authenticated', - childButtonText: buttonText, - }); - - // Capture button after it first appears on the screen - const button = await screen.findByRole('button', { name: buttonText }); - - rerender( - - - - - , - ); - - // Assert that the button is now gone - expect(button).not.toBeInTheDocument(); - }); }); describe('Token submission form', () => { it("Is displayed when the token either doesn't exist or is definitely not valid", async () => { - const buttonText = "You're not allowed to gaze upon my visage"; - const tokenFormMatcher = /Please enter a new token/; const statusesForInvalidUser: readonly CoderAuthStatus[] = [ 'invalid', 'tokenMissing', ]; - for (const status of statusesForInvalidUser) { - const { unmount } = await renderAuthWrapper({ - authStatus: status, - childButtonText: buttonText, + for (const authStatus of statusesForInvalidUser) { + const { unmount } = await renderAuthWrapper({ authStatus }); + const form = screen.getByRole('form', { + name: /Authenticate with Coder/, }); - await screen.findByText(tokenFormMatcher); - const button = screen.queryByRole('button', { name: buttonText }); - expect(button).not.toBeInTheDocument(); - + expect(form).toBeInTheDocument(); unmount(); } }); @@ -178,7 +113,8 @@ describe(`${CoderAuthWrapper.name}`, () => { * 1. The auth input is of type password, which does not have a role * compatible with Testing Library; can't use getByRole to select it * 2. MUI adds a star to its labels that are required, meaning that any - * attempts at trying to match the string "Auth token" will fail + * attempts at trying to match string literal "Auth token" will fail; + * have to use a regex selector */ const inputField = screen.getByLabelText(/Auth token/); const submitButton = screen.getByRole('button', { name: 'Authenticate' }); diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx similarity index 53% rename from plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx rename to plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx index b0e6ee22..638a1a75 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthWrapper.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.tsx @@ -1,52 +1,30 @@ -import React, { type FC, type PropsWithChildren } from 'react'; -import { useCoderAuth } from '../CoderProvider'; -import { InfoCard } from '@backstage/core-components'; +import React from 'react'; +import { useInternalCoderAuth } from '../CoderProvider'; import { CoderAuthDistrustedForm } from './CoderAuthDistrustedForm'; -import { makeStyles } from '@material-ui/core'; import { CoderAuthLoadingState } from './CoderAuthLoadingState'; import { CoderAuthInputForm } from './CoderAuthInputForm'; +import { CoderAuthSuccessStatus } from './CoderAuthSuccessStatus'; -const useStyles = makeStyles(theme => ({ - cardContent: { - paddingTop: theme.spacing(5), - paddingBottom: theme.spacing(5), - }, -})); +export type CoderAuthFormProps = Readonly<{ + descriptionId?: string; +}>; -function CoderAuthCard({ children }: PropsWithChildren) { - const styles = useStyles(); - return ( - -
{children}
-
- ); -} - -type WrapperProps = Readonly< - PropsWithChildren<{ - type: 'card'; - }> ->; - -export const CoderAuthWrapper = ({ children, type }: WrapperProps) => { - const auth = useCoderAuth(); - if (auth.isAuthenticated) { - return <>{children}; - } - - let Wrapper: FC>; - switch (type) { - case 'card': { - Wrapper = CoderAuthCard; - break; - } - default: { - assertExhaustion(type); - } - } +export const CoderAuthForm = ({ descriptionId }: CoderAuthFormProps) => { + const auth = useInternalCoderAuth(); return ( - + <> + {/* + * By default this text will be inert, and not be exposed anywhere + * (Sighted and blind users won't be able to interact with it). To enable + * it for screen readers, a consuming component will need bind an ID to + * another component via aria-describedby and then pass the same ID down + * as props. + */} + + {/* Slightly awkward syntax with the IIFE, but need something switch-like to make sure that all status cases are handled exhaustively */} {(() => { @@ -69,9 +47,7 @@ export const CoderAuthWrapper = ({ children, type }: WrapperProps) => { case 'authenticated': case 'distrustedWithGracePeriod': { - throw new Error( - 'Tried to process authenticated user after main content should already be shown', - ); + return ; } default: { @@ -79,7 +55,7 @@ export const CoderAuthWrapper = ({ children, type }: WrapperProps) => { } } })()} - + ); }; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx similarity index 98% rename from plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx rename to plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx index f7e926b2..ae527e28 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthInputForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthInputForm.tsx @@ -3,7 +3,7 @@ import { useId } from '../../hooks/hookPolyfills'; import { type CoderAuthStatus, useCoderAppConfig, - useCoderAuth, + useInternalCoderAuth, } from '../CoderProvider'; import { CoderLogo } from '../CoderLogo'; @@ -49,7 +49,7 @@ export const CoderAuthInputForm = () => { const hookId = useId(); const styles = useStyles(); const appConfig = useCoderAppConfig(); - const { status, registerNewToken } = useCoderAuth(); + const { status, registerNewToken } = useInternalCoderAuth(); const onSubmit = (event: FormEvent) => { event.preventDefault(); diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthLoadingState.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthLoadingState.tsx similarity index 100% rename from plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/CoderAuthLoadingState.tsx rename to plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthLoadingState.tsx diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthSuccessStatus.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthSuccessStatus.tsx new file mode 100644 index 00000000..d2c71513 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthSuccessStatus.tsx @@ -0,0 +1,61 @@ +/** + * @file In practice, this is a component that ideally shouldn't ever be seen by + * the end user. Any component rendering out CoderAuthForm should ideally be set + * up so that when a user is authenticated, the entire component will be + * unmounted before CoderAuthForm has a chance to handle successful states. + * + * But just for the sake of completion (and to remove the risk of runtime render + * errors), this component has been added to provide a form of double + * book-keeping for the auth status switch checks in the parent component. Don't + * want the entire plugin to blow up if an auth conditional in a different + * component is accidentally set up wrong. + */ +import React from 'react'; +import { makeStyles } from '@material-ui/core'; +import { CoderLogo } from '../CoderLogo'; +import { UnlinkAccountButton } from './UnlinkAccountButton'; + +const useStyles = makeStyles(theme => ({ + root: { + display: 'flex', + flexFlow: 'column nowrap', + alignItems: 'center', + rowGap: theme.spacing(1), + + maxWidth: '30em', + marginLeft: 'auto', + marginRight: 'auto', + color: theme.palette.text.primary, + fontSize: '1rem', + }, + + statusArea: { + display: 'flex', + flexFlow: 'column nowrap', + alignItems: 'center', + }, + + logo: { + // + }, + + text: { + textAlign: 'center', + lineHeight: '1rem', + }, +})); + +export function CoderAuthSuccessStatus() { + const styles = useStyles(); + + return ( +
+
+ +

You are fully authenticated with Coder!

+
+ + +
+ ); +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx new file mode 100644 index 00000000..63b9fdd0 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx @@ -0,0 +1,42 @@ +import React, { type ComponentProps } from 'react'; +import { LinkButton } from '@backstage/core-components'; +import { makeStyles } from '@material-ui/core'; +import { useInternalCoderAuth } from '../CoderProvider'; + +type Props = Readonly, 'to'>>; + +const useStyles = makeStyles(() => ({ + root: { + display: 'block', + maxWidth: 'fit-content', + }, +})); + +export function UnlinkAccountButton({ + className, + onClick, + type = 'button', + ...delegatedProps +}: Props) { + const styles = useStyles(); + const { ejectToken } = useInternalCoderAuth(); + + return ( + { + ejectToken(); + onClick?.(event); + }} + {...delegatedProps} + > + Unlink Coder account + + ); +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/index.ts b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/index.ts new file mode 100644 index 00000000..752873c4 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/index.ts @@ -0,0 +1 @@ +export * from './CoderAuthForm'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.test.tsx new file mode 100644 index 00000000..2a0c7cb1 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { CoderProviderWithMockAuth } from '../../testHelpers/setup'; +import type { CoderAuthStatus } from '../CoderProvider'; +import { + mockAppConfig, + mockAuthStates, +} from '../../testHelpers/mockBackstageData'; +import { CoderAuthFormCardWrapper } from './CoderAuthFormCardWrapper'; +import { renderInTestApp } from '@backstage/test-utils'; + +type RenderInputs = Readonly<{ + authStatus: CoderAuthStatus; + childButtonText: string; +}>; + +async function renderAuthWrapper({ + authStatus, + childButtonText, +}: RenderInputs) { + return renderInTestApp( + + + + + , + ); +} + +describe(`${CoderAuthFormCardWrapper.name}`, () => { + it('Displays the main children when the user is authenticated', async () => { + const childButtonText = 'I have secret Coder content!'; + const validStatuses: readonly CoderAuthStatus[] = [ + 'authenticated', + 'distrustedWithGracePeriod', + ]; + + for (const authStatus of validStatuses) { + const { unmount } = await renderAuthWrapper({ + authStatus, + childButtonText, + }); + + const button = await screen.findByRole('button', { + name: childButtonText, + }); + + // This assertion isn't necessary because findByRole will throw an error + // if the button can't be found within the expected period of time. Doing + // this purely to make the Backstage linter happy + expect(button).toBeInTheDocument(); + unmount(); + } + }); + + it('Hides the main children for any invalid/untrustworthy auth status', async () => { + const childButtonText = 'I should never be visible on the screen!'; + const invalidStatuses: readonly CoderAuthStatus[] = [ + 'deploymentUnavailable', + 'distrusted', + 'initializing', + 'invalid', + 'noInternetConnection', + 'tokenMissing', + ]; + + for (const authStatus of invalidStatuses) { + const { unmount } = await renderAuthWrapper({ + authStatus, + childButtonText, + }); + + const button = screen.queryByRole('button', { name: childButtonText }); + expect(button).not.toBeInTheDocument(); + unmount(); + } + }); + + it('Will go back to hiding content if auth state becomes invalid after re-renders', async () => { + const buttonText = "Now you see me, now you don't"; + const { rerender } = await renderAuthWrapper({ + authStatus: 'authenticated', + childButtonText: buttonText, + }); + + // Capture button after it first appears on the screen; findBy will throw if + // the button is not actually visible + const button = await screen.findByRole('button', { name: buttonText }); + + rerender( + + + + + , + ); + + // Assert that the button is gone after the re-render flushes + expect(button).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.tsx new file mode 100644 index 00000000..1fa0f9fc --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/CoderAuthFormCardWrapper.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { A11yInfoCard, A11yInfoCardProps } from '../A11yInfoCard'; +import { useInternalCoderAuth } from '../CoderProvider'; +import { + type CoderAuthFormProps, + CoderAuthForm, +} from '../CoderAuthForm/CoderAuthForm'; +import { makeStyles } from '@material-ui/core'; + +type Props = A11yInfoCardProps & CoderAuthFormProps; + +const useStyles = makeStyles(theme => ({ + root: { + paddingTop: theme.spacing(6), + paddingBottom: theme.spacing(6), + }, +})); + +export function CoderAuthFormCardWrapper({ + children, + headerContent, + descriptionId, + ...delegatedCardProps +}: Props) { + const { isAuthenticated } = useInternalCoderAuth(); + const styles = useStyles(); + + return ( + Authenticate with Coder + } + {...delegatedCardProps} + > + {isAuthenticated ? ( + <>{children} + ) : ( +
+ +
+ )} +
+ ); +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/index.ts b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/index.ts new file mode 100644 index 00000000..e59d2626 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormCardWrapper/index.ts @@ -0,0 +1 @@ +export * from './CoderAuthFormCardWrapper'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx new file mode 100644 index 00000000..7c39fc95 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/CoderAuthFormDialog.tsx @@ -0,0 +1,145 @@ +import React, { type HTMLAttributes, useState } from 'react'; +import { useId } from '../../hooks/hookPolyfills'; +import { makeStyles } from '@material-ui/core'; +import { LinkButton } from '@backstage/core-components'; +import Dialog from '@material-ui/core/Dialog'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogActions from '@material-ui/core/DialogActions'; +import { CoderAuthForm } from '../CoderAuthForm/CoderAuthForm'; + +const useStyles = makeStyles(theme => ({ + trigger: { + cursor: 'pointer', + color: theme.palette.primary.contrastText, + backgroundColor: theme.palette.primary.main, + width: 'fit-content', + border: 'none', + fontWeight: 600, + borderRadius: theme.shape.borderRadius, + transition: '10s color ease-in-out', + padding: `${theme.spacing(1.5)}px ${theme.spacing(2)}px`, + boxShadow: theme.shadows[10], + + '&:hover': { + backgroundColor: theme.palette.primary.dark, + boxShadow: theme.shadows[15], + }, + }, + + dialogContainer: { + width: '100%', + height: '100%', + display: 'flex', + flexFlow: 'column nowrap', + justifyContent: 'center', + alignItems: 'center', + }, + + dialogPaper: { + width: '100%', + }, + + dialogTitle: { + fontSize: '24px', + borderBottom: `${theme.palette.divider} 1px solid`, + padding: `${theme.spacing(1)}px ${theme.spacing(3)}px`, + }, + + contentContainer: { + padding: `${theme.spacing(6)}px ${theme.spacing(3)}px 0`, + }, + + actionsRow: { + display: 'flex', + flexFlow: 'row nowrap', + justifyContent: 'center', + padding: `${theme.spacing(1)}px ${theme.spacing(2)}px ${theme.spacing( + 6, + )}px`, + }, + + closeButton: { + letterSpacing: '0.05em', + padding: `${theme.spacing(0.5)}px ${theme.spacing(1)}px`, + color: theme.palette.primary.main, + + '&:hover': { + textDecoration: 'none', + }, + }, +})); + +type DialogProps = Readonly< + Omit, 'onClick' | 'className'> & { + open?: boolean; + onOpen?: () => void; + onClose?: () => void; + triggerClassName?: string; + } +>; + +export function CoderAuthFormDialog({ + children, + onOpen, + onClose, + triggerClassName, + open: outerIsOpen, +}: DialogProps) { + const hookId = useId(); + const styles = useStyles(); + const [innerIsOpen, setInnerIsOpen] = useState(false); + + const handleClose = () => { + setInnerIsOpen(false); + onClose?.(); + }; + + const isOpen = outerIsOpen ?? innerIsOpen; + const titleId = `${hookId}-dialog-title`; + const descriptionId = `${hookId}-dialog-description`; + + return ( + <> + + + + + Authenticate with Coder + + + + + + + + + Close + + + + + ); +} diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/index.ts b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/index.ts new file mode 100644 index 00000000..3b1069e3 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthFormDialog/index.ts @@ -0,0 +1 @@ +export * from './CoderAuthFormDialog'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/index.ts b/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/index.ts deleted file mode 100644 index 3d0896b5..00000000 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthWrapper/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './CoderAuthWrapper'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 852abce1..c9b6fbb1 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -1,24 +1,34 @@ import React, { type PropsWithChildren, createContext, + useCallback, useContext, useEffect, + useLayoutEffect, + useRef, useState, } from 'react'; - +import { createPortal } from 'react-dom'; import { + type QueryCacheNotifyEvent, type UseQueryResult, useQuery, useQueryClient, } from '@tanstack/react-query'; +import { useApi } from '@backstage/core-plugin-api'; +import { type Theme, makeStyles } from '@material-ui/core'; +import { useId } from '../../hooks/hookPolyfills'; import { BackstageHttpError } from '../../api/errors'; import { CODER_QUERY_KEY_PREFIX, sharedAuthQueryKey, } from '../../api/queryOptions'; import { coderClientApiRef } from '../../api/CoderClient'; -import { useApi } from '@backstage/core-plugin-api'; +import { CoderLogo } from '../CoderLogo'; +import { CoderAuthFormDialog } from '../CoderAuthFormDialog'; +const BACKSTAGE_APP_ROOT_ID = '#root'; +const FALLBACK_UI_OVERRIDE_CLASS_NAME = 'backstage-root-override'; const TOKEN_STORAGE_KEY = 'coder-backstage-plugin/token'; // Handles auth edge case where a previously-valid token can't be verified. Not @@ -55,52 +65,28 @@ export type CoderAuthStatus = AuthState['status']; export type CoderAuth = Readonly< AuthState & { isAuthenticated: boolean; - tokenLoadedOnMount: boolean; registerNewToken: (newToken: string) => void; ejectToken: () => void; } >; -function isAuthValid(state: AuthState): boolean { - return ( - state.status === 'authenticated' || - state.status === 'distrustedWithGracePeriod' - ); -} - -type ValidCoderAuth = Extract< - CoderAuth, - { status: 'authenticated' | 'distrustedWithGracePeriod' } ->; - -export function assertValidCoderAuth( - auth: CoderAuth, -): asserts auth is ValidCoderAuth { - if (!isAuthValid(auth)) { - throw new Error('Coder auth is not valid'); - } -} - -export const AuthContext = createContext(null); +type TrackComponent = (componentInstanceId: string) => () => void; +export const AuthTrackingContext = createContext(null); +export const AuthStateContext = createContext(null); -export function useCoderAuth(): CoderAuth { - const contextValue = useContext(AuthContext); - if (contextValue === null) { - throw new Error( - `Hook ${useCoderAuth.name} is being called outside of CoderProvider`, - ); - } - - return contextValue; -} +const validAuthStatuses: readonly CoderAuthStatus[] = [ + 'authenticated', + 'distrustedWithGracePeriod', +]; -type CoderAuthProviderProps = Readonly>; +function useAuthState(): CoderAuth { + const [authToken, setAuthToken] = useState( + () => window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? '', + ); -export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { - // Need to split hairs, because the query object can be disabled. Only want to - // expose the initializing state if the app mounts with a token already in - // localStorage - const [authToken, setAuthToken] = useState(readAuthToken); + // Need to differentiate the current token from the token loaded on mount + // because the query object can be disabled. Only want to expose the + // initializing state if the app mounts with a token already in localStorage const [readonlyInitialAuthToken] = useState(authToken); const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); @@ -112,6 +98,8 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { queryFn: () => coderClient.syncToken(authToken), enabled: queryIsEnabled, keepPreviousData: queryIsEnabled, + + // Can't use !query.state.data because we want to refetch on undefined cases refetchOnWindowFocus: query => query.state.data !== false, }); @@ -123,8 +111,8 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { }); // Mid-render state sync to avoid unnecessary re-renders that useEffect would - // introduce, especially since we don't know how costly re-renders could be in - // someone's arbitrarily-large Backstage deployment + // introduce. We don't know how costly re-renders could be in someone's + // arbitrarily-large Backstage deployment, so erring on the side of caution if (!isInsideGracePeriod && authState.status === 'authenticated') { setIsInsideGracePeriod(true); } @@ -152,13 +140,14 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { // outside React because we let the user connect their own queryClient const queryClient = useQueryClient(); useEffect(() => { - let isRefetchingTokenQuery = false; - const queryCache = queryClient.getQueryCache(); + // Pseudo-mutex; makes sure that if we get a bunch of errors, only one + // revalidation will be processed at a time + let isRevalidatingToken = false; - const unsubscribe = queryCache.subscribe(async event => { + const revalidateTokenOnError = async (event: QueryCacheNotifyEvent) => { const queryError = event.query.state.error; const shouldRevalidate = - !isRefetchingTokenQuery && + !isRevalidatingToken && BackstageHttpError.isInstance(queryError) && queryError.status === 401; @@ -166,36 +155,125 @@ export const CoderAuthProvider = ({ children }: CoderAuthProviderProps) => { return; } - isRefetchingTokenQuery = true; + isRevalidatingToken = true; await queryClient.refetchQueries({ queryKey: sharedAuthQueryKey }); - isRefetchingTokenQuery = false; - }); + isRevalidatingToken = false; + }; + const queryCache = queryClient.getQueryCache(); + const unsubscribe = queryCache.subscribe(revalidateTokenOnError); return unsubscribe; }, [queryClient]); - return ( - { - if (newToken !== '') { - setAuthToken(newToken); - } - }, - ejectToken: () => { - window.localStorage.removeItem(TOKEN_STORAGE_KEY); - queryClient.removeQueries({ queryKey: [CODER_QUERY_KEY_PREFIX] }); - setAuthToken(''); - }, - }} - > - {children} - - ); -}; + return { + ...authState, + isAuthenticated: validAuthStatuses.includes(authState.status), + registerNewToken: newToken => { + if (newToken !== '') { + setAuthToken(newToken); + } + }, + ejectToken: () => { + setAuthToken(''); + window.localStorage.removeItem(TOKEN_STORAGE_KEY); + queryClient.removeQueries({ queryKey: [CODER_QUERY_KEY_PREFIX] }); + }, + }; +} + +type AuthFallbackState = Readonly<{ + trackComponent: TrackComponent; + hasNoAuthInputs: boolean; +}>; + +function useAuthFallbackState(): AuthFallbackState { + // Can't do state syncs or anything else that would normally minimize + // re-renders here because we have to wait for the entire application to + // complete its initial render before we can decide if we need a fallback UI + const [isMounted, setIsMounted] = useState(false); + useEffect(() => { + setIsMounted(true); + }, []); + + // Not the biggest fan of needing to keep the two pieces of state in sync, but + // setting the render state to a simple boolean rather than the whole Set + // means that we re-render only when we go from 0 trackers to 1+, or from 1+ + // trackers to 0. We don't care about the exact number of components being + // tracked - just whether we have any at all + const [hasTrackers, setHasTrackers] = useState(false); + const trackedComponentsRef = useRef>(null!); + if (trackedComponentsRef.current === null) { + trackedComponentsRef.current = new Set(); + } + + const trackComponent = useCallback((componentId: string) => { + // React will bail out of re-renders if you dispatch the same state value + // that it already has, and that's easier to guarantee since the UI state + // only has a primitive. Calling this function too often should cause no + // problems, and most calls should be a no-op + const syncTrackerToUi = () => { + setHasTrackers(trackedComponentsRef.current.size > 0); + }; + + trackedComponentsRef.current.add(componentId); + syncTrackerToUi(); + + return () => { + trackedComponentsRef.current.delete(componentId); + syncTrackerToUi(); + }; + }, []); + + return { + trackComponent, + hasNoAuthInputs: isMounted && !hasTrackers, + }; +} + +/** + * Exposes auth state for other components, but has additional logic for spying + * on consumers of the hook. + * + * Caveats: + * 1. This hook should *NEVER* be exposed to the end user + * 2. All official Coder plugin components should favor this hook over + * useEndUserCoderAuth when possible + * + * A fallback UI for letting the user input auth information will appear if + * there are no official Coder components that are able to give the user a way + * to do that through normal user flows. + */ +export function useInternalCoderAuth(): CoderAuth { + const trackComponent = useContext(AuthTrackingContext); + if (trackComponent === null) { + throw new Error('Unable to retrieve state for displaying fallback auth UI'); + } + + // Assuming trackComponent is set up properly, the values of it and instanceId + // should both be stable until whatever component is using this hook unmounts. + // Values only added to dependency array to satisfy ESLint + const instanceId = useId(); + useEffect(() => { + const cleanupTracking = trackComponent(instanceId); + return cleanupTracking; + }, [instanceId, trackComponent]); + + return useEndUserCoderAuth(); +} + +/** + * Exposes Coder auth state to the rest of the UI. + */ +// This hook should only be used by end users trying to use the Coder SDK inside +// Backstage. The hook is renamed on final export to avoid confusion +export function useEndUserCoderAuth(): CoderAuth { + const authContextValue = useContext(AuthStateContext); + if (authContextValue === null) { + throw new Error('Cannot retrieve auth information from CoderProvider'); + } + + return authContextValue; +} type GenerateAuthStateInputs = Readonly<{ authToken: string; @@ -331,6 +409,218 @@ function generateAuthState({ }; } -function readAuthToken(): string { - return window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? ''; +// Have to get the root of the React application to adjust its dimensions when +// we display the fallback UI. Sadly, we can't assert that the root is always +// defined from outside a UI component, because throwing any errors here would +// blow up the entire Backstage application, and wreck all the other plugins +const mainAppRoot = document.querySelector(BACKSTAGE_APP_ROOT_ID); + +type StyleKey = 'landmarkWrapper' | 'dialogButton' | 'logo'; +type StyleProps = Readonly<{ isDialogOpen: boolean }>; + +const useFallbackStyles = makeStyles(theme => ({ + landmarkWrapper: ({ isDialogOpen }) => ({ + zIndex: isDialogOpen ? 0 : 9999, + position: 'fixed', + bottom: theme.spacing(2), + width: '100%', + maxWidth: 'fit-content', + left: '50%', + transform: 'translateX(-50%)', + }), + + dialogButton: { + display: 'flex', + flexFlow: 'row nowrap', + columnGap: theme.spacing(1), + alignItems: 'center', + }, + + logo: { + fill: theme.palette.primary.contrastText, + width: theme.spacing(3), + }, +})); + +function FallbackAuthUi() { + /** + * Add additional padding to the bottom of the main app to make sure that even + * with the fallback UI in place, it won't permanently cover up any of the + * other content as long as the user scrolls down far enough. + * + * Involves jumping through a bunch of hoops since we don't have 100% control + * over the Backstage application. Need to minimize risks of breaking existing + * Backstage styling or other plugins + */ + const fallbackRef = useRef(null); + useLayoutEffect(() => { + const fallback = fallbackRef.current; + const mainAppContainer = + mainAppRoot?.querySelector('main') ?? null; + + if (fallback === null || mainAppContainer === null) { + return undefined; + } + + // Adding a new style node lets us override the existing styles via the CSS + // cascade rather than directly modifying them, which minimizes the risks of + // breaking anything. If we were to modify the styles and try resetting them + // with the cleanup function, there's a risk the cleanup function would have + // closure over stale values and try "resetting" things to a value that is + // no longer used + const overrideStyleNode = document.createElement('style'); + overrideStyleNode.type = 'text/css'; + + // Using ComputedStyle objects because they maintain live links to computed + // properties. Plus, since most styling goes through MUI's makeStyles (which + // is based on CSS classes), trying to access properties directly off the + // nodes won't always work + const liveAppStyles = getComputedStyle(mainAppContainer); + const liveFallbackStyles = getComputedStyle(fallback); + + let prevPaddingBottom: string | undefined = undefined; + const updatePaddingForFallbackUi: MutationCallback = () => { + const prevInnerHtml = overrideStyleNode.innerHTML; + overrideStyleNode.innerHTML = ''; + const paddingBottomWithNoOverride = liveAppStyles.paddingBottom || '0px'; + + if (paddingBottomWithNoOverride === prevPaddingBottom) { + overrideStyleNode.innerHTML = prevInnerHtml; + return; + } + + // parseInt will automatically remove units from bottom property + const fallbackBottom = parseInt(liveFallbackStyles.bottom || '0', 10); + const normalized = Number.isNaN(fallbackBottom) ? 0 : fallbackBottom; + const paddingToAdd = fallback.offsetHeight + normalized; + + overrideStyleNode.innerHTML = ` + .${FALLBACK_UI_OVERRIDE_CLASS_NAME} { + padding-bottom: calc(${paddingBottomWithNoOverride} + ${paddingToAdd}px) !important; + } + `; + + // Only update prev padding after state changes have definitely succeeded + prevPaddingBottom = paddingBottomWithNoOverride; + }; + + const observer = new MutationObserver(updatePaddingForFallbackUi); + observer.observe(document.head, { childList: true }); + observer.observe(mainAppContainer, { + childList: false, + subtree: false, + attributes: true, + attributeFilter: ['class', 'style'], + }); + + // Applying mutations after we've started observing will trigger the + // callback, but as long as it's set up properly, the user shouldn't notice. + // Also serves a way to ensure the mutation callback runs at least once + document.head.append(overrideStyleNode); + mainAppContainer.classList.add(FALLBACK_UI_OVERRIDE_CLASS_NAME); + + return () => { + // Be sure to disconnect observer before applying other cleanup mutations + observer.disconnect(); + overrideStyleNode.remove(); + mainAppContainer.classList.remove(FALLBACK_UI_OVERRIDE_CLASS_NAME); + }; + }, []); + + const hookId = useId(); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const styles = useFallbackStyles({ isDialogOpen }); + + // Wrapping fallback button in landmark so that screen reader users can jump + // straight to the button from a screen reader directory rotor, and don't have + // to navigate through every single other element first + const landmarkId = `${hookId}-landmark`; + const fallbackUi = ( +
+ + + setIsDialogOpen(true)} + onClose={() => setIsDialogOpen(false)} + triggerClassName={styles.dialogButton} + > + + Authenticate with Coder + +
+ ); + + return createPortal(fallbackUi, document.body); +} + +/** + * Sorry about how wacky this approach is, but this setup should simplify the + * code literally everywhere else in the plugin. + * + * The setup is that we have two versions of the tracking context: one that has + * the live trackComponent function, and one that has the dummy. The main parts + * of the UI get the live version, and the parts of the UI that deal with the + * fallback auth UI get the dummy version. + * + * By having two contexts, we can dynamically expose or hide the tracking + * state for any components that use any version of the Coder auth state. All + * other components can use the same hook without being aware of where they're + * being mounted. That means you can use the exact same components in either + * region without needing to rewrite anything outside this file. + * + * Any other component that uses useInternalCoderAuth will reach up the + * component tree until it can grab *some* kind of tracking function. The hook + * only cares about whether it got a function at all; it doesn't care about what + * it does. The hook will call the function either way, but only the components + * in the "live" region will influence whether the fallback UI should be + * displayed. + * + * Dummy function defined outside the component to prevent risk of needless + * re-renders through Context. + */ + +/** + * A dummy version of the component tracker function. + * + * In production, this is used to define a dummy version of the context + * dependency for the "fallback auth UI" portion of the app. + * + * In testing, this is used for the vast majority of component tests to provide + * the tracker dependency and make sure that the components can properly render + * without having to be wired up to the entire plugin. + */ +export const dummyTrackComponent: TrackComponent = () => { + // Deliberately perform a no-op on initial call + return () => { + // And deliberately perform a no-op on cleanup + }; +}; + +export function CoderAuthProvider({ + children, +}: Readonly>) { + const authState = useAuthState(); + const { hasNoAuthInputs, trackComponent } = useAuthFallbackState(); + const needFallbackUi = !authState.isAuthenticated && hasNoAuthInputs; + + return ( + + + {children} + + + {needFallbackUi && ( + + + + )} + + ); } diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx index 955aae28..73acc13c 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx @@ -12,7 +12,7 @@ import { import { CoderProvider } from './CoderProvider'; import { useCoderAppConfig } from './CoderAppConfigProvider'; -import { type CoderAuth, useCoderAuth } from './CoderAuthProvider'; +import { type CoderAuth, useInternalCoderAuth } from './CoderAuthProvider'; import { getMockConfigApi, @@ -56,7 +56,7 @@ describe(`${CoderProvider.name}`, () => { describe('Auth', () => { // Can't use the render helpers because they all assume that the auth isn't // core to the functionality. In this case, you do need to bring in the full - // CoderProvider + // CoderProvider to make sure that it's working properly const renderUseCoderAuth = () => { const discoveryApi = getMockDiscoveryApi(); const configApi = getMockConfigApi(); @@ -70,7 +70,7 @@ describe(`${CoderProvider.name}`, () => { apis: { urlSync, identityApi }, }); - return renderHook(useCoderAuth, { + return renderHook(useInternalCoderAuth, { wrapper: ({ children }) => ( { const styles = useStyles(); return ( - - - - - - } - /> - + + + + + } + /> + } + {...props} + >
diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx index 57a41922..3d9dbcf6 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx @@ -7,7 +7,7 @@ import React, { } from 'react'; import { useId } from '../../hooks/hookPolyfills'; -import { useCoderAuth } from '../CoderProvider'; +import { useInternalCoderAuth } from '../CoderProvider'; import { useWorkspacesCardContext } from './Root'; import { VisuallyHidden } from '../VisuallyHidden'; @@ -102,7 +102,7 @@ export const ExtraActionsButton = ({ const hookId = useId(); const [loadedAnchor, setLoadedAnchor] = useState(); const refreshWorkspaces = useRefreshWorkspaces(); - const { ejectToken } = useCoderAuth(); + const { ejectToken } = useInternalCoderAuth(); const styles = useStyles(); const closeMenu = () => setLoadedAnchor(undefined); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx index 8c67d5e5..b96f2361 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/HeaderRow.tsx @@ -1,50 +1,37 @@ import React, { HTMLAttributes, ReactNode } from 'react'; -import { Theme, makeStyles } from '@material-ui/core'; +import { type Theme, makeStyles } from '@material-ui/core'; import { useWorkspacesCardContext } from './Root'; +import type { HtmlHeader } from '../../typesConstants'; type StyleKey = 'root' | 'header' | 'hgroup' | 'subheader'; - -type MakeStylesInputs = Readonly<{ - fullBleedLayout: boolean; -}>; - -const useStyles = makeStyles(theme => ({ - root: ({ fullBleedLayout }) => ({ +const useStyles = makeStyles(theme => ({ + root: { color: theme.palette.text.primary, display: 'flex', flexFlow: 'row nowrap', alignItems: 'center', gap: theme.spacing(1), - - // Have to jump through some hoops for the border; have to extend out the - // root to make sure that the border stretches all the way across the - // parent, and then add padding back to just the main content - borderBottom: `1px solid ${theme.palette.divider}`, - marginLeft: fullBleedLayout ? `-${theme.spacing(2)}px` : 0, - marginRight: fullBleedLayout ? `-${theme.spacing(2)}px` : 0, - padding: `0 ${theme.spacing(2)}px ${theme.spacing(2)}px ${theme.spacing( - 2.5, - )}px`, - }), + }, hgroup: { marginRight: 'auto', }, header: { - fontSize: '24px', + fontSize: '1.5rem', lineHeight: 1, margin: 0, }, subheader: { margin: '0', + fontSize: '0.875rem', + fontWeight: 400, color: theme.palette.text.secondary, paddingTop: theme.spacing(0.5), }, })); -type HtmlHeader = `h${1 | 2 | 3 | 4 | 5 | 6}`; type ClassName = `${Exclude}ClassName`; type HeaderProps = Readonly< @@ -67,11 +54,10 @@ export const HeaderRow = ({ subheaderClassName, activeRepoFilteringText, headerText = 'Coder Workspaces', - fullBleedLayout = true, ...delegatedProps }: HeaderProps) => { const { headerId, workspacesConfig } = useWorkspacesCardContext(); - const styles = useStyles({ fullBleedLayout }); + const styles = useStyles(); const HeadingComponent = headerLevel ?? 'h2'; const { repoUrl } = workspacesConfig; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx index 9a2d118f..0866d95a 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx @@ -4,6 +4,7 @@ */ import React, { type HTMLAttributes, + type ReactNode, createContext, useContext, useState, @@ -14,11 +15,9 @@ import { useCoderWorkspacesConfig, type CoderWorkspacesConfig, } from '../../hooks/useCoderWorkspacesConfig'; - import type { Workspace } from '../../typesConstants'; import { useCoderWorkspacesQuery } from '../../hooks/useCoderWorkspacesQuery'; -import { Card } from '../Card'; -import { CoderAuthWrapper } from '../CoderAuthWrapper'; +import { CoderAuthFormCardWrapper } from '../CoderAuthFormCardWrapper'; export type WorkspacesQuery = UseQueryResult; @@ -40,12 +39,14 @@ export type WorkspacesCardProps = Readonly< defaultQueryFilter?: string; onFilterChange?: (newFilter: string) => void; readEntityData?: boolean; + headerContent?: ReactNode; } >; const InnerRoot = ({ children, className, + headerContent, queryFilter: outerFilter, onFilterChange: onOuterFilterChange, defaultQueryFilter = 'owner:me', @@ -65,44 +66,49 @@ const InnerRoot = ({ const headerId = `${hookId}-header`; return ( - - { - setInnerFilter(newFilter); - onOuterFilterChange?.(newFilter); - }, - }} + { + setInnerFilter(newFilter); + onOuterFilterChange?.(newFilter); + }, + }} + > + - {/* - * 2024-01-31: This output is a
, but that should be changed to a - * once that element is supported by more browsers. Setting up - * accessibility markup and landmark behavior manually in the meantime - */} - - {/* Want to expose the overall container as a form for good - semantics and screen reader support, but since there isn't an - explicit submission process (queries happen automatically), it - felt better to use a
with a role override to side-step edge - cases around keyboard input and button children that native
- elements automatically introduce */} -
{children}
- - - + {/* Want to expose the overall container as a form for good + semantics and screen reader support, but since there isn't an + explicit submission process (queries happen automatically), it + felt better to use a
with a role override to side-step edge + cases around keyboard input and button children that native + elements automatically introduce */} +
{children}
+ + ); }; export function Root(props: WorkspacesCardProps) { - // Doing this to insulate the user from needing to worry about accidentally - // flipping the value of readEntityData between renders. If this value - // changes, it will cause the component to unmount and remount, but that - // should be painless/maybe invisible compared to having the component throw - // a full error and triggering an error boundary + /** + * Binding the value of readEntityData as a render key to make using the + * component less painful to use overall for end users. + * + * Without this, the component will throw an error anytime the user flips the + * value of readEntityData from false to true, or vice-versa. + * + * With a render key, whenever the key changes, the whole component will + * unmount and then remount. This isn't a problem because all its important + * state is stored outside React via React Query, so on the remount, it can + * reuse the existing state and just has rebuild itself via the new props. + */ const renderKey = String(props.readEntityData ?? false); return ; } diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts index a3b22d3d..4e41ef86 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { workspaces, workspacesByRepo } from '../api/queryOptions'; import type { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; import { useCoderSdk } from './useCoderSdk'; -import { useCoderAuth } from '../components/CoderProvider'; +import { useInternalCoderAuth } from '../components/CoderProvider'; type QueryInput = Readonly<{ coderQuery: string; @@ -13,7 +13,7 @@ export function useCoderWorkspacesQuery({ coderQuery, workspacesConfig, }: QueryInput) { - const auth = useCoderAuth(); + const auth = useInternalCoderAuth(); const coderSdk = useCoderSdk(); const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index 5dad65dc..2aaaab89 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -58,16 +58,6 @@ export const CoderProvider = coderPlugin.provide( }), ); -export const CoderAuthWrapper = coderPlugin.provide( - createComponentExtension({ - name: 'CoderAuthWrapper', - component: { - lazy: () => - import('./components/CoderAuthWrapper').then(m => m.CoderAuthWrapper), - }, - }), -); - export const CoderErrorBoundary = coderPlugin.provide( createComponentExtension({ name: 'CoderErrorBoundary', @@ -192,12 +182,17 @@ export const CoderWorkspacesReminderAccordion = coderPlugin.provide( ); /** - * All custom hooks exposed by the plugin. + * Custom hooks needed for some of the custom Coder components */ -export { useCoderWorkspacesConfig } from './hooks/useCoderWorkspacesConfig'; -export { useCoderWorkspacesQuery } from './hooks/useCoderWorkspacesQuery'; export { useWorkspacesCardContext } from './components/CoderWorkspacesCard/Root'; +/** + * General custom hooks that can be used in various places. + */ +export { useCoderWorkspacesConfig } from './hooks/useCoderWorkspacesConfig'; +export { useCoderSdk } from './hooks/useCoderSdk'; +export { useEndUserCoderAuth as useCoderAuth } from './components/CoderProvider/CoderAuthProvider'; + /** * All custom types */ diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 28e258f5..34f11218 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -159,7 +159,6 @@ export const mockCoderWorkspacesConfig = (() => { const authedState = { token: mockCoderAuthToken, error: undefined, - tokenLoadedOnMount: true, isAuthenticated: true, registerNewToken: jest.fn(), ejectToken: jest.fn(), @@ -168,7 +167,6 @@ const authedState = { const notAuthedState = { token: undefined, error: undefined, - tokenLoadedOnMount: false, isAuthenticated: false, registerNewToken: jest.fn(), ejectToken: jest.fn(), diff --git a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx index 0cef032f..86ceedcb 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx +++ b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx @@ -17,8 +17,10 @@ import { type CoderAuthStatus, type CoderAppConfig, type CoderProviderProps, - AuthContext, + AuthStateContext, + AuthTrackingContext, CoderAppConfigProvider, + dummyTrackComponent, } from '../components/CoderProvider'; import { mockAppConfig, @@ -128,9 +130,11 @@ export const CoderProviderWithMockAuth = ({ - - {children} - + + + {children} + + @@ -164,7 +168,9 @@ export const renderHookAsCoderEntity = async < queryClient={mockQueryClient} authStatus={authStatus} > - {children} + + <>{children} + ); diff --git a/plugins/backstage-plugin-coder/src/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts index d9922920..76551f89 100644 --- a/plugins/backstage-plugin-coder/src/typesConstants.ts +++ b/plugins/backstage-plugin-coder/src/typesConstants.ts @@ -103,3 +103,11 @@ export type User = Readonly<{ username: string; avatar_url: string; }>; + +/** + * 2024-05-22 - While this isn't documented anywhere, TanStack Query defaults to + * retrying a failed API request 3 times before exposing an error to the UI + */ +export const DEFAULT_TANSTACK_QUERY_RETRY_COUNT = 3; + +export type HtmlHeader = `h${1 | 2 | 3 | 4 | 5 | 6}`; diff --git a/yarn.lock b/yarn.lock index b13b38c9..e7553d7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8719,9 +8719,9 @@ integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== "@types/react-dom@*", "@types/react-dom@^18.0.0": - version "18.2.21" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.21.tgz#b8c81715cebdebb2994378616a8d54ace54f043a" - integrity sha512-gnvBA/21SA4xxqNXEwNiVcP0xSGHh/gi1VhWv9Bl46a0ItbTT5nFY+G9VSQpaG/8N/qdJpJ+vftQ4zflTtnjLw== + version "18.3.0" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.0.tgz#0cbc818755d87066ab6ca74fbedb2547d74a82b0" + integrity sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg== dependencies: "@types/react" "*" @@ -8757,12 +8757,11 @@ "@types/react" "*" "@types/react@*", "@types/react@^16.13.1 || ^17.0.0 || ^18.0.0": - version "18.2.64" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.64.tgz#3700fbb6b2fa60a6868ec1323ae4cbd446a2197d" - integrity sha512-MlmPvHgjj2p3vZaxbQgFUQFvD8QiZwACfGqEdDSWou5yISWxDQ4/74nCAwsUiX7UFLKZz3BbVSPj+YxeoGGCfg== + version "18.3.2" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.2.tgz#462ae4904973bc212fa910424d901e3d137dbfcd" + integrity sha512-Btgg89dAnqD4vV7R3hlwOxgqobUQKgx3MmrQRi0yYbs/P0ym8XozIAlkqVilPqHQwXs4e9Tf63rrCgl58BcO4w== dependencies: "@types/prop-types" "*" - "@types/scheduler" "*" csstype "^3.0.2" "@types/react@^16.13.1 || ^17.0.0": @@ -8801,7 +8800,7 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== -"@types/scheduler@*", "@types/scheduler@^0.16": +"@types/scheduler@^0.16": version "0.16.8" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff" integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A== @@ -20249,6 +20248,14 @@ react-dom@^18.0.2: loose-envify "^1.1.0" scheduler "^0.23.0" +react-dom@^18.3.1: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" + integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.2" + react-double-scrollbar@0.0.15: version "0.0.15" resolved "https://registry.yarnpkg.com/react-double-scrollbar/-/react-double-scrollbar-0.0.15.tgz#e915ab8cb3b959877075f49436debfdb04288fe4" @@ -21181,6 +21188,13 @@ scheduler@^0.23.0: dependencies: loose-envify "^1.1.0" +scheduler@^0.23.2: + version "0.23.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" + integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== + dependencies: + loose-envify "^1.1.0" + schema-utils@2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7" @@ -21918,16 +21932,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -22001,7 +22006,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -22015,13 +22020,6 @@ strip-ansi@5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -23830,7 +23828,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -23848,15 +23846,6 @@ wrap-ansi@^6.0.1: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"