From 3752493da75f4d03aeefd875f28944c76be80d0d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 25 Feb 2026 18:28:43 -0800 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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({