Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0b29881
First pass with tabs on access pages
charliepark Dec 2, 2025
8d3a4f0
Refactoring
charliepark Dec 2, 2025
a4fc3be
Add test
charliepark Dec 2, 2025
bedc0b9
copy
charliepark Dec 3, 2025
b6db222
Refactoring
charliepark Dec 4, 2025
69a0c13
revert roleScope to roleSource for now
charliepark Dec 4, 2025
c1a13ef
Refactor tabs
charliepark Dec 5, 2025
261492e
Split again so hooks work correctly
charliepark Dec 5, 2025
5e130c5
More refactoring
charliepark Dec 5, 2025
1c1caaf
Post review adjustments
charliepark Dec 5, 2025
e0b79a5
Refactor
charliepark Dec 5, 2025
c60cc4a
toast cleanup
charliepark Dec 6, 2025
a9b8888
string cleanup
charliepark Dec 6, 2025
4573ba0
tweaks
charliepark Dec 7, 2025
954bc46
More deduping and cleanup
charliepark Dec 7, 2025
010424b
a little more cleanup
charliepark Dec 7, 2025
7abcc89
Refactor forms to show filtered lists based on tab
charliepark Dec 8, 2025
d290af5
formatting
charliepark Dec 8, 2025
5ea2466
Adjust imports
charliepark Dec 8, 2025
9c4ec12
Remove extraneous comments
charliepark Dec 8, 2025
0b5b170
Merge branch 'main' into access_tabs
charliepark Dec 11, 2025
ef15ce6
Add modal showing group member list
charliepark Dec 11, 2025
371f570
Add in some forgotten files
charliepark Dec 12, 2025
04465d4
A few post-review adjustments
charliepark Dec 12, 2025
4aa13b3
Rename tests to remove warning
charliepark Dec 12, 2025
52250b9
linter and formatting fix
charliepark Dec 12, 2025
3cd8f1a
Merge branch 'main' into access_tabs
charliepark Dec 19, 2025
ae2e084
Remove n+1 query from Groups tables
charliepark Dec 19, 2025
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
34 changes: 26 additions & 8 deletions app/api/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import { useMemo } from 'react'
import * as R from 'remeda'

import type { IdentityFilter } from '~/util/access'

import type { FleetRole, IdentityType, ProjectRole, SiloRole } from './__generated__/Api'
import { api, q, usePrefetchedQuery } from './client'

Expand All @@ -23,6 +25,11 @@ import { api, q, usePrefetchedQuery } from './client'
*/
export type RoleKey = FleetRole | SiloRole | ProjectRole

/**
* The source of a role assignment (silo-level or project-level).
*/
export type RoleSource = 'silo' | 'project'

/** Turn a role order record into a sorted array of strings. */
// used for displaying lists of roles, like in a <select>
const flatRoles = (roleOrder: Record<RoleKey, number>): RoleKey[] =>
Expand Down Expand Up @@ -77,12 +84,12 @@ export function deleteRole(identityId: string, policy: Policy): Policy {
return { roleAssignments }
}

type UserAccessRow = {
export type UserAccessRow = {
id: string
identityType: IdentityType
name: string
roleName: RoleKey
roleSource: string
roleSource: RoleSource
}

/**
Expand All @@ -94,7 +101,7 @@ type UserAccessRow = {
*/
export function useUserRows(
roleAssignments: RoleAssignment[],
roleSource: string
roleSource: RoleSource
): UserAccessRow[] {
// 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
Expand Down Expand Up @@ -134,9 +141,9 @@ export type Actor = {

/**
* Fetch lists of users and groups, filtering out the ones that are already in
* the given policy.
* the given policy. Optionally filter to only users or only groups.
*/
export function useActorsNotInPolicy(policy: Policy): Actor[] {
export function useActorsNotInPolicy(policy: Policy, filter?: IdentityFilter): Actor[] {
const { data: users } = usePrefetchedQuery(q(api.userList, {}))
const { data: groups } = usePrefetchedQuery(q(api.groupList, {}))
return useMemo(() => {
Expand All @@ -150,9 +157,20 @@ export function useActorsNotInPolicy(policy: Policy): Actor[] {
...u,
identityType: 'silo_user' as IdentityType,
}))
// groups go before users
return allGroups.concat(allUsers).filter((u) => !actorsInPolicy.has(u.id)) || []
}, [users, groups, policy])

// Select which actors to include based on filter
let actors: Actor[]
if (filter === 'users') {
actors = allUsers
} else if (filter === 'groups') {
actors = allGroups
} else {
// 'all' or undefined; groups go before users
actors = allGroups.concat(allUsers)
}

return actors.filter((u) => !actorsInPolicy.has(u.id))
}, [policy, users, groups, filter])
}

export function userRoleFromPolicies(
Expand Down
31 changes: 31 additions & 0 deletions app/components/AccessEmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import type { RoleSource } from '@oxide/api'
import { Access24Icon } from '@oxide/design-system/icons/react'

import { EmptyMessage } from '~/ui/lib/EmptyMessage'
import { TableEmptyBox } from '~/ui/lib/Table'
import { identityFilterLabel, type IdentityFilter } from '~/util/access'

type AccessEmptyStateProps = {
onClick: () => void
scope: RoleSource
filter: IdentityFilter
}

export const AccessEmptyState = ({ onClick, scope, filter }: AccessEmptyStateProps) => (
<TableEmptyBox>
<EmptyMessage
icon={<Access24Icon />}
title={`No authorized ${filter === 'all' ? 'users or groups' : filter}`}
body={`Give permission to view, edit, or administer this ${scope}`}
buttonText={`Add ${identityFilterLabel[filter]} to ${scope}`}
onClick={onClick}
/>
</TableEmptyBox>
)
268 changes: 268 additions & 0 deletions app/components/ProjectAccessTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { useMemo, useState, type ReactNode } from 'react'
import * as R from 'remeda'

import {
api,
byGroupThenName,
deleteRole,
q,
queryClient,
roleOrder,
useApiMutation,
usePrefetchedQuery,
useUserRows,
type IdentityType,
type Policy,
type RoleKey,
type RoleSource,
type UserAccessRow,
} from '@oxide/api'
import { Badge } from '@oxide/design-system/ui'

import { AccessEmptyState } from '~/components/AccessEmptyState'
import { HL } from '~/components/HL'
import { ListPlusCell } from '~/components/ListPlusCell'
import {
ProjectAccessAddUserSideModal,
ProjectAccessEditUserSideModal,
} from '~/forms/project-access'
import { useProjectSelector } from '~/hooks/use-params'
import { confirmDelete } from '~/stores/confirm-delete'
import { addToast } from '~/stores/toast'
import { getActionsCol } from '~/table/columns/action-col'
import { Table } from '~/table/Table'
import { CreateButton } from '~/ui/lib/CreateButton'
import { TableActions } from '~/ui/lib/Table'
import { TipIcon } from '~/ui/lib/TipIcon'
import {
filterByIdentityType,
identityFilterLabel,
identityTypeLabel,
roleColor,
type IdentityFilter,
} from '~/util/access'
import { groupBy } from '~/util/array'

type ProjectAccessRow = {
id: string
identityType: IdentityType
name: string
// projectRole is optional because users can appear in this table with only inherited silo roles.
// Actions that modify project-level roles check this field to determine if they should be disabled.
projectRole: RoleKey | undefined
roleBadges: { roleSource: RoleSource; roleName: RoleKey }[]
}

type ProjectAccessTabProps = {
filter: IdentityFilter
children?: ReactNode
}

function useProjectAccessRows(
siloRows: UserAccessRow[],
projectRows: UserAccessRow[],
filter: IdentityFilter
): ProjectAccessRow[] {
return useMemo(() => {
const rows = groupBy(siloRows.concat(projectRows), (u) => u.id).map(
([userId, userAssignments]) => {
const { name, identityType } = userAssignments[0]
const siloAccessRow = userAssignments.find((a) => a.roleSource === 'silo')
const projectAccessRow = userAssignments.find((a) => a.roleSource === 'project')

// Filter out undefined values, then map to expected shape
const roleBadges = R.sortBy(
[siloAccessRow, projectAccessRow].filter(
(r): r is UserAccessRow => r !== undefined
),
(r) => roleOrder[r.roleName] // sorts strongest role first
).map((r) => ({
roleSource: r.roleSource,
roleName: r.roleName,
}))

return {
id: userId,
identityType,
name,
projectRole: projectAccessRow?.roleName,
roleBadges,
} satisfies ProjectAccessRow
}
)

return filterByIdentityType(rows, filter).sort(byGroupThenName)
}, [siloRows, projectRows, filter])
}

/**
* Message explaining that an inherited silo role cannot be modified at the project level
*/
const getInheritedRoleMessage = (action: 'change' | 'delete', identityType: IdentityType) =>
`Cannot ${action} inherited silo role. This ${identityTypeLabel[identityType].toLowerCase()}'s role is set at the silo level.`

function ProjectAccessTable({
filter,
rows,
policy,
projectName,
onEditRow,
}: {
filter: IdentityFilter
rows: ProjectAccessRow[]
policy: Policy
projectName: string
onEditRow: (row: ProjectAccessRow) => void
}) {
const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, {
onSuccess: () => {
queryClient.invalidateEndpoint('projectPolicyView')
},
})

const columns = useMemo(() => {
const colHelper = createColumnHelper<ProjectAccessRow>()

return [
colHelper.accessor('name', { header: 'Name' }),
// TODO: Add lastAccessed column for users once API provides it.
...(filter === 'all'
? [
colHelper.accessor('identityType', {
header: 'Type',
cell: (info) => identityTypeLabel[info.getValue()],
}),
]
: []),
colHelper.accessor('roleBadges', {
header: () => (
<span className="inline-flex items-center">
Role
<TipIcon className="ml-2">
A {identityFilterLabel[filter]}&apos;s effective role for this project is the
strongest role on either the silo or project
</TipIcon>
</span>
),
cell: (info) => (
<ListPlusCell tooltipTitle="Other roles">
{info.getValue().map(({ roleName, roleSource }) => (
<Badge key={roleSource} color={roleColor[roleName]}>
{roleSource}.{roleName}
</Badge>
))}
</ListPlusCell>
),
}),
getActionsCol((row: ProjectAccessRow) => [
{
label: 'Change role',
onActivate: () => onEditRow(row),
disabled: !row.projectRole && getInheritedRoleMessage('change', row.identityType),
},
{
label: 'Delete',
// TODO: explain that delete will not affect the role inherited from the silo or
// roles inherited from group membership. Ideally we'd be able to say: this will
// cause the user to have an effective role of X. However we would have to look at
// their groups too.
onActivate: confirmDelete({
doDelete: async () => {
await updatePolicy({
path: { project: projectName },
body: deleteRole(row.id, policy),
})
addToast({ content: 'Access removed' })
},
label: (
<span>
the <HL>{row.projectRole}</HL> role for <HL>{row.name}</HL>
</span>
),
}),
disabled: !row.projectRole && getInheritedRoleMessage('delete', row.identityType),
},
]),
]
}, [filter, policy, projectName, updatePolicy, onEditRow])

const tableInstance = useReactTable<ProjectAccessRow>({
columns,
data: rows,
getCoreRowModel: getCoreRowModel(),
})

return <Table table={tableInstance} />
}

/**
* Access control tab for project-level permissions.
* Displays users and groups with their project and inherited silo roles,
* and allows adding/editing/deleting role assignments.
*/
export function ProjectAccessTab({ filter, children }: ProjectAccessTabProps) {
const [addModalOpen, setAddModalOpen] = useState(false)
const [editingRow, setEditingRow] = useState<ProjectAccessRow | null>(null)

const { project } = useProjectSelector()

const { data: siloPolicy } = usePrefetchedQuery(q(api.policyView, {}))
const { data: projectPolicy } = usePrefetchedQuery(
q(api.projectPolicyView, { path: { project } })
)

const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo')
const projectRows = useUserRows(projectPolicy.roleAssignments, 'project')
const rows = useProjectAccessRows(siloRows, projectRows, filter)

const addButtonText = `Add ${identityFilterLabel[filter]}`

return (
<>
<TableActions>
<CreateButton onClick={() => setAddModalOpen(true)}>{addButtonText}</CreateButton>
</TableActions>
{projectPolicy && addModalOpen && (
<ProjectAccessAddUserSideModal
onDismiss={() => setAddModalOpen(false)}
policy={projectPolicy}
filter={filter}
/>
)}
{projectPolicy && editingRow && editingRow.projectRole && (
<ProjectAccessEditUserSideModal
onDismiss={() => setEditingRow(null)}
policy={projectPolicy}
name={editingRow.name}
identityId={editingRow.id}
identityType={editingRow.identityType}
defaultValues={{ roleName: editingRow.projectRole }}
/>
)}
{children}
{rows.length === 0 ? (
<AccessEmptyState
scope="project"
filter={filter}
onClick={() => setAddModalOpen(true)}
/>
) : (
<ProjectAccessTable
filter={filter}
rows={rows}
policy={projectPolicy}
projectName={project}
onEditRow={setEditingRow}
/>
)}
</>
)
}
Loading
Loading