Skip to content
Open
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
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>
)
72 changes: 72 additions & 0 deletions app/components/GroupMembersModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* 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 } from '~/api'
import { Modal } from '~/ui/lib/Modal'
import { Spinner } from '~/ui/lib/Spinner'
import { ALL_ISH } from '~/util/consts'

type GroupMembersModalProps = {
groupId: string
groupName: string
onDismiss: () => void
}

export const GroupMembersModal = ({
groupId,
groupName,
onDismiss,
}: GroupMembersModalProps) => {
const { data: users, isLoading } = useQuery(
q(api.userList, { query: { group: groupId, limit: ALL_ISH } })
)

const hasMore = users ? !!users.nextPage : false

return (
<Modal isOpen title={`Members of ${groupName}`} onDismiss={onDismiss}>
<Modal.Body>
<Modal.Section>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner />
</div>
) : !users ? (
<div className="text-secondary">Failed to load members</div>
) : users.items.length === 0 ? (
<div className="text-secondary">No members in this group</div>
) : (
<>
{hasMore && (
<div className="text-sans-md text-secondary mb-3">
These are the first {users.items.length.toLocaleString()} results
returned.
</div>
)}
<ul className="flex flex-col gap-2">
{users.items.map((user) => (
<li key={user.id} className="text-default">
{user.displayName}
</li>
))}
</ul>
</>
)}
</Modal.Section>
</Modal.Body>
<Modal.Footer
onDismiss={onDismiss}
actionText="Close"
onAction={onDismiss}
showCancel={false}
/>
</Modal>
)
}
Loading
Loading