Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
152 changes: 152 additions & 0 deletions app/forms/org-access.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import * as Yup from 'yup'
import { useMemo } from 'react'

import type { OrganizationRole, OrganizationRolePolicy } from '@oxide/api'
import { setUserRole } from '@oxide/api'
import { useApiQueryClient } from '@oxide/api'
import { useApiMutation } from '@oxide/api'
import { useApiQuery } from '@oxide/api'

import { Form, ListboxField, SideModalForm } from 'app/components/form'
import { useParams } from 'app/hooks'

import type { CreateSideModalFormProps, EditSideModalFormProps } from '.'

type AddUserValues = {
userId: string
roleName: OrganizationRole | ''
}

const initialValues: AddUserValues = {
userId: '',
roleName: '',
}

type RoleItem = { value: OrganizationRole; label: string }

const roles: RoleItem[] = [
{ value: 'admin', label: 'Admin' },
{ value: 'collaborator', label: 'Collaborator' },
{ value: 'viewer', label: 'Viewer' },
]

type AddRoleModalProps = CreateSideModalFormProps<AddUserValues, OrganizationRolePolicy> & {
policy: OrganizationRolePolicy
}

export function OrgAccessAddUserSideModal({
onSubmit,
onSuccess,
onDismiss,
policy,
...props
}: AddRoleModalProps) {
const orgParams = useParams('orgName')
const { data: users } = useApiQuery('usersGet', {})

const userItems = useMemo(() => {
// IDs are UUIDs, so no need to include identity type in set value to disambiguate
const usersInPolicy = new Set(policy?.roleAssignments.map((ra) => ra.identityId) || [])
return (
users?.items
// only show users for adding if they're not already in the policy
.filter((u) => !usersInPolicy.has(u.id))
.map((u) => ({ value: u.id, label: u.name })) || []
)
}, [users, policy])

const queryClient = useApiQueryClient()
const updatePolicy = useApiMutation('organizationPutPolicy', {
onSuccess: (data) => {
queryClient.invalidateQueries('organizationGetPolicy', orgParams)
onSuccess?.(data)
onDismiss()
},
})

return (
<SideModalForm
onDismiss={onDismiss}
title="Add user to organization"
id="org-access-add-user"
initialValues={initialValues}
onSubmit={
onSubmit ||
(({ userId, roleName }) => {
// can't happen because roleName is validated not to be '', but TS
// wants to be sure
if (roleName === '') return

updatePolicy.mutate({
...orgParams,
body: setUserRole(userId, roleName, policy),
})
})
}
validationSchema={Yup.object({
userId: Yup.string().required(),
roleName: Yup.string().required(),
})}
submitDisabled={updatePolicy.isLoading}
error={updatePolicy.error?.error as Error | undefined}
{...props}
>
<ListboxField id="userId" name="userId" items={userItems} label="User" required />
<ListboxField id="roleName" name="roleName" label="Role" items={roles} required />
<Form.Submit>Add user</Form.Submit>
</SideModalForm>
)
}

type EditUserValues = {
roleName: OrganizationRole
}

type EditRoleModalProps = EditSideModalFormProps<EditUserValues, OrganizationRolePolicy> & {
userId: string
policy: OrganizationRolePolicy
}

export function OrgAccessEditUserSideModal({
onSubmit,
onSuccess,
onDismiss,
userId,
policy,
...props
}: EditRoleModalProps) {
const orgParams = useParams('orgName')

const queryClient = useApiQueryClient()
const updatePolicy = useApiMutation('organizationPutPolicy', {
onSuccess: (data) => {
queryClient.invalidateQueries('organizationGetPolicy', orgParams)
onSuccess?.(data)
onDismiss()
},
})

return (
<SideModalForm
onDismiss={onDismiss}
// TODO: show user name in header or SOMEWHERE
title="Change user role"
id="org-access-edit-user"
onSubmit={
onSubmit ||
(({ roleName }) => {
updatePolicy.mutate({
...orgParams,
body: setUserRole(userId, roleName, policy),
})
})
}
submitDisabled={updatePolicy.isLoading || !policy}
error={updatePolicy.error?.error as Error | undefined}
{...props}
>
<ListboxField id="roleName" name="roleName" label="Role" items={roles} required />
<Form.Submit>Update role</Form.Submit>
</SideModalForm>
)
}
3 changes: 3 additions & 0 deletions app/layouts/OrgLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ const OrgLayout = () => {
</NavLinkItem>
))}
</Sidebar.Nav>
<Sidebar.Nav heading="Organization">
<NavLinkItem to="../../access">Access &amp; IAM</NavLinkItem>
</Sidebar.Nav>
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

unfortunately the paths here are kind of messed up and I think they depend on my RR fix and another prerelease, which we've been waiting on longer than I expected

</Sidebar>
<ContentPaneWrapper>
<ContentPane>
Expand Down
133 changes: 133 additions & 0 deletions app/pages/OrgAccessPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { getCoreRowModel, useTableInstance } from '@tanstack/react-table'
import { useMemo, useState } from 'react'

import { getOrgRole, setUserRole, useApiMutation, useApiQueryClient } from '@oxide/api'
import type { OrganizationRole } from '@oxide/api'
import { useApiQuery } from '@oxide/api'
import { Table, createTable, getActionsCol } from '@oxide/table'
import { Access24Icon, Badge, Button, PageHeader, PageTitle, TableActions } from '@oxide/ui'
import { groupBy } from '@oxide/util'

import { OrgAccessAddUserSideModal, OrgAccessEditUserSideModal } from 'app/forms/org-access'
import { useParams } from 'app/hooks'

type UserRow = {
id: string
name: string
roleName: OrganizationRole
}

const table = createTable().setRowType<UserRow>()

// when you build this page for real, check the git history of this file. there
// might be something useful in the old placeholder
export const OrgAccessPage = () => {
const [addModalOpen, setAddModalOpen] = useState(false)
const [editingUserRow, setEditingUserRow] = useState<UserRow | null>(null)
const orgParams = useParams('orgName')
const { data: policy } = useApiQuery('organizationGetPolicy', orgParams)

// TODO: this hits /users, which returns system users, not silo users. We need
// an endpoint to list silo users. I'm hoping we might end up using /users for
// that. See https://github.com/oxidecomputer/omicron/issues/1235
const { data: users } = useApiQuery('usersGet', { limit: 200 })

// 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 usersDict = useMemo(
() => Object.fromEntries((users?.items || []).map((u) => [u.id, u])),
[users]
)

const rows: UserRow[] = useMemo(() => {
// each group represents a user with multiple role assignments
const groups = groupBy(policy?.roleAssignments || [], (u) => u.identityId)
return Object.entries(groups).map(([userId, roleAssignments]) => ({
id: userId,
name: usersDict[userId]?.name || '',
// assert non-null because we know there has to be one, otherwise there
// wouldn't be a group
roleName: getOrgRole(roleAssignments.map((ra) => ra.roleName))!,
}))
}, [policy, usersDict])

const queryClient = useApiQueryClient()
const updatePolicy = useApiMutation('organizationPutPolicy', {
onSuccess: () => queryClient.invalidateQueries('organizationGetPolicy', orgParams),
// TODO: handle 403
})

// TODO: checkboxes and bulk delete? not sure
// TODO: disable delete on permissions you can't delete

const columns = useMemo(
() => [
table.createDataColumn('id', { header: 'ID' }),
table.createDataColumn('name', { header: 'Name' }),
table.createDataColumn('roleName', {
header: 'Role',
cell: (info) => <Badge color="neutral">{info.getValue()}</Badge>,
}),
table.createDisplayColumn(
getActionsCol((row) => [
{
label: 'Change role',
onActivate: () => setEditingUserRow(row),
},
// TODO: only show if you have permission to do this
{
label: 'Delete',
onActivate() {
// TODO: confirm delete
updatePolicy.mutate({
...orgParams,
// we know policy is there, otherwise there's no row to display
body: setUserRole(row.id, null, policy!),
})
},
},
])
),
],
[policy, orgParams, updatePolicy]
)

const tableInstance = useTableInstance(table, {
columns,
data: rows,
getCoreRowModel: getCoreRowModel(),
})

return (
<>
<PageHeader>
<PageTitle icon={<Access24Icon />}>Access &amp; IAM</PageTitle>
</PageHeader>

<TableActions>
<Button size="xs" variant="secondary" onClick={() => setAddModalOpen(true)}>
Add user to organization
</Button>
</TableActions>
{policy && (
<OrgAccessAddUserSideModal
isOpen={addModalOpen}
onDismiss={() => setAddModalOpen(false)}
onSuccess={() => setAddModalOpen(false)}
policy={policy}
/>
)}
{policy && editingUserRow && (
<OrgAccessEditUserSideModal
isOpen={!!editingUserRow}
onDismiss={() => setEditingUserRow(null)}
onSuccess={() => setEditingUserRow(null)}
policy={policy}
userId={editingUserRow.id}
initialValues={{ roleName: editingUserRow.roleName }}
/>
)}
<Table table={tableInstance} />
</>
)
}
8 changes: 8 additions & 0 deletions app/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import RootLayout from './layouts/RootLayout'
import SettingsLayout from './layouts/SettingsLayout'
import LoginPage from './pages/LoginPage'
import NotFound from './pages/NotFound'
import { OrgAccessPage } from './pages/OrgAccessPage'
import OrgsPage from './pages/OrgsPage'
import ProjectsPage from './pages/ProjectsPage'
import {
Expand Down Expand Up @@ -55,6 +56,13 @@ export const Router = () => (

<Route path=":orgName" handle={{ crumb: orgCrumb }}>
<Route index element={<Navigate to="projects" replace />} />
<Route element={<OrgLayout />}>
<Route
path="access"
element={<OrgAccessPage />}
handle={{ crumb: 'Access & IAM' }}
/>
</Route>
<Route path="projects" handle={{ crumb: 'Projects' }}>
{/* ORG */}
<Route element={<OrgLayout />}>
Expand Down
37 changes: 37 additions & 0 deletions libs/api-mocks/msw/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,43 @@ export const handlers = [
}
),

rest.get<never, ProjectParams, Json<Api.ProjectRolePolicy> | GetErr>(
'/api/organizations/:orgName/policy',
(req, res) => {
const [org, err] = lookupOrg(req.params)
if (err) return res(err)
const role_assignments = db.roleAssignments
.filter((r) => r.resource_type === 'organization' && r.resource_id === org.id)
.map((r) => pick(r, 'identity_id', 'identity_type', 'role_name'))

return res(json({ role_assignments }))
}
),

rest.put<
Json<Api.OrganizationRolePolicy>,
ProjectParams,
Json<Api.OrganizationRolePolicy> | PostErr
>('/api/organizations/:orgName/policy', (req, res) => {
const [org, err] = lookupOrg(req.params)
if (err) return res(err)

// TODO: validate input lol
const newAssignments = req.body.role_assignments.map((r) => ({
resource_type: 'organization' as const,
resource_id: org.id,
...pick(r, 'identity_id', 'identity_type', 'role_name'),
}))

const unrelatedAssignments = db.roleAssignments.filter(
(r) => !(r.resource_type === 'project' && r.resource_id === org.id)
)

db.roleAssignments = [...unrelatedAssignments, ...newAssignments]

return res(json(req.body))
}),

rest.delete<never, OrgParams, GetErr>('/api/organizations/:orgName', (req, res, ctx) => {
const [org, err] = lookupOrg(req.params)
if (err) return res(err)
Expand Down