Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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
4e4c42a
Refactor to eliminate `IpPoolSelector` (#3113)
david-crespo Mar 7, 2026
a0c5c41
tools: fix deploy-dogfood for pilot -r flag (#3114)
david-crespo Mar 7, 2026
f57ec49
SystemAccess -> FleetAccess, remove message for now
david-crespo Mar 10, 2026
2cba589
merge mapped fleet roles into fleet access table
david-crespo Mar 10, 2026
2bc8e49
refactor useMemo and use ts-pattern, say "any"
david-crespo Mar 10, 2026
9429b8a
fall back to ID when we can't resolve user or group name
david-crespo Mar 10, 2026
58fb35e
fix ancient bug (intro'd #1595) in fleet roles page, they were flipped!
david-crespo Mar 10, 2026
d2de1e1
comment on ID fallback and other stuff
david-crespo Mar 10, 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 @@ -39,6 +39,7 @@ 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",
Expand Down
49 changes: 34 additions & 15 deletions app/api/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 All @@ -107,7 +120,11 @@ export function useUserRows(
return roleAssignments.map((ra) => ({
id: ra.identityId,
identityType: ra.identityType,
name: usersDict[ra.identityId]?.displayName || '', // placeholder until we get names, obviously
// 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,
}))
Expand Down Expand Up @@ -136,7 +153,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
9 changes: 9 additions & 0 deletions app/api/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ export const poolHasIpVersion = (versions: Iterable<IpVersion>) => {
return (pool: { ipVersion: IpVersion }): boolean => versionSet.has(pool.ipVersion)
}

/** Sort pools: defaults first, then v4 before v6, then by name */
export const sortPools = <T extends SiloIpPool>(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
Expand Down
14 changes: 9 additions & 5 deletions app/components/AttachEphemeralIpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -84,12 +86,14 @@ export const AttachEphemeralIpModal = ({
<Modal.Section>
{infoMessage && <Message variant="info" content={infoMessage} />}
<form>
<IpPoolSelector
<ListboxField
name="pool"
label="Pool"
control={form.control}
poolFieldName="pool"
pools={compatibleUnicastPools}
items={sortPools(compatibleUnicastPools).map(toIpPoolItem)}
disabled={compatibleUnicastPools.length === 0}
compatibleVersions={availableVersions}
placeholder="Select a pool"
noItemsPlaceholder="No pools available"
/>
</form>
</Modal.Section>
Expand Down
32 changes: 32 additions & 0 deletions app/components/IpPoolListboxItem.tsx
Original file line number Diff line number Diff line change
@@ -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 = (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5">
{p.name}
{p.isDefault && <Badge color="neutral">default</Badge>}
<IpVersionBadge ipVersion={p.ipVersion} />
</div>
{!!p.description && (
<div className="text-secondary selected:text-accent-secondary">{p.description}</div>
)}
</div>
)
return { value, selectedLabel, label }
}
105 changes: 0 additions & 105 deletions app/components/form/fields/IpPoolSelector.tsx

This file was deleted.

Loading
Loading