diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 229e577a36..2cfe670aa3 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -a9d9f1e1a17736aecc8d644c8d8c6174c1a5bc14 +c52ed36e07d70b6f968c177b27f606d9a91ca279 diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index fd23132f0d..683333f601 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -1323,7 +1323,11 @@ export type Cumulativeuint64 = { startTime: Date; value: number } export type CurrentUser = { /** Human-readable name that can identify the user */ displayName: string + /** Whether this user has the viewer role on the fleet. Used by the web console to determine whether to show system-level UI. */ + fleetViewer: boolean id: string + /** Whether this user has the admin role on their silo. Used by the web console to determine whether to show admin-only UI elements. */ + siloAdmin: boolean /** Uuid of the silo to which this user belongs */ siloId: string /** Name of the silo to which this user belongs. */ @@ -2353,6 +2357,8 @@ export type InstanceNetworkInterfaceCreate = { name: Name /** The VPC Subnet in which to create the interface. */ subnetName: Name + /** A set of additional networks that this interface may send and receive traffic on. */ + transitIps?: IpNet[] /** The VPC in which to create the interface. */ vpcName: Name } diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index c089538b02..e323c78f1c 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -a9d9f1e1a17736aecc8d644c8d8c6174c1a5bc14 +c52ed36e07d70b6f968c177b27f606d9a91ca279 diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 80b9ba0738..939a0a6dcd 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -1270,7 +1270,14 @@ export const Cumulativeuint64 = z.preprocess( */ export const CurrentUser = z.preprocess( processResponseBody, - z.object({ displayName: z.string(), id: z.uuid(), siloId: z.uuid(), siloName: Name }) + z.object({ + displayName: z.string(), + fleetViewer: SafeBoolean, + id: z.uuid(), + siloAdmin: SafeBoolean, + siloId: z.uuid(), + siloName: Name, + }) ) /** @@ -2189,6 +2196,7 @@ export const InstanceNetworkInterfaceCreate = z.preprocess( ip: z.ipv4().nullable().optional(), name: Name, subnetName: Name, + transitIps: IpNet.array().default([]).optional(), vpcName: Name, }) ) diff --git a/app/api/hooks.ts b/app/api/hooks.ts index 0d218a5e6c..510bdc048a 100644 --- a/app/api/hooks.ts +++ b/app/api/hooks.ts @@ -305,40 +305,6 @@ export const wrapQueryClient = (api: A, queryClient: QueryC queryFn: () => api[method](params).then(handleResult(method)), ...options, }), - /** - * Loader analog to `useApiQueryErrorsAllowed`. Prefetch a query that can - * error, converting the error to a valid result so RQ will cache it. - */ - prefetchQueryErrorsAllowed: ( - method: M, - params: Params, - options: FetchQueryOtherOptions, ApiError>> & { - /** - * HTTP errors will show up unexplained in the browser console. It can be - * helpful to reassure people they're normal. - */ - explanation: string - expectedStatusCode: 403 | 404 - } - ) => - queryClient.prefetchQuery({ - queryKey: [method, params, ERRORS_ALLOWED], - queryFn: () => - api[method](params) - .then(handleResult(method)) - .then((data) => ({ type: 'success' as const, data })) - .catch((data: ApiError) => { - // if we get an unexpected error, we're still throwing - if (data.statusCode !== options.expectedStatusCode) { - // data is the result of handleResult, so it's ready to through - // directly without further processing - throw data - } - console.info(options.explanation) - return { type: 'error' as const, data } - }), - ...options, - }), }) /* diff --git a/app/components/TopBar.tsx b/app/components/TopBar.tsx index 08144a23ce..1206d8d452 100644 --- a/app/components/TopBar.tsx +++ b/app/components/TopBar.tsx @@ -27,7 +27,7 @@ import { intersperse } from '~/util/array' import { pb } from '~/util/path-builder' export function TopBar({ systemOrSilo }: { systemOrSilo: 'system' | 'silo' }) { - const { isFleetViewer } = useCurrentUser() + const { me } = useCurrentUser() // The height of this component is governed by the `PageContainer` // It's important that this component returns two distinct elements (wrapped in a fragment). // Each element will occupy one of the top column slots provided by `PageContainer`. @@ -42,7 +42,7 @@ export function TopBar({ systemOrSilo }: { systemOrSilo: 'system' | 'silo' }) {
- {isFleetViewer && } + {me.fleetViewer && }
@@ -155,8 +155,8 @@ function UserMenu() { /** * Choose between System and Silo-scoped route trees, or if the user doesn't - * have access to system routes (i.e., if systemPolicyView 403s) show the - * current silo. + * have access to system routes (i.e., if /v1/me has fleetViewer: false) show + * the current silo. */ function SiloSystemPicker({ level }: { level: 'silo' | 'system' }) { return ( diff --git a/app/hooks/use-current-user.ts b/app/hooks/use-current-user.ts index b6ddd36f0d..163480c706 100644 --- a/app/hooks/use-current-user.ts +++ b/app/hooks/use-current-user.ts @@ -6,10 +6,7 @@ * Copyright Oxide Computer Company */ -import { useQuery } from '@tanstack/react-query' - -import { apiqErrorsAllowed, usePrefetchedApiQuery } from '~/api/client' -import { invariant } from '~/util/invariant' +import { apiq, usePrefetchedQuery } from '~/api/client' /** * Access all the data fetched by the loader. Because of the `shouldRevalidate` @@ -18,19 +15,7 @@ import { invariant } from '~/util/invariant' * loaders. */ export function useCurrentUser() { - const { data: me } = usePrefetchedApiQuery('currentUserView', {}) - const { data: myGroups } = usePrefetchedApiQuery('currentUserGroups', {}) - - // User can only get to system routes if they have viewer perms (at least) on - // the fleet. The natural place to find out whether they have such perms is - // the fleet (system) policy, but if the user doesn't have fleet read, we'll - // get a 403 from that endpoint. So we simply check whether that endpoint 200s - // or not to determine whether the user is a fleet viewer. - const { data: systemPolicy } = useQuery(apiqErrorsAllowed('systemPolicyView', {})) - // don't use usePrefetchedApiQuery because it's not worth making an errors - // allowed version of that - invariant(systemPolicy, 'System policy must be prefetched') - const isFleetViewer = systemPolicy.type === 'success' - - return { me, myGroups, isFleetViewer } + const { data: me } = usePrefetchedQuery(apiq('currentUserView', {})) + const { data: myGroups } = usePrefetchedQuery(apiq('currentUserGroups', {})) + return { me, myGroups } } diff --git a/app/hooks/use-quick-actions.tsx b/app/hooks/use-quick-actions.tsx index f9f2051f30..eaf16463b7 100644 --- a/app/hooks/use-quick-actions.tsx +++ b/app/hooks/use-quick-actions.tsx @@ -57,7 +57,7 @@ function closeQuickActions() { function useGlobalActions() { const location = useLocation() const navigate = useNavigate() - const { isFleetViewer } = useCurrentUser() + const { me } = useCurrentUser() return useMemo(() => { const actions = [] @@ -69,7 +69,7 @@ function useGlobalActions() { onSelect: () => navigate(pb.profile()), }) } - if (isFleetViewer && !location.pathname.startsWith('/system/')) { + if (me.fleetViewer && !location.pathname.startsWith('/system/')) { actions.push({ navGroup: 'System', value: 'Manage system', @@ -77,7 +77,7 @@ function useGlobalActions() { }) } return actions - }, [location.pathname, navigate, isFleetViewer]) + }, [location.pathname, navigate, me.fleetViewer]) } /** diff --git a/app/layouts/AuthenticatedLayout.tsx b/app/layouts/AuthenticatedLayout.tsx index e4dcdcba97..ce2a1d27f8 100644 --- a/app/layouts/AuthenticatedLayout.tsx +++ b/app/layouts/AuthenticatedLayout.tsx @@ -28,18 +28,6 @@ export async function clientLoader() { await Promise.all([ apiQueryClient.prefetchQuery('currentUserView', {}, { staleTime }), apiQueryClient.prefetchQuery('currentUserGroups', {}, { staleTime }), - // Need to prefetch this because every layout hits it when deciding whether - // to show the silo/system picker. It's also fetched by the SystemLayout - // loader to figure out whether to 404, but RQ dedupes the request. - apiQueryClient.prefetchQueryErrorsAllowed( - 'systemPolicyView', - {}, - { - explanation: '/v1/system/policy 403 is expected if user is not a fleet viewer.', - expectedStatusCode: 403, - staleTime, - } - ), ]) return null } diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 69ff5828e5..2b4a2da687 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -27,23 +27,13 @@ import { inventoryBase, pb } from '~/util/path-builder' import { ContentPane, PageContainer } from './helpers' /** - * If we can see the policy, we're a fleet viewer, and we need to be a fleet - * viewer in order to see any of the routes under this layout. We need to - * `fetchQuery` instead of `prefetchQuery` because the latter doesn't return the - * result, and then we need to `.catch()` because `fetchQuery` throws on request - * error. We're being a little cavalier here with the error. If it's something - * other than a 403, that would be strange and we would want to know. + * We need to be a fleet viewer in order to see any of the routes under this + * layout. We need to `fetchQuery` instead of `prefetchQuery` because the latter + * doesn't return the result. */ export async function clientLoader() { - // we don't need to use the ErrorsAllowed version here because we're 404ing - // immediately on error, so we don't need to pick the result up from the cache - const isFleetViewer = await apiQueryClient - .fetchQuery('systemPolicyView', {}) - .then(() => true) - .catch(() => false) - - if (!isFleetViewer) throw trigger404 - + const me = await apiQueryClient.fetchQuery('currentUserView', {}) + if (!me.fleetViewer) throw trigger404 return null } diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index b6e0778198..1f5cf71e40 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -59,6 +59,7 @@ import { requireRole, unavailableErr, updateDesc, + userHasRole, } from './util' // Note the *JSON types. Those represent actual API request and response bodies, @@ -1428,7 +1429,13 @@ export const handlers = makeHandlers({ return paginated(query, db.racks) }, currentUserView({ cookies }) { - return { ...currentUser(cookies), silo_name: defaultSilo.name } + const user = currentUser(cookies) + return { + ...user, + silo_name: defaultSilo.name, + fleet_viewer: userHasRole(user, 'fleet', FLEET_ID, 'viewer'), + silo_admin: userHasRole(user, 'silo', defaultSilo.id, 'admin'), + } }, currentUserGroups({ cookies }) { const user = currentUser(cookies)