Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c59c55a
Add access tabs showing all users and groups
charliepark Mar 5, 2026
2df92c6
Add role and member count to users and groups tabs
charliepark Mar 5, 2026
74708fc
add docs link, better microcopy
charliepark Mar 5, 2026
f0dfa91
Roles tab is redundant, with Users / Groups present
charliepark Mar 5, 2026
f8785c8
Add tooltip to show source of role when it comes from group
charliepark Mar 5, 2026
1a6461c
Update tests
charliepark Mar 6, 2026
2b61971
Updated styling on sidebars
charliepark Mar 6, 2026
b959e59
Use consistent subtitle style for group sidebars
charliepark Mar 6, 2026
ff357a6
Merge branch 'main' into full_user_group_lists
charliepark Mar 7, 2026
7f05526
Add time_created column
charliepark Mar 7, 2026
7d966e2
stub out user sidebars for more info, tabs
charliepark Mar 7, 2026
c18f2d7
Merge main and resolve conflict
charliepark Mar 11, 2026
c83f6ea
Add additional data to User and Group sidebars
charliepark Mar 11, 2026
d40e4c0
Updates to sidebar and roles table
charliepark Mar 12, 2026
35c79bd
Refactor / consolidate
charliepark Mar 12, 2026
7de7785
More refactoring, plus remeda
charliepark Mar 12, 2026
f0cadbf
Add button to copy IDs of Users, Groups, though will try to set up li…
charliepark Mar 12, 2026
997b892
Merge branch 'main' into full_user_group_lists
charliepark Mar 16, 2026
f663d9d
Add Users & Groups nav
charliepark Mar 16, 2026
e852345
Refactor
charliepark Mar 16, 2026
8ca63fd
Fix broken access form
charliepark Mar 16, 2026
6423d4b
merge main and resolve conflicts
charliepark Mar 18, 2026
e6304ed
merge main and resolve conflicts (again)
charliepark Mar 18, 2026
7ccc0fc
Remove Users & Groups from Project page / nav
charliepark Mar 19, 2026
9b0710d
Don't show all users on access pages; only assigned, inherited from s…
charliepark Mar 19, 2026
a14f499
Merge branch 'main' into full_user_group_lists
charliepark Mar 19, 2026
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
1 change: 0 additions & 1 deletion app/api/__tests__/safety.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ it('mock-api is only referenced in test files', () => {
"test/e2e/ip-pool-silo-config.e2e.ts",
"test/e2e/profile.e2e.ts",
"test/e2e/project-access.e2e.ts",
"test/e2e/silo-access.e2e.ts",
"tsconfig.json",
]
`)
Expand Down
63 changes: 18 additions & 45 deletions app/api/roles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,68 +82,41 @@ const user1 = {
const groups = [{ id: 'group1' }, { id: 'group2' }]

describe('getEffectiveRole', () => {
it('returns null when there are no policies', () => {
expect(userRoleFromPolicies(user1, groups, [])).toBe(null)
})

it('returns null when there are no roles', () => {
expect(userRoleFromPolicies(user1, groups, [{ roleAssignments: [] }])).toBe(null)
expect(userRoleFromPolicies(user1, groups, { roleAssignments: [] })).toBe(null)
})

it('returns role if user matches directly', () => {
expect(
userRoleFromPolicies(user1, groups, [
{
roleAssignments: [
{ identityId: 'user1', identityType: 'silo_user', roleName: 'admin' },
],
},
])
userRoleFromPolicies(user1, groups, {
roleAssignments: [
{ identityId: 'user1', identityType: 'silo_user', roleName: 'admin' },
],
})
).toEqual('admin')
})

it('returns strongest role if both group and user match', () => {
expect(
userRoleFromPolicies(user1, groups, [
{
roleAssignments: [
{ identityId: 'user1', identityType: 'silo_user', roleName: 'viewer' },
{ identityId: 'group1', identityType: 'silo_group', roleName: 'collaborator' },
],
},
])
userRoleFromPolicies(user1, groups, {
roleAssignments: [
{ identityId: 'user1', identityType: 'silo_user', roleName: 'viewer' },
{ identityId: 'group1', identityType: 'silo_group', roleName: 'collaborator' },
],
})
).toEqual('collaborator')
})

it('ignores groups and users that do not match', () => {
expect(
userRoleFromPolicies(user1, groups, [
{
roleAssignments: [
{ identityId: 'other', identityType: 'silo_user', roleName: 'viewer' },
{ identityId: 'group3', identityType: 'silo_group', roleName: 'viewer' },
],
},
])
userRoleFromPolicies(user1, groups, {
roleAssignments: [
{ identityId: 'other', identityType: 'silo_user', roleName: 'viewer' },
{ identityId: 'group3', identityType: 'silo_group', roleName: 'viewer' },
],
})
).toEqual(null)
})

it('resolves multiple policies', () => {
expect(
userRoleFromPolicies(user1, groups, [
{
roleAssignments: [
{ identityId: 'user1', identityType: 'silo_user', roleName: 'viewer' },
],
},
{
roleAssignments: [
{ identityId: 'group1', identityType: 'silo_group', roleName: 'admin' },
],
},
])
).toEqual('admin')
})
})

test('byGroupThenName sorts as expected', () => {
Expand Down
128 changes: 82 additions & 46 deletions app/api/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,19 @@
* layer and not in app/ because we are experimenting with it to decide whether
* it belongs in the API proper.
*/
import { useMemo } from 'react'
import { useQueries } from '@tanstack/react-query'
import { useMemo, useRef } from 'react'
import * as R from 'remeda'

import type { FleetRole, IdentityType, ProjectRole, SiloRole } from './__generated__/Api'
import { ALL_ISH } from '~/util/consts'

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

/**
Expand Down Expand Up @@ -76,6 +85,13 @@ export function updateRole<Role extends RoleKey>(
return { roleAssignments }
}

/** Map from identity ID to role name for quick lookup. */
export function rolesByIdFromPolicy<Role extends RoleKey>(
policy: Policy<Role>
): Map<string, Role> {
return new Map(policy.roleAssignments.map((a) => [a.identityId, a.roleName]))
}

/**
* Delete any role assignments for user or group ID. Returns a new updated
* policy. Does not modify the passed-in policy.
Expand All @@ -90,47 +106,6 @@ export function deleteRole<Role extends RoleKey>(
return { roleAssignments }
}

type UserAccessRow<Role extends RoleKey = RoleKey> = {
id: string
identityType: IdentityType
name: string
roleName: Role
roleSource: string
}

/**
* Role assignments come from the API in (user, role) pairs without display
* names and without info about which resource the role came from. This tags
* each row with that info. It has to be a hook because it depends on the result
* of an API request for the list of users. It's a bit awkward, but the logic is
* identical between projects and orgs so it is worth sharing.
*/
export function useUserRows<Role extends RoleKey = RoleKey>(
roleAssignments: RoleAssignment<Role>[],
roleSource: string
): UserAccessRow<Role>[] {
// 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 { data: users } = usePrefetchedQuery(q(api.userList, {}))
const { data: groups } = usePrefetchedQuery(q(api.groupList, {}))
return useMemo(() => {
const userItems = users?.items || []
const groupItems = groups?.items || []
const usersDict = Object.fromEntries(userItems.concat(groupItems).map((u) => [u.id, u]))
return roleAssignments.map((ra) => ({
id: ra.identityId,
identityType: ra.identityType,
// A user might not appear here if they are not in the current user's
// silo. This could happen in a fleet policy, which might have users from
// different silos. Hence the ID fallback. The code that displays this
// detects when we've fallen back and includes an explanatory tooltip.
name: usersDict[ra.identityId]?.displayName || ra.identityId,
roleName: ra.roleName,
roleSource,
}))
}, [roleAssignments, roleSource, users, groups])
}

type SortableUserRow = { identityType: IdentityType; name: string }

/**
Expand Down Expand Up @@ -177,12 +152,73 @@ export function useActorsNotInPolicy<Role extends RoleKey = RoleKey>(
export function userRoleFromPolicies(
user: { id: string },
groups: { id: string }[],
policies: Policy[]
policy: Policy
): RoleKey | null {
const myIds = new Set([user.id, ...groups.map((g) => g.id)])
const myRoles = policies
.flatMap((p) => p.roleAssignments) // concat all the role assignments together
const myRoles = policy.roleAssignments
.filter((ra) => myIds.has(ra.identityId))
.map((ra) => ra.roleName)
return getEffectiveRole(myRoles) || null
}

export type ScopedRoleEntry = {
roleName: RoleKey
source: { type: 'direct' } | { type: 'group'; group: { id: string; displayName: string } }
}

/**
* Enumerate all role assignments relevant to a user — one entry per direct
* assignment and one per group assignment — from the silo policy.
* Callers are responsible for sorting and any display-layer merging.
*/
export function userScopedRoleEntries(
userId: string,
userGroups: { id: string; displayName: string }[],
policy: Policy
): ScopedRoleEntry[] {
const entries: ScopedRoleEntry[] = []
const direct = policy.roleAssignments.find((ra) => ra.identityId === userId)
if (direct) entries.push({ roleName: direct.roleName, source: { type: 'direct' } })
for (const group of userGroups) {
const via = policy.roleAssignments.find((ra) => ra.identityId === group.id)
if (via) entries.push({ roleName: via.roleName, source: { type: 'group', group } })
}
return entries
}

/**
* Builds a map from user ID to the list of groups that user belongs to,
* firing one query per group to fetch members. Shared between user tabs.
*/
export function useGroupsByUserId(groups: Group[]): Map<string, Group[]> {
const groupMemberQueries = useQueries({
queries: groups.map((g) => q(api.userList, { query: { group: g.id, limit: ALL_ISH } })),
})

// Use refs to return a stable Map reference when the underlying data hasn't
// changed. Without this, a new Map on every render causes downstream useMemos
// to recompute continuously, which destabilizes table rows in Playwright.
const mapRef = useRef<Map<string, Group[]>>(new Map())
const versionRef = useRef<string>('')

const version = [
groups.map((g) => g.id).join(','),
...groupMemberQueries.map((q) => q.dataUpdatedAt),
].join('|')

if (version !== versionRef.current) {
versionRef.current = version
const map = new Map<string, Group[]>()
groups.forEach((group, i) => {
const members = groupMemberQueries[i]?.data?.items ?? []
members.forEach((member) => {
const existing = map.get(member.id)
if (existing) existing.push(group)
else map.set(member.id, [group])
})
})
mapRef.current = map
}

return mapRef.current
}
108 changes: 108 additions & 0 deletions app/components/access/GroupMembersSideModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* 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 { useQuery } from '@tanstack/react-query'

import { api, q, type Group, type Policy, type User } from '@oxide/api'
import { PersonGroup16Icon, PersonGroup24Icon } from '@oxide/design-system/icons/react'
import { Badge } from '@oxide/design-system/ui'

import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm'
import { RowActions } from '~/table/columns/action-col'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
import { ResourceLabel } from '~/ui/lib/SideModal'
import { Table } from '~/ui/lib/Table'
import { roleColor } from '~/util/access'
import { ALL_ISH } from '~/util/consts'

type Props = {
group: Group
onDismiss: () => void
policy: Policy
}

export function GroupMembersSideModal({ group, onDismiss, policy }: Props) {
const { data } = useQuery(q(api.userList, { query: { group: group.id, limit: ALL_ISH } }))
const members = data?.items ?? []

const assignment = policy.roleAssignments.find((ra) => ra.identityId === group.id)

return (
<ReadOnlySideModalForm
title="Group"
subtitle={
<ResourceLabel>
<PersonGroup16Icon /> {group.displayName}
</ResourceLabel>
}
onDismiss={onDismiss}
animate
>
<PropertiesTable>
<PropertiesTable.IdRow id={group.id} />
<PropertiesTable.DateRow label="Created" date={group.timeCreated} />
</PropertiesTable>
<div className="mt-6">
<table className="ox-table text-sans-md w-full border-separate">
<Table.Header>
<Table.HeaderRow>
<Table.HeadCell>Role</Table.HeadCell>
<Table.HeadCell>Source</Table.HeadCell>
</Table.HeaderRow>
</Table.Header>
<Table.Body>
{!assignment ? (
<Table.Row>
<Table.Cell colSpan={2} className="text-secondary">
No roles assigned
</Table.Cell>
</Table.Row>
) : (
<Table.Row>
<Table.Cell>
<Badge color={roleColor[assignment.roleName]}>
silo.{assignment.roleName}
</Badge>
</Table.Cell>
<Table.Cell>Assigned</Table.Cell>
</Table.Row>
)}
</Table.Body>
</table>
</div>
<div className="mt-6">
{members.length === 0 ? (
<EmptyMessage
icon={<PersonGroup24Icon />}
title="No members"
body="This group has no members"
/>
) : (
<table className="ox-table text-sans-md w-full border-separate">
<Table.Header>
<Table.HeaderRow>
<Table.HeadCell>Members</Table.HeadCell>
<Table.HeadCell />
</Table.HeaderRow>
</Table.Header>
<Table.Body>
{members.map((member: User) => (
<Table.Row key={member.id}>
<Table.Cell>{member.displayName}</Table.Cell>
<Table.Cell className="action-col w-10 *:p-0">
<RowActions id={member.id} />
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</table>
)}
</div>
</ReadOnlySideModalForm>
)
}
Loading
Loading