Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3752493
Add System Access page
charliepark Feb 26, 2026
ec6f242
a few small refactors
charliepark Feb 26, 2026
376b153
Remove some casts; change import
charliepark Feb 26, 2026
fb0e217
List system roles with least permissions first, like silo/project
charliepark Feb 26, 2026
2c912ab
Fix a few small inconsistencies between System / Silo / Project acces…
charliepark Feb 26, 2026
cb3680b
Add warning about deleting own access
charliepark Feb 26, 2026
0f0f6b4
copy adjustment
charliepark Feb 26, 2026
d8715ef
Clean up a few bits of legacy logic
charliepark Feb 26, 2026
fb50d25
use getEffectiveRole to handle edge case in role assignments
charliepark Feb 26, 2026
7a9ebbc
Add test for limited permission user
charliepark Feb 26, 2026
518b6e6
tweak test for error message
charliepark Feb 26, 2026
9423ea7
simplify test
charliepark Feb 26, 2026
636b90e
R -> Role for generic, to cut down on R[emeda] confusion/collisions
charliepark Feb 26, 2026
83c12c4
R -> Role for generic, to cut down on R[emeda] confusion/collisions
charliepark Feb 26, 2026
99d0009
type predicate to specify fleetRoles as FleetRole[] instead of RoleKey[]
charliepark Feb 26, 2026
c3d6755
Use more precise FleetRole type; add warning on Silo Access removal
charliepark Feb 27, 2026
8ec9546
tweaks
david-crespo Mar 5, 2026
5260d0c
convert locators in e2e test to getByRole
david-crespo Mar 5, 2026
4fd561b
fleet access
charliepark Mar 7, 2026
56ec1fd
merge main and resolve conflict
charliepark Mar 7, 2026
2f4bf12
fix TS issue
charliepark Mar 7, 2026
09b03d6
Added placeholder copy about fleet role mapping to silos
charliepark Mar 7, 2026
74246ea
Better copy on fleet role mappings
charliepark Mar 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
1 change: 1 addition & 0 deletions app/api/__tests__/safety.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
`)
Expand Down
39 changes: 27 additions & 12 deletions app/api/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,35 @@ export const roleOrder: Record<RoleKey, number> = {
/** `roleOrder` record converted to a sorted array of roles. */
export const allRoles = flatRoles(roleOrder)

// Fleet roles don't include 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: RoleKey[]): RoleKey | undefined =>
export const getEffectiveRole = <Role extends RoleKey>(roles: Role[]): Role | undefined =>
R.firstBy(roles, (role) => roleOrder[role])

////////////////////////////
// Policy helpers
////////////////////////////

type RoleAssignment = {
type RoleAssignment<Role extends RoleKey = RoleKey> = {
identityId: string
identityType: IdentityType
roleName: RoleKey
roleName: Role
}
export type Policy<Role extends RoleKey = RoleKey> = {
roleAssignments: RoleAssignment<Role>[]
}
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<Role extends RoleKey>(
newAssignment: RoleAssignment<Role>,
policy: Policy<Role>
): Policy<Role> {
const roleAssignments = policy.roleAssignments.filter(
(ra) => ra.identityId !== newAssignment.identityId
)
Expand All @@ -70,18 +80,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<Role extends RoleKey>(
identityId: string,
policy: Policy<Role>
): Policy<Role> {
const roleAssignments = policy.roleAssignments.filter(
(ra) => ra.identityId !== identityId
)
return { roleAssignments }
}

type UserAccessRow = {
type UserAccessRow<Role extends RoleKey = RoleKey> = {
id: string
identityType: IdentityType
name: string
roleName: RoleKey
roleName: Role
roleSource: string
}

Expand All @@ -92,10 +105,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<Role extends RoleKey = RoleKey>(
roleAssignments: RoleAssignment<Role>[],
roleSource: string
): UserAccessRow[] {
): UserAccessRow<Role>[] {
// 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, {}))
Expand Down Expand Up @@ -136,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<Role extends RoleKey = RoleKey>(
policy: Policy<Role>
): Actor[] {
const { data: users } = usePrefetchedQuery(q(api.userList, {}))
const { data: groups } = usePrefetchedQuery(q(api.groupList, {}))
return useMemo(() => {
Expand Down
37 changes: 29 additions & 8 deletions app/forms/access-util.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import * as R from 'remeda'

import {
allRoles,
fleetRoles,
type Actor,
type FleetRole,
type IdentityType,
type Policy,
type RoleKey,
Expand Down Expand Up @@ -50,6 +52,13 @@ const siloRoleDescriptions: Record<RoleKey, string> = {
viewer: 'View resources within the silo',
}

// Role descriptions for fleet-level roles
const fleetRoleDescriptions: Record<FleetRole, string> = {
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: (
Expand All @@ -65,16 +74,16 @@ export const actorToItem = (actor: Actor): ListboxItem => ({
selectedLabel: actor.displayName,
})

export type AddRoleModalProps = {
export type AddRoleModalProps<Role extends RoleKey = RoleKey> = {
onDismiss: () => void
policy: Policy
policy: Policy<Role>
}

export type EditRoleModalProps = AddRoleModalProps & {
export type EditRoleModalProps<Role extends RoleKey = RoleKey> = AddRoleModalProps<Role> & {
name?: string
identityId: string
identityType: IdentityType
defaultValues: { roleName: RoleKey }
defaultValues: { roleName: Role }
}

const AccessDocs = () => (
Expand All @@ -92,9 +101,15 @@ export function RoleRadioField<
}: {
name: TName
control: Control<TFieldValues>
scope: 'Silo' | 'Project'
scope: 'Fleet' | 'Silo' | 'Project'
}) {
const roleDescriptions = scope === 'Silo' ? siloRoleDescriptions : projectRoleDescriptions
const roles = R.reverse(scope === 'Fleet' ? fleetRoles : allRoles)
const roleDescriptions: Partial<Record<RoleKey, string>> =
scope === 'Fleet'
? fleetRoleDescriptions
: scope === 'Silo'
? siloRoleDescriptions
: projectRoleDescriptions
return (
<>
<RadioFieldDyn
Expand All @@ -105,7 +120,7 @@ export function RoleRadioField<
column
className="mt-2"
>
{R.reverse(allRoles).map((role) => (
{roles.map((role) => (
<Radio name="roleName" key={role} value={role}>
<div className="text-sans-md text-raise">
{capitalize(role).replace('_', ' ')}
Expand All @@ -117,7 +132,13 @@ export function RoleRadioField<
<Message
variant="info"
content={
scope === 'Silo' ? (
scope === 'Fleet' ? (
<>
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 <AccessDocs /> guide.
</>
) : scope === 'Silo' ? (
<>
Silo roles are inherited by all projects in the silo and override weaker
roles. For example, a silo viewer is <em>at least</em> a viewer on all
Expand Down
10 changes: 8 additions & 2 deletions app/forms/silo-access.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}}
>
<RoleRadioField name="roleName" control={form.control} scope="Silo" />
<SideModalFormDocs docs={[docLinks.access]} />
Expand Down
128 changes: 128 additions & 0 deletions app/forms/system-access.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* 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 FleetRole,
} 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,
RoleRadioField,
type AddRoleModalProps,
type EditRoleModalProps,
} from './access-util'

export function SystemAccessAddUserSideModal({
onDismiss,
policy,
}: AddRoleModalProps<FleetRole>) {
const actors = useActorsNotInPolicy(policy)

const updatePolicy = useApiMutation(api.systemPolicyUpdate, {
onSuccess: () => {
queryClient.invalidateEndpoint('systemPolicyView')
onDismiss()
},
})

const form = useForm<{ identityId: string; roleName: FleetRole }>({
defaultValues: { identityId: '', roleName: 'viewer' },
})

return (
<SideModalForm
form={form}
formType="create"
resourceName="role"
title="Add user or group"
submitLabel="Assign role"
onDismiss={() => {
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({
body: updateRole({ identityId, identityType, roleName }, policy),
})
}}
loading={updatePolicy.isPending}
submitError={updatePolicy.error}
>
<ListboxField
name="identityId"
items={actors.map(actorToItem)}
label="User or group"
required
control={form.control}
/>
<RoleRadioField name="roleName" control={form.control} scope="Fleet" />
<SideModalFormDocs docs={[docLinks.access]} />
</SideModalForm>
)
}

export function SystemAccessEditUserSideModal({
onDismiss,
name,
identityId,
identityType,
policy,
defaultValues,
}: EditRoleModalProps<FleetRole>) {
const updatePolicy = useApiMutation(api.systemPolicyUpdate, {
onSuccess: () => {
queryClient.invalidateEndpoint('systemPolicyView')
onDismiss()
},
})
const form = useForm({ defaultValues })

return (
<SideModalForm
form={form}
formType="edit"
resourceName="role"
title="Edit role"
subtitle={
<ResourceLabel>
<Access16Icon /> {name}
</ResourceLabel>
}
onSubmit={({ roleName }) => {
updatePolicy.mutate({
body: updateRole({ identityId, identityType, roleName }, policy),
})
}}
loading={updatePolicy.isPending}
submitError={updatePolicy.error}
onDismiss={() => {
updatePolicy.reset() // clear API error state so it doesn't persist on next open
onDismiss()
}}
>
<RoleRadioField name="roleName" control={form.control} scope="Fleet" />
<SideModalFormDocs docs={[docLinks.access]} />
</SideModalForm>
)
}
5 changes: 5 additions & 0 deletions app/layouts/SystemLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useLocation, useNavigate } from 'react-router'

import { api, q, queryClient } from '@oxide/api'
import {
Access16Icon,
Cloud16Icon,
IpGlobal16Icon,
Metrics16Icon,
Expand Down Expand Up @@ -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: 'Fleet Access', path: pb.systemAccess() },
]
// filter out the entry for the path we're currently on
.filter((i) => i.path !== pathname)
Expand Down Expand Up @@ -101,6 +103,9 @@ export default function SystemLayout() {
<NavLinkItem to={pb.systemUpdate()}>
<SoftwareUpdate16Icon /> System Update
</NavLinkItem>
<NavLinkItem to={pb.systemAccess()}>
<Access16Icon /> Fleet Access
</NavLinkItem>
</Sidebar.Nav>
</Sidebar>
<ContentPane />
Expand Down
Loading
Loading