diff --git a/app/components/ErrorBoundary.tsx b/app/components/ErrorBoundary.tsx index 32baf81ded..be419ffa09 100644 --- a/app/components/ErrorBoundary.tsx +++ b/app/components/ErrorBoundary.tsx @@ -5,23 +5,67 @@ * * Copyright Oxide Computer Company */ +import { useQuery } from '@tanstack/react-query' import { ErrorBoundary as BaseErrorBoundary } from 'react-error-boundary' import { useRouteError } from 'react-router' +import { apiq } from '~/api' import { type ApiError } from '~/api/errors' +import { Message } from '~/ui/lib/Message' +import { links } from '~/util/links' import { ErrorPage, NotFound } from './ErrorPage' +const IdpMisconfig = () => ( + + You are not in any groups and have no role on the silo. This usually means the + identity provider is not set up correctly. Read the{' '} + + docs + {' '} + for more information. +

+ } + /> +) + +function useDetectNoSiloRole(enabled: boolean): boolean { + // this is kind of a hail mary, so if any of this goes wrong we need to ignore it + const options = { enabled, throwOnError: false } + const { data: me } = useQuery(apiq('currentUserView', {}, options)) + const { data: myGroups } = useQuery(apiq('currentUserGroups', {}, options)) + const { data: siloPolicy } = useQuery(apiq('policyView', {}, options)) + + if (!me || !myGroups || !siloPolicy) return false + + const noGroups = myGroups.items.length === 0 + const hasDirectRoleOnSilo = siloPolicy.roleAssignments.some((r) => r.identityId === me.id) + return noGroups && !hasDirectRoleOnSilo +} + export const trigger404 = { type: 'error', statusCode: 404 } type Props = { error: Error | ApiError } function ErrorFallback({ error }: Props) { console.error(error) + const statusCode = 'statusCode' in error ? error.statusCode : undefined + + // if the error is a 403, make API calls to check whether the user has any + // groups or any roles directly on the silo + const showIdpMisconfig = useDetectNoSiloRole(statusCode === 403) - if ('statusCode' in error && error.statusCode === 404) { - return - } + if (statusCode === 404) return return ( @@ -29,6 +73,7 @@ function ErrorFallback({ error }: Props) {

Please try again. If the problem persists, contact your administrator.

+ {showIdpMisconfig && }
) } diff --git a/app/forms/image-upload.tsx b/app/forms/image-upload.tsx index c3e554a57d..cec930ca37 100644 --- a/app/forms/image-upload.tsx +++ b/app/forms/image-upload.tsx @@ -539,6 +539,7 @@ export function Component() { await onSubmit(values) } catch (e) { if (e !== ABORT_ERROR) { + console.error(e) setModalError('Something went wrong. Please try again.') // abort anything in flight in case cancelEverything() diff --git a/app/pages/ProjectsPage.tsx b/app/pages/ProjectsPage.tsx index 9359e471f2..5e059cf1af 100644 --- a/app/pages/ProjectsPage.tsx +++ b/app/pages/ProjectsPage.tsx @@ -39,7 +39,11 @@ const EmptyState = () => ( const projectList = getListQFn('projectList', {}) export async function loader() { - await queryClient.prefetchQuery(projectList.optionsFn()) + // fetchQuery instead of prefetchQuery means errors blow up here instead of + // waiting to hit the invariant in the useQuery call. We may end up doing this + // everywhere, but here in particular it is needed to trigger the silo group + // misconfig detection case in ErrorBoundary. + await queryClient.fetchQuery(projectList.optionsFn()) return null } diff --git a/app/ui/lib/Message.tsx b/app/ui/lib/Message.tsx index d28641410f..0f48a57cc6 100644 --- a/app/ui/lib/Message.tsx +++ b/app/ui/lib/Message.tsx @@ -27,8 +27,7 @@ export interface MessageProps { text: string link: To } - // try to use icons from the ___12Icon set, rather than forcing a 16px or 24px icon - icon?: ReactElement + showIcon?: boolean } const defaultIcon: Record = { @@ -73,21 +72,21 @@ export const Message = ({ className, variant = 'info', cta, - icon, + showIcon = true, }: MessageProps) => { return (
-
- {icon || defaultIcon[variant]} -
-
+ {showIcon && ( +
{defaultIcon[variant]}
+ )} +
{title &&
{title}
}
(password === 'bad' ? 401 : 200), groupList: (params) => paginated(params.query, db.userGroups), groupView: (params) => lookupById(db.userGroups, params.path.groupId), - - projectList: (params) => paginated(params.query, db.projects), + projectList: ({ query, cookies }) => { + // this is used to test for the IdP misconfig situation where the user has + // no role on the silo (see error-pages.e2e.ts). requireRole checks for _at + // least_ viewer, and viewer is the weakest role, so checking for viewer + // effectively means "do I have any role at all" + requireRole(cookies, 'silo', defaultSilo.id, 'viewer') + return paginated(query, db.projects) + }, projectCreate({ body }) { errIfExists(db.projects, { name: body.name }, 'project') @@ -189,7 +195,7 @@ export const handlers = makeHandlers({ throw 'Can only enter state importing_from_bulk_write from import_ready' } - await delay(1000) // slow it down for the tests + await delay(2000) // slow it down for the tests db.diskBulkImportState.set(disk.id, { blocks: {} }) disk.state = { state: 'importing_from_bulk_writes' } @@ -203,7 +209,7 @@ export const handlers = makeHandlers({ if (disk.state.state !== 'importing_from_bulk_writes') { throw 'Can only stop import for disk in state importing_from_bulk_write' } - await delay(1000) // slow it down for the tests + await delay(2000) // slow it down for the tests db.diskBulkImportState.delete(disk.id) disk.state = { state: 'import_ready' } diff --git a/test/e2e/error-pages.e2e.ts b/test/e2e/error-pages.e2e.ts index d697e5cf5b..93340de431 100644 --- a/test/e2e/error-pages.e2e.ts +++ b/test/e2e/error-pages.e2e.ts @@ -7,6 +7,8 @@ */ import { expect, test } from '@playwright/test' +import { getPageAsUser } from './utils' + test('Shows 404 page when a resource is not found', async ({ page }) => { await page.goto('/nonexistent') await expect(page.locator('text=Page not found')).toBeVisible() @@ -42,3 +44,10 @@ test('Shows something went wrong page on other errors', async ({ page }) => { // up at the right URL await expect(page).toHaveURL('/login') }) + +test('error page for user with no groups or silo role', async ({ browser }) => { + const page = await getPageAsUser(browser, 'Jacob Klein') + await page.goto('/projects') + await expect(page.getByText('Something went wrong')).toBeVisible() + await expect(page.getByText('identity provider is not set up correctly')).toBeVisible() +}) diff --git a/test/e2e/image-upload.e2e.ts b/test/e2e/image-upload.e2e.ts index a1d8315a13..28e188eb91 100644 --- a/test/e2e/image-upload.e2e.ts +++ b/test/e2e/image-upload.e2e.ts @@ -159,7 +159,8 @@ test.describe('Image upload', () => { await page.getByRole('button', { name: 'Cancel' }).click() await page.getByRole('link', { name: 'Disks' }).click() await expect(page.getByRole('cell', { name: 'disk-1', exact: true })).toBeVisible() - await expect(page.getByRole('cell', { name: 'tmp' })).toBeHidden() + // needs a little extra time for delete to go through + await expect(page.getByRole('cell', { name: 'tmp' })).toBeHidden({ timeout: 10000 }) }) } diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts index 4bb0638434..10c719a44e 100644 --- a/test/e2e/utils.ts +++ b/test/e2e/utils.ts @@ -133,11 +133,16 @@ export async function expectNoToast(page: Page, expectedText: string | RegExp) { } /** - * Close toast and wait for it to fade out. For some reason it prevents things - * from working, but only in tests as far as we can tell. + * Close first toast and wait for it to fade out. For some reason it prevents + * things from working, but only in tests as far as we can tell. */ export async function closeToast(page: Page) { - await page.getByRole('button', { name: 'Dismiss notification' }).click() + // first() is a hack aimed at situations where we're testing an error + // response, which usually means we have an initial "creating..." toast + // followed by an error toast. Sometimes the error toast shows up so fast that + // we don't have time to close the first one. Without first(), this errors out + // because there are two toasts. + await page.getByRole('button', { name: 'Dismiss notification' }).first().click() await sleep(1000) } @@ -185,7 +190,7 @@ export async function selectOption( export async function getPageAsUser( browser: Browser, - user: 'Hans Jonas' | 'Simone de Beauvoir' + user: 'Hans Jonas' | 'Simone de Beauvoir' | 'Jacob Klein' ): Promise { const browserContext = await browser.newContext() await browserContext.addCookies([