diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 542956ae1..ad0d5dd2e 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -9617f40aa6f35b58ffd6b4e92c02ba8e7dd95033 +a6e111cf72ab987b2ea5acd7d26610ea2a55bf0f diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index 1697a6c0a..72bbed88f 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -3297,7 +3297,7 @@ export type ProjectResultsPage = { nextPage?: string | null } -export type ProjectRole = 'admin' | 'collaborator' | 'viewer' +export type ProjectRole = 'admin' | 'collaborator' | 'limited_collaborator' | 'viewer' /** * Describes the assignment of a particular role on a particular resource to a particular identity (user, group, etc.) @@ -3738,7 +3738,7 @@ export type SiloResultsPage = { nextPage?: string | null } -export type SiloRole = 'admin' | 'collaborator' | 'viewer' +export type SiloRole = 'admin' | 'collaborator' | 'limited_collaborator' | 'viewer' /** * Describes the assignment of a particular role on a particular resource to a particular identity (user, group, etc.) @@ -10110,7 +10110,7 @@ export class Api extends HttpClient { }) }, /** - * Fetch system release repository description by version + * Fetch system release repository by version */ systemUpdateRepositoryView: ( { path }: { path: SystemUpdateRepositoryViewPathParams }, diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index c5a25f06b..c092490dd 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -9617f40aa6f35b58ffd6b4e92c02ba8e7dd95033 +a6e111cf72ab987b2ea5acd7d26610ea2a55bf0f diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index f7a6949ea..a1af9104e 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -3056,7 +3056,7 @@ export const ProjectResultsPage = z.preprocess( export const ProjectRole = z.preprocess( processResponseBody, - z.enum(['admin', 'collaborator', 'viewer']) + z.enum(['admin', 'collaborator', 'limited_collaborator', 'viewer']) ) /** @@ -3438,7 +3438,7 @@ export const SiloResultsPage = z.preprocess( export const SiloRole = z.preprocess( processResponseBody, - z.enum(['admin', 'collaborator', 'viewer']) + z.enum(['admin', 'collaborator', 'limited_collaborator', 'viewer']) ) /** diff --git a/app/api/roles.spec.ts b/app/api/roles.spec.ts index 81b6418f4..e8b44c638 100644 --- a/app/api/roles.spec.ts +++ b/app/api/roles.spec.ts @@ -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']) }) diff --git a/app/api/roles.ts b/app/api/roles.ts index e5c912538..8c7467dcd 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -33,7 +33,8 @@ const flatRoles = (roleOrder: Record): RoleKey[] => export const roleOrder: Record = { collaborator: 1, admin: 0, - viewer: 2, + viewer: 3, + limited_collaborator: 2, } /** `roleOrder` record converted to a sorted array of roles. */ diff --git a/app/components/form/fields/RadioField.tsx b/app/components/form/fields/RadioField.tsx index 2d0813950..9319b4996 100644 --- a/app/components/form/fields/RadioField.tsx +++ b/app/components/form/fields/RadioField.tsx @@ -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< @@ -99,11 +101,23 @@ export function RadioField< type RadioElt = React.ReactElement +// 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, -> = Omit, 'parseValue' | 'items'> & { +> = { + name: TName + label?: string + description?: string | React.ReactNode + units?: string + control: Control children: RadioElt | RadioElt[] + column?: boolean + className?: string + required?: boolean + disabled?: boolean } /** @@ -117,7 +131,7 @@ export function RadioFieldDyn< TName extends FieldPath, >({ name, - label = capitalize(name), + label, description, units, control, @@ -126,9 +140,13 @@ export function RadioFieldDyn< }: RadioFieldDynProps) { const id = useId() const { field } = useController({ name, control }) + invariant( + isOneOf(children, [Radio, RadioCard]), + 'Children of RadioFieldDyn must be Radio or RadioCard' + ) return (
-
+
{label && ( {label} {units && ({units})} diff --git a/app/forms/access-util.tsx b/app/forms/access-util.tsx index cff6f0ee3..e30aa4478 100644 --- a/app/forms/access-util.tsx +++ b/app/forms/access-util.tsx @@ -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, @@ -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 = { + 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 = { + 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, @@ -55,3 +76,62 @@ export type EditRoleModalProps = AddRoleModalProps & { identityType: IdentityType defaultValues: { roleName: RoleKey } } + +const AccessDocs = () => ( + + Access Control + +) +export function RoleRadioField< + TFieldValues extends FieldValues, + TName extends FieldPath, +>({ + name, + control, + scope, +}: { + name: TName + control: Control + scope: 'Silo' | 'Project' +}) { + const roleDescriptions = scope === 'Silo' ? siloRoleDescriptions : projectRoleDescriptions + return ( + <> + + {R.reverse(allRoles).map((role) => ( + +
+ {capitalize(role).replace('_', ' ')} +
+
{roleDescriptions[role]}
+
+ ))} +
+ + 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 + projects in the silo. Learn more in the guide. + + ) : ( + <> + Project roles can be overridden by a stronger role on the silo. For example, a + silo viewer is at least a viewer on all projects in the silo. Learn + more in the guide. + + ) + } + /> + + ) +} diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 41827bf95..28dc4552d 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -394,34 +394,19 @@ export default function CreateInstanceForm() { - + {renderLargeRadioCards('general')} - + {renderLargeRadioCards('highCPU')} - + {renderLargeRadioCards('highMemory')} diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx index ae9551cd3..9addcd85a 100644 --- a/app/forms/project-access.tsx +++ b/app/forms/project-access.tsx @@ -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' @@ -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 @@ -65,7 +64,6 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa }} loading={updatePolicy.isPending} submitError={updatePolicy.error} - submitLabel="Assign role" onDismiss={onDismiss} > - + ) } @@ -109,11 +101,15 @@ export function ProjectAccessEditUserSideModal({ return ( + {name} + + } onSubmit={({ roleName }) => { updatePolicy.mutate({ path: { project }, @@ -124,13 +120,7 @@ export function ProjectAccessEditUserSideModal({ submitError={updatePolicy.error} onDismiss={onDismiss} > - + ) } diff --git a/app/forms/silo-access.tsx b/app/forms/silo-access.tsx index 6eb2e2f70..e816a3095 100644 --- a/app/forms/silo-access.tsx +++ b/app/forms/silo-access.tsx @@ -13,14 +13,16 @@ 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 { ResourceLabel } from '~/ui/lib/SideModal' import { actorToItem, defaultValues, - roleItems, + RoleRadioField, type AddRoleModalProps, type EditRoleModalProps, } from './access-util' @@ -44,12 +46,9 @@ export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalPr formType="create" resourceName="role" title="Add user or group" + submitLabel="Assign role" onDismiss={onDismiss} onSubmit={({ identityId, roleName }) => { - // can't happen because roleName is validated not to be '', but TS - // wants to be sure - if (roleName === '') return - // TODO: DRY logic // actor is guaranteed to be in the list because it came from there const identityType = actors.find((a) => a.id === identityId)!.identityType @@ -60,7 +59,6 @@ export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalPr }} loading={updatePolicy.isPending} submitError={updatePolicy.error} - submitLabel="Assign role" > - + ) } @@ -99,11 +91,15 @@ export function SiloAccessEditUserSideModal({ return ( + {name} + + } onSubmit={({ roleName }) => { updatePolicy.mutate({ body: updateRole({ identityId, identityType, roleName }, policy), @@ -113,13 +109,7 @@ export function SiloAccessEditUserSideModal({ submitError={updatePolicy.error} onDismiss={onDismiss} > - + ) } diff --git a/app/ui/lib/Radio.tsx b/app/ui/lib/Radio.tsx index 646c8a364..c674eec14 100644 --- a/app/ui/lib/Radio.tsx +++ b/app/ui/lib/Radio.tsx @@ -17,7 +17,10 @@ import cn from 'classnames' import type { ComponentProps } from 'react' // input type is fixed to "radio" -export type RadioProps = Omit, 'type'> +export type RadioProps = Omit, 'type'> & { + /** Align radio button with top of content instead of center (useful for multi-line content) */ + alignTop?: boolean +} const fieldStyles = ` peer appearance-none absolute outline-hidden @@ -26,9 +29,9 @@ const fieldStyles = ` disabled:hover:bg-transparent ` -export const Radio = ({ children, className, ...inputProps }: RadioProps) => ( -