Skip to content

Commit a6319fc

Browse files
authored
Better error for admin group name misconfig (#2671)
* better error for group misconfig * no "hint:" * much better look after a ben consultation * move docs link to links.ts, update URL
1 parent 5bd61e6 commit a6319fc

File tree

9 files changed

+93
-21
lines changed

9 files changed

+93
-21
lines changed

app/components/ErrorBoundary.tsx

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,75 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
import { useQuery } from '@tanstack/react-query'
89
import { ErrorBoundary as BaseErrorBoundary } from 'react-error-boundary'
910
import { useRouteError } from 'react-router'
1011

12+
import { apiq } from '~/api'
1113
import { type ApiError } from '~/api/errors'
14+
import { Message } from '~/ui/lib/Message'
15+
import { links } from '~/util/links'
1216

1317
import { ErrorPage, NotFound } from './ErrorPage'
1418

19+
const IdpMisconfig = () => (
20+
<Message
21+
variant="notice"
22+
className="!mt-6"
23+
showIcon={false}
24+
content={
25+
<p>
26+
You are not in any groups and have no role on the silo. This usually means the
27+
identity provider is not set up correctly. Read the{' '}
28+
<a
29+
href={links.troubleshootingAccess}
30+
className="underline"
31+
target="_blank"
32+
rel="noreferrer"
33+
>
34+
docs
35+
</a>{' '}
36+
for more information.
37+
</p>
38+
}
39+
/>
40+
)
41+
42+
function useDetectNoSiloRole(enabled: boolean): boolean {
43+
// this is kind of a hail mary, so if any of this goes wrong we need to ignore it
44+
const options = { enabled, throwOnError: false }
45+
const { data: me } = useQuery(apiq('currentUserView', {}, options))
46+
const { data: myGroups } = useQuery(apiq('currentUserGroups', {}, options))
47+
const { data: siloPolicy } = useQuery(apiq('policyView', {}, options))
48+
49+
if (!me || !myGroups || !siloPolicy) return false
50+
51+
const noGroups = myGroups.items.length === 0
52+
const hasDirectRoleOnSilo = siloPolicy.roleAssignments.some((r) => r.identityId === me.id)
53+
return noGroups && !hasDirectRoleOnSilo
54+
}
55+
1556
export const trigger404 = { type: 'error', statusCode: 404 }
1657

1758
type Props = { error: Error | ApiError }
1859

1960
function ErrorFallback({ error }: Props) {
2061
console.error(error)
62+
const statusCode = 'statusCode' in error ? error.statusCode : undefined
63+
64+
// if the error is a 403, make API calls to check whether the user has any
65+
// groups or any roles directly on the silo
66+
const showIdpMisconfig = useDetectNoSiloRole(statusCode === 403)
2167

22-
if ('statusCode' in error && error.statusCode === 404) {
23-
return <NotFound />
24-
}
68+
if (statusCode === 404) return <NotFound />
2569

2670
return (
2771
<ErrorPage>
2872
<h1 className="text-sans-2xl">Something went wrong</h1>
2973
<p className="text-secondary">
3074
Please try again. If the problem persists, contact your administrator.
3175
</p>
76+
{showIdpMisconfig && <IdpMisconfig />}
3277
</ErrorPage>
3378
)
3479
}

app/forms/image-upload.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,7 @@ export function Component() {
539539
await onSubmit(values)
540540
} catch (e) {
541541
if (e !== ABORT_ERROR) {
542+
console.error(e)
542543
setModalError('Something went wrong. Please try again.')
543544
// abort anything in flight in case
544545
cancelEverything()

app/pages/ProjectsPage.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ const EmptyState = () => (
3939
const projectList = getListQFn('projectList', {})
4040

4141
export async function loader() {
42-
await queryClient.prefetchQuery(projectList.optionsFn())
42+
// fetchQuery instead of prefetchQuery means errors blow up here instead of
43+
// waiting to hit the invariant in the useQuery call. We may end up doing this
44+
// everywhere, but here in particular it is needed to trigger the silo group
45+
// misconfig detection case in ErrorBoundary.
46+
await queryClient.fetchQuery(projectList.optionsFn())
4347
return null
4448
}
4549

app/ui/lib/Message.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@ export interface MessageProps {
2727
text: string
2828
link: To
2929
}
30-
// try to use icons from the ___12Icon set, rather than forcing a 16px or 24px icon
31-
icon?: ReactElement
30+
showIcon?: boolean
3231
}
3332

3433
const defaultIcon: Record<Variant, ReactElement> = {
@@ -73,21 +72,21 @@ export const Message = ({
7372
className,
7473
variant = 'info',
7574
cta,
76-
icon,
75+
showIcon = true,
7776
}: MessageProps) => {
7877
return (
7978
<div
8079
className={cn(
81-
'relative flex items-start overflow-hidden rounded-lg p-4 elevation-1',
80+
'relative flex items-start gap-2.5 overflow-hidden rounded-lg p-4 elevation-1',
8281
color[variant],
8382
textColor[variant],
8483
className
8584
)}
8685
>
87-
<div className="mt-[2px] flex [&>svg]:h-3 [&>svg]:w-3">
88-
{icon || defaultIcon[variant]}
89-
</div>
90-
<div className="flex-1 pl-2.5">
86+
{showIcon && (
87+
<div className="mt-[2px] flex [&>svg]:h-3 [&>svg]:w-3">{defaultIcon[variant]}</div>
88+
)}
89+
<div className="flex-1">
9190
{title && <div className="text-sans-semi-md">{title}</div>}
9291
<div
9392
className={cn(

app/util/links.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export const links = {
5050
systemSiloDocs: 'https://docs.oxide.computer/guides/operator/silo-management',
5151
transitIpsDocs:
5252
'https://docs.oxide.computer/guides/configuring-guest-networking#_example_4_software_routing_tunnels',
53+
troubleshootingAccess:
54+
'https://docs.oxide.computer/guides/operator/faq#_how_do_i_fix_the_something_went_wrong_error',
5355
instancesDocs: 'https://docs.oxide.computer/guides/deploying-workloads',
5456
vpcsDocs: 'https://docs.oxide.computer/guides/configuring-guest-networking',
5557
}

mock-api/msw/handlers.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,14 @@ export const handlers = makeHandlers({
7272
loginLocal: ({ body: { password } }) => (password === 'bad' ? 401 : 200),
7373
groupList: (params) => paginated(params.query, db.userGroups),
7474
groupView: (params) => lookupById(db.userGroups, params.path.groupId),
75-
76-
projectList: (params) => paginated(params.query, db.projects),
75+
projectList: ({ query, cookies }) => {
76+
// this is used to test for the IdP misconfig situation where the user has
77+
// no role on the silo (see error-pages.e2e.ts). requireRole checks for _at
78+
// least_ viewer, and viewer is the weakest role, so checking for viewer
79+
// effectively means "do I have any role at all"
80+
requireRole(cookies, 'silo', defaultSilo.id, 'viewer')
81+
return paginated(query, db.projects)
82+
},
7783
projectCreate({ body }) {
7884
errIfExists(db.projects, { name: body.name }, 'project')
7985

@@ -189,7 +195,7 @@ export const handlers = makeHandlers({
189195
throw 'Can only enter state importing_from_bulk_write from import_ready'
190196
}
191197

192-
await delay(1000) // slow it down for the tests
198+
await delay(2000) // slow it down for the tests
193199

194200
db.diskBulkImportState.set(disk.id, { blocks: {} })
195201
disk.state = { state: 'importing_from_bulk_writes' }
@@ -203,7 +209,7 @@ export const handlers = makeHandlers({
203209
if (disk.state.state !== 'importing_from_bulk_writes') {
204210
throw 'Can only stop import for disk in state importing_from_bulk_write'
205211
}
206-
await delay(1000) // slow it down for the tests
212+
await delay(2000) // slow it down for the tests
207213

208214
db.diskBulkImportState.delete(disk.id)
209215
disk.state = { state: 'import_ready' }

test/e2e/error-pages.e2e.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
*/
88
import { expect, test } from '@playwright/test'
99

10+
import { getPageAsUser } from './utils'
11+
1012
test('Shows 404 page when a resource is not found', async ({ page }) => {
1113
await page.goto('/nonexistent')
1214
await expect(page.locator('text=Page not found')).toBeVisible()
@@ -42,3 +44,10 @@ test('Shows something went wrong page on other errors', async ({ page }) => {
4244
// up at the right URL
4345
await expect(page).toHaveURL('/login')
4446
})
47+
48+
test('error page for user with no groups or silo role', async ({ browser }) => {
49+
const page = await getPageAsUser(browser, 'Jacob Klein')
50+
await page.goto('/projects')
51+
await expect(page.getByText('Something went wrong')).toBeVisible()
52+
await expect(page.getByText('identity provider is not set up correctly')).toBeVisible()
53+
})

test/e2e/image-upload.e2e.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,8 @@ test.describe('Image upload', () => {
159159
await page.getByRole('button', { name: 'Cancel' }).click()
160160
await page.getByRole('link', { name: 'Disks' }).click()
161161
await expect(page.getByRole('cell', { name: 'disk-1', exact: true })).toBeVisible()
162-
await expect(page.getByRole('cell', { name: 'tmp' })).toBeHidden()
162+
// needs a little extra time for delete to go through
163+
await expect(page.getByRole('cell', { name: 'tmp' })).toBeHidden({ timeout: 10000 })
163164
})
164165
}
165166

test/e2e/utils.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,16 @@ export async function expectNoToast(page: Page, expectedText: string | RegExp) {
133133
}
134134

135135
/**
136-
* Close toast and wait for it to fade out. For some reason it prevents things
137-
* from working, but only in tests as far as we can tell.
136+
* Close first toast and wait for it to fade out. For some reason it prevents
137+
* things from working, but only in tests as far as we can tell.
138138
*/
139139
export async function closeToast(page: Page) {
140-
await page.getByRole('button', { name: 'Dismiss notification' }).click()
140+
// first() is a hack aimed at situations where we're testing an error
141+
// response, which usually means we have an initial "creating..." toast
142+
// followed by an error toast. Sometimes the error toast shows up so fast that
143+
// we don't have time to close the first one. Without first(), this errors out
144+
// because there are two toasts.
145+
await page.getByRole('button', { name: 'Dismiss notification' }).first().click()
141146
await sleep(1000)
142147
}
143148

@@ -185,7 +190,7 @@ export async function selectOption(
185190

186191
export async function getPageAsUser(
187192
browser: Browser,
188-
user: 'Hans Jonas' | 'Simone de Beauvoir'
193+
user: 'Hans Jonas' | 'Simone de Beauvoir' | 'Jacob Klein'
189194
): Promise<Page> {
190195
const browserContext = await browser.newContext()
191196
await browserContext.addCookies([

0 commit comments

Comments
 (0)