diff --git a/AGENTS.md b/AGENTS.md index 838c84d6d6..fb0f63eda9 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/app/api/__tests__/safety.spec.ts b/app/api/__tests__/safety.spec.ts index 46a484d7c1..c31f4621a2 100644 --- a/app/api/__tests__/safety.spec.ts +++ b/app/api/__tests__/safety.spec.ts @@ -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", diff --git a/app/api/roles.ts b/app/api/roles.ts index 9c3ff07da8..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 @@ -40,25 +40,35 @@ 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 = 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 = (roles: Role[]): Role | undefined => R.firstBy(roles, (role) => roleOrder[role]) //////////////////////////// // Policy helpers //////////////////////////// -type RoleAssignment = { +type RoleAssignment = { identityId: string identityType: IdentityType - roleName: RoleKey + 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 ) @@ -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( + 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: Role roleSource: string } @@ -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( + 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, {})) @@ -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, })) @@ -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( + policy: Policy +): Actor[] { const { data: users } = usePrefetchedQuery(q(api.userList, {})) const { data: groups } = usePrefetchedQuery(q(api.groupList, {})) return useMemo(() => { 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/access-util.tsx b/app/forms/access-util.tsx index 49d1378163..de4a93b94a 100644 --- a/app/forms/access-util.tsx +++ b/app/forms/access-util.tsx @@ -10,7 +10,9 @@ import * as R from 'remeda' import { allRoles, + fleetRoles, type Actor, + type FleetRole, type IdentityType, type Policy, type RoleKey, @@ -50,6 +52,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: ( @@ -65,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: Role } } const AccessDocs = () => ( @@ -92,9 +101,15 @@ export function RoleRadioField< }: { name: TName control: Control - scope: 'Silo' | 'Project' + scope: 'Fleet' | 'Silo' | 'Project' }) { - const roleDescriptions = scope === 'Silo' ? siloRoleDescriptions : projectRoleDescriptions + const roles = R.reverse(scope === 'Fleet' ? fleetRoles : allRoles) + const roleDescriptions: Partial> = + scope === 'Fleet' + ? fleetRoleDescriptions + : scope === 'Silo' + ? siloRoleDescriptions + : projectRoleDescriptions return ( <> - {R.reverse(allRoles).map((role) => ( + {roles.map((role) => (
{capitalize(role).replace('_', ' ')} @@ -117,7 +132,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/fleet-access.tsx b/app/forms/fleet-access.tsx new file mode 100644 index 0000000000..018097f55e --- /dev/null +++ b/app/forms/fleet-access.tsx @@ -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 FleetAccessAddUserSideModal({ + onDismiss, + policy, +}: AddRoleModalProps) { + 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 ( + { + 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} + > + + + + + ) +} + +export function FleetAccessEditUserSideModal({ + 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({ + 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() + }} + > + + + + ) +} 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/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/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 1e3a10c4d0..9faa528c98 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: 'Fleet Access', path: pb.fleetAccess() }, ] // 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 + + Fleet Access + diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index eb65e95359..6b274f91b0 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -12,7 +12,6 @@ import { api, byGroupThenName, deleteRole, - getEffectiveRole, q, queryClient, useApiMutation, @@ -30,7 +29,9 @@ 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' import { Table } from '~/table/Table' import { CreateButton } from '~/ui/lib/CreateButton' @@ -74,7 +75,6 @@ type UserRow = { identityType: IdentityType name: string siloRole: RoleKey | undefined - effectiveRole: RoleKey } const colHelper = createColumnHelper() @@ -83,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') @@ -91,8 +92,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 +99,6 @@ export default function SiloAccessPage() { identityType, name, siloRole, - // we know there has to be at least one - effectiveRole: getEffectiveRole(roles)!, } return row @@ -110,7 +107,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 }) @@ -142,22 +142,20 @@ 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} ), + 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({ @@ -181,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/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/FleetAccessPage.tsx b/app/pages/system/FleetAccessPage.tsx new file mode 100644 index 0000000000..ec48e5063b --- /dev/null +++ b/app/pages/system/FleetAccessPage.tsx @@ -0,0 +1,285 @@ +/* + * 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 { Link, useNavigate } from 'react-router' +import { match } from 'ts-pattern' + +import { + api, + byGroupThenName, + deleteRole, + getEffectiveRole, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + useUserRows, + type FleetRole, + type IdentityType, +} 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 { + FleetAccessAddUserSideModal, + FleetAccessEditUserSideModal, +} from '~/forms/fleet-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' +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 { 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' + +// 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 roles assigned" + 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, {}) +const siloList = q(api.siloList, { query: { limit: ALL_ISH } }) + +export async function clientLoader() { + await Promise.all([ + queryClient.prefetchQuery(systemPolicyView), + // used to resolve user names + queryClient.prefetchQuery(userList), + queryClient.prefetchQuery(groupList), + queryClient.prefetchQuery(siloList), + ]) + return null +} + +export const handle = { crumb: 'Fleet Access' } + +type AssignmentRow = { + kind: 'assignment' + id: string + identityType: IdentityType + name: string + fleetRole: FleetRole +} + +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 navigate = useNavigate() + const { me } = useCurrentUser() + const { data: fleetPolicy } = usePrefetchedQuery(systemPolicyView) + const { data: silos } = usePrefetchedQuery(siloList) + const fleetRows = useUserRows(fleetPolicy.roleAssignments, 'fleet') + + 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) + // 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))! + return { kind: 'assignment' as const, id: userId, identityType, name, fleetRole } + }) + .sort(byGroupThenName) + + 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, + })) + ) + ) + + return [...assignmentRows, ...mappingRows] + }, [fleetRows, silos]) + + const { mutateAsync: updatePolicy } = useApiMutation(api.systemPolicyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('systemPolicyView') + addToast({ content: 'Access removed' }) + }, + }) + + const columns = useMemo( + () => [ + colHelper.display({ + id: 'name', + header: 'Name', + cell: (info) => + match(info.row.original) + .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{' '} + + silo.{row.siloRole} + {' '} + in{' '} + + {row.siloName} + + + )) + .exhaustive(), + }), + colHelper.display({ + id: 'type', + header: 'Type', + 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', + cell: (info) => { + const role = info.getValue() + return fleet.{role} + }, + }), + getActionsCol((row: AccessRow) => + match(row) + .with({ kind: 'mapping' }, (row) => [ + { + label: 'View silo', + onActivate: () => navigate(pb.siloFleetRoles({ silo: row.siloName })), + }, + ]) + .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] + ) + + const tableInstance = useReactTable({ + columns, + data: rows, + getCoreRowModel: getCoreRowModel(), + }) + + return ( + <> + + }>Fleet Access + } + summary="Roles determine who can view, edit, or administer this fleet." + links={[docLinks.keyConceptsIam, docLinks.access, docLinks.fleetRoleMappings]} + /> + + + + setAddModalOpen(true)}>Add user or group + + {addModalOpen && ( + setAddModalOpen(false)} + policy={fleetPolicy} + /> + )} + {editingUserRow && ( + 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/pages/system/silos/SiloFleetRolesTab.tsx b/app/pages/system/silos/SiloFleetRolesTab.tsx index 3de28b31a2..bc75a9400f 100644 --- a/app/pages/system/silos/SiloFleetRolesTab.tsx +++ b/app/pages/system/silos/SiloFleetRolesTab.tsx @@ -22,9 +22,10 @@ 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]) + // `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]) ) if (roleMapPairs.length === 0) { diff --git a/app/routes.tsx b/app/routes.tsx index dbd05d4380..03da82fa4c 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/FleetAccessPage').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..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", 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', diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 368731c03d..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", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 6d55092139..b7470e8456 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`, + fleetAccess: () => '/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 2944628591..cd42a33e39 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, @@ -1905,10 +1906,9 @@ 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)) + .filter((r) => fleetRoles.some((role) => role === r.role_name)) .map((r) => ({ identity_id: r.identity_id, identity_type: r.identity_type, @@ -2348,7 +2348,25 @@ export const handlers = makeHandlers({ supportBundleUpdate: NotImplemented, supportBundleView: NotImplemented, switchView: NotImplemented, - systemPolicyUpdate: NotImplemented, + systemPolicyUpdate({ body, cookies }) { + requireFleetAdmin(cookies) + + const newAssignments = body.role_assignments + .filter((r) => fleetRoles.some((role) => role === 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, systemUpdateRecoveryFinish: NotImplemented, 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/mock-api/silo.ts b/mock-api/silo.ts index 35b65a27c9..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'], }, }, { @@ -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 new file mode 100644 index 0000000000..865de1d55f --- /dev/null +++ b/test/e2e/fleet-access.e2e.ts @@ -0,0 +1,194 @@ +/* + * 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 { crossSiloGroupId, crossSiloUserId, user3 } from '@oxide/api-mocks' + +import { expect, expectRowVisible, expectToast, getPageAsUser, test } from './utils' + +test('Click through fleet access page', async ({ page }) => { + await page.goto('/system/access') + + const table = page.getByRole('table') + + // initial fleet role assignments: Hannah Arendt (admin), Jane Austen (viewer) + await expect(page.getByRole('heading', { name: /Fleet Access/ })).toBeVisible() + await expectRowVisible(table, { + Name: 'Hannah Arendt', + Type: 'User', + 'Fleet role': 'fleet.admin', + }) + await expectRowVisible(table, { + Name: 'Jane Austen', + Type: 'User', + 'Fleet role': 'fleet.viewer', + }) + 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: 'Any silo.collaborator in maze-war', + Type: 'Role mapping', + 'Fleet role': 'fleet.admin', + }) + await expectRowVisible(table, { + Name: 'Any 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() + + 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.getByRole('option', { name: 'Jacob Klein' }).click() + await page.getByRole('radio', { name: /^Collaborator / }).click() + await page.getByRole('button', { name: 'Assign role' }).click() + + // user 3 shows up in the table + await expectRowVisible(table, { + Name: 'Jacob Klein', + Type: 'User', + 'Fleet role': 'fleet.collaborator', + }) + + // change user 3's role from collaborator to viewer + await page + .getByRole('row', { name: user3.display_name, exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + 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.getByRole('button', { name: 'Update role' }).click() + + 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 }) + 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 expectToast(page, 'Access removed') + await expect(user3Row).toBeHidden() +}) + +test('Add a group to fleet access', async ({ page }) => { + await page.goto('/system/access') + + const table = page.getByRole('table') + + // groups should not already be in the table + await expect(page.getByRole('cell', { name: 'web-devs' })).toBeHidden() + + 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() + 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.getByRole('button', { name: 'Assign role' }).click() + + await expectRowVisible(table, { + Name: 'web-devs', + Type: 'Group', + 'Fleet 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 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', '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() + 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.getByRole('button', { name: 'Cancel' }).click() + 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') + + // 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() +}) 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() 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 }) => { 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 <