Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
715c605
Bump Omicron version and add limited_collaborator role
charliepark Oct 31, 2025
2a6079f
Add restriction in MSW around VPC creation and add tests
charliepark Oct 31, 2025
1a3d6dc
Use radio buttons for forms; refactor
charliepark Oct 31, 2025
eb5d2a4
Better spacing, copy
charliepark Oct 31, 2025
6650f4a
More spacing tweaks; message about stacked role rules
charliepark Oct 31, 2025
3cc46c8
copy
charliepark Oct 31, 2025
955df89
Show current state in form
charliepark Oct 31, 2025
c1dd1b2
Cleanup unused variable
charliepark Oct 31, 2025
4b3731d
let RadioFieldDyn handle its own radio state
david-crespo Nov 2, 2025
a5646b8
update import
charliepark Nov 3, 2025
38881d4
improve tests; add silo-to-project authz conferral mechanism to msw
charliepark Nov 3, 2025
59167e5
Add new unit test for silo limited-collaborator
charliepark Nov 3, 2025
f63e66c
Microcopy tweaks
charliepark Nov 3, 2025
7e40272
Move silo and project access forms into regular modal
charliepark Nov 3, 2025
a5ce69b
Move back to side modal
charliepark Nov 3, 2025
58611cb
update button copy
charliepark Nov 3, 2025
f3fc028
Update header for edit modals
charliepark Nov 3, 2025
c6bffdc
Update string matching in tests
charliepark Nov 3, 2025
0f86eaf
go to great lengths to avoid casting
david-crespo Nov 3, 2025
b607d6b
also show message box on silo access
david-crespo Nov 3, 2025
c7d60a5
add space under role radio heading
david-crespo Nov 3, 2025
04db59b
Bump Omicron version
charliepark Nov 4, 2025
5808180
special message text for project and silo access, link to docs
david-crespo Nov 4, 2025
3d2dd5a
remove unneeded import
charliepark Nov 4, 2025
18c257c
final copy
david-crespo Nov 4, 2025
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 OMICRON_VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
9617f40aa6f35b58ffd6b4e92c02ba8e7dd95033
a6e111cf72ab987b2ea5acd7d26610ea2a55bf0f
6 changes: 3 additions & 3 deletions app/api/__generated__/Api.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion app/api/__generated__/OMICRON_VERSION

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions app/api/__generated__/validate.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion app/api/roles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,5 +157,5 @@ test('byGroupThenName sorts as expected', () => {
})

test('allRoles', () => {
expect(allRoles).toEqual(['admin', 'collaborator', 'viewer'])
expect(allRoles).toEqual(['admin', 'collaborator', 'limited_collaborator', 'viewer'])
})
3 changes: 2 additions & 1 deletion app/api/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ const flatRoles = (roleOrder: Record<RoleKey, number>): RoleKey[] =>
export const roleOrder: Record<RoleKey, number> = {
collaborator: 1,
admin: 0,
viewer: 2,
viewer: 3,
limited_collaborator: 2,
}

/** `roleOrder` record converted to a sorted array of roles. */
Expand Down
26 changes: 22 additions & 4 deletions app/components/form/fields/RadioField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ import {
} from 'react-hook-form'

import { FieldLabel } from '~/ui/lib/FieldLabel'
import { Radio, type RadioProps } from '~/ui/lib/Radio'
import { Radio, RadioCard, type RadioProps } from '~/ui/lib/Radio'
import { RadioGroup, type RadioGroupProps } from '~/ui/lib/RadioGroup'
import { TextInputHint } from '~/ui/lib/TextInput'
import { isOneOf } from '~/util/children'
import { invariant } from '~/util/invariant'
import { capitalize } from '~/util/str'

export type RadioFieldProps<
Expand Down Expand Up @@ -99,11 +101,23 @@ export function RadioField<

type RadioElt = React.ReactElement<RadioProps>

// we do not extend RadioFieldProps here because it limits our ability to type
// components like RoleRadioField. see https://tsplay.dev/ND13Om

export type RadioFieldDynProps<
TFieldValues extends FieldValues,
TName extends FieldPath<TFieldValues>,
> = Omit<RadioFieldProps<TFieldValues, TName>, 'parseValue' | 'items'> & {
> = {
name: TName
label?: string
description?: string | React.ReactNode
units?: string
control: Control<TFieldValues>
children: RadioElt | RadioElt[]
column?: boolean
className?: string
required?: boolean
disabled?: boolean
}

/**
Expand All @@ -117,7 +131,7 @@ export function RadioFieldDyn<
TName extends FieldPath<TFieldValues>,
>({
name,
label = capitalize(name),
label,
description,
units,
control,
Expand All @@ -126,9 +140,13 @@ export function RadioFieldDyn<
}: RadioFieldDynProps<TFieldValues, TName>) {
const id = useId()
const { field } = useController({ name, control })
invariant(
isOneOf(children, [Radio, RadioCard]),
'Children of RadioFieldDyn must be Radio or RadioCard'
)
return (
<div>
<div className="mb-2">
<div className="mb-3">
Copy link
Collaborator

@david-crespo david-crespo Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fact that the only existing callers don't pass a label means that I can change the spacing here without consequence. Looks fine. I considered mb-4 but it was slightly too much. I think the real issue here is that the label and the values have the same treatment, so there's no sense of hierarchy. A problem for another time.

Image

{label && (
<FieldLabel id={`${id}-label`}>
{label} {units && <span className="text-default ml-1">({units})</span>}
Expand Down
86 changes: 83 additions & 3 deletions app/forms/access-util.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
*
* Copyright Oxide Computer Company
*/
import type { Control, FieldPath, FieldValues } from 'react-hook-form'
import * as R from 'remeda'

import {
allRoles,
type Actor,
Expand All @@ -14,20 +17,38 @@ import {
} from '@oxide/api'
import { Badge } from '@oxide/design-system/ui'

import { RadioFieldDyn } from '~/components/form/fields/RadioField'
import { type ListboxItem } from '~/ui/lib/Listbox'
import { Message } from '~/ui/lib/Message'
import { Radio } from '~/ui/lib/Radio'
import { links } from '~/util/links'
import { capitalize } from '~/util/str'

type AddUserValues = {
identityId: string
roleName: RoleKey | ''
roleName: RoleKey
}

export const defaultValues: AddUserValues = {
identityId: '',
roleName: '',
roleName: 'viewer',
}

// Role descriptions for project-level roles
const projectRoleDescriptions: Record<RoleKey, string> = {
admin: 'Control all aspects of the project',
collaborator: 'Manage all project resources, including networking',
limited_collaborator: 'Manage project resources except networking configuration',
viewer: 'View resources within the project',
}

export const roleItems = allRoles.map((role) => ({ value: role, label: capitalize(role) }))
// Role descriptions for silo-level roles
const siloRoleDescriptions: Record<RoleKey, string> = {
admin: 'Control all aspects of the silo',
collaborator: 'Create and administer projects',
limited_collaborator: 'Manage project resources except networking configuration',
viewer: 'View resources within the silo',
}

export const actorToItem = (actor: Actor): ListboxItem => ({
value: actor.id,
Expand Down Expand Up @@ -55,3 +76,62 @@ export type EditRoleModalProps = AddRoleModalProps & {
identityType: IdentityType
defaultValues: { roleName: RoleKey }
}

const AccessDocs = () => (
<a href={links.accessDocs} target="_blank" rel="noreferrer">
Access Control
</a>
)
export function RoleRadioField<
TFieldValues extends FieldValues,
TName extends FieldPath<TFieldValues>,
>({
name,
control,
scope,
}: {
name: TName
control: Control<TFieldValues>
scope: 'Silo' | 'Project'
}) {
const roleDescriptions = scope === 'Silo' ? siloRoleDescriptions : projectRoleDescriptions
return (
<>
<RadioFieldDyn
name={name}
label={`${scope} role`}
required
control={control}
column
className="mt-2"
>
{R.reverse(allRoles).map((role) => (
<Radio name="roleName" key={role} value={role} alignTop>
<div className="text-sans-md text-raise">
{capitalize(role).replace('_', ' ')}
</div>
<div className="text-sans-sm text-secondary">{roleDescriptions[role]}</div>
</Radio>
))}
</RadioFieldDyn>
<Message
variant="info"
content={
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
projects in the silo. Learn more in the <AccessDocs /> guide.
</>
) : (
<>
Project roles can be overridden by a stronger role on the silo. For example, a
silo viewer is <em>at least</em> a viewer on all projects in the silo. Learn
more in the <AccessDocs /> guide.
</>
)
}
/>
</>
)
}
21 changes: 3 additions & 18 deletions app/forms/instance-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -394,34 +394,19 @@ export default function CreateInstanceForm() {
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="general">
<RadioFieldDyn
name="presetId"
label=""
control={control}
disabled={isSubmitting}
>
<RadioFieldDyn name="presetId" control={control} disabled={isSubmitting}>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these were the only other RadioFieldDyn and they were using label="" as a hack around the fact there was a default value if label wasn't passed. That's silly, so I got rid of the default value and stopped passing a label here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

{renderLargeRadioCards('general')}
</RadioFieldDyn>
</Tabs.Content>

<Tabs.Content value="highCPU">
<RadioFieldDyn
name="presetId"
label=""
control={control}
disabled={isSubmitting}
>
<RadioFieldDyn name="presetId" control={control} disabled={isSubmitting}>
{renderLargeRadioCards('highCPU')}
</RadioFieldDyn>
</Tabs.Content>

<Tabs.Content value="highMemory">
<RadioFieldDyn
name="presetId"
label=""
control={control}
disabled={isSubmitting}
>
<RadioFieldDyn name="presetId" control={control} disabled={isSubmitting}>
{renderLargeRadioCards('highMemory')}
</RadioFieldDyn>
</Tabs.Content>
Expand Down
34 changes: 12 additions & 22 deletions app/forms/project-access.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,18 @@ import {
useApiMutation,
useApiQueryClient,
} 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 { useProjectSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { ResourceLabel } from '~/ui/lib/SideModal'

import {
actorToItem,
defaultValues,
roleItems,
RoleRadioField,
type AddRoleModalProps,
type EditRoleModalProps,
} from './access-util'
Expand Down Expand Up @@ -50,11 +52,8 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa
resourceName="role"
form={form}
formType="create"
submitLabel="Assign role"
onSubmit={({ identityId, roleName }) => {
// can't happen because roleName is validated not to be '', but TS
// wants to be sure
if (roleName === '') return

// actor is guaranteed to be in the list because it came from there
const identityType = actors.find((a) => a.id === identityId)!.identityType

Expand All @@ -65,7 +64,6 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa
}}
loading={updatePolicy.isPending}
submitError={updatePolicy.error}
submitLabel="Assign role"
onDismiss={onDismiss}
>
<ListboxField
Expand All @@ -75,13 +73,7 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa
required
control={form.control}
/>
<ListboxField
name="roleName"
label="Role"
items={roleItems}
required
control={form.control}
/>
<RoleRadioField name="roleName" control={form.control} scope="Project" />
</SideModalForm>
)
}
Expand Down Expand Up @@ -109,11 +101,15 @@ export function ProjectAccessEditUserSideModal({

return (
<SideModalForm
// TODO: show user name in header or SOMEWHERE
form={form}
formType="edit"
resourceName="role"
title={`Change project role for ${name}`}
title="Edit role"
subtitle={
<ResourceLabel>
<Access16Icon /> {name}
</ResourceLabel>
}
onSubmit={({ roleName }) => {
updatePolicy.mutate({
path: { project },
Expand All @@ -124,13 +120,7 @@ export function ProjectAccessEditUserSideModal({
submitError={updatePolicy.error}
onDismiss={onDismiss}
>
<ListboxField
name="roleName"
label="Role"
items={roleItems}
required
control={form.control}
/>
<RoleRadioField name="roleName" control={form.control} scope="Project" />
</SideModalForm>
)
}
Loading
Loading