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

import type { OrganizationRole, OrganizationRolePolicy } from '@oxide/api'
import { orgRoles, useUsersNotInPolicy } from '@oxide/api'
import { setUserRole } from '@oxide/api'
import { useApiQueryClient } from '@oxide/api'
import { useApiMutation } from '@oxide/api'
import { capitalize } from '@oxide/util'

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: '',
}

const roleItems = orgRoles.map((role) => ({ value: role, label: capitalize(role) }))

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

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

const users = useUsersNotInPolicy(policy)
const userItems = users.map((u) => ({ value: u.id, label: u.name }))

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={roleItems} 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={roleItems} required />
<Form.Submit>Update role</Form.Submit>
</SideModalForm>
)
}
32 changes: 9 additions & 23 deletions app/forms/project-access.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import * as Yup from 'yup'
import { useMemo } from 'react'

import type { ProjectRole, ProjectRolePolicy } from '@oxide/api'
import { projectRoles } from '@oxide/api'
import { useUsersNotInPolicy } from '@oxide/api'
import { setUserRole } from '@oxide/api'
import { useApiQueryClient } from '@oxide/api'
import { useApiMutation } from '@oxide/api'
import { useApiQuery } from '@oxide/api'
import { capitalize } from '@oxide/util'

import { Form, ListboxField, SideModalForm } from 'app/components/form'
import { useParams } from 'app/hooks'
Expand All @@ -22,13 +23,7 @@ const initialValues: AddUserValues = {
roleName: '',
}

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

const roles: RoleItem[] = [
{ value: 'admin', label: 'Admin' },
{ value: 'collaborator', label: 'Collaborator' },
{ value: 'viewer', label: 'Viewer' },
]
const roleItems = projectRoles.map((role) => ({ value: role, label: capitalize(role) }))

type AddRoleModalProps = CreateSideModalFormProps<AddUserValues, ProjectRolePolicy> & {
policy: ProjectRolePolicy
Expand All @@ -42,18 +37,9 @@ export function ProjectAccessAddUserSideModal({
...props
}: AddRoleModalProps) {
const projectParams = useParams('orgName', 'projectName')
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 users = useUsersNotInPolicy(policy)
const userItems = users.map((u) => ({ value: u.id, label: u.name }))

const queryClient = useApiQueryClient()
const updatePolicy = useApiMutation('organizationProjectsPutProjectPolicy', {
Expand Down Expand Up @@ -92,7 +78,7 @@ export function ProjectAccessAddUserSideModal({
{...props}
>
<ListboxField id="userId" name="userId" items={userItems} label="User" required />
<ListboxField id="roleName" name="roleName" label="Role" items={roles} required />
<ListboxField id="roleName" name="roleName" label="Role" items={roleItems} required />
<Form.Submit>Add user</Form.Submit>
</SideModalForm>
)
Expand Down Expand Up @@ -145,7 +131,7 @@ export function ProjectAccessEditUserSideModal({
error={updatePolicy.error?.error as Error | undefined}
{...props}
>
<ListboxField id="roleName" name="roleName" label="Role" items={roles} required />
<ListboxField id="roleName" name="roleName" label="Role" items={roleItems} required />
<Form.Submit>Update role</Form.Submit>
</SideModalForm>
)
Expand Down
9 changes: 8 additions & 1 deletion app/layouts/OrgLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,30 @@ import {
PageContainer,
} from './helpers'

// absolute paths are a workaround for
// https://github.com/remix-run/react-router/pull/8985 not being released yet

const OrgLayout = () => {
const { orgName } = useParams('orgName')
const { data: projects } = useApiQuery('organizationProjectsGet', {
orgName,
limit: 10,
})

return (
<PageContainer>
<Sidebar>
<ProjectSelector />
<Sidebar.Nav heading="projects">
{projects?.items.map((project) => (
<NavLinkItem key={project.id} to={project.name}>
<NavLinkItem key={project.id} to={`/orgs/${orgName}/projects/${project.name}`}>
{project.name}
</NavLinkItem>
))}
</Sidebar.Nav>
<Sidebar.Nav heading="Organization">
<NavLinkItem to={`/orgs/${orgName}/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
110 changes: 110 additions & 0 deletions app/pages/OrgAccessPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { getCoreRowModel, useTableInstance } from '@tanstack/react-table'
import { useMemo, useState } from 'react'

import {
orgRoleOrder,
setUserRole,
useApiMutation,
useApiQueryClient,
useUserAccessRows,
} from '@oxide/api'
import type { OrganizationRole, UserAccessRow } 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 { OrgAccessAddUserSideModal, OrgAccessEditUserSideModal } from 'app/forms/org-access'
import { useParams } from 'app/hooks'

type UserRow = UserAccessRow<OrganizationRole>

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

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)

const rows = useUserAccessRows(policy, orgRoleOrder)

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} />
</>
)
}
Loading