From 3752493da75f4d03aeefd875f28944c76be80d0d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 25 Feb 2026 18:28:43 -0800 Subject: [PATCH 01/30] Add System Access page --- app/forms/access-util.tsx | 32 ++- app/forms/system-access.tsx | 134 ++++++++++++ app/layouts/SystemLayout.tsx | 5 + app/pages/system/SystemAccessPage.tsx | 204 ++++++++++++++++++ app/routes.tsx | 4 + .../__snapshots__/path-builder.spec.ts.snap | 6 + app/util/path-builder.spec.ts | 1 + app/util/path-builder.ts | 1 + mock-api/msw/handlers.ts | 21 +- test/e2e/system-access.e2e.ts | 77 +++++++ 10 files changed, 480 insertions(+), 5 deletions(-) create mode 100644 app/forms/system-access.tsx create mode 100644 app/pages/system/SystemAccessPage.tsx create mode 100644 test/e2e/system-access.e2e.ts diff --git a/app/forms/access-util.tsx b/app/forms/access-util.tsx index 1987be8408..955c53762f 100644 --- a/app/forms/access-util.tsx +++ b/app/forms/access-util.tsx @@ -11,6 +11,7 @@ import * as R from 'remeda' import { allRoles, type Actor, + type FleetRole, type IdentityType, type Policy, type RoleKey, @@ -24,6 +25,9 @@ import { Radio } from '~/ui/lib/Radio' import { docLinks } from '~/util/links' import { capitalize } from '~/util/str' +// Fleet roles don't include limited_collaborator +const fleetRoles: FleetRole[] = ['admin', 'collaborator', 'viewer'] + type AddUserValues = { identityId: string roleName: RoleKey @@ -50,6 +54,13 @@ const siloRoleDescriptions: Record = { viewer: 'View resources within the silo', } +// Role descriptions for fleet-level roles +const fleetRoleDescriptions: Record = { + admin: 'Control all aspects of the fleet', + collaborator: 'Administer silos and fleet-level resources', + viewer: 'View fleet-level resources', +} + export const actorToItem = (actor: Actor): ListboxItem => ({ value: actor.id, label: ( @@ -92,9 +103,16 @@ export function RoleRadioField< }: { name: TName control: Control - scope: 'Silo' | 'Project' + scope: 'Fleet' | 'Silo' | 'Project' }) { - const roleDescriptions = scope === 'Silo' ? siloRoleDescriptions : projectRoleDescriptions + const roles = scope === 'Fleet' ? fleetRoles : R.reverse(allRoles) + // Explicit annotation widens the type so indexing with RoleKey works for all scopes + const roleDescriptions: Partial> = + scope === 'Fleet' + ? fleetRoleDescriptions + : scope === 'Silo' + ? siloRoleDescriptions + : projectRoleDescriptions return ( <> - {R.reverse(allRoles).map((role) => ( + {roles.map((role) => (
{capitalize(role).replace('_', ' ')} @@ -117,7 +135,13 @@ export function RoleRadioField< + Fleet roles grant access to fleet-level resources and administration. To + maintain tenancy separation between silos, fleet roles do not cascade into + silos. Learn more in the guide. + + ) : scope === 'Silo' ? ( <> Silo roles are inherited by all projects in the silo and override weaker roles. For example, a silo viewer is at least a viewer on all diff --git a/app/forms/system-access.tsx b/app/forms/system-access.tsx new file mode 100644 index 0000000000..1560c7c630 --- /dev/null +++ b/app/forms/system-access.tsx @@ -0,0 +1,134 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useForm } from 'react-hook-form' + +import { + api, + queryClient, + updateRole, + useActorsNotInPolicy, + useApiMutation, + type FleetRolePolicy, +} from '@oxide/api' +import { Access16Icon } from '@oxide/design-system/icons/react' + +import { ListboxField } from '~/components/form/fields/ListboxField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { ResourceLabel } from '~/ui/lib/SideModal' +import { docLinks } from '~/util/links' + +import { + actorToItem, + defaultValues, + RoleRadioField, + type AddRoleModalProps, + type EditRoleModalProps, +} from './access-util' + +export function SystemAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalProps) { + const actors = useActorsNotInPolicy(policy) + + const updatePolicy = useApiMutation(api.systemPolicyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('systemPolicyView') + onDismiss() + }, + }) + + const form = useForm({ defaultValues }) + + return ( + { + updatePolicy.reset() // clear API error state so it doesn't persist on next open + onDismiss() + }} + onSubmit={({ identityId, roleName }) => { + // actor is guaranteed to be in the list because it came from there + const identityType = actors.find((a) => a.id === identityId)!.identityType + + updatePolicy.mutate({ + // Fleet roles are a subset of RoleKey; the UI restricts role selection + // to fleet roles only, so this cast is safe + body: updateRole( + { identityId, identityType, roleName }, + policy + ) as FleetRolePolicy, + }) + }} + loading={updatePolicy.isPending} + submitError={updatePolicy.error} + > + + + + + ) +} + +export function SystemAccessEditUserSideModal({ + onDismiss, + name, + identityId, + identityType, + policy, + defaultValues, +}: EditRoleModalProps) { + const updatePolicy = useApiMutation(api.systemPolicyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('systemPolicyView') + onDismiss() + }, + }) + const form = useForm({ defaultValues }) + + return ( + + {name} + + } + onSubmit={({ roleName }) => { + updatePolicy.mutate({ + // Fleet roles are a subset of RoleKey; the UI restricts role selection + // to fleet roles only, so this cast is safe + body: updateRole( + { identityId, identityType, roleName }, + policy + ) as FleetRolePolicy, + }) + }} + loading={updatePolicy.isPending} + submitError={updatePolicy.error} + onDismiss={() => { + updatePolicy.reset() // clear API error state so it doesn't persist on next open + onDismiss() + }} + > + + + + ) +} diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 1e3a10c4d0..24222deaa9 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -10,6 +10,7 @@ import { useLocation, useNavigate } from 'react-router' import { api, q, queryClient } from '@oxide/api' import { + Access16Icon, Cloud16Icon, IpGlobal16Icon, Metrics16Icon, @@ -55,6 +56,7 @@ export default function SystemLayout() { { value: 'Inventory', path: pb.sledInventory() }, { value: 'IP Pools', path: pb.ipPools() }, { value: 'System Update', path: pb.systemUpdate() }, + { value: 'Access', path: pb.systemAccess() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -101,6 +103,9 @@ export default function SystemLayout() { System Update + + Access + diff --git a/app/pages/system/SystemAccessPage.tsx b/app/pages/system/SystemAccessPage.tsx new file mode 100644 index 0000000000..b8d2c67b3b --- /dev/null +++ b/app/pages/system/SystemAccessPage.tsx @@ -0,0 +1,204 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { useMemo, useState } from 'react' + +import { + api, + byGroupThenName, + deleteRole, + getEffectiveRole, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + useUserRows, + type FleetRolePolicy, + type IdentityType, + type RoleKey, +} from '@oxide/api' +import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' +import { + SystemAccessAddUserSideModal, + SystemAccessEditUserSideModal, +} from '~/forms/system-access' +import { confirmDelete } from '~/stores/confirm-delete' +import { getActionsCol } from '~/table/columns/action-col' +import { Table } from '~/table/Table' +import { CreateButton } from '~/ui/lib/CreateButton' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { identityTypeLabel, roleColor } from '~/util/access' +import { groupBy } from '~/util/array' +import { docLinks } from '~/util/links' + +const EmptyState = ({ onClick }: { onClick: () => void }) => ( + + } + title="No authorized users" + body="Give permission to view, edit, or administer this fleet" + buttonText="Add user or group" + onClick={onClick} + /> + +) + +const systemPolicyView = q(api.systemPolicyView, {}) +const userList = q(api.userList, {}) +const groupList = q(api.groupList, {}) + +export async function clientLoader() { + await Promise.all([ + queryClient.prefetchQuery(systemPolicyView), + // used to resolve user names + queryClient.prefetchQuery(userList), + queryClient.prefetchQuery(groupList), + ]) + return null +} + +export const handle = { crumb: 'Access' } + +type UserRow = { + id: string + identityType: IdentityType + name: string + fleetRole: RoleKey | undefined + effectiveRole: RoleKey +} + +const colHelper = createColumnHelper() + +export default function SystemAccessPage() { + const [addModalOpen, setAddModalOpen] = useState(false) + const [editingUserRow, setEditingUserRow] = useState(null) + + const { data: fleetPolicy } = usePrefetchedQuery(systemPolicyView) + const fleetRows = useUserRows(fleetPolicy.roleAssignments, 'fleet') + + const rows = useMemo(() => { + return groupBy(fleetRows, (u) => u.id) + .map(([userId, userAssignments]) => { + const fleetRole = userAssignments.find((a) => a.roleSource === 'fleet')?.roleName + + const roles = fleetRole ? [fleetRole] : [] + + const { name, identityType } = userAssignments[0] + + const row: UserRow = { + id: userId, + identityType, + name, + fleetRole, + // we know there has to be at least one + effectiveRole: getEffectiveRole(roles)!, + } + + return row + }) + .sort(byGroupThenName) + }, [fleetRows]) + + const { mutateAsync: updatePolicy } = useApiMutation(api.systemPolicyUpdate, { + onSuccess: () => queryClient.invalidateEndpoint('systemPolicyView'), + }) + + const columns = useMemo( + () => [ + colHelper.accessor('name', { header: 'Name' }), + colHelper.accessor('identityType', { + header: 'Type', + cell: (info) => identityTypeLabel[info.getValue()], + }), + colHelper.accessor('fleetRole', { + header: 'Role', + cell: (info) => { + const role = info.getValue() + return role ? fleet.{role} : null + }, + }), + getActionsCol((row: UserRow) => [ + { + label: 'Change role', + onActivate: () => setEditingUserRow(row), + disabled: + !row.fleetRole && "You don't have permission to change this user's role", + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => + updatePolicy({ + // we know policy is there, otherwise there's no row to display + // Fleet roles are a subset of RoleKey, so this cast is safe + body: deleteRole(row.id, fleetPolicy) as FleetRolePolicy, + }), + label: ( + + the {row.fleetRole} role for {row.name} + + ), + }), + disabled: !row.fleetRole && "You don't have permission to delete this user", + }, + ]), + ], + [fleetPolicy, updatePolicy] + ) + + const tableInstance = useReactTable({ + columns, + data: rows, + getCoreRowModel: getCoreRowModel(), + }) + + return ( + <> + + }>Access + } + summary="Roles determine who can view, edit, or administer this fleet." + links={[docLinks.keyConceptsIam, docLinks.access]} + /> + + + + setAddModalOpen(true)}>Add user or group + + {fleetPolicy && addModalOpen && ( + setAddModalOpen(false)} + policy={fleetPolicy} + /> + )} + {fleetPolicy && editingUserRow?.fleetRole && ( + setEditingUserRow(null)} + policy={fleetPolicy} + name={editingUserRow.name} + identityId={editingUserRow.id} + identityType={editingUserRow.identityType} + defaultValues={{ roleName: editingUserRow.fleetRole }} + /> + )} + {rows.length === 0 ? ( + setAddModalOpen(true)} /> + ) : ( + + )} + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index dbd05d4380..4cb4b2ec28 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -234,6 +234,10 @@ export const routes = createRoutesFromElements( path="update" lazy={() => import('./pages/system/UpdatePage').then(convert)} /> + import('./pages/system/SystemAccessPage').then(convert)} + /> redirect(pb.projects())} element={null} /> diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 3dcb8ddac3..e25f78f8c1 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -789,6 +789,12 @@ exports[`breadcrumbs 2`] = ` "path": "/settings/ssh-keys", }, ], + "systemAccess (/system/access)": [ + { + "label": "Access", + "path": "/system/access", + }, + ], "systemUpdate (/system/update)": [ { "label": "System Update", diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 368731c03d..e20bf6d1eb 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -102,6 +102,7 @@ test('path builder', () => { "sshKeyEdit": "/settings/ssh-keys/ss/edit", "sshKeys": "/settings/ssh-keys", "sshKeysNew": "/settings/ssh-keys-new", + "systemAccess": "/system/access", "systemUpdate": "/system/update", "systemUtilization": "/system/utilization", "vpc": "/projects/p/vpcs/v/firewall-rules", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 6d55092139..a49f038c5c 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -110,6 +110,7 @@ export const pb = { siloImages: () => '/images', siloImageEdit: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}/edit`, + systemAccess: () => '/system/access', systemUtilization: () => '/system/utilization', ipPools: () => '/system/networking/ip-pools', diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 1ad7369446..5502864520 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -2319,7 +2319,26 @@ export const handlers = makeHandlers({ supportBundleUpdate: NotImplemented, supportBundleView: NotImplemented, switchView: NotImplemented, - systemPolicyUpdate: NotImplemented, + systemPolicyUpdate({ body, cookies }) { + requireFleetAdmin(cookies) + + const fleetRoles: FleetRole[] = ['admin', 'collaborator', 'viewer'] + const newAssignments = body.role_assignments + .filter((r) => fleetRoles.includes(r.role_name)) + .map((r) => ({ + resource_type: 'fleet' as const, + resource_id: FLEET_ID, + ...R.pick(r, ['identity_id', 'identity_type', 'role_name']), + })) + + const unrelatedAssignments = db.roleAssignments.filter( + (r) => !(r.resource_type === 'fleet' && r.resource_id === FLEET_ID) + ) + + db.roleAssignments = [...unrelatedAssignments, ...newAssignments] + + return body + }, systemQuotasList: NotImplemented, systemTimeseriesSchemaList: NotImplemented, systemUpdateRepositoryView: NotImplemented, diff --git a/test/e2e/system-access.e2e.ts b/test/e2e/system-access.e2e.ts new file mode 100644 index 0000000000..759b26c201 --- /dev/null +++ b/test/e2e/system-access.e2e.ts @@ -0,0 +1,77 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { user3 } from '@oxide/api-mocks' + +import { expect, expectNotVisible, expectRowVisible, expectVisible, test } from './utils' + +test('Click through system access page', async ({ page }) => { + await page.goto('/system/access') + + const table = page.locator('role=table') + + // initial fleet role assignments: Hannah Arendt (admin), Jane Austen (viewer) + await expectVisible(page, ['role=heading[name*="Access"]']) + await expectRowVisible(table, { + Name: 'Hannah Arendt', + Type: 'User', + Role: 'fleet.admin', + }) + await expectRowVisible(table, { + Name: 'Jane Austen', + Type: 'User', + Role: 'fleet.viewer', + }) + await expectNotVisible(page, [`role=cell[name="${user3.display_name}"]`]) + + // Add user 3 as collaborator + await page.click('role=button[name="Add user or group"]') + await expectVisible(page, ['role=heading[name*="Add user or group"]']) + + await page.click('role=button[name*="User or group"]') + // users already assigned should not be in the list + await expectNotVisible(page, ['role=option[name="Hannah Arendt"]']) + await expectVisible(page, [ + 'role=option[name="Jacob Klein"]', + 'role=option[name="Hans Jonas"]', + 'role=option[name="Simone de Beauvoir"]', + ]) + + await page.click('role=option[name="Jacob Klein"]') + await page.getByRole('radio', { name: /^Collaborator / }).click() + await page.click('role=button[name="Assign role"]') + + // user 3 shows up in the table + await expectRowVisible(table, { + Name: 'Jacob Klein', + Type: 'User', + Role: 'fleet.collaborator', + }) + + // change user 3's role from collaborator to viewer + await page + .locator('role=row', { hasText: user3.display_name }) + .locator('role=button[name="Row actions"]') + .click() + await page.click('role=menuitem[name="Change role"]') + + await expectVisible(page, ['role=heading[name*="Edit role"]']) + await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() + + await page.getByRole('radio', { name: /^Viewer / }).click() + await page.click('role=button[name="Update role"]') + + await expectRowVisible(table, { Name: user3.display_name, Role: 'fleet.viewer' }) + + // delete user 3 + const user3Row = page.getByRole('row', { name: user3.display_name, exact: false }) + await expect(user3Row).toBeVisible() + await user3Row.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(user3Row).toBeHidden() +}) From ec6f2421ea3ed53e6b3b42013c568bd1fd29fa15 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 25 Feb 2026 21:37:16 -0800 Subject: [PATCH 02/30] a few small refactors --- app/api/__tests__/safety.spec.ts | 1 + app/forms/access-util.tsx | 2 +- app/pages/SiloAccessPage.tsx | 6 ------ app/pages/system/SystemAccessPage.tsx | 6 ------ mock-api/msw/handlers.ts | 3 +-- test/e2e/system-access.e2e.ts | 20 +++++++++----------- 6 files changed, 12 insertions(+), 26 deletions(-) diff --git a/app/api/__tests__/safety.spec.ts b/app/api/__tests__/safety.spec.ts index 46a484d7c1..e31c892027 100644 --- a/app/api/__tests__/safety.spec.ts +++ b/app/api/__tests__/safety.spec.ts @@ -45,6 +45,7 @@ it('mock-api is only referenced in test files', () => { "test/e2e/profile.e2e.ts", "test/e2e/project-access.e2e.ts", "test/e2e/silo-access.e2e.ts", + "test/e2e/system-access.e2e.ts", "tsconfig.json", ] `) diff --git a/app/forms/access-util.tsx b/app/forms/access-util.tsx index 955c53762f..e51928fd2d 100644 --- a/app/forms/access-util.tsx +++ b/app/forms/access-util.tsx @@ -26,7 +26,7 @@ import { docLinks } from '~/util/links' import { capitalize } from '~/util/str' // Fleet roles don't include limited_collaborator -const fleetRoles: FleetRole[] = ['admin', 'collaborator', 'viewer'] +export const fleetRoles: FleetRole[] = ['admin', 'collaborator', 'viewer'] type AddUserValues = { identityId: string diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index eb65e95359..2cafeb1b6c 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -12,7 +12,6 @@ import { api, byGroupThenName, deleteRole, - getEffectiveRole, q, queryClient, useApiMutation, @@ -74,7 +73,6 @@ type UserRow = { identityType: IdentityType name: string siloRole: RoleKey | undefined - effectiveRole: RoleKey } const colHelper = createColumnHelper() @@ -91,8 +89,6 @@ export default function SiloAccessPage() { .map(([userId, userAssignments]) => { const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName - const roles = siloRole ? [siloRole] : [] - const { name, identityType } = userAssignments[0] const row: UserRow = { @@ -100,8 +96,6 @@ export default function SiloAccessPage() { identityType, name, siloRole, - // we know there has to be at least one - effectiveRole: getEffectiveRole(roles)!, } return row diff --git a/app/pages/system/SystemAccessPage.tsx b/app/pages/system/SystemAccessPage.tsx index b8d2c67b3b..27472611ca 100644 --- a/app/pages/system/SystemAccessPage.tsx +++ b/app/pages/system/SystemAccessPage.tsx @@ -12,7 +12,6 @@ import { api, byGroupThenName, deleteRole, - getEffectiveRole, q, queryClient, useApiMutation, @@ -75,7 +74,6 @@ type UserRow = { identityType: IdentityType name: string fleetRole: RoleKey | undefined - effectiveRole: RoleKey } const colHelper = createColumnHelper() @@ -92,8 +90,6 @@ export default function SystemAccessPage() { .map(([userId, userAssignments]) => { const fleetRole = userAssignments.find((a) => a.roleSource === 'fleet')?.roleName - const roles = fleetRole ? [fleetRole] : [] - const { name, identityType } = userAssignments[0] const row: UserRow = { @@ -101,8 +97,6 @@ export default function SystemAccessPage() { identityType, name, fleetRole, - // we know there has to be at least one - effectiveRole: getEffectiveRole(roles)!, } return row diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 5502864520..42f50d3a98 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -30,6 +30,7 @@ import { import { json, makeHandlers, type Json } from '~/api/__generated__/msw-handlers' import { instanceCan, OXQL_GROUP_BY_ERROR } from '~/api/util' +import { fleetRoles } from '~/forms/access-util' import { parseIpNet } from '~/util/ip' import { commaSeries } from '~/util/str' import { GiB } from '~/util/units' @@ -1875,7 +1876,6 @@ export const handlers = makeHandlers({ systemPolicyView({ cookies }) { requireFleetViewer(cookies) - const fleetRoles: FleetRole[] = ['admin', 'collaborator', 'viewer'] const role_assignments = db.roleAssignments .filter((r) => r.resource_type === 'fleet' && r.resource_id === FLEET_ID) .filter((r) => fleetRoles.includes(r.role_name as FleetRole)) @@ -2322,7 +2322,6 @@ export const handlers = makeHandlers({ systemPolicyUpdate({ body, cookies }) { requireFleetAdmin(cookies) - const fleetRoles: FleetRole[] = ['admin', 'collaborator', 'viewer'] const newAssignments = body.role_assignments .filter((r) => fleetRoles.includes(r.role_name)) .map((r) => ({ diff --git a/test/e2e/system-access.e2e.ts b/test/e2e/system-access.e2e.ts index 759b26c201..51becb21be 100644 --- a/test/e2e/system-access.e2e.ts +++ b/test/e2e/system-access.e2e.ts @@ -7,7 +7,7 @@ */ import { user3 } from '@oxide/api-mocks' -import { expect, expectNotVisible, expectRowVisible, expectVisible, test } from './utils' +import { expect, expectRowVisible, test } from './utils' test('Click through system access page', async ({ page }) => { await page.goto('/system/access') @@ -15,7 +15,7 @@ test('Click through system access page', async ({ page }) => { const table = page.locator('role=table') // initial fleet role assignments: Hannah Arendt (admin), Jane Austen (viewer) - await expectVisible(page, ['role=heading[name*="Access"]']) + await expect(page.getByRole('heading', { name: /Access/ })).toBeVisible() await expectRowVisible(table, { Name: 'Hannah Arendt', Type: 'User', @@ -26,20 +26,18 @@ test('Click through system access page', async ({ page }) => { Type: 'User', Role: 'fleet.viewer', }) - await expectNotVisible(page, [`role=cell[name="${user3.display_name}"]`]) + await expect(page.getByRole('cell', { name: user3.display_name })).toBeHidden() // Add user 3 as collaborator await page.click('role=button[name="Add user or group"]') - await expectVisible(page, ['role=heading[name*="Add user or group"]']) + await expect(page.getByRole('heading', { name: /Add user or group/ })).toBeVisible() await page.click('role=button[name*="User or group"]') // users already assigned should not be in the list - await expectNotVisible(page, ['role=option[name="Hannah Arendt"]']) - await expectVisible(page, [ - 'role=option[name="Jacob Klein"]', - 'role=option[name="Hans Jonas"]', - 'role=option[name="Simone de Beauvoir"]', - ]) + await expect(page.getByRole('option', { name: 'Hannah Arendt' })).toBeHidden() + await expect(page.getByRole('option', { name: 'Jacob Klein' })).toBeVisible() + await expect(page.getByRole('option', { name: 'Hans Jonas' })).toBeVisible() + await expect(page.getByRole('option', { name: 'Simone de Beauvoir' })).toBeVisible() await page.click('role=option[name="Jacob Klein"]') await page.getByRole('radio', { name: /^Collaborator / }).click() @@ -59,7 +57,7 @@ test('Click through system access page', async ({ page }) => { .click() await page.click('role=menuitem[name="Change role"]') - await expectVisible(page, ['role=heading[name*="Edit role"]']) + await expect(page.getByRole('heading', { name: /Edit role/ })).toBeVisible() await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() await page.getByRole('radio', { name: /^Viewer / }).click() From 376b1537fe0af5ef7a152c2adf981a6ee8a1ef5d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 25 Feb 2026 22:11:53 -0800 Subject: [PATCH 03/30] Remove some casts; change import --- app/api/roles.ts | 29 ++++++++++++++++++--------- app/forms/access-util.tsx | 12 +++++------ app/forms/system-access.tsx | 28 ++++++++++---------------- app/pages/system/SystemAccessPage.tsx | 13 ++++-------- mock-api/msw/handlers.ts | 6 +++--- 5 files changed, 42 insertions(+), 46 deletions(-) diff --git a/app/api/roles.ts b/app/api/roles.ts index 9c3ff07da8..06169e940a 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -40,6 +40,9 @@ export const roleOrder: Record = { /** `roleOrder` record converted to a sorted array of roles. */ export const allRoles = flatRoles(roleOrder) +// Fleet roles don't include limited_collaborator +export const fleetRoles: FleetRole[] = ['admin', 'collaborator', 'viewer'] + /** Given a list of roles, get the most permissive one */ export const getEffectiveRole = (roles: RoleKey[]): RoleKey | undefined => R.firstBy(roles, (role) => roleOrder[role]) @@ -48,17 +51,20 @@ export const getEffectiveRole = (roles: RoleKey[]): RoleKey | undefined => // Policy helpers //////////////////////////// -type RoleAssignment = { +type RoleAssignment = { identityId: string identityType: IdentityType - roleName: RoleKey + roleName: R } -export type Policy = { roleAssignments: RoleAssignment[] } +export type Policy = { roleAssignments: RoleAssignment[] } /** * Returns a new updated policy. Does not modify the passed-in policy. */ -export function updateRole(newAssignment: RoleAssignment, policy: Policy): Policy { +export function updateRole( + newAssignment: RoleAssignment, + policy: Policy +): Policy { const roleAssignments = policy.roleAssignments.filter( (ra) => ra.identityId !== newAssignment.identityId ) @@ -70,18 +76,21 @@ export function updateRole(newAssignment: RoleAssignment, policy: Policy): Polic * Delete any role assignments for user or group ID. Returns a new updated * policy. Does not modify the passed-in policy. */ -export function deleteRole(identityId: string, policy: Policy): Policy { +export function deleteRole( + identityId: string, + policy: Policy +): Policy { const roleAssignments = policy.roleAssignments.filter( (ra) => ra.identityId !== identityId ) return { roleAssignments } } -type UserAccessRow = { +type UserAccessRow = { id: string identityType: IdentityType name: string - roleName: RoleKey + roleName: R roleSource: string } @@ -92,10 +101,10 @@ type UserAccessRow = { * of an API request for the list of users. It's a bit awkward, but the logic is * identical between projects and orgs so it is worth sharing. */ -export function useUserRows( - roleAssignments: RoleAssignment[], +export function useUserRows( + roleAssignments: RoleAssignment[], roleSource: string -): UserAccessRow[] { +): UserAccessRow[] { // HACK: because the policy has no names, we are fetching ~all the users, // putting them in a dictionary, and adding the names to the rows const { data: users } = usePrefetchedQuery(q(api.userList, {})) diff --git a/app/forms/access-util.tsx b/app/forms/access-util.tsx index e51928fd2d..68a5328ef2 100644 --- a/app/forms/access-util.tsx +++ b/app/forms/access-util.tsx @@ -10,6 +10,7 @@ import * as R from 'remeda' import { allRoles, + fleetRoles, type Actor, type FleetRole, type IdentityType, @@ -25,9 +26,6 @@ import { Radio } from '~/ui/lib/Radio' import { docLinks } from '~/util/links' import { capitalize } from '~/util/str' -// Fleet roles don't include limited_collaborator -export const fleetRoles: FleetRole[] = ['admin', 'collaborator', 'viewer'] - type AddUserValues = { identityId: string roleName: RoleKey @@ -76,16 +74,16 @@ export const actorToItem = (actor: Actor): ListboxItem => ({ selectedLabel: actor.displayName, }) -export type AddRoleModalProps = { +export type AddRoleModalProps = { onDismiss: () => void - policy: Policy + policy: Policy } -export type EditRoleModalProps = AddRoleModalProps & { +export type EditRoleModalProps = AddRoleModalProps & { name?: string identityId: string identityType: IdentityType - defaultValues: { roleName: RoleKey } + defaultValues: { roleName: R } } const AccessDocs = () => ( diff --git a/app/forms/system-access.tsx b/app/forms/system-access.tsx index 1560c7c630..fd93378a63 100644 --- a/app/forms/system-access.tsx +++ b/app/forms/system-access.tsx @@ -13,7 +13,7 @@ import { updateRole, useActorsNotInPolicy, useApiMutation, - type FleetRolePolicy, + type FleetRole, } from '@oxide/api' import { Access16Icon } from '@oxide/design-system/icons/react' @@ -25,13 +25,15 @@ import { docLinks } from '~/util/links' import { actorToItem, - defaultValues, RoleRadioField, type AddRoleModalProps, type EditRoleModalProps, } from './access-util' -export function SystemAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalProps) { +export function SystemAccessAddUserSideModal({ + onDismiss, + policy, +}: AddRoleModalProps) { const actors = useActorsNotInPolicy(policy) const updatePolicy = useApiMutation(api.systemPolicyUpdate, { @@ -41,7 +43,9 @@ export function SystemAccessAddUserSideModal({ onDismiss, policy }: AddRoleModal }, }) - const form = useForm({ defaultValues }) + const form = useForm<{ identityId: string; roleName: FleetRole }>({ + defaultValues: { identityId: '', roleName: 'viewer' }, + }) return ( a.id === identityId)!.identityType updatePolicy.mutate({ - // Fleet roles are a subset of RoleKey; the UI restricts role selection - // to fleet roles only, so this cast is safe - body: updateRole( - { identityId, identityType, roleName }, - policy - ) as FleetRolePolicy, + body: updateRole({ identityId, identityType, roleName }, policy), }) }} loading={updatePolicy.isPending} @@ -90,7 +89,7 @@ export function SystemAccessEditUserSideModal({ identityType, policy, defaultValues, -}: EditRoleModalProps) { +}: EditRoleModalProps) { const updatePolicy = useApiMutation(api.systemPolicyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('systemPolicyView') @@ -112,12 +111,7 @@ export function SystemAccessEditUserSideModal({ } onSubmit={({ roleName }) => { updatePolicy.mutate({ - // Fleet roles are a subset of RoleKey; the UI restricts role selection - // to fleet roles only, so this cast is safe - body: updateRole( - { identityId, identityType, roleName }, - policy - ) as FleetRolePolicy, + body: updateRole({ identityId, identityType, roleName }, policy), }) }} loading={updatePolicy.isPending} diff --git a/app/pages/system/SystemAccessPage.tsx b/app/pages/system/SystemAccessPage.tsx index 27472611ca..669ec2252a 100644 --- a/app/pages/system/SystemAccessPage.tsx +++ b/app/pages/system/SystemAccessPage.tsx @@ -17,9 +17,8 @@ import { useApiMutation, usePrefetchedQuery, useUserRows, - type FleetRolePolicy, + type FleetRole, type IdentityType, - type RoleKey, } from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' @@ -73,7 +72,7 @@ type UserRow = { id: string identityType: IdentityType name: string - fleetRole: RoleKey | undefined + fleetRole: FleetRole | undefined } const colHelper = createColumnHelper() @@ -83,7 +82,7 @@ export default function SystemAccessPage() { const [editingUserRow, setEditingUserRow] = useState(null) const { data: fleetPolicy } = usePrefetchedQuery(systemPolicyView) - const fleetRows = useUserRows(fleetPolicy.roleAssignments, 'fleet') + const fleetRows = useUserRows(fleetPolicy?.roleAssignments ?? [], 'fleet') const rows = useMemo(() => { return groupBy(fleetRows, (u) => u.id) @@ -126,8 +125,6 @@ export default function SystemAccessPage() { { label: 'Change role', onActivate: () => setEditingUserRow(row), - disabled: - !row.fleetRole && "You don't have permission to change this user's role", }, { label: 'Delete', @@ -135,8 +132,7 @@ export default function SystemAccessPage() { doDelete: () => updatePolicy({ // we know policy is there, otherwise there's no row to display - // Fleet roles are a subset of RoleKey, so this cast is safe - body: deleteRole(row.id, fleetPolicy) as FleetRolePolicy, + body: deleteRole(row.id, fleetPolicy), }), label: ( @@ -144,7 +140,6 @@ export default function SystemAccessPage() { ), }), - disabled: !row.fleetRole && "You don't have permission to delete this user", }, ]), ], diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 42f50d3a98..3e0cb03f0d 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -14,6 +14,7 @@ import { validate as isUuid, v4 as uuid } from 'uuid' import { diskCan, + fleetRoles, FLEET_ID, INSTANCE_MAX_CPU, INSTANCE_MAX_RAM_GiB, @@ -30,7 +31,6 @@ import { import { json, makeHandlers, type Json } from '~/api/__generated__/msw-handlers' import { instanceCan, OXQL_GROUP_BY_ERROR } from '~/api/util' -import { fleetRoles } from '~/forms/access-util' import { parseIpNet } from '~/util/ip' import { commaSeries } from '~/util/str' import { GiB } from '~/util/units' @@ -1878,7 +1878,7 @@ export const handlers = makeHandlers({ const role_assignments = db.roleAssignments .filter((r) => r.resource_type === 'fleet' && r.resource_id === FLEET_ID) - .filter((r) => fleetRoles.includes(r.role_name as FleetRole)) + .filter((r) => fleetRoles.some((role) => role === r.role_name)) .map((r) => ({ identity_id: r.identity_id, identity_type: r.identity_type, @@ -2323,7 +2323,7 @@ export const handlers = makeHandlers({ requireFleetAdmin(cookies) const newAssignments = body.role_assignments - .filter((r) => fleetRoles.includes(r.role_name)) + .filter((r) => fleetRoles.some((role) => role === r.role_name)) .map((r) => ({ resource_type: 'fleet' as const, resource_id: FLEET_ID, From fb0e217824d017d4d834f4dd585982f45e8429ed Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 25 Feb 2026 22:25:19 -0800 Subject: [PATCH 04/30] List system roles with least permissions first, like silo/project --- app/api/roles.ts | 2 +- app/pages/system/SystemAccessPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/api/roles.ts b/app/api/roles.ts index 06169e940a..6d7f87f76e 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -41,7 +41,7 @@ export const roleOrder: Record = { export const allRoles = flatRoles(roleOrder) // Fleet roles don't include limited_collaborator -export const fleetRoles: FleetRole[] = ['admin', 'collaborator', 'viewer'] +export const fleetRoles: FleetRole[] = ['viewer', 'collaborator', 'admin'] /** Given a list of roles, get the most permissive one */ export const getEffectiveRole = (roles: RoleKey[]): RoleKey | undefined => diff --git a/app/pages/system/SystemAccessPage.tsx b/app/pages/system/SystemAccessPage.tsx index 669ec2252a..6b0f3078ec 100644 --- a/app/pages/system/SystemAccessPage.tsx +++ b/app/pages/system/SystemAccessPage.tsx @@ -82,7 +82,7 @@ export default function SystemAccessPage() { const [editingUserRow, setEditingUserRow] = useState(null) const { data: fleetPolicy } = usePrefetchedQuery(systemPolicyView) - const fleetRows = useUserRows(fleetPolicy?.roleAssignments ?? [], 'fleet') + const fleetRows = useUserRows(fleetPolicy.roleAssignments, 'fleet') const rows = useMemo(() => { return groupBy(fleetRows, (u) => u.id) From 2c912ab79dee1abb866a9d72957b8356e413d093 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 26 Feb 2026 03:29:41 -0800 Subject: [PATCH 05/30] Fix a few small inconsistencies between System / Silo / Project access pages --- app/forms/access-util.tsx | 1 - app/forms/silo-access.tsx | 10 ++++++++-- app/pages/SiloAccessPage.tsx | 6 +++++- app/pages/system/SystemAccessPage.tsx | 6 +++++- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/app/forms/access-util.tsx b/app/forms/access-util.tsx index 68a5328ef2..c241f874d3 100644 --- a/app/forms/access-util.tsx +++ b/app/forms/access-util.tsx @@ -104,7 +104,6 @@ export function RoleRadioField< scope: 'Fleet' | 'Silo' | 'Project' }) { const roles = scope === 'Fleet' ? fleetRoles : R.reverse(allRoles) - // Explicit annotation widens the type so indexing with RoleKey works for all scopes const roleDescriptions: Partial> = scope === 'Fleet' ? fleetRoleDescriptions diff --git a/app/forms/silo-access.tsx b/app/forms/silo-access.tsx index 7ccaeab087..6bc711230b 100644 --- a/app/forms/silo-access.tsx +++ b/app/forms/silo-access.tsx @@ -49,7 +49,10 @@ export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalPr resourceName="role" title="Add user or group" submitLabel="Assign role" - onDismiss={onDismiss} + onDismiss={() => { + updatePolicy.reset() // clear API error state so it doesn't persist on next open + onDismiss() + }} onSubmit={({ identityId, roleName }) => { // TODO: DRY logic // actor is guaranteed to be in the list because it came from there @@ -109,7 +112,10 @@ export function SiloAccessEditUserSideModal({ }} loading={updatePolicy.isPending} submitError={updatePolicy.error} - onDismiss={onDismiss} + onDismiss={() => { + updatePolicy.reset() // clear API error state so it doesn't persist on next open + onDismiss() + }} > diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 2cafeb1b6c..aa9b97c1e0 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -30,6 +30,7 @@ import { SiloAccessEditUserSideModal, } from '~/forms/silo-access' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' import { CreateButton } from '~/ui/lib/CreateButton' @@ -104,7 +105,10 @@ export default function SiloAccessPage() { }, [siloRows]) const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { - onSuccess: () => queryClient.invalidateEndpoint('policyView'), + onSuccess: () => { + queryClient.invalidateEndpoint('policyView') + addToast({ content: 'Access removed' }) + }, // TODO: handle 403 }) diff --git a/app/pages/system/SystemAccessPage.tsx b/app/pages/system/SystemAccessPage.tsx index 6b0f3078ec..ef2d5cf7d4 100644 --- a/app/pages/system/SystemAccessPage.tsx +++ b/app/pages/system/SystemAccessPage.tsx @@ -30,6 +30,7 @@ import { SystemAccessEditUserSideModal, } from '~/forms/system-access' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' import { CreateButton } from '~/ui/lib/CreateButton' @@ -104,7 +105,10 @@ export default function SystemAccessPage() { }, [fleetRows]) const { mutateAsync: updatePolicy } = useApiMutation(api.systemPolicyUpdate, { - onSuccess: () => queryClient.invalidateEndpoint('systemPolicyView'), + onSuccess: () => { + queryClient.invalidateEndpoint('systemPolicyView') + addToast({ content: 'Access removed' }) + }, }) const columns = useMemo( From cb3680b834b67248e42db20252d20e36203bff9d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 26 Feb 2026 04:56:00 -0800 Subject: [PATCH 06/30] Add warning about deleting own access --- app/pages/system/SystemAccessPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/pages/system/SystemAccessPage.tsx b/app/pages/system/SystemAccessPage.tsx index ef2d5cf7d4..1196214ab7 100644 --- a/app/pages/system/SystemAccessPage.tsx +++ b/app/pages/system/SystemAccessPage.tsx @@ -29,6 +29,7 @@ import { SystemAccessAddUserSideModal, SystemAccessEditUserSideModal, } from '~/forms/system-access' +import { useCurrentUser } from '~/hooks/use-current-user' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { getActionsCol } from '~/table/columns/action-col' @@ -82,6 +83,7 @@ export default function SystemAccessPage() { const [addModalOpen, setAddModalOpen] = useState(false) const [editingUserRow, setEditingUserRow] = useState(null) + const { me } = useCurrentUser() const { data: fleetPolicy } = usePrefetchedQuery(systemPolicyView) const fleetRows = useUserRows(fleetPolicy.roleAssignments, 'fleet') @@ -143,11 +145,13 @@ export default function SystemAccessPage() { the {row.fleetRole} role for {row.name} ), + extraContent: + row.id === me.id ? 'This will remove your own fleet access.' : undefined, }), }, ]), ], - [fleetPolicy, updatePolicy] + [fleetPolicy, updatePolicy, me] ) const tableInstance = useReactTable({ From 0f0f6b4ef23343d2cfcb13057d2d9c09be342645 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 26 Feb 2026 06:19:28 -0800 Subject: [PATCH 07/30] copy adjustment --- app/layouts/SystemLayout.tsx | 4 ++-- app/pages/system/SystemAccessPage.tsx | 4 ++-- app/util/__snapshots__/path-builder.spec.ts.snap | 2 +- test/e2e/system-access.e2e.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 24222deaa9..f324dd363c 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -56,7 +56,7 @@ export default function SystemLayout() { { value: 'Inventory', path: pb.sledInventory() }, { value: 'IP Pools', path: pb.ipPools() }, { value: 'System Update', path: pb.systemUpdate() }, - { value: 'Access', path: pb.systemAccess() }, + { value: 'System Access', path: pb.systemAccess() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -104,7 +104,7 @@ export default function SystemLayout() { System Update - Access + System Access diff --git a/app/pages/system/SystemAccessPage.tsx b/app/pages/system/SystemAccessPage.tsx index 1196214ab7..4c7ab76879 100644 --- a/app/pages/system/SystemAccessPage.tsx +++ b/app/pages/system/SystemAccessPage.tsx @@ -68,7 +68,7 @@ export async function clientLoader() { return null } -export const handle = { crumb: 'Access' } +export const handle = { crumb: 'System Access' } type UserRow = { id: string @@ -163,7 +163,7 @@ export default function SystemAccessPage() { return ( <> - }>Access + }>System Access } diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index e25f78f8c1..5c4ca9cff6 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -791,7 +791,7 @@ exports[`breadcrumbs 2`] = ` ], "systemAccess (/system/access)": [ { - "label": "Access", + "label": "System Access", "path": "/system/access", }, ], diff --git a/test/e2e/system-access.e2e.ts b/test/e2e/system-access.e2e.ts index 51becb21be..5999943b52 100644 --- a/test/e2e/system-access.e2e.ts +++ b/test/e2e/system-access.e2e.ts @@ -15,7 +15,7 @@ test('Click through system access page', async ({ page }) => { const table = page.locator('role=table') // initial fleet role assignments: Hannah Arendt (admin), Jane Austen (viewer) - await expect(page.getByRole('heading', { name: /Access/ })).toBeVisible() + await expect(page.getByRole('heading', { name: /System Access/ })).toBeVisible() await expectRowVisible(table, { Name: 'Hannah Arendt', Type: 'User', From d8715ef1d3d46052ac0fefca1efbb468455c2e89 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 26 Feb 2026 09:02:28 -0800 Subject: [PATCH 08/30] Clean up a few bits of legacy logic --- app/pages/SiloAccessPage.tsx | 4 ++-- app/pages/system/SystemAccessPage.tsx | 12 +++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index aa9b97c1e0..43e8714a5a 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -179,13 +179,13 @@ export default function SiloAccessPage() { setAddModalOpen(true)}>Add user or group - {siloPolicy && addModalOpen && ( + {addModalOpen && ( setAddModalOpen(false)} policy={siloPolicy} /> )} - {siloPolicy && editingUserRow?.siloRole && ( + {editingUserRow?.siloRole && ( setEditingUserRow(null)} policy={siloPolicy} diff --git a/app/pages/system/SystemAccessPage.tsx b/app/pages/system/SystemAccessPage.tsx index 4c7ab76879..4b2096eead 100644 --- a/app/pages/system/SystemAccessPage.tsx +++ b/app/pages/system/SystemAccessPage.tsx @@ -74,7 +74,7 @@ type UserRow = { id: string identityType: IdentityType name: string - fleetRole: FleetRole | undefined + fleetRole: FleetRole } const colHelper = createColumnHelper() @@ -90,9 +90,7 @@ export default function SystemAccessPage() { const rows = useMemo(() => { return groupBy(fleetRows, (u) => u.id) .map(([userId, userAssignments]) => { - const fleetRole = userAssignments.find((a) => a.roleSource === 'fleet')?.roleName - - const { name, identityType } = userAssignments[0] + const { name, identityType, roleName: fleetRole } = userAssignments[0] const row: UserRow = { id: userId, @@ -124,7 +122,7 @@ export default function SystemAccessPage() { header: 'Role', cell: (info) => { const role = info.getValue() - return role ? fleet.{role} : null + return fleet.{role} }, }), getActionsCol((row: UserRow) => [ @@ -175,13 +173,13 @@ export default function SystemAccessPage() { setAddModalOpen(true)}>Add user or group - {fleetPolicy && addModalOpen && ( + {addModalOpen && ( setAddModalOpen(false)} policy={fleetPolicy} /> )} - {fleetPolicy && editingUserRow?.fleetRole && ( + {editingUserRow && ( setEditingUserRow(null)} policy={fleetPolicy} From fb50d25bfdc05706949e729e174ad33229257b29 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 26 Feb 2026 09:40:12 -0800 Subject: [PATCH 09/30] use getEffectiveRole to handle edge case in role assignments --- app/api/roles.ts | 4 ++-- app/forms/access-util.tsx | 2 +- app/pages/system/SystemAccessPage.tsx | 6 +++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/api/roles.ts b/app/api/roles.ts index 6d7f87f76e..1b1d8ffe89 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -41,10 +41,10 @@ export const roleOrder: Record = { export const allRoles = flatRoles(roleOrder) // Fleet roles don't include limited_collaborator -export const fleetRoles: FleetRole[] = ['viewer', 'collaborator', 'admin'] +export const fleetRoles = allRoles.filter((r) => r !== 'limited_collaborator') /** Given a list of roles, get the most permissive one */ -export const getEffectiveRole = (roles: RoleKey[]): RoleKey | undefined => +export const getEffectiveRole = (roles: R[]): R | undefined => R.firstBy(roles, (role) => roleOrder[role]) //////////////////////////// diff --git a/app/forms/access-util.tsx b/app/forms/access-util.tsx index c241f874d3..5bec86c01d 100644 --- a/app/forms/access-util.tsx +++ b/app/forms/access-util.tsx @@ -103,7 +103,7 @@ export function RoleRadioField< control: Control scope: 'Fleet' | 'Silo' | 'Project' }) { - const roles = scope === 'Fleet' ? fleetRoles : R.reverse(allRoles) + const roles = R.reverse(scope === 'Fleet' ? fleetRoles : allRoles) const roleDescriptions: Partial> = scope === 'Fleet' ? fleetRoleDescriptions diff --git a/app/pages/system/SystemAccessPage.tsx b/app/pages/system/SystemAccessPage.tsx index 4b2096eead..15cf5b3948 100644 --- a/app/pages/system/SystemAccessPage.tsx +++ b/app/pages/system/SystemAccessPage.tsx @@ -12,6 +12,7 @@ import { api, byGroupThenName, deleteRole, + getEffectiveRole, q, queryClient, useApiMutation, @@ -90,7 +91,10 @@ export default function SystemAccessPage() { const rows = useMemo(() => { return groupBy(fleetRows, (u) => u.id) .map(([userId, userAssignments]) => { - const { name, identityType, roleName: fleetRole } = userAssignments[0] + const { name, identityType } = userAssignments[0] + // non-null: userAssignments is non-empty (groupBy only creates groups for existing items) + // getEffectiveRole needed because API allows multiple fleet role assignments for the same user, even though that's probably rare + const fleetRole = getEffectiveRole(userAssignments.map((a) => a.roleName))! const row: UserRow = { id: userId, From 7a9ebbcc9b98bfa22bb50f9aa3d703ff0cde3647 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 26 Feb 2026 11:18:25 -0800 Subject: [PATCH 10/30] Add test for limited permission user --- test/e2e/system-access.e2e.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/test/e2e/system-access.e2e.ts b/test/e2e/system-access.e2e.ts index 5999943b52..5c8b317222 100644 --- a/test/e2e/system-access.e2e.ts +++ b/test/e2e/system-access.e2e.ts @@ -7,7 +7,7 @@ */ import { user3 } from '@oxide/api-mocks' -import { expect, expectRowVisible, test } from './utils' +import { expect, expectRowVisible, expectToast, getPageAsUser, test } from './utils' test('Click through system access page', async ({ page }) => { await page.goto('/system/access') @@ -73,3 +73,22 @@ test('Click through system access page', async ({ page }) => { await page.getByRole('button', { name: 'Confirm' }).click() await expect(user3Row).toBeHidden() }) + +test('Fleet viewer cannot modify system access', async ({ browser }) => { + const page = await getPageAsUser(browser, 'Jane Austen') + await page.goto('/system/access') + + const table = page.locator('role=table') + await expect(page.getByRole('heading', { name: /System Access/ })).toBeVisible() + await expectRowVisible(table, { Name: 'Hannah Arendt', Role: 'fleet.admin' }) + + // attempt to add a user — the submit should fail with 403 + await page.click('role=button[name="Add user or group"]') + await page.click('role=button[name*="User or group"]') + await page.click('role=option[name="Jacob Klein"]') + await page.click('role=button[name="Assign role"]') + await expectToast(page, 'Action not authorized') + + // table is unchanged + await expect(page.getByRole('cell', { name: 'Jacob Klein' })).toBeHidden() +}) From 518b6e69c503e4aea80b36ddeb96d349fc59f8a3 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 26 Feb 2026 12:16:44 -0800 Subject: [PATCH 11/30] tweak test for error message --- test/e2e/system-access.e2e.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/e2e/system-access.e2e.ts b/test/e2e/system-access.e2e.ts index 5c8b317222..7657b2d2d9 100644 --- a/test/e2e/system-access.e2e.ts +++ b/test/e2e/system-access.e2e.ts @@ -7,7 +7,7 @@ */ import { user3 } from '@oxide/api-mocks' -import { expect, expectRowVisible, expectToast, getPageAsUser, test } from './utils' +import { expect, expectRowVisible, getPageAsUser, test } from './utils' test('Click through system access page', async ({ page }) => { await page.goto('/system/access') @@ -87,8 +87,10 @@ test('Fleet viewer cannot modify system access', async ({ browser }) => { await page.click('role=button[name*="User or group"]') await page.click('role=option[name="Jacob Klein"]') await page.click('role=button[name="Assign role"]') - await expectToast(page, 'Action not authorized') + await expect(page.getByRole('heading', { name: 'Error' })).toBeVisible() + await expect(page.getByText('Action not authorized')).toBeVisible() - // table is unchanged + // dismiss the modal and confirm the table is unchanged + await page.click('role=button[name="Cancel"]') await expect(page.getByRole('cell', { name: 'Jacob Klein' })).toBeHidden() }) From 9423ea7c06f5fb37e6501ac60188f9764098322b Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 26 Feb 2026 12:50:29 -0800 Subject: [PATCH 12/30] simplify test --- test/e2e/system-access.e2e.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/e2e/system-access.e2e.ts b/test/e2e/system-access.e2e.ts index 7657b2d2d9..3572354a21 100644 --- a/test/e2e/system-access.e2e.ts +++ b/test/e2e/system-access.e2e.ts @@ -87,7 +87,6 @@ test('Fleet viewer cannot modify system access', async ({ browser }) => { await page.click('role=button[name*="User or group"]') await page.click('role=option[name="Jacob Klein"]') await page.click('role=button[name="Assign role"]') - await expect(page.getByRole('heading', { name: 'Error' })).toBeVisible() await expect(page.getByText('Action not authorized')).toBeVisible() // dismiss the modal and confirm the table is unchanged From 636b90e1117fd6444ed5cf8e8b13ca9874971223 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 26 Feb 2026 15:05:55 -0800 Subject: [PATCH 13/30] R -> Role for generic, to cut down on R[emeda] confusion/collisions --- app/api/roles.ts | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/app/api/roles.ts b/app/api/roles.ts index 1b1d8ffe89..1fd4798dc4 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -44,27 +44,29 @@ export const allRoles = flatRoles(roleOrder) export const fleetRoles = allRoles.filter((r) => r !== 'limited_collaborator') /** Given a list of roles, get the most permissive one */ -export const getEffectiveRole = (roles: R[]): R | undefined => +export const getEffectiveRole = (roles: Role[]): Role | undefined => R.firstBy(roles, (role) => roleOrder[role]) //////////////////////////// // Policy helpers //////////////////////////// -type RoleAssignment = { +type RoleAssignment = { identityId: string identityType: IdentityType - roleName: R + roleName: Role +} +export type Policy = { + roleAssignments: RoleAssignment[] } -export type Policy = { roleAssignments: RoleAssignment[] } /** * Returns a new updated policy. Does not modify the passed-in policy. */ -export function updateRole( - newAssignment: RoleAssignment, - policy: Policy -): Policy { +export function updateRole( + newAssignment: RoleAssignment, + policy: Policy +): Policy { const roleAssignments = policy.roleAssignments.filter( (ra) => ra.identityId !== newAssignment.identityId ) @@ -76,21 +78,21 @@ export function updateRole( * Delete any role assignments for user or group ID. Returns a new updated * policy. Does not modify the passed-in policy. */ -export function deleteRole( +export function deleteRole( identityId: string, - policy: Policy -): Policy { + policy: Policy +): Policy { const roleAssignments = policy.roleAssignments.filter( (ra) => ra.identityId !== identityId ) return { roleAssignments } } -type UserAccessRow = { +type UserAccessRow = { id: string identityType: IdentityType name: string - roleName: R + roleName: Role roleSource: string } @@ -101,10 +103,10 @@ type UserAccessRow = { * of an API request for the list of users. It's a bit awkward, but the logic is * identical between projects and orgs so it is worth sharing. */ -export function useUserRows( - roleAssignments: RoleAssignment[], +export function useUserRows( + roleAssignments: RoleAssignment[], roleSource: string -): UserAccessRow[] { +): UserAccessRow[] { // HACK: because the policy has no names, we are fetching ~all the users, // putting them in a dictionary, and adding the names to the rows const { data: users } = usePrefetchedQuery(q(api.userList, {})) From 83c12c457c35edfcb9a89408e86fd9bb3f851d61 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 26 Feb 2026 15:05:55 -0800 Subject: [PATCH 14/30] R -> Role for generic, to cut down on R[emeda] confusion/collisions --- app/forms/access-util.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/forms/access-util.tsx b/app/forms/access-util.tsx index 5bec86c01d..5adcb0f48d 100644 --- a/app/forms/access-util.tsx +++ b/app/forms/access-util.tsx @@ -74,16 +74,16 @@ export const actorToItem = (actor: Actor): ListboxItem => ({ selectedLabel: actor.displayName, }) -export type AddRoleModalProps = { +export type AddRoleModalProps = { onDismiss: () => void - policy: Policy + policy: Policy } -export type EditRoleModalProps = AddRoleModalProps & { +export type EditRoleModalProps = AddRoleModalProps & { name?: string identityId: string identityType: IdentityType - defaultValues: { roleName: R } + defaultValues: { roleName: Role } } const AccessDocs = () => ( From 99d000917a25c9c72e9aba0cfa2efb64eaf047c6 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 26 Feb 2026 15:25:06 -0800 Subject: [PATCH 15/30] type predicate to specify fleetRoles as FleetRole[] instead of RoleKey[] --- app/api/roles.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/api/roles.ts b/app/api/roles.ts index 1fd4798dc4..419985dffb 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -41,7 +41,9 @@ export const roleOrder: Record = { export const allRoles = flatRoles(roleOrder) // Fleet roles don't include limited_collaborator -export const fleetRoles = allRoles.filter((r) => r !== 'limited_collaborator') +export const fleetRoles = allRoles.filter( + (r): r is FleetRole => r !== 'limited_collaborator' +) /** Given a list of roles, get the most permissive one */ export const getEffectiveRole = (roles: Role[]): Role | undefined => From c3d6755496b57b719d7f5f91af9436410af977bb Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 26 Feb 2026 16:25:52 -0800 Subject: [PATCH 16/30] Use more precise FleetRole type; add warning on Silo Access removal --- app/api/roles.ts | 4 +++- app/pages/SiloAccessPage.tsx | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/api/roles.ts b/app/api/roles.ts index 419985dffb..d17c1d7df9 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -149,7 +149,9 @@ export type Actor = { * Fetch lists of users and groups, filtering out the ones that are already in * the given policy. */ -export function useActorsNotInPolicy(policy: Policy): Actor[] { +export function useActorsNotInPolicy( + policy: Policy +): Actor[] { const { data: users } = usePrefetchedQuery(q(api.userList, {})) const { data: groups } = usePrefetchedQuery(q(api.groupList, {})) return useMemo(() => { diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 43e8714a5a..06d972b9d1 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -29,6 +29,7 @@ import { SiloAccessAddUserSideModal, SiloAccessEditUserSideModal, } from '~/forms/silo-access' +import { useCurrentUser } from '~/hooks/use-current-user' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { getActionsCol } from '~/table/columns/action-col' @@ -82,6 +83,7 @@ export default function SiloAccessPage() { const [addModalOpen, setAddModalOpen] = useState(false) const [editingUserRow, setEditingUserRow] = useState(null) + const { me } = useCurrentUser() const { data: siloPolicy } = usePrefetchedQuery(policyView) const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') @@ -150,12 +152,14 @@ export default function SiloAccessPage() { the {row.siloRole} role for {row.name} ), + extraContent: + row.id === me.id ? 'This will remove your own silo access.' : undefined, }), disabled: !row.siloRole && "You don't have permission to delete this user", }, ]), ], - [siloPolicy, updatePolicy] + [siloPolicy, updatePolicy, me] ) const tableInstance = useReactTable({ From 8ec95466c250b71e38e87a1b19e9917d6d84a9d1 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 5 Mar 2026 15:29:42 -0600 Subject: [PATCH 17/30] tweaks --- app/pages/SiloAccessPage.tsx | 6 +-- .../project/access/ProjectAccessPage.tsx | 1 - app/pages/system/SystemAccessPage.tsx | 6 +-- test/e2e/system-access.e2e.ts | 44 ++++++++++++++++++- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 06d972b9d1..6b274f91b0 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -142,11 +142,7 @@ export default function SiloAccessPage() { { label: 'Delete', onActivate: confirmDelete({ - doDelete: () => - updatePolicy({ - // we know policy is there, otherwise there's no row to display - body: deleteRole(row.id, siloPolicy), - }), + doDelete: () => updatePolicy({ body: deleteRole(row.id, siloPolicy) }), label: ( the {row.siloRole} role for {row.name} diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 17cf4d4538..460bf3aaef 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -180,7 +180,6 @@ export default function ProjectAccessPage() { doDelete: () => updatePolicy({ path: { project: projectSelector.project }, - // we know policy is there, otherwise there's no row to display body: deleteRole(row.id, projectPolicy), }), // TODO: explain that this will not affect the role inherited from diff --git a/app/pages/system/SystemAccessPage.tsx b/app/pages/system/SystemAccessPage.tsx index 15cf5b3948..89a21d3559 100644 --- a/app/pages/system/SystemAccessPage.tsx +++ b/app/pages/system/SystemAccessPage.tsx @@ -137,11 +137,7 @@ export default function SystemAccessPage() { { label: 'Delete', onActivate: confirmDelete({ - doDelete: () => - updatePolicy({ - // we know policy is there, otherwise there's no row to display - body: deleteRole(row.id, fleetPolicy), - }), + doDelete: () => updatePolicy({ body: deleteRole(row.id, fleetPolicy) }), label: ( the {row.fleetRole} role for {row.name} diff --git a/test/e2e/system-access.e2e.ts b/test/e2e/system-access.e2e.ts index 3572354a21..51503f8cce 100644 --- a/test/e2e/system-access.e2e.ts +++ b/test/e2e/system-access.e2e.ts @@ -7,7 +7,7 @@ */ import { user3 } from '@oxide/api-mocks' -import { expect, expectRowVisible, getPageAsUser, test } from './utils' +import { expect, expectRowVisible, expectToast, getPageAsUser, test } from './utils' test('Click through system access page', async ({ page }) => { await page.goto('/system/access') @@ -71,9 +71,51 @@ test('Click through system access page', async ({ page }) => { await user3Row.getByRole('button', { name: 'Row actions' }).click() await page.getByRole('menuitem', { name: 'Delete' }).click() await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, 'Access removed') await expect(user3Row).toBeHidden() }) +test('Add a group to system access', async ({ page }) => { + await page.goto('/system/access') + + const table = page.locator('role=table') + + // groups should not already be in the table + await expect(page.getByRole('cell', { name: 'web-devs' })).toBeHidden() + + await page.click('role=button[name="Add user or group"]') + await page.click('role=button[name*="User or group"]') + + // groups appear before users in the picker, with a "Group" badge + await expect(page.getByRole('option', { name: /web-devs/ })).toBeVisible() + await expect(page.getByRole('option', { name: /kernel-devs/ })).toBeVisible() + + await page.getByRole('option', { name: /web-devs/ }).click() + await page.getByRole('radio', { name: /^Viewer / }).click() + await page.click('role=button[name="Assign role"]') + + await expectRowVisible(table, { + Name: 'web-devs', + Type: 'Group', + Role: 'fleet.viewer', + }) +}) + +test('Self-removal warning on delete', async ({ page }) => { + await page.goto('/system/access') + + // Hannah Arendt is the logged-in user with fleet admin + const hannahRow = page.getByRole('row', { name: 'Hannah Arendt', exact: false }) + await hannahRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + + // confirm dialog should show the self-removal warning + await expect(page.getByText('This will remove your own fleet access.')).toBeVisible() + + // cancel instead of confirming + await page.getByRole('button', { name: 'Cancel' }).click() +}) + test('Fleet viewer cannot modify system access', async ({ browser }) => { const page = await getPageAsUser(browser, 'Jane Austen') await page.goto('/system/access') From 5260d0c140d4a919594b519965540c90ea4268e7 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 5 Mar 2026 15:39:06 -0600 Subject: [PATCH 18/30] convert locators in e2e test to getByRole --- AGENTS.md | 2 +- test/e2e/system-access.e2e.ts | 38 +++++++++++++++++------------------ 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index be17d56fec..a7bbc32688 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ - Run local checks before sending PRs: `npm run lint`, `npm run tsc`, `npm test run`, and `npm run e2ec`. - You don't usually need to run all the e2e tests, so try to filter by file and tes t name like `npm run e2ec -- instance -g 'boot disk'`. CI will run the full set. -- Keep Playwright specs focused on user-visible behavior—use accessible locators (`getByRole`, `getByLabel`), the helpers in `test/e2e/utils.ts` (`expectToast`, `expectRowVisible`, `selectOption`, `clickRowAction`), and close toasts so follow-on assertions aren’t blocked. +- Keep Playwright specs focused on user-visible behavior—use accessible locators (`getByRole`, `getByLabel`), the helpers in `test/e2e/utils.ts` (`expectToast`, `expectRowVisible`, `selectOption`, `clickRowAction`), and close toasts so follow-on assertions aren’t blocked. Avoid Playwright’s legacy string selector syntax like `page.click(‘role=button[name="..."]’)`; prefer `page.getByRole(‘button’, { name: ‘...’ }).click()` and friends. - Cover role-gated flows by logging in with `getPageAsUser`; exercise negative paths (e.g., forbidden actions) alongside happy paths as shown in `test/e2e/system-update.e2e.ts`. - Consider `expectVisible` and `expectNotVisible` deprecated: prefer `expect().toBeVisible()` and `toBeHidden()` in new code. - When UI needs new mock behavior, extend the MSW handlers/db minimally so E2E tests stay deterministic; prefer storing full API responses so subsequent calls see the updated state (`mock-api/msw/db.ts`, `mock-api/msw/handlers.ts`). diff --git a/test/e2e/system-access.e2e.ts b/test/e2e/system-access.e2e.ts index 51503f8cce..9fc95c4521 100644 --- a/test/e2e/system-access.e2e.ts +++ b/test/e2e/system-access.e2e.ts @@ -12,7 +12,7 @@ import { expect, expectRowVisible, expectToast, getPageAsUser, test } from './ut test('Click through system access page', async ({ page }) => { await page.goto('/system/access') - const table = page.locator('role=table') + const table = page.getByRole('table') // initial fleet role assignments: Hannah Arendt (admin), Jane Austen (viewer) await expect(page.getByRole('heading', { name: /System Access/ })).toBeVisible() @@ -29,19 +29,19 @@ test('Click through system access page', async ({ page }) => { await expect(page.getByRole('cell', { name: user3.display_name })).toBeHidden() // Add user 3 as collaborator - await page.click('role=button[name="Add user or group"]') + await page.getByRole('button', { name: 'Add user or group' }).click() await expect(page.getByRole('heading', { name: /Add user or group/ })).toBeVisible() - await page.click('role=button[name*="User or group"]') + await page.getByRole('button', { name: /User or group/ }).click() // users already assigned should not be in the list await expect(page.getByRole('option', { name: 'Hannah Arendt' })).toBeHidden() await expect(page.getByRole('option', { name: 'Jacob Klein' })).toBeVisible() await expect(page.getByRole('option', { name: 'Hans Jonas' })).toBeVisible() await expect(page.getByRole('option', { name: 'Simone de Beauvoir' })).toBeVisible() - await page.click('role=option[name="Jacob Klein"]') + await page.getByRole('option', { name: 'Jacob Klein' }).click() await page.getByRole('radio', { name: /^Collaborator / }).click() - await page.click('role=button[name="Assign role"]') + await page.getByRole('button', { name: 'Assign role' }).click() // user 3 shows up in the table await expectRowVisible(table, { @@ -52,16 +52,16 @@ test('Click through system access page', async ({ page }) => { // change user 3's role from collaborator to viewer await page - .locator('role=row', { hasText: user3.display_name }) - .locator('role=button[name="Row actions"]') + .getByRole('row', { name: user3.display_name, exact: false }) + .getByRole('button', { name: 'Row actions' }) .click() - await page.click('role=menuitem[name="Change role"]') + await page.getByRole('menuitem', { name: 'Change role' }).click() await expect(page.getByRole('heading', { name: /Edit role/ })).toBeVisible() await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() await page.getByRole('radio', { name: /^Viewer / }).click() - await page.click('role=button[name="Update role"]') + await page.getByRole('button', { name: 'Update role' }).click() await expectRowVisible(table, { Name: user3.display_name, Role: 'fleet.viewer' }) @@ -78,13 +78,13 @@ test('Click through system access page', async ({ page }) => { test('Add a group to system access', async ({ page }) => { await page.goto('/system/access') - const table = page.locator('role=table') + const table = page.getByRole('table') // groups should not already be in the table await expect(page.getByRole('cell', { name: 'web-devs' })).toBeHidden() - await page.click('role=button[name="Add user or group"]') - await page.click('role=button[name*="User or group"]') + await page.getByRole('button', { name: 'Add user or group' }).click() + await page.getByRole('button', { name: /User or group/ }).click() // groups appear before users in the picker, with a "Group" badge await expect(page.getByRole('option', { name: /web-devs/ })).toBeVisible() @@ -92,7 +92,7 @@ test('Add a group to system access', async ({ page }) => { await page.getByRole('option', { name: /web-devs/ }).click() await page.getByRole('radio', { name: /^Viewer / }).click() - await page.click('role=button[name="Assign role"]') + await page.getByRole('button', { name: 'Assign role' }).click() await expectRowVisible(table, { Name: 'web-devs', @@ -120,18 +120,18 @@ test('Fleet viewer cannot modify system access', async ({ browser }) => { const page = await getPageAsUser(browser, 'Jane Austen') await page.goto('/system/access') - const table = page.locator('role=table') + const table = page.getByRole('table') await expect(page.getByRole('heading', { name: /System Access/ })).toBeVisible() await expectRowVisible(table, { Name: 'Hannah Arendt', Role: 'fleet.admin' }) // attempt to add a user — the submit should fail with 403 - await page.click('role=button[name="Add user or group"]') - await page.click('role=button[name*="User or group"]') - await page.click('role=option[name="Jacob Klein"]') - await page.click('role=button[name="Assign role"]') + await page.getByRole('button', { name: 'Add user or group' }).click() + await page.getByRole('button', { name: /User or group/ }).click() + await page.getByRole('option', { name: 'Jacob Klein' }).click() + await page.getByRole('button', { name: 'Assign role' }).click() await expect(page.getByText('Action not authorized')).toBeVisible() // dismiss the modal and confirm the table is unchanged - await page.click('role=button[name="Cancel"]') + await page.getByRole('button', { name: 'Cancel' }).click() await expect(page.getByRole('cell', { name: 'Jacob Klein' })).toBeHidden() }) From 4fd561b12a7b10b5e6b39a97ae7207b79c1f132c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Mar 2026 16:40:37 -0800 Subject: [PATCH 19/30] fleet access --- app/layouts/SystemLayout.tsx | 4 ++-- app/pages/system/SystemAccessPage.tsx | 4 ++-- app/util/__snapshots__/path-builder.spec.ts.snap | 2 +- test/e2e/system-access.e2e.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index f324dd363c..cbebadac41 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -56,7 +56,7 @@ export default function SystemLayout() { { value: 'Inventory', path: pb.sledInventory() }, { value: 'IP Pools', path: pb.ipPools() }, { value: 'System Update', path: pb.systemUpdate() }, - { value: 'System Access', path: pb.systemAccess() }, + { value: 'Fleet Access', path: pb.systemAccess() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -104,7 +104,7 @@ export default function SystemLayout() { System Update - System Access + Fleet Access diff --git a/app/pages/system/SystemAccessPage.tsx b/app/pages/system/SystemAccessPage.tsx index 89a21d3559..5a695be395 100644 --- a/app/pages/system/SystemAccessPage.tsx +++ b/app/pages/system/SystemAccessPage.tsx @@ -69,7 +69,7 @@ export async function clientLoader() { return null } -export const handle = { crumb: 'System Access' } +export const handle = { crumb: 'Fleet Access' } type UserRow = { id: string @@ -161,7 +161,7 @@ export default function SystemAccessPage() { return ( <> - }>System Access + }>Fleet Access } diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 5c4ca9cff6..cfd93b3f43 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -791,7 +791,7 @@ exports[`breadcrumbs 2`] = ` ], "systemAccess (/system/access)": [ { - "label": "System Access", + "label": "Fleet Access", "path": "/system/access", }, ], diff --git a/test/e2e/system-access.e2e.ts b/test/e2e/system-access.e2e.ts index 9fc95c4521..8ce6ae767b 100644 --- a/test/e2e/system-access.e2e.ts +++ b/test/e2e/system-access.e2e.ts @@ -15,7 +15,7 @@ test('Click through system access page', async ({ page }) => { const table = page.getByRole('table') // initial fleet role assignments: Hannah Arendt (admin), Jane Austen (viewer) - await expect(page.getByRole('heading', { name: /System Access/ })).toBeVisible() + await expect(page.getByRole('heading', { name: /Fleet Access/ })).toBeVisible() await expectRowVisible(table, { Name: 'Hannah Arendt', Type: 'User', @@ -121,7 +121,7 @@ test('Fleet viewer cannot modify system access', async ({ browser }) => { await page.goto('/system/access') const table = page.getByRole('table') - await expect(page.getByRole('heading', { name: /System Access/ })).toBeVisible() + await expect(page.getByRole('heading', { name: /Fleet Access/ })).toBeVisible() await expectRowVisible(table, { Name: 'Hannah Arendt', Role: 'fleet.admin' }) // attempt to add a user — the submit should fail with 403 From 2f4bf12dc7d75fa134ec5ddbd0586e3eb895948d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Mar 2026 16:50:18 -0800 Subject: [PATCH 20/30] fix TS issue --- app/forms/access-util.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/forms/access-util.tsx b/app/forms/access-util.tsx index 5adcb0f48d..de4a93b94a 100644 --- a/app/forms/access-util.tsx +++ b/app/forms/access-util.tsx @@ -121,7 +121,7 @@ export function RoleRadioField< className="mt-2" > {roles.map((role) => ( - +
{capitalize(role).replace('_', ' ')}
From 09b03d641465beb59d1180a6274fc4b3f0900ced Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Mar 2026 17:00:45 -0800 Subject: [PATCH 21/30] Added placeholder copy about fleet role mapping to silos --- app/pages/system/SystemAccessPage.tsx | 41 +++++++++++++++++++++------ 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/app/pages/system/SystemAccessPage.tsx b/app/pages/system/SystemAccessPage.tsx index 5a695be395..9a32eedcf8 100644 --- a/app/pages/system/SystemAccessPage.tsx +++ b/app/pages/system/SystemAccessPage.tsx @@ -37,6 +37,7 @@ import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' import { CreateButton } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { Message } from '~/ui/lib/Message' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { TableActions, TableEmptyBox } from '~/ui/lib/Table' import { identityTypeLabel, roleColor } from '~/util/access' @@ -44,15 +45,28 @@ import { groupBy } from '~/util/array' import { docLinks } from '~/util/links' const EmptyState = ({ onClick }: { onClick: () => void }) => ( - - } - title="No authorized users" - body="Give permission to view, edit, or administer this fleet" - buttonText="Add user or group" - onClick={onClick} + <> + + } + title="No authorized users" + body="Give permission to view, edit, or administer this fleet" + buttonText="Add user or group" + onClick={onClick} + /> + + + Users may also have fleet access through silo role mappings. Check each silo's{' '} + Fleet Roles tab to see whether permissions granted through Fleet roles apply to + Silos and Projects. + + } /> - + ) const systemPolicyView = q(api.systemPolicyView, {}) @@ -170,6 +184,17 @@ export default function SystemAccessPage() { />
+ + Users may also have fleet access through silo role mappings. Check each silo’s{' '} + Fleet Roles tab to see whether permissions granted through Fleet roles apply to + Silos and Projects. + + } + /> setAddModalOpen(true)}>Add user or group From 74246ea3d137181b0dd36619ae69c89eceb5b78d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Mar 2026 19:03:41 -0800 Subject: [PATCH 22/30] Better copy on fleet role mappings --- app/pages/system/SystemAccessPage.tsx | 63 +++++++++++++-------------- app/util/links.ts | 4 ++ 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/app/pages/system/SystemAccessPage.tsx b/app/pages/system/SystemAccessPage.tsx index 9a32eedcf8..4e9df96b7f 100644 --- a/app/pages/system/SystemAccessPage.tsx +++ b/app/pages/system/SystemAccessPage.tsx @@ -45,28 +45,23 @@ import { groupBy } from '~/util/array' import { docLinks } from '~/util/links' const EmptyState = ({ onClick }: { onClick: () => void }) => ( - <> - - } - title="No authorized users" - body="Give permission to view, edit, or administer this fleet" - buttonText="Add user or group" - onClick={onClick} - /> - - - Users may also have fleet access through silo role mappings. Check each silo's{' '} - Fleet Roles tab to see whether permissions granted through Fleet roles apply to - Silos and Projects. - + + } + title="No authorized users" + body={ +
+

Give permission to view, edit, or administer this fleet.

+

+ Note: Depending on silo fleet role mappings, silo admins could have fleet + permissions, even if not listed here. +

+
} + buttonText="Add user or group" + onClick={onClick} /> - +
) const systemPolicyView = q(api.systemPolicyView, {}) @@ -180,21 +175,25 @@ export default function SystemAccessPage() { heading="access" icon={} summary="Roles determine who can view, edit, or administer this fleet." - links={[docLinks.keyConceptsIam, docLinks.access]} + links={[docLinks.keyConceptsIam, docLinks.access, docLinks.fleetRoleMappings]} />
- - Users may also have fleet access through silo role mappings. Check each silo’s{' '} - Fleet Roles tab to see whether permissions granted through Fleet roles apply to - Silos and Projects. - - } - /> + {rows.length >= 0 && ( +
+ + Silos can also be configured with mapped_fleet_roles, which + grant fleet-level roles to users based on their silo-level roles. Check each + silo’s Fleet Roles tab for more. + + } + /> +
+ )} setAddModalOpen(true)}>Add user or group diff --git a/app/util/links.ts b/app/util/links.ts index 41fc043c86..b1aad7cd0e 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -52,6 +52,10 @@ export const docLinks = { href: links.firewallRulesDocs, linkText: 'Firewall Rules', }, + fleetRoleMappings: { + href: 'https://docs.oxide.computer/guides/configuring-access#_silo_admin_group_and_mapped_fleet_roles', + linkText: 'Fleet Role Mappings', + }, floatingIps: { href: 'https://docs.oxide.computer/guides/managing-floating-ips', linkText: 'Floating IPs', From 4e4c42a1ec475c5d820ed676a5ae5f4682aebc93 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Sat, 7 Mar 2026 12:47:58 -0600 Subject: [PATCH 23/30] Refactor to eliminate `IpPoolSelector` (#3113) After refactors at the end of #3057, there wasn't much left of `IpPoolSelector`, so I intended to remove it. Essentially all it did was sort the pools, which we can do at the call sites. Removing it is a net -40 line change with no loss of functionality. --- app/api/util.ts | 9 ++ app/components/AttachEphemeralIpModal.tsx | 14 ++- app/components/IpPoolListboxItem.tsx | 32 ++++++ app/components/form/fields/IpPoolSelector.tsx | 105 ------------------ app/forms/floating-ip-create.tsx | 14 ++- app/forms/instance-create.tsx | 11 +- test/e2e/floating-ip-create.e2e.ts | 7 ++ 7 files changed, 76 insertions(+), 116 deletions(-) create mode 100644 app/components/IpPoolListboxItem.tsx delete mode 100644 app/components/form/fields/IpPoolSelector.tsx diff --git a/app/api/util.ts b/app/api/util.ts index 94492377f6..f3091f865c 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -107,6 +107,15 @@ export const poolHasIpVersion = (versions: Iterable) => { return (pool: { ipVersion: IpVersion }): boolean => versionSet.has(pool.ipVersion) } +/** Sort pools: defaults first, then v4 before v6, then by name */ +export const sortPools = (pools: T[]) => + R.sortBy( + pools, + (p) => !p.isDefault, // false sorts first → defaults first + (p) => p.ipVersion, // v4 before v6 + (p) => p.name + ) + const instanceActions = { // NoVmm maps to to Stopped: // https://github.com/oxidecomputer/omicron/blob/6dd9802/nexus/db-model/src/instance_state.rs#L55 diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 57d0927fc7..06fa56d7a3 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -15,12 +15,14 @@ import { poolHasIpVersion, q, queryClient, + sortPools, useApiMutation, usePrefetchedQuery, type IpVersion, } from '~/api' -import { IpPoolSelector } from '~/components/form/fields/IpPoolSelector' +import { ListboxField } from '~/components/form/fields/ListboxField' import { HL } from '~/components/HL' +import { toIpPoolItem } from '~/components/IpPoolListboxItem' import { useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' @@ -84,12 +86,14 @@ export const AttachEphemeralIpModal = ({ {infoMessage && }
-
diff --git a/app/components/IpPoolListboxItem.tsx b/app/components/IpPoolListboxItem.tsx new file mode 100644 index 0000000000..8de4e0bc05 --- /dev/null +++ b/app/components/IpPoolListboxItem.tsx @@ -0,0 +1,32 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import type { SiloIpPool } from '@oxide/api' +import { Badge } from '@oxide/design-system/ui' + +import { IpVersionBadge } from '~/components/IpVersionBadge' +import type { ListboxItem } from '~/ui/lib/Listbox' + +/** Format a SiloIpPool for use as a ListboxField item */ +export function toIpPoolItem(p: SiloIpPool): ListboxItem { + const value = p.name + const selectedLabel = p.name + const label = ( +
+
+ {p.name} + {p.isDefault && default} + +
+ {!!p.description && ( +
{p.description}
+ )} +
+ ) + return { value, selectedLabel, label } +} diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx deleted file mode 100644 index f5cede7f9e..0000000000 --- a/app/components/form/fields/IpPoolSelector.tsx +++ /dev/null @@ -1,105 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ -import cn from 'classnames' -import { useMemo } from 'react' -import type { Control, FieldPath, FieldValues } from 'react-hook-form' -import * as R from 'remeda' - -import { - poolHasIpVersion, - type IpVersion, - type SiloIpPool, - type UnicastIpPool, -} from '@oxide/api' -import { Badge } from '@oxide/design-system/ui' - -import { IpVersionBadge } from '~/components/IpVersionBadge' - -import { ListboxField } from './ListboxField' - -function toIpPoolItem(p: SiloIpPool) { - const value = p.name - const selectedLabel = p.name - const label = ( -
-
- {p.name} - {p.isDefault && default} - -
- {!!p.description && ( -
{p.description}
- )} -
- ) - return { value, selectedLabel, label } -} - -const ALL_IP_VERSIONS: IpVersion[] = ['v4', 'v6'] - -type IpPoolSelectorProps< - TFieldValues extends FieldValues, - TName extends FieldPath, -> = { - className?: string - control: Control - poolFieldName: TName - pools: UnicastIpPool[] - disabled?: boolean - /** Compatible IP versions based on network interface type */ - compatibleVersions?: IpVersion[] - required?: boolean - hideOptionalTag?: boolean - label?: string - /** Hide visible label, using it as aria-label instead */ - hideLabel?: boolean -} - -export function IpPoolSelector< - TFieldValues extends FieldValues, - TName extends FieldPath, ->({ - className, - control, - poolFieldName, - pools, - disabled = false, - compatibleVersions = ALL_IP_VERSIONS, - required = true, - hideOptionalTag = false, - label = 'Pool', - hideLabel = false, -}: IpPoolSelectorProps) { - // Note: pools are already filtered by poolType before being passed to this component - const sortedPools = useMemo(() => { - const compatPools = pools.filter(poolHasIpVersion(compatibleVersions)) - return R.sortBy( - compatPools, - (p) => !p.isDefault, // false sorts first, so this defaults first - (p) => p.ipVersion, // sort v4 first - (p) => p.name - ) - }, [pools, compatibleVersions]) - - return ( -
- -
- ) -} diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 9da15c2415..27e772d773 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -14,16 +14,18 @@ import { isUnicastPool, q, queryClient, + sortPools, useApiMutation, usePrefetchedQuery, type FloatingIpCreate, } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' -import { IpPoolSelector } from '~/components/form/fields/IpPoolSelector' +import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' +import { toIpPoolItem } from '~/components/IpPoolListboxItem' import { titleCrumb } from '~/hooks/use-crumbs' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' @@ -97,7 +99,15 @@ export default function CreateFloatingIpSideModalForm() { > - +
) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index a1872401fa..4659af7e27 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -54,7 +54,7 @@ import { } from '~/components/form/fields/DisksTableField' import { FileField } from '~/components/form/fields/FileField' import { BootDiskImageSelectField as ImageSelectField } from '~/components/form/fields/ImageSelectField' -import { IpPoolSelector } from '~/components/form/fields/IpPoolSelector' +import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { NetworkInterfaceField } from '~/components/form/fields/NetworkInterfaceField' import { NumberField } from '~/components/form/fields/NumberField' @@ -63,6 +63,7 @@ import { SshKeysField } from '~/components/form/fields/SshKeysField' import { Form } from '~/components/form/Form' import { FullPageForm } from '~/components/form/FullPageForm' import { HL } from '~/components/HL' +import { toIpPoolItem } from '~/components/IpPoolListboxItem' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Button } from '~/ui/lib/Button' @@ -331,15 +332,17 @@ function EphemeralIpCheckbox({
-
diff --git a/test/e2e/floating-ip-create.e2e.ts b/test/e2e/floating-ip-create.e2e.ts index 208674ba67..c33bfeef47 100644 --- a/test/e2e/floating-ip-create.e2e.ts +++ b/test/e2e/floating-ip-create.e2e.ts @@ -30,6 +30,13 @@ test('can create a floating IP', async ({ page }) => { // Default silo has both v4 and v6 defaults, so no pool is preselected const poolDropdown = page.getByLabel('Pool') await expect(poolDropdown).toContainText('Select a pool') + + // Pool selection is required when no default can be chosen automatically + const dialog = page.getByRole('dialog', { name: 'Create floating IP' }) + await page.getByRole('button', { name: 'Create floating IP' }).click() + await expect(dialog).toBeVisible() + await expect(dialog.getByText('Pool is required')).toBeVisible() + await poolDropdown.click() await page.getByRole('option', { name: 'ip-pool-1' }).click() await page.getByRole('button', { name: 'Create floating IP' }).click() From a0c5c411c6b25d1e79488e7ebb00563e8fba9fde Mon Sep 17 00:00:00 2001 From: David Crespo Date: Sat, 7 Mar 2026 13:31:35 -0600 Subject: [PATCH 24/30] tools: fix deploy-dogfood for pilot -r flag (#3114) --- tools/deno/deploy-dogfood.ts | 2 +- tools/dogfood/find-zone.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/deno/deploy-dogfood.ts b/tools/deno/deploy-dogfood.ts index 35b0a2b442..6b3bc1cd6d 100755 --- a/tools/deno/deploy-dogfood.ts +++ b/tools/deno/deploy-dogfood.ts @@ -16,7 +16,7 @@ import { parseArgs } from 'jsr:@std/cli@0.224.7' // StrictHostKeyChecking no // UserKnownHostsFile /dev/null // User root -// ProxyCommand ssh castle.eng.oxide.computer pilot tp nc any $(echo "%h" | sed s/gc//) %p +// ProxyCommand ssh castle.eng.oxide.computer pilot -r rack2 tp nc any $(echo "%h" | sed s/gc//) %p // ServerAliveInterval 15 // ForwardAgent yes // diff --git a/tools/dogfood/find-zone.sh b/tools/dogfood/find-zone.sh index 286ef7bd6b..cbd2427601 100755 --- a/tools/dogfood/find-zone.sh +++ b/tools/dogfood/find-zone.sh @@ -17,7 +17,7 @@ if [ -z "$1" ]; then fi ssh castle < Date: Tue, 10 Mar 2026 13:14:17 -0500 Subject: [PATCH 25/30] SystemAccess -> FleetAccess, remove message for now --- app/api/__tests__/safety.spec.ts | 2 +- .../{system-access.tsx => fleet-access.tsx} | 4 +-- app/layouts/SystemLayout.tsx | 4 +-- ...stemAccessPage.tsx => FleetAccessPage.tsx} | 28 ++++--------------- app/routes.tsx | 2 +- .../__snapshots__/path-builder.spec.ts.snap | 12 ++++---- app/util/path-builder.spec.ts | 2 +- app/util/path-builder.ts | 2 +- ...stem-access.e2e.ts => fleet-access.e2e.ts} | 0 9 files changed, 20 insertions(+), 36 deletions(-) rename app/forms/{system-access.tsx => fleet-access.tsx} (97%) rename app/pages/system/{SystemAccessPage.tsx => FleetAccessPage.tsx} (89%) rename test/e2e/{system-access.e2e.ts => fleet-access.e2e.ts} (100%) diff --git a/app/api/__tests__/safety.spec.ts b/app/api/__tests__/safety.spec.ts index e31c892027..c31f4621a2 100644 --- a/app/api/__tests__/safety.spec.ts +++ b/app/api/__tests__/safety.spec.ts @@ -39,13 +39,13 @@ it('mock-api is only referenced in test files', () => { "AGENTS.md", "app/api/__tests__/client.spec.tsx", "mock-api/msw/db.ts", + "test/e2e/fleet-access.e2e.ts", "test/e2e/instance-create.e2e.ts", "test/e2e/inventory.e2e.ts", "test/e2e/ip-pool-silo-config.e2e.ts", "test/e2e/profile.e2e.ts", "test/e2e/project-access.e2e.ts", "test/e2e/silo-access.e2e.ts", - "test/e2e/system-access.e2e.ts", "tsconfig.json", ] `) diff --git a/app/forms/system-access.tsx b/app/forms/fleet-access.tsx similarity index 97% rename from app/forms/system-access.tsx rename to app/forms/fleet-access.tsx index fd93378a63..018097f55e 100644 --- a/app/forms/system-access.tsx +++ b/app/forms/fleet-access.tsx @@ -30,7 +30,7 @@ import { type EditRoleModalProps, } from './access-util' -export function SystemAccessAddUserSideModal({ +export function FleetAccessAddUserSideModal({ onDismiss, policy, }: AddRoleModalProps) { @@ -82,7 +82,7 @@ export function SystemAccessAddUserSideModal({ ) } -export function SystemAccessEditUserSideModal({ +export function FleetAccessEditUserSideModal({ onDismiss, name, identityId, diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index cbebadac41..9faa528c98 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -56,7 +56,7 @@ export default function SystemLayout() { { value: 'Inventory', path: pb.sledInventory() }, { value: 'IP Pools', path: pb.ipPools() }, { value: 'System Update', path: pb.systemUpdate() }, - { value: 'Fleet Access', path: pb.systemAccess() }, + { value: 'Fleet Access', path: pb.fleetAccess() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -103,7 +103,7 @@ export default function SystemLayout() { System Update - + Fleet Access diff --git a/app/pages/system/SystemAccessPage.tsx b/app/pages/system/FleetAccessPage.tsx similarity index 89% rename from app/pages/system/SystemAccessPage.tsx rename to app/pages/system/FleetAccessPage.tsx index 4e9df96b7f..ea76e273f1 100644 --- a/app/pages/system/SystemAccessPage.tsx +++ b/app/pages/system/FleetAccessPage.tsx @@ -27,9 +27,9 @@ import { Badge } from '@oxide/design-system/ui' import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' import { - SystemAccessAddUserSideModal, - SystemAccessEditUserSideModal, -} from '~/forms/system-access' + FleetAccessAddUserSideModal, + FleetAccessEditUserSideModal, +} from '~/forms/fleet-access' import { useCurrentUser } from '~/hooks/use-current-user' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' @@ -37,7 +37,6 @@ import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' import { CreateButton } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { Message } from '~/ui/lib/Message' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { TableActions, TableEmptyBox } from '~/ui/lib/Table' import { identityTypeLabel, roleColor } from '~/util/access' @@ -89,7 +88,7 @@ type UserRow = { const colHelper = createColumnHelper() -export default function SystemAccessPage() { +export default function FleetAccessPage() { const [addModalOpen, setAddModalOpen] = useState(false) const [editingUserRow, setEditingUserRow] = useState(null) @@ -179,32 +178,17 @@ export default function SystemAccessPage() { /> - {rows.length >= 0 && ( -
- - Silos can also be configured with mapped_fleet_roles, which - grant fleet-level roles to users based on their silo-level roles. Check each - silo’s Fleet Roles tab for more. - - } - /> -
- )} setAddModalOpen(true)}>Add user or group {addModalOpen && ( - setAddModalOpen(false)} policy={fleetPolicy} /> )} {editingUserRow && ( - setEditingUserRow(null)} policy={fleetPolicy} name={editingUserRow.name} diff --git a/app/routes.tsx b/app/routes.tsx index 4cb4b2ec28..03da82fa4c 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -236,7 +236,7 @@ export const routes = createRoutesFromElements( /> import('./pages/system/SystemAccessPage').then(convert)} + lazy={() => import('./pages/system/FleetAccessPage').then(convert)} /> diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index cfd93b3f43..a2c90e4436 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -129,6 +129,12 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/disks", }, ], + "fleetAccess (/system/access)": [ + { + "label": "Fleet Access", + "path": "/system/access", + }, + ], "floatingIpEdit (/projects/p/floating-ips/f/edit)": [ { "label": "Projects", @@ -789,12 +795,6 @@ exports[`breadcrumbs 2`] = ` "path": "/settings/ssh-keys", }, ], - "systemAccess (/system/access)": [ - { - "label": "Fleet Access", - "path": "/system/access", - }, - ], "systemUpdate (/system/update)": [ { "label": "System Update", diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index e20bf6d1eb..b55e30828e 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -52,6 +52,7 @@ test('path builder', () => { "diskInventory": "/system/inventory/disks", "disks": "/projects/p/disks", "disksNew": "/projects/p/disks-new", + "fleetAccess": "/system/access", "floatingIpEdit": "/projects/p/floating-ips/f/edit", "floatingIps": "/projects/p/floating-ips", "floatingIpsNew": "/projects/p/floating-ips-new", @@ -102,7 +103,6 @@ test('path builder', () => { "sshKeyEdit": "/settings/ssh-keys/ss/edit", "sshKeys": "/settings/ssh-keys", "sshKeysNew": "/settings/ssh-keys-new", - "systemAccess": "/system/access", "systemUpdate": "/system/update", "systemUtilization": "/system/utilization", "vpc": "/projects/p/vpcs/v/firewall-rules", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index a49f038c5c..b7470e8456 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -110,7 +110,7 @@ export const pb = { siloImages: () => '/images', siloImageEdit: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}/edit`, - systemAccess: () => '/system/access', + fleetAccess: () => '/system/access', systemUtilization: () => '/system/utilization', ipPools: () => '/system/networking/ip-pools', diff --git a/test/e2e/system-access.e2e.ts b/test/e2e/fleet-access.e2e.ts similarity index 100% rename from test/e2e/system-access.e2e.ts rename to test/e2e/fleet-access.e2e.ts From 2cba5896e1c56c059c93d3724960690e53c3174b Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 10 Mar 2026 13:34:14 -0500 Subject: [PATCH 26/30] merge mapped fleet roles into fleet access table --- app/pages/system/FleetAccessPage.tsx | 162 +++++++++++++++++++-------- mock-api/silo.ts | 4 +- test/e2e/fleet-access.e2e.ts | 38 +++++-- 3 files changed, 147 insertions(+), 57 deletions(-) diff --git a/app/pages/system/FleetAccessPage.tsx b/app/pages/system/FleetAccessPage.tsx index ea76e273f1..8cb91ce0ad 100644 --- a/app/pages/system/FleetAccessPage.tsx +++ b/app/pages/system/FleetAccessPage.tsx @@ -7,6 +7,7 @@ */ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useMemo, useState } from 'react' +import { Link, useNavigate } from 'react-router' import { api, @@ -39,24 +40,19 @@ import { CreateButton } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { TipIcon } from '~/ui/lib/TipIcon' import { identityTypeLabel, roleColor } from '~/util/access' import { groupBy } from '~/util/array' +import { ALL_ISH } from '~/util/consts' import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' const EmptyState = ({ onClick }: { onClick: () => void }) => ( } - title="No authorized users" - body={ -
-

Give permission to view, edit, or administer this fleet.

-

- Note: Depending on silo fleet role mappings, silo admins could have fleet - permissions, even if not listed here. -

-
- } + title="No fleet access" + body="Give permission to view, edit, or administer this fleet." buttonText="Add user or group" onClick={onClick} /> @@ -66,6 +62,7 @@ const EmptyState = ({ onClick }: { onClick: () => void }) => ( const systemPolicyView = q(api.systemPolicyView, {}) const userList = q(api.userList, {}) const groupList = q(api.groupList, {}) +const siloList = q(api.siloList, { query: { limit: ALL_ISH } }) export async function clientLoader() { await Promise.all([ @@ -73,49 +70,76 @@ export async function clientLoader() { // used to resolve user names queryClient.prefetchQuery(userList), queryClient.prefetchQuery(groupList), + queryClient.prefetchQuery(siloList), ]) return null } export const handle = { crumb: 'Fleet Access' } -type UserRow = { +type AssignmentRow = { + kind: 'assignment' id: string identityType: IdentityType name: string fleetRole: FleetRole } -const colHelper = createColumnHelper() +type MappingRow = { + kind: 'mapping' + siloName: string + siloRole: string + fleetRole: FleetRole +} + +type AccessRow = AssignmentRow | MappingRow + +const colHelper = createColumnHelper() export default function FleetAccessPage() { const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) + const [editingUserRow, setEditingUserRow] = useState(null) + const navigate = useNavigate() const { me } = useCurrentUser() const { data: fleetPolicy } = usePrefetchedQuery(systemPolicyView) + const { data: silos } = usePrefetchedQuery(siloList) const fleetRows = useUserRows(fleetPolicy.roleAssignments, 'fleet') - const rows = useMemo(() => { + const assignmentRows: AssignmentRow[] = useMemo(() => { return groupBy(fleetRows, (u) => u.id) .map(([userId, userAssignments]) => { const { name, identityType } = userAssignments[0] // non-null: userAssignments is non-empty (groupBy only creates groups for existing items) // getEffectiveRole needed because API allows multiple fleet role assignments for the same user, even though that's probably rare const fleetRole = getEffectiveRole(userAssignments.map((a) => a.roleName))! - - const row: UserRow = { - id: userId, - identityType, - name, - fleetRole, - } - - return row + return { kind: 'assignment' as const, id: userId, identityType, name, fleetRole } }) .sort(byGroupThenName) }, [fleetRows]) + const mappingRows: MappingRow[] = useMemo( + () => + silos.items + .filter((s) => Object.keys(s.mappedFleetRoles).length > 0) + .flatMap((silo) => + Object.entries(silo.mappedFleetRoles).flatMap(([siloRole, fleetRoles]) => + fleetRoles.map((fleetRole) => ({ + kind: 'mapping' as const, + siloName: silo.name, + siloRole, + fleetRole, + })) + ) + ), + [silos] + ) + + const rows: AccessRow[] = useMemo( + () => [...assignmentRows, ...mappingRows], + [assignmentRows, mappingRows] + ) + const { mutateAsync: updatePolicy } = useApiMutation(api.systemPolicyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('systemPolicyView') @@ -125,39 +149,83 @@ export default function FleetAccessPage() { const columns = useMemo( () => [ - colHelper.accessor('name', { header: 'Name' }), - colHelper.accessor('identityType', { + colHelper.display({ + id: 'name', + header: 'Name', + cell: (info) => { + const row = info.row.original + if (row.kind === 'assignment') return row.name + return ( + + + silo.{row.siloRole} + {' '} + in{' '} + + {row.siloName} + + + ) + }, + }), + colHelper.display({ + id: 'type', header: 'Type', - cell: (info) => identityTypeLabel[info.getValue()], + cell: (info) => { + const row = info.row.original + if (row.kind === 'assignment') return identityTypeLabel[row.identityType] + return ( + + Role mapping + + mapped_fleet_roles on the silo grants fleet roles to users with + certain silo roles + + + ) + }, }), colHelper.accessor('fleetRole', { - header: 'Role', + header: 'Fleet role', cell: (info) => { const role = info.getValue() return fleet.{role} }, }), - getActionsCol((row: UserRow) => [ - { - label: 'Change role', - onActivate: () => setEditingUserRow(row), - }, - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => updatePolicy({ body: deleteRole(row.id, fleetPolicy) }), - label: ( - - the {row.fleetRole} role for {row.name} - - ), - extraContent: - row.id === me.id ? 'This will remove your own fleet access.' : undefined, - }), - }, - ]), + getActionsCol((row: AccessRow) => { + if (row.kind === 'mapping') { + return [ + { + label: 'View silo', + onActivate: () => navigate(pb.siloFleetRoles({ silo: row.siloName })), + }, + ] + } + return [ + { + label: 'Change role', + onActivate: () => setEditingUserRow(row), + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => updatePolicy({ body: deleteRole(row.id, fleetPolicy) }), + label: ( + + the {row.fleetRole} role for {row.name} + + ), + extraContent: + row.id === me.id ? 'This will remove your own fleet access.' : undefined, + }), + }, + ] + }), ], - [fleetPolicy, updatePolicy, me] + [fleetPolicy, updatePolicy, me, navigate] ) const tableInstance = useReactTable({ diff --git a/mock-api/silo.ts b/mock-api/silo.ts index 35b65a27c9..ab851966db 100644 --- a/mock-api/silo.ts +++ b/mock-api/silo.ts @@ -41,7 +41,9 @@ export const silos: Json = [ time_modified: new Date(2023, 6, 12).toISOString(), discoverable: true, identity_mode: 'saml_jit', - mapped_fleet_roles: {}, + mapped_fleet_roles: { + viewer: ['viewer'], + }, }, // Test silos for IP pool configuration scenarios { diff --git a/test/e2e/fleet-access.e2e.ts b/test/e2e/fleet-access.e2e.ts index 8ce6ae767b..ed1038d6ae 100644 --- a/test/e2e/fleet-access.e2e.ts +++ b/test/e2e/fleet-access.e2e.ts @@ -9,7 +9,7 @@ import { user3 } from '@oxide/api-mocks' import { expect, expectRowVisible, expectToast, getPageAsUser, test } from './utils' -test('Click through system access page', async ({ page }) => { +test('Click through fleet access page', async ({ page }) => { await page.goto('/system/access') const table = page.getByRole('table') @@ -19,15 +19,27 @@ test('Click through system access page', async ({ page }) => { await expectRowVisible(table, { Name: 'Hannah Arendt', Type: 'User', - Role: 'fleet.admin', + 'Fleet role': 'fleet.admin', }) await expectRowVisible(table, { Name: 'Jane Austen', Type: 'User', - Role: 'fleet.viewer', + 'Fleet role': 'fleet.viewer', }) await expect(page.getByRole('cell', { name: user3.display_name })).toBeHidden() + // role mapping rows from silos with mapped_fleet_roles + await expectRowVisible(table, { + Name: 'silo.admin in maze-war', + Type: 'Role mapping', + 'Fleet role': 'fleet.admin', + }) + await expectRowVisible(table, { + Name: 'silo.viewer in myriad', + Type: 'Role mapping', + 'Fleet role': 'fleet.viewer', + }) + // Add user 3 as collaborator await page.getByRole('button', { name: 'Add user or group' }).click() await expect(page.getByRole('heading', { name: /Add user or group/ })).toBeVisible() @@ -47,7 +59,7 @@ test('Click through system access page', async ({ page }) => { await expectRowVisible(table, { Name: 'Jacob Klein', Type: 'User', - Role: 'fleet.collaborator', + 'Fleet role': 'fleet.collaborator', }) // change user 3's role from collaborator to viewer @@ -63,7 +75,7 @@ test('Click through system access page', async ({ page }) => { await page.getByRole('radio', { name: /^Viewer / }).click() await page.getByRole('button', { name: 'Update role' }).click() - await expectRowVisible(table, { Name: user3.display_name, Role: 'fleet.viewer' }) + await expectRowVisible(table, { Name: user3.display_name, 'Fleet role': 'fleet.viewer' }) // delete user 3 const user3Row = page.getByRole('row', { name: user3.display_name, exact: false }) @@ -75,7 +87,7 @@ test('Click through system access page', async ({ page }) => { await expect(user3Row).toBeHidden() }) -test('Add a group to system access', async ({ page }) => { +test('Add a group to fleet access', async ({ page }) => { await page.goto('/system/access') const table = page.getByRole('table') @@ -97,7 +109,7 @@ test('Add a group to system access', async ({ page }) => { await expectRowVisible(table, { Name: 'web-devs', Type: 'Group', - Role: 'fleet.viewer', + 'Fleet role': 'fleet.viewer', }) }) @@ -116,13 +128,13 @@ test('Self-removal warning on delete', async ({ page }) => { await page.getByRole('button', { name: 'Cancel' }).click() }) -test('Fleet viewer cannot modify system access', async ({ browser }) => { +test('Fleet viewer cannot modify fleet access', async ({ browser }) => { const page = await getPageAsUser(browser, 'Jane Austen') await page.goto('/system/access') const table = page.getByRole('table') await expect(page.getByRole('heading', { name: /Fleet Access/ })).toBeVisible() - await expectRowVisible(table, { Name: 'Hannah Arendt', Role: 'fleet.admin' }) + await expectRowVisible(table, { Name: 'Hannah Arendt', 'Fleet role': 'fleet.admin' }) // attempt to add a user — the submit should fail with 403 await page.getByRole('button', { name: 'Add user or group' }).click() @@ -135,3 +147,11 @@ test('Fleet viewer cannot modify system access', async ({ browser }) => { await page.getByRole('button', { name: 'Cancel' }).click() await expect(page.getByRole('cell', { name: 'Jacob Klein' })).toBeHidden() }) + +test('Role mapping row links to silo fleet roles', async ({ page }) => { + await page.goto('/system/access') + + // click the silo name link in a mapping row + await page.getByRole('link', { name: 'maze-war' }).click() + await expect(page).toHaveURL(/\/system\/silos\/maze-war\/fleet-roles/) +}) From 2bc8e497f6c9d77b2d25337132808a136df4ac81 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 10 Mar 2026 14:00:30 -0500 Subject: [PATCH 27/30] refactor useMemo and use ts-pattern, say "any" --- app/pages/system/FleetAccessPage.tsx | 157 +++++++++++++-------------- 1 file changed, 77 insertions(+), 80 deletions(-) diff --git a/app/pages/system/FleetAccessPage.tsx b/app/pages/system/FleetAccessPage.tsx index 8cb91ce0ad..64d0b5dfcf 100644 --- a/app/pages/system/FleetAccessPage.tsx +++ b/app/pages/system/FleetAccessPage.tsx @@ -8,6 +8,7 @@ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useMemo, useState } from 'react' import { Link, useNavigate } from 'react-router' +import { match } from 'ts-pattern' import { api, @@ -47,11 +48,13 @@ import { ALL_ISH } from '~/util/consts' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' +// It should be impossible to see this empty state because if you can see this +// view at all, it means you're at least a fleet viewer. const EmptyState = ({ onClick }: { onClick: () => void }) => ( } - title="No fleet access" + title="No fleet roles assigned" body="Give permission to view, edit, or administer this fleet." buttonText="Add user or group" onClick={onClick} @@ -106,8 +109,8 @@ export default function FleetAccessPage() { const { data: silos } = usePrefetchedQuery(siloList) const fleetRows = useUserRows(fleetPolicy.roleAssignments, 'fleet') - const assignmentRows: AssignmentRow[] = useMemo(() => { - return groupBy(fleetRows, (u) => u.id) + const rows: AccessRow[] = useMemo(() => { + const assignmentRows: AssignmentRow[] = groupBy(fleetRows, (u) => u.id) .map(([userId, userAssignments]) => { const { name, identityType } = userAssignments[0] // non-null: userAssignments is non-empty (groupBy only creates groups for existing items) @@ -116,29 +119,22 @@ export default function FleetAccessPage() { return { kind: 'assignment' as const, id: userId, identityType, name, fleetRole } }) .sort(byGroupThenName) - }, [fleetRows]) - const mappingRows: MappingRow[] = useMemo( - () => - silos.items - .filter((s) => Object.keys(s.mappedFleetRoles).length > 0) - .flatMap((silo) => - Object.entries(silo.mappedFleetRoles).flatMap(([siloRole, fleetRoles]) => - fleetRoles.map((fleetRole) => ({ - kind: 'mapping' as const, - siloName: silo.name, - siloRole, - fleetRole, - })) - ) - ), - [silos] - ) + const mappingRows: MappingRow[] = silos.items + .filter((s) => Object.keys(s.mappedFleetRoles).length > 0) + .flatMap((silo) => + Object.entries(silo.mappedFleetRoles).flatMap(([siloRole, fleetRoles]) => + fleetRoles.map((fleetRole) => ({ + kind: 'mapping' as const, + siloName: silo.name, + siloRole, + fleetRole, + })) + ) + ) - const rows: AccessRow[] = useMemo( - () => [...assignmentRows, ...mappingRows], - [assignmentRows, mappingRows] - ) + return [...assignmentRows, ...mappingRows] + }, [fleetRows, silos]) const { mutateAsync: updatePolicy } = useApiMutation(api.systemPolicyUpdate, { onSuccess: () => { @@ -152,41 +148,42 @@ export default function FleetAccessPage() { colHelper.display({ id: 'name', header: 'Name', - cell: (info) => { - const row = info.row.original - if (row.kind === 'assignment') return row.name - return ( - - - silo.{row.siloRole} - {' '} - in{' '} - - {row.siloName} - - - ) - }, + cell: (info) => + match(info.row.original) + .with({ kind: 'assignment' }, (row) => row.name) + .with({ kind: 'mapping' }, (row) => ( + + Any{' '} + + silo.{row.siloRole} + {' '} + in{' '} + + {row.siloName} + + + )) + .exhaustive(), }), colHelper.display({ id: 'type', header: 'Type', - cell: (info) => { - const row = info.row.original - if (row.kind === 'assignment') return identityTypeLabel[row.identityType] - return ( - - Role mapping - - mapped_fleet_roles on the silo grants fleet roles to users with - certain silo roles - - - ) - }, + cell: (info) => + match(info.row.original) + .with({ kind: 'assignment' }, (row) => identityTypeLabel[row.identityType]) + .with({ kind: 'mapping' }, () => ( + + Role mapping + + mapped_fleet_roles on the silo grants fleet roles to users + with certain silo roles + + + )) + .exhaustive(), }), colHelper.accessor('fleetRole', { header: 'Fleet role', @@ -195,35 +192,35 @@ export default function FleetAccessPage() { return fleet.{role} }, }), - getActionsCol((row: AccessRow) => { - if (row.kind === 'mapping') { - return [ + getActionsCol((row: AccessRow) => + match(row) + .with({ kind: 'mapping' }, (row) => [ { label: 'View silo', onActivate: () => navigate(pb.siloFleetRoles({ silo: row.siloName })), }, - ] - } - return [ - { - label: 'Change role', - onActivate: () => setEditingUserRow(row), - }, - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => updatePolicy({ body: deleteRole(row.id, fleetPolicy) }), - label: ( - - the {row.fleetRole} role for {row.name} - - ), - extraContent: - row.id === me.id ? 'This will remove your own fleet access.' : undefined, - }), - }, - ] - }), + ]) + .with({ kind: 'assignment' }, (row) => [ + { + label: 'Change role', + onActivate: () => setEditingUserRow(row), + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => updatePolicy({ body: deleteRole(row.id, fleetPolicy) }), + label: ( + + the {row.fleetRole} role for {row.name} + + ), + extraContent: + row.id === me.id ? 'This will remove your own fleet access.' : undefined, + }), + }, + ]) + .exhaustive() + ), ], [fleetPolicy, updatePolicy, me, navigate] ) From 9429b8a213a690213079c3485cb9211b3e6563d3 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 10 Mar 2026 15:21:56 -0500 Subject: [PATCH 28/30] fall back to ID when we can't resolve user or group name --- app/api/roles.ts | 2 +- app/pages/system/FleetAccessPage.tsx | 15 +++++++++- mock-api/role-assignment.ts | 20 ++++++++++++++ test/e2e/fleet-access.e2e.ts | 41 ++++++++++++++++++++++++++-- test/e2e/silos.e2e.ts | 15 +++------- 5 files changed, 77 insertions(+), 16 deletions(-) diff --git a/app/api/roles.ts b/app/api/roles.ts index d17c1d7df9..779ab503c4 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -120,7 +120,7 @@ export function useUserRows( return roleAssignments.map((ra) => ({ id: ra.identityId, identityType: ra.identityType, - name: usersDict[ra.identityId]?.displayName || '', // placeholder until we get names, obviously + name: usersDict[ra.identityId]?.displayName || ra.identityId, roleName: ra.roleName, roleSource, })) diff --git a/app/pages/system/FleetAccessPage.tsx b/app/pages/system/FleetAccessPage.tsx index 64d0b5dfcf..ec48e5063b 100644 --- a/app/pages/system/FleetAccessPage.tsx +++ b/app/pages/system/FleetAccessPage.tsx @@ -150,7 +150,20 @@ export default function FleetAccessPage() { header: 'Name', cell: (info) => match(info.row.original) - .with({ kind: 'assignment' }, (row) => row.name) + .with({ kind: 'assignment' }, (row) => + row.name === row.id ? ( + + {row.id} + + Can't resolve name because{' '} + {row.identityType === 'silo_user' ? 'user' : 'group'} is not in your + silo + + + ) : ( + row.name + ) + ) .with({ kind: 'mapping' }, (row) => ( Any{' '} diff --git a/mock-api/role-assignment.ts b/mock-api/role-assignment.ts index 14cf10f7e9..933f02b246 100644 --- a/mock-api/role-assignment.ts +++ b/mock-api/role-assignment.ts @@ -38,6 +38,12 @@ type DbRoleAssignment = { role_name: RoleKey } +// Simulate a user and group from another silo who have been granted fleet +// roles. They won't appear in the current silo's userList/groupList, so the +// UI must fall back to displaying their UUIDs. +export const crossSiloUserId = 'd4e5f6a7-b8c9-4d0e-a1f2-b3c4d5e6f7a8' +export const crossSiloGroupId = 'e5f6a7b8-c9d0-4e1f-b2a3-c4d5e6f7a8b9' + export const roleAssignments: DbRoleAssignment[] = [ { resource_type: 'fleet', @@ -53,6 +59,20 @@ export const roleAssignments: DbRoleAssignment[] = [ identity_type: 'silo_user', role_name: 'viewer', }, + { + resource_type: 'fleet', + resource_id: FLEET_ID, + identity_id: crossSiloUserId, + identity_type: 'silo_user', + role_name: 'collaborator', + }, + { + resource_type: 'fleet', + resource_id: FLEET_ID, + identity_id: crossSiloGroupId, + identity_type: 'silo_group', + role_name: 'viewer', + }, { resource_type: 'silo', resource_id: defaultSilo.id, diff --git a/test/e2e/fleet-access.e2e.ts b/test/e2e/fleet-access.e2e.ts index ed1038d6ae..b26fd88d02 100644 --- a/test/e2e/fleet-access.e2e.ts +++ b/test/e2e/fleet-access.e2e.ts @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { user3 } from '@oxide/api-mocks' +import { crossSiloGroupId, crossSiloUserId, user3 } from '@oxide/api-mocks' import { expect, expectRowVisible, expectToast, getPageAsUser, test } from './utils' @@ -28,14 +28,26 @@ test('Click through fleet access page', async ({ page }) => { }) await expect(page.getByRole('cell', { name: user3.display_name })).toBeHidden() + // cross-silo user and group show UUID fallback since names can't be resolved + await expectRowVisible(table, { + Name: crossSiloUserId, + Type: 'User', + 'Fleet role': 'fleet.collaborator', + }) + await expectRowVisible(table, { + Name: crossSiloGroupId, + Type: 'Group', + 'Fleet role': 'fleet.viewer', + }) + // role mapping rows from silos with mapped_fleet_roles await expectRowVisible(table, { - Name: 'silo.admin in maze-war', + Name: 'Any silo.admin in maze-war', Type: 'Role mapping', 'Fleet role': 'fleet.admin', }) await expectRowVisible(table, { - Name: 'silo.viewer in myriad', + Name: 'Any silo.viewer in myriad', Type: 'Role mapping', 'Fleet role': 'fleet.viewer', }) @@ -148,6 +160,29 @@ test('Fleet viewer cannot modify fleet access', async ({ browser }) => { await expect(page.getByRole('cell', { name: 'Jacob Klein' })).toBeHidden() }) +test('Cross-silo user shows UUID with tooltip', async ({ page }) => { + await page.goto('/system/access') + + // cross-silo user's name can't be resolved, so UUID is shown + const userCell = page.getByRole('cell', { name: crossSiloUserId }) + await expect(userCell).toBeVisible() + await userCell.getByRole('button', { name: 'Tip' }).hover() + await expect( + page.getByText("Can't resolve name because user is not in your silo") + ).toBeVisible() + + // dismiss the first tooltip before checking the group's + await page.getByRole('heading', { name: /Fleet Access/ }).click() + + // same for a cross-silo group + const groupCell = page.getByRole('cell', { name: crossSiloGroupId }) + await expect(groupCell).toBeVisible() + await groupCell.getByRole('button', { name: 'Tip' }).hover() + await expect( + page.getByText("Can't resolve name because group is not in your silo") + ).toBeVisible() +}) + test('Role mapping row links to silo fleet roles', async ({ page }) => { await page.goto('/system/access') diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index f61d9d20ed..1801c64fee 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -168,21 +168,14 @@ test('Create silo', async ({ page }) => { await expect(otherSiloCell).toBeHidden() }) -test('Default silo', async ({ page }) => { +test('Silo with no fleet role mappings', async ({ page }) => { await page.goto('/system/silos') - await page.getByRole('link', { name: 'myriad' }).click() + await page.getByRole('link', { name: 'thrax' }).click() - await expect(page.getByRole('heading', { name: 'myriad' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'thrax' })).toBeVisible() await page.getByRole('tab', { name: 'Fleet roles' }).click() - await expect( - page.getByText('Silo roles can automatically grant a fleet role.') - ).toBeVisible() - - await expectNotVisible(page, [ - page.getByText('Silo adminFleet admin'), - page.getByText('Silo viewerFleet viewer'), - ]) + await expect(page.getByText('This silo has no role mappings configured.')).toBeVisible() }) test('Identity providers', async ({ page }) => { From 58fb35e4a66a050577ca43853a5dfd88bd4cb28e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 10 Mar 2026 15:51:50 -0500 Subject: [PATCH 29/30] fix ancient bug (intro'd #1595) in fleet roles page, they were flipped! --- app/pages/system/silos/SiloFleetRolesTab.tsx | 5 ++--- mock-api/silo.ts | 2 +- test/e2e/fleet-access.e2e.ts | 4 +++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/pages/system/silos/SiloFleetRolesTab.tsx b/app/pages/system/silos/SiloFleetRolesTab.tsx index 3de28b31a2..2ec60b299a 100644 --- a/app/pages/system/silos/SiloFleetRolesTab.tsx +++ b/app/pages/system/silos/SiloFleetRolesTab.tsx @@ -22,9 +22,8 @@ export default function SiloFleetRolesTab() { const siloSelector = useSiloSelector() const { data: silo } = usePrefetchedQuery(siloView(siloSelector)) - const roleMapPairs = Object.entries(silo.mappedFleetRoles).flatMap( - ([fleetRole, siloRoles]) => - siloRoles.map((siloRole) => [siloRole, fleetRole] as [string, string]) + const roleMapPairs: [string, string][] = Object.entries(silo.mappedFleetRoles).flatMap( + ([siloRole, fleetRoles]) => fleetRoles.map((fleetRole) => [siloRole, fleetRole]) ) if (roleMapPairs.length === 0) { diff --git a/mock-api/silo.ts b/mock-api/silo.ts index ab851966db..7f02a0e15c 100644 --- a/mock-api/silo.ts +++ b/mock-api/silo.ts @@ -30,7 +30,7 @@ export const silos: Json = [ discoverable: true, identity_mode: 'saml_jit', mapped_fleet_roles: { - admin: ['admin'], + collaborator: ['admin'], }, }, { diff --git a/test/e2e/fleet-access.e2e.ts b/test/e2e/fleet-access.e2e.ts index b26fd88d02..865de1d55f 100644 --- a/test/e2e/fleet-access.e2e.ts +++ b/test/e2e/fleet-access.e2e.ts @@ -42,7 +42,7 @@ test('Click through fleet access page', async ({ page }) => { // role mapping rows from silos with mapped_fleet_roles await expectRowVisible(table, { - Name: 'Any silo.admin in maze-war', + Name: 'Any silo.collaborator in maze-war', Type: 'Role mapping', 'Fleet role': 'fleet.admin', }) @@ -189,4 +189,6 @@ test('Role mapping row links to silo fleet roles', async ({ page }) => { // click the silo name link in a mapping row await page.getByRole('link', { name: 'maze-war' }).click() await expect(page).toHaveURL(/\/system\/silos\/maze-war\/fleet-roles/) + await expect(page.getByText('Silo collaboratorFleet admin')).toBeVisible() + await expect(page.getByText('Silo adminFleet collaborator')).toBeHidden() }) From d2de1e1c861003f20a358da0f0d12a5cf404d255 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 10 Mar 2026 16:13:59 -0500 Subject: [PATCH 30/30] comment on ID fallback and other stuff --- app/api/roles.ts | 8 ++++++-- app/pages/system/silos/SiloFleetRolesTab.tsx | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/api/roles.ts b/app/api/roles.ts index 779ab503c4..70c12ecaaf 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -18,8 +18,8 @@ import type { FleetRole, IdentityType, ProjectRole, SiloRole } from './__generat import { api, q, usePrefetchedQuery } from './client' /** - * Union of all the specific roles, which are all the same, which makes making - * our methods generic on the *Role type is pointless (until they stop being the same). + * Union of all the specific roles, which used to all be the same until we added + * limited collaborator to silo. */ export type RoleKey = FleetRole | SiloRole | ProjectRole @@ -120,6 +120,10 @@ export function useUserRows( return roleAssignments.map((ra) => ({ id: ra.identityId, identityType: ra.identityType, + // A user might not appear here if they are not in the current user's + // silo. This could happen in a fleet policy, which might have users from + // different silos. Hence the ID fallback. The code that displays this + // detects when we've fallen back and includes an explanatory tooltip. name: usersDict[ra.identityId]?.displayName || ra.identityId, roleName: ra.roleName, roleSource, diff --git a/app/pages/system/silos/SiloFleetRolesTab.tsx b/app/pages/system/silos/SiloFleetRolesTab.tsx index 2ec60b299a..bc75a9400f 100644 --- a/app/pages/system/silos/SiloFleetRolesTab.tsx +++ b/app/pages/system/silos/SiloFleetRolesTab.tsx @@ -22,6 +22,8 @@ export default function SiloFleetRolesTab() { const siloSelector = useSiloSelector() const { data: silo } = usePrefetchedQuery(siloView(siloSelector)) + // `mappedFleetRoles` is keyed by silo role, with each value listing the + // fleet roles granted to actors who have that silo role. const roleMapPairs: [string, string][] = Object.entries(silo.mappedFleetRoles).flatMap( ([siloRole, fleetRoles]) => fleetRoles.map((fleetRole) => [siloRole, fleetRole]) )