Skip to content

Commit ab76183

Browse files
Limited collaborator UI (#2960)
* Bump Omicron version and add limited_collaborator role * Add restriction in MSW around VPC creation and add tests * Use radio buttons for forms; refactor * Better spacing, copy * More spacing tweaks; message about stacked role rules * copy * Show current state in form * Cleanup unused variable * let RadioFieldDyn handle its own radio state the div wrapping the radios was messing up the way RadioFieldDyn passes checked state to its child radios because they're expected to be direct children * update import * improve tests; add silo-to-project authz conferral mechanism to msw * Add new unit test for silo limited-collaborator * Microcopy tweaks * Move silo and project access forms into regular modal * Move back to side modal * update button copy * Update header for edit modals * Update string matching in tests * go to great lengths to avoid casting * also show message box on silo access * add space under role radio heading * Bump Omicron version * special message text for project and silo access, link to docs * remove unneeded import * final copy --------- Co-authored-by: David Crespo <[email protected]>
1 parent 6764ba5 commit ab76183

File tree

22 files changed

+354
-128
lines changed

22 files changed

+354
-128
lines changed

OMICRON_VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
9617f40aa6f35b58ffd6b4e92c02ba8e7dd95033
1+
a6e111cf72ab987b2ea5acd7d26610ea2a55bf0f

app/api/__generated__/Api.ts

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/api/__generated__/OMICRON_VERSION

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/api/__generated__/validate.ts

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/api/roles.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,5 +157,5 @@ test('byGroupThenName sorts as expected', () => {
157157
})
158158

159159
test('allRoles', () => {
160-
expect(allRoles).toEqual(['admin', 'collaborator', 'viewer'])
160+
expect(allRoles).toEqual(['admin', 'collaborator', 'limited_collaborator', 'viewer'])
161161
})

app/api/roles.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ const flatRoles = (roleOrder: Record<RoleKey, number>): RoleKey[] =>
3333
export const roleOrder: Record<RoleKey, number> = {
3434
collaborator: 1,
3535
admin: 0,
36-
viewer: 2,
36+
viewer: 3,
37+
limited_collaborator: 2,
3738
}
3839

3940
/** `roleOrder` record converted to a sorted array of roles. */

app/components/form/fields/RadioField.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ import {
1616
} from 'react-hook-form'
1717

1818
import { FieldLabel } from '~/ui/lib/FieldLabel'
19-
import { Radio, type RadioProps } from '~/ui/lib/Radio'
19+
import { Radio, RadioCard, type RadioProps } from '~/ui/lib/Radio'
2020
import { RadioGroup, type RadioGroupProps } from '~/ui/lib/RadioGroup'
2121
import { TextInputHint } from '~/ui/lib/TextInput'
22+
import { isOneOf } from '~/util/children'
23+
import { invariant } from '~/util/invariant'
2224
import { capitalize } from '~/util/str'
2325

2426
export type RadioFieldProps<
@@ -99,11 +101,23 @@ export function RadioField<
99101

100102
type RadioElt = React.ReactElement<RadioProps>
101103

104+
// we do not extend RadioFieldProps here because it limits our ability to type
105+
// components like RoleRadioField. see https://tsplay.dev/ND13Om
106+
102107
export type RadioFieldDynProps<
103108
TFieldValues extends FieldValues,
104109
TName extends FieldPath<TFieldValues>,
105-
> = Omit<RadioFieldProps<TFieldValues, TName>, 'parseValue' | 'items'> & {
110+
> = {
111+
name: TName
112+
label?: string
113+
description?: string | React.ReactNode
114+
units?: string
115+
control: Control<TFieldValues>
106116
children: RadioElt | RadioElt[]
117+
column?: boolean
118+
className?: string
119+
required?: boolean
120+
disabled?: boolean
107121
}
108122

109123
/**
@@ -117,7 +131,7 @@ export function RadioFieldDyn<
117131
TName extends FieldPath<TFieldValues>,
118132
>({
119133
name,
120-
label = capitalize(name),
134+
label,
121135
description,
122136
units,
123137
control,
@@ -126,9 +140,13 @@ export function RadioFieldDyn<
126140
}: RadioFieldDynProps<TFieldValues, TName>) {
127141
const id = useId()
128142
const { field } = useController({ name, control })
143+
invariant(
144+
isOneOf(children, [Radio, RadioCard]),
145+
'Children of RadioFieldDyn must be Radio or RadioCard'
146+
)
129147
return (
130148
<div>
131-
<div className="mb-2">
149+
<div className="mb-3">
132150
{label && (
133151
<FieldLabel id={`${id}-label`}>
134152
{label} {units && <span className="text-default ml-1">({units})</span>}

app/forms/access-util.tsx

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
import type { Control, FieldPath, FieldValues } from 'react-hook-form'
9+
import * as R from 'remeda'
10+
811
import {
912
allRoles,
1013
type Actor,
@@ -14,20 +17,38 @@ import {
1417
} from '@oxide/api'
1518
import { Badge } from '@oxide/design-system/ui'
1619

20+
import { RadioFieldDyn } from '~/components/form/fields/RadioField'
1721
import { type ListboxItem } from '~/ui/lib/Listbox'
22+
import { Message } from '~/ui/lib/Message'
23+
import { Radio } from '~/ui/lib/Radio'
24+
import { links } from '~/util/links'
1825
import { capitalize } from '~/util/str'
1926

2027
type AddUserValues = {
2128
identityId: string
22-
roleName: RoleKey | ''
29+
roleName: RoleKey
2330
}
2431

2532
export const defaultValues: AddUserValues = {
2633
identityId: '',
27-
roleName: '',
34+
roleName: 'viewer',
35+
}
36+
37+
// Role descriptions for project-level roles
38+
const projectRoleDescriptions: Record<RoleKey, string> = {
39+
admin: 'Control all aspects of the project',
40+
collaborator: 'Manage all project resources, including networking',
41+
limited_collaborator: 'Manage project resources except networking configuration',
42+
viewer: 'View resources within the project',
2843
}
2944

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

3253
export const actorToItem = (actor: Actor): ListboxItem => ({
3354
value: actor.id,
@@ -55,3 +76,62 @@ export type EditRoleModalProps = AddRoleModalProps & {
5576
identityType: IdentityType
5677
defaultValues: { roleName: RoleKey }
5778
}
79+
80+
const AccessDocs = () => (
81+
<a href={links.accessDocs} target="_blank" rel="noreferrer">
82+
Access Control
83+
</a>
84+
)
85+
export function RoleRadioField<
86+
TFieldValues extends FieldValues,
87+
TName extends FieldPath<TFieldValues>,
88+
>({
89+
name,
90+
control,
91+
scope,
92+
}: {
93+
name: TName
94+
control: Control<TFieldValues>
95+
scope: 'Silo' | 'Project'
96+
}) {
97+
const roleDescriptions = scope === 'Silo' ? siloRoleDescriptions : projectRoleDescriptions
98+
return (
99+
<>
100+
<RadioFieldDyn
101+
name={name}
102+
label={`${scope} role`}
103+
required
104+
control={control}
105+
column
106+
className="mt-2"
107+
>
108+
{R.reverse(allRoles).map((role) => (
109+
<Radio name="roleName" key={role} value={role} alignTop>
110+
<div className="text-sans-md text-raise">
111+
{capitalize(role).replace('_', ' ')}
112+
</div>
113+
<div className="text-sans-sm text-secondary">{roleDescriptions[role]}</div>
114+
</Radio>
115+
))}
116+
</RadioFieldDyn>
117+
<Message
118+
variant="info"
119+
content={
120+
scope === 'Silo' ? (
121+
<>
122+
Silo roles are inherited by all projects in the silo and override weaker
123+
roles. For example, a silo viewer is <em>at least</em> a viewer on all
124+
projects in the silo. Learn more in the <AccessDocs /> guide.
125+
</>
126+
) : (
127+
<>
128+
Project roles can be overridden by a stronger role on the silo. For example, a
129+
silo viewer is <em>at least</em> a viewer on all projects in the silo. Learn
130+
more in the <AccessDocs /> guide.
131+
</>
132+
)
133+
}
134+
/>
135+
</>
136+
)
137+
}

app/forms/instance-create.tsx

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -394,34 +394,19 @@ export default function CreateInstanceForm() {
394394
</Tabs.Trigger>
395395
</Tabs.List>
396396
<Tabs.Content value="general">
397-
<RadioFieldDyn
398-
name="presetId"
399-
label=""
400-
control={control}
401-
disabled={isSubmitting}
402-
>
397+
<RadioFieldDyn name="presetId" control={control} disabled={isSubmitting}>
403398
{renderLargeRadioCards('general')}
404399
</RadioFieldDyn>
405400
</Tabs.Content>
406401

407402
<Tabs.Content value="highCPU">
408-
<RadioFieldDyn
409-
name="presetId"
410-
label=""
411-
control={control}
412-
disabled={isSubmitting}
413-
>
403+
<RadioFieldDyn name="presetId" control={control} disabled={isSubmitting}>
414404
{renderLargeRadioCards('highCPU')}
415405
</RadioFieldDyn>
416406
</Tabs.Content>
417407

418408
<Tabs.Content value="highMemory">
419-
<RadioFieldDyn
420-
name="presetId"
421-
label=""
422-
control={control}
423-
disabled={isSubmitting}
424-
>
409+
<RadioFieldDyn name="presetId" control={control} disabled={isSubmitting}>
425410
{renderLargeRadioCards('highMemory')}
426411
</RadioFieldDyn>
427412
</Tabs.Content>

app/forms/project-access.tsx

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,18 @@ import {
1313
useApiMutation,
1414
useApiQueryClient,
1515
} from '@oxide/api'
16+
import { Access16Icon } from '@oxide/design-system/icons/react'
1617

1718
import { ListboxField } from '~/components/form/fields/ListboxField'
1819
import { SideModalForm } from '~/components/form/SideModalForm'
1920
import { useProjectSelector } from '~/hooks/use-params'
2021
import { addToast } from '~/stores/toast'
22+
import { ResourceLabel } from '~/ui/lib/SideModal'
2123

2224
import {
2325
actorToItem,
2426
defaultValues,
25-
roleItems,
27+
RoleRadioField,
2628
type AddRoleModalProps,
2729
type EditRoleModalProps,
2830
} from './access-util'
@@ -50,11 +52,8 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa
5052
resourceName="role"
5153
form={form}
5254
formType="create"
55+
submitLabel="Assign role"
5356
onSubmit={({ identityId, roleName }) => {
54-
// can't happen because roleName is validated not to be '', but TS
55-
// wants to be sure
56-
if (roleName === '') return
57-
5857
// actor is guaranteed to be in the list because it came from there
5958
const identityType = actors.find((a) => a.id === identityId)!.identityType
6059

@@ -65,7 +64,6 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa
6564
}}
6665
loading={updatePolicy.isPending}
6766
submitError={updatePolicy.error}
68-
submitLabel="Assign role"
6967
onDismiss={onDismiss}
7068
>
7169
<ListboxField
@@ -75,13 +73,7 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa
7573
required
7674
control={form.control}
7775
/>
78-
<ListboxField
79-
name="roleName"
80-
label="Role"
81-
items={roleItems}
82-
required
83-
control={form.control}
84-
/>
76+
<RoleRadioField name="roleName" control={form.control} scope="Project" />
8577
</SideModalForm>
8678
)
8779
}
@@ -109,11 +101,15 @@ export function ProjectAccessEditUserSideModal({
109101

110102
return (
111103
<SideModalForm
112-
// TODO: show user name in header or SOMEWHERE
113104
form={form}
114105
formType="edit"
115106
resourceName="role"
116-
title={`Change project role for ${name}`}
107+
title="Edit role"
108+
subtitle={
109+
<ResourceLabel>
110+
<Access16Icon /> {name}
111+
</ResourceLabel>
112+
}
117113
onSubmit={({ roleName }) => {
118114
updatePolicy.mutate({
119115
path: { project },
@@ -124,13 +120,7 @@ export function ProjectAccessEditUserSideModal({
124120
submitError={updatePolicy.error}
125121
onDismiss={onDismiss}
126122
>
127-
<ListboxField
128-
name="roleName"
129-
label="Role"
130-
items={roleItems}
131-
required
132-
control={form.control}
133-
/>
123+
<RoleRadioField name="roleName" control={form.control} scope="Project" />
134124
</SideModalForm>
135125
)
136126
}

0 commit comments

Comments
 (0)