Skip to content

Commit 9ff81c7

Browse files
authored
Roles page for orgs (#948)
* roles page for orgs * share user row aggregation logic * extract useUsersNotInPolicy * add orgs e2e test, get dropdown items directly from role order * comments for organization * use absolute paths to work around RR bug
1 parent 287890f commit 9ff81c7

File tree

9 files changed

+480
-61
lines changed

9 files changed

+480
-61
lines changed

app/forms/org-access.tsx

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import * as Yup from 'yup'
2+
3+
import type { OrganizationRole, OrganizationRolePolicy } from '@oxide/api'
4+
import { orgRoles, useUsersNotInPolicy } from '@oxide/api'
5+
import { setUserRole } from '@oxide/api'
6+
import { useApiQueryClient } from '@oxide/api'
7+
import { useApiMutation } from '@oxide/api'
8+
import { capitalize } from '@oxide/util'
9+
10+
import { Form, ListboxField, SideModalForm } from 'app/components/form'
11+
import { useParams } from 'app/hooks'
12+
13+
import type { CreateSideModalFormProps, EditSideModalFormProps } from '.'
14+
15+
type AddUserValues = {
16+
userId: string
17+
roleName: OrganizationRole | ''
18+
}
19+
20+
const initialValues: AddUserValues = {
21+
userId: '',
22+
roleName: '',
23+
}
24+
25+
const roleItems = orgRoles.map((role) => ({ value: role, label: capitalize(role) }))
26+
27+
type AddRoleModalProps = CreateSideModalFormProps<AddUserValues, OrganizationRolePolicy> & {
28+
policy: OrganizationRolePolicy
29+
}
30+
31+
export function OrgAccessAddUserSideModal({
32+
onSubmit,
33+
onSuccess,
34+
onDismiss,
35+
policy,
36+
...props
37+
}: AddRoleModalProps) {
38+
const orgParams = useParams('orgName')
39+
40+
const users = useUsersNotInPolicy(policy)
41+
const userItems = users.map((u) => ({ value: u.id, label: u.name }))
42+
43+
const queryClient = useApiQueryClient()
44+
const updatePolicy = useApiMutation('organizationPutPolicy', {
45+
onSuccess: (data) => {
46+
queryClient.invalidateQueries('organizationGetPolicy', orgParams)
47+
onSuccess?.(data)
48+
onDismiss()
49+
},
50+
})
51+
52+
return (
53+
<SideModalForm
54+
onDismiss={onDismiss}
55+
title="Add user to organization"
56+
id="org-access-add-user"
57+
initialValues={initialValues}
58+
onSubmit={
59+
onSubmit ||
60+
(({ userId, roleName }) => {
61+
// can't happen because roleName is validated not to be '', but TS
62+
// wants to be sure
63+
if (roleName === '') return
64+
65+
updatePolicy.mutate({
66+
...orgParams,
67+
body: setUserRole(userId, roleName, policy),
68+
})
69+
})
70+
}
71+
validationSchema={Yup.object({
72+
userId: Yup.string().required(),
73+
roleName: Yup.string().required(),
74+
})}
75+
submitDisabled={updatePolicy.isLoading}
76+
error={updatePolicy.error?.error as Error | undefined}
77+
{...props}
78+
>
79+
<ListboxField id="userId" name="userId" items={userItems} label="User" required />
80+
<ListboxField id="roleName" name="roleName" label="Role" items={roleItems} required />
81+
<Form.Submit>Add user</Form.Submit>
82+
</SideModalForm>
83+
)
84+
}
85+
86+
type EditUserValues = {
87+
roleName: OrganizationRole
88+
}
89+
90+
type EditRoleModalProps = EditSideModalFormProps<EditUserValues, OrganizationRolePolicy> & {
91+
userId: string
92+
policy: OrganizationRolePolicy
93+
}
94+
95+
export function OrgAccessEditUserSideModal({
96+
onSubmit,
97+
onSuccess,
98+
onDismiss,
99+
userId,
100+
policy,
101+
...props
102+
}: EditRoleModalProps) {
103+
const orgParams = useParams('orgName')
104+
105+
const queryClient = useApiQueryClient()
106+
const updatePolicy = useApiMutation('organizationPutPolicy', {
107+
onSuccess: (data) => {
108+
queryClient.invalidateQueries('organizationGetPolicy', orgParams)
109+
onSuccess?.(data)
110+
onDismiss()
111+
},
112+
})
113+
114+
return (
115+
<SideModalForm
116+
onDismiss={onDismiss}
117+
// TODO: show user name in header or SOMEWHERE
118+
title="Change user role"
119+
id="org-access-edit-user"
120+
onSubmit={
121+
onSubmit ||
122+
(({ roleName }) => {
123+
updatePolicy.mutate({
124+
...orgParams,
125+
body: setUserRole(userId, roleName, policy),
126+
})
127+
})
128+
}
129+
submitDisabled={updatePolicy.isLoading || !policy}
130+
error={updatePolicy.error?.error as Error | undefined}
131+
{...props}
132+
>
133+
<ListboxField id="roleName" name="roleName" label="Role" items={roleItems} required />
134+
<Form.Submit>Update role</Form.Submit>
135+
</SideModalForm>
136+
)
137+
}

app/forms/project-access.tsx

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import * as Yup from 'yup'
2-
import { useMemo } from 'react'
32

43
import type { ProjectRole, ProjectRolePolicy } from '@oxide/api'
4+
import { projectRoles } from '@oxide/api'
5+
import { useUsersNotInPolicy } from '@oxide/api'
56
import { setUserRole } from '@oxide/api'
67
import { useApiQueryClient } from '@oxide/api'
78
import { useApiMutation } from '@oxide/api'
8-
import { useApiQuery } from '@oxide/api'
9+
import { capitalize } from '@oxide/util'
910

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

25-
type RoleItem = { value: ProjectRole; label: string }
26-
27-
const roles: RoleItem[] = [
28-
{ value: 'admin', label: 'Admin' },
29-
{ value: 'collaborator', label: 'Collaborator' },
30-
{ value: 'viewer', label: 'Viewer' },
31-
]
26+
const roleItems = projectRoles.map((role) => ({ value: role, label: capitalize(role) }))
3227

3328
type AddRoleModalProps = CreateSideModalFormProps<AddUserValues, ProjectRolePolicy> & {
3429
policy: ProjectRolePolicy
@@ -42,18 +37,9 @@ export function ProjectAccessAddUserSideModal({
4237
...props
4338
}: AddRoleModalProps) {
4439
const projectParams = useParams('orgName', 'projectName')
45-
const { data: users } = useApiQuery('usersGet', {})
46-
47-
const userItems = useMemo(() => {
48-
// IDs are UUIDs, so no need to include identity type in set value to disambiguate
49-
const usersInPolicy = new Set(policy?.roleAssignments.map((ra) => ra.identityId) || [])
50-
return (
51-
users?.items
52-
// only show users for adding if they're not already in the policy
53-
.filter((u) => !usersInPolicy.has(u.id))
54-
.map((u) => ({ value: u.id, label: u.name })) || []
55-
)
56-
}, [users, policy])
40+
41+
const users = useUsersNotInPolicy(policy)
42+
const userItems = users.map((u) => ({ value: u.id, label: u.name }))
5743

5844
const queryClient = useApiQueryClient()
5945
const updatePolicy = useApiMutation('organizationProjectsPutProjectPolicy', {
@@ -92,7 +78,7 @@ export function ProjectAccessAddUserSideModal({
9278
{...props}
9379
>
9480
<ListboxField id="userId" name="userId" items={userItems} label="User" required />
95-
<ListboxField id="roleName" name="roleName" label="Role" items={roles} required />
81+
<ListboxField id="roleName" name="roleName" label="Role" items={roleItems} required />
9682
<Form.Submit>Add user</Form.Submit>
9783
</SideModalForm>
9884
)
@@ -145,7 +131,7 @@ export function ProjectAccessEditUserSideModal({
145131
error={updatePolicy.error?.error as Error | undefined}
146132
{...props}
147133
>
148-
<ListboxField id="roleName" name="roleName" label="Role" items={roles} required />
134+
<ListboxField id="roleName" name="roleName" label="Role" items={roleItems} required />
149135
<Form.Submit>Update role</Form.Submit>
150136
</SideModalForm>
151137
)

app/layouts/OrgLayout.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,23 +18,30 @@ import {
1818
PageContainer,
1919
} from './helpers'
2020

21+
// absolute paths are a workaround for
22+
// https://github.com/remix-run/react-router/pull/8985 not being released yet
23+
2124
const OrgLayout = () => {
2225
const { orgName } = useParams('orgName')
2326
const { data: projects } = useApiQuery('organizationProjectsGet', {
2427
orgName,
2528
limit: 10,
2629
})
30+
2731
return (
2832
<PageContainer>
2933
<Sidebar>
3034
<ProjectSelector />
3135
<Sidebar.Nav heading="projects">
3236
{projects?.items.map((project) => (
33-
<NavLinkItem key={project.id} to={project.name}>
37+
<NavLinkItem key={project.id} to={`/orgs/${orgName}/projects/${project.name}`}>
3438
{project.name}
3539
</NavLinkItem>
3640
))}
3741
</Sidebar.Nav>
42+
<Sidebar.Nav heading="Organization">
43+
<NavLinkItem to={`/orgs/${orgName}/access`}>Access &amp; IAM</NavLinkItem>
44+
</Sidebar.Nav>
3845
</Sidebar>
3946
<ContentPaneWrapper>
4047
<ContentPane>

app/pages/OrgAccessPage.tsx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { getCoreRowModel, useTableInstance } from '@tanstack/react-table'
2+
import { useMemo, useState } from 'react'
3+
4+
import {
5+
orgRoleOrder,
6+
setUserRole,
7+
useApiMutation,
8+
useApiQueryClient,
9+
useUserAccessRows,
10+
} from '@oxide/api'
11+
import type { OrganizationRole, UserAccessRow } from '@oxide/api'
12+
import { useApiQuery } from '@oxide/api'
13+
import { Table, createTable, getActionsCol } from '@oxide/table'
14+
import { Access24Icon, Badge, Button, PageHeader, PageTitle, TableActions } from '@oxide/ui'
15+
16+
import { OrgAccessAddUserSideModal, OrgAccessEditUserSideModal } from 'app/forms/org-access'
17+
import { useParams } from 'app/hooks'
18+
19+
type UserRow = UserAccessRow<OrganizationRole>
20+
21+
const table = createTable().setRowType<UserRow>()
22+
23+
export const OrgAccessPage = () => {
24+
const [addModalOpen, setAddModalOpen] = useState(false)
25+
const [editingUserRow, setEditingUserRow] = useState<UserRow | null>(null)
26+
const orgParams = useParams('orgName')
27+
const { data: policy } = useApiQuery('organizationGetPolicy', orgParams)
28+
29+
const rows = useUserAccessRows(policy, orgRoleOrder)
30+
31+
const queryClient = useApiQueryClient()
32+
const updatePolicy = useApiMutation('organizationPutPolicy', {
33+
onSuccess: () => queryClient.invalidateQueries('organizationGetPolicy', orgParams),
34+
// TODO: handle 403
35+
})
36+
37+
// TODO: checkboxes and bulk delete? not sure
38+
// TODO: disable delete on permissions you can't delete
39+
40+
const columns = useMemo(
41+
() => [
42+
table.createDataColumn('id', { header: 'ID' }),
43+
table.createDataColumn('name', { header: 'Name' }),
44+
table.createDataColumn('roleName', {
45+
header: 'Role',
46+
cell: (info) => <Badge color="neutral">{info.getValue()}</Badge>,
47+
}),
48+
table.createDisplayColumn(
49+
getActionsCol((row) => [
50+
{
51+
label: 'Change role',
52+
onActivate: () => setEditingUserRow(row),
53+
},
54+
// TODO: only show if you have permission to do this
55+
{
56+
label: 'Delete',
57+
onActivate() {
58+
// TODO: confirm delete
59+
updatePolicy.mutate({
60+
...orgParams,
61+
// we know policy is there, otherwise there's no row to display
62+
body: setUserRole(row.id, null, policy!),
63+
})
64+
},
65+
},
66+
])
67+
),
68+
],
69+
[policy, orgParams, updatePolicy]
70+
)
71+
72+
const tableInstance = useTableInstance(table, {
73+
columns,
74+
data: rows,
75+
getCoreRowModel: getCoreRowModel(),
76+
})
77+
78+
return (
79+
<>
80+
<PageHeader>
81+
<PageTitle icon={<Access24Icon />}>Access &amp; IAM</PageTitle>
82+
</PageHeader>
83+
84+
<TableActions>
85+
<Button size="xs" variant="secondary" onClick={() => setAddModalOpen(true)}>
86+
Add user to organization
87+
</Button>
88+
</TableActions>
89+
{policy && (
90+
<OrgAccessAddUserSideModal
91+
isOpen={addModalOpen}
92+
onDismiss={() => setAddModalOpen(false)}
93+
onSuccess={() => setAddModalOpen(false)}
94+
policy={policy}
95+
/>
96+
)}
97+
{policy && editingUserRow && (
98+
<OrgAccessEditUserSideModal
99+
isOpen={!!editingUserRow}
100+
onDismiss={() => setEditingUserRow(null)}
101+
onSuccess={() => setEditingUserRow(null)}
102+
policy={policy}
103+
userId={editingUserRow.id}
104+
initialValues={{ roleName: editingUserRow.roleName }}
105+
/>
106+
)}
107+
<Table table={tableInstance} />
108+
</>
109+
)
110+
}

0 commit comments

Comments
 (0)