diff --git a/AGENTS.md b/AGENTS.md index 92ca0e08b8..351f85fc6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,6 +90,7 @@ - Breadcrumbs come from route `handle.crumb`; use `makeCrumb`/`titleCrumb` and provide a `path` when the parent route redirects (`app/hooks/use-crumbs.ts`). Use `titleCrumb` for side modal forms that should appear in page title but not nav breadcrumbs (check `Crumb.titleOnly` flag). - When adding tabs or redirects, wire the canonical link in the path builder (e.g., point to the default tab) and update the sidebar/quick actions as needed. - For tabs synced with the URL, use `QueryParamTabs` (`app/components/QueryParamTabs.tsx`). +- Adding a new _top-level_ path segment (e.g., `/users`) requires a matching console page endpoint in Nexus, or direct navigation and refresh at that URL will 404. Nexus can't use a catchall route (it would overlap API routes), so each top-level console prefix is registered as an `unpublished` endpoint serving the console index: definitions in [`nexus/external-api/src/lib.rs`](https://github.com/oxidecomputer/omicron/blob/4b0ce4b/nexus/external-api/src/lib.rs#L8921-L8928), handlers in [`nexus/src/external_api/http_entrypoints.rs`](https://github.com/oxidecomputer/omicron/blob/4b0ce4b/nexus/src/external_api/http_entrypoints.rs#L8765-L8769). Routes nested under existing prefixes (`/projects/*`, `/system/*`, `/settings/*`, `/lookup/*`) need no Nexus change. # Forms diff --git a/OMICRON_VERSION b/OMICRON_VERSION index c7ee013e62..1b1a1f2823 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -6d241a7d21d11cb7979122bde9a906d8d4837d09 +26f5e91524fe0ca6c61a8c90f2321d59f323f60e diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index fc3991af55..548d33d714 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -6d241a7d21d11cb7979122bde9a906d8d4837d09 +26f5e91524fe0ca6c61a8c90f2321d59f323f60e diff --git a/app/api/__generated__/nexus-console.ts b/app/api/__generated__/nexus-console.ts index e5a59a336b..baf48a9865 100644 --- a/app/api/__generated__/nexus-console.ts +++ b/app/api/__generated__/nexus-console.ts @@ -23,6 +23,7 @@ export const nexusConsoleRoutes = [ '/access', '/device/success', '/device/verify', + '/groups/{path:.*}', '/images/{path:.*}', '/login/{silo_name}/local', '/login/{silo_name}/saml/{provider_name}', @@ -31,5 +32,6 @@ export const nexusConsoleRoutes = [ '/projects/{path:.*}', '/settings/{path:.*}', '/system/{path:.*}', + '/users/{path:.*}', '/utilization', ] diff --git a/app/api/roles.spec.ts b/app/api/roles.spec.ts index e8b44c6381..cb3bd6b011 100644 --- a/app/api/roles.spec.ts +++ b/app/api/roles.spec.ts @@ -13,8 +13,9 @@ import { deleteRole, getEffectiveRole, roleOrder, + rolesByIdFromPolicy, updateRole, - userRoleFromPolicies, + userScopedRoleEntries, type Policy, } from './roles' @@ -75,87 +76,76 @@ describe('deleteRole', () => { }) }) -const user1 = { - id: 'user1', -} - -const groups = [{ id: 'group1' }, { id: 'group2' }] +test('byGroupThenName sorts as expected', () => { + const a = { identityType: 'silo_group' as const, name: 'a' } + const b = { identityType: 'silo_group' as const, name: 'b' } + const c = { identityType: 'silo_user' as const, name: 'c' } + const d = { identityType: 'silo_user' as const, name: 'd' } + const e = { identityType: 'silo_user' as const, name: 'e' } -describe('getEffectiveRole', () => { - it('returns null when there are no policies', () => { - expect(userRoleFromPolicies(user1, groups, [])).toBe(null) - }) + expect([c, e, b, d, a].sort(byGroupThenName)).toEqual([a, b, c, d, e]) +}) - it('returns null when there are no roles', () => { - expect(userRoleFromPolicies(user1, groups, [{ roleAssignments: [] }])).toBe(null) +describe('rolesByIdFromPolicy', () => { + it('maps each identity to its role', () => { + expect(rolesByIdFromPolicy(abcAdminPolicy)).toEqual(new Map([['abc', 'admin']])) }) - it('returns role if user matches directly', () => { - expect( - userRoleFromPolicies(user1, groups, [ - { - roleAssignments: [ - { identityId: 'user1', identityType: 'silo_user', roleName: 'admin' }, - ], - }, - ]) - ).toEqual('admin') + it('keeps the strongest role when an identity has multiple assignments', () => { + const policy: Policy = { roleAssignments: [abcViewer, abcAdmin] } + expect(rolesByIdFromPolicy(policy)).toEqual(new Map([['abc', 'admin']])) + const reversed: Policy = { roleAssignments: [abcAdmin, abcViewer] } + expect(rolesByIdFromPolicy(reversed)).toEqual(new Map([['abc', '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' }, - ], - }, - ]) - ).toEqual('collaborator') +describe('userScopedRoleEntries', () => { + it('collapses multiple assignments for the same identity to the strongest role', () => { + // API permits multiple assignments for one identity in a single policy + const policy: Policy = { + roleAssignments: [ + { identityId: 'u', identityType: 'silo_user', roleName: 'viewer' }, + { identityId: 'u', identityType: 'silo_user', roleName: 'admin' }, + ], + } + expect(userScopedRoleEntries('u', [], policy)).toEqual([ + { roleName: 'admin', scope: 'silo', source: { type: 'direct' } }, + ]) }) - 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' }, - ], - }, - ]) - ).toEqual(null) + it('emits one entry per direct assignment and per group, tagged by scope', () => { + const group = { id: 'g', displayName: 'g' } + const silo: Policy = { + roleAssignments: [ + { identityId: 'g', identityType: 'silo_group', roleName: 'viewer' }, + ], + } + const project: Policy = { + roleAssignments: [{ identityId: 'u', identityType: 'silo_user', roleName: 'admin' }], + } + expect(userScopedRoleEntries('u', [group], silo, project)).toEqual([ + { roleName: 'viewer', scope: 'silo', source: { type: 'group', group } }, + { roleName: 'admin', scope: 'project', source: { type: 'direct' } }, + ]) }) - 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') + it('keeps a separate entry per group even when the role is identical', () => { + const groupA = { id: 'a', displayName: 'a' } + const groupB = { id: 'b', displayName: 'b' } + const silo: Policy = { + roleAssignments: [ + { identityId: 'a', identityType: 'silo_group', roleName: 'collaborator' }, + { identityId: 'b', identityType: 'silo_group', roleName: 'collaborator' }, + ], + } + // same role via two groups must not collapse — each source is shown separately + expect(userScopedRoleEntries('u', [groupA, groupB], silo)).toEqual([ + { roleName: 'collaborator', scope: 'silo', source: { type: 'group', group: groupA } }, + { roleName: 'collaborator', scope: 'silo', source: { type: 'group', group: groupB } }, + ]) }) }) -test('byGroupThenName sorts as expected', () => { - const a = { identityType: 'silo_group' as const, name: 'a' } - const b = { identityType: 'silo_group' as const, name: 'b' } - const c = { identityType: 'silo_user' as const, name: 'c' } - const d = { identityType: 'silo_user' as const, name: 'd' } - const e = { identityType: 'silo_user' as const, name: 'e' } - - expect([c, e, b, d, a].sort(byGroupThenName)).toEqual([a, b, c, d, e]) -}) - test('allRoles', () => { expect(allRoles).toEqual(['admin', 'collaborator', 'limited_collaborator', 'viewer']) }) diff --git a/app/api/roles.ts b/app/api/roles.ts index d83a3d1f20..4e5b4fb476 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -11,12 +11,20 @@ * layer and not in app/ because we are experimenting with it to decide whether * it belongs in the API proper. */ +import { useQueries } from '@tanstack/react-query' import { useMemo } from 'react' import * as R from 'remeda' import { ALL_ISH } from '~/util/consts' +import type * as PP from '~/util/path-params' -import type { FleetRole, IdentityType, ProjectRole, SiloRole } from './__generated__/Api' +import type { + FleetRole, + Group, + IdentityType, + ProjectRole, + SiloRole, +} from './__generated__/Api' import { api, q, usePrefetchedQuery } from './client' /** @@ -78,6 +86,22 @@ export function updateRole( return { roleAssignments } } +/** + * Map from identity ID to role name for quick lookup. If an actor has multiple + * assignments in the same policy (which the API allows) take the strongest one. + */ +export function rolesByIdFromPolicy( + policy: Policy +): Map { + const map = new Map() + for (const { identityId, roleName } of policy.roleAssignments) { + const existing = map.get(identityId) + // non-null: list is non-empty + map.set(identityId, getEffectiveRole(existing ? [existing, roleName] : [roleName])!) + } + return map +} + /** * Delete any role assignments for user or group ID. Returns a new updated * policy. Does not modify the passed-in policy. @@ -92,49 +116,6 @@ export function deleteRole( return { roleAssignments } } -type UserAccessRow = { - 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( - roleAssignments: RoleAssignment[], - roleSource: string -): 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 - const { data: users } = usePrefetchedQuery(q(api.userList, { query: { limit: ALL_ISH } })) - const { data: groups } = usePrefetchedQuery( - q(api.groupList, { query: { limit: ALL_ISH } }) - ) - 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 } /** @@ -167,28 +148,135 @@ export function useActorsNotInPolicy( return useMemo(() => { // IDs are UUIDs, so no need to include identity type in set value to disambiguate const actorsInPolicy = new Set(policy?.roleAssignments.map((ra) => ra.identityId) || []) - const allGroups = groups.items.map((g) => ({ - ...g, - identityType: 'silo_group' as IdentityType, - })) - const allUsers = users.items.map((u) => ({ - ...u, - identityType: 'silo_user' as IdentityType, - })) - // groups go before users - return allGroups.concat(allUsers).filter((u) => !actorsInPolicy.has(u.id)) || [] + // groups first, then users; each sorted alphabetically by display name + const allGroups = R.sortBy( + groups.items.map((g) => ({ ...g, identityType: 'silo_group' as IdentityType })), + (g) => g.displayName.toLowerCase() + ) + const allUsers = R.sortBy( + users.items.map((u) => ({ ...u, identityType: 'silo_user' as IdentityType })), + (u) => u.displayName.toLowerCase() + ) + return allGroups.concat(allUsers).filter((u) => !actorsInPolicy.has(u.id)) }, [users, groups, policy]) } -export function userRoleFromPolicies( - user: { id: string }, - groups: { id: string }[], - policies: 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 - .filter((ra) => myIds.has(ra.identityId)) - .map((ra) => ra.roleName) - return getEffectiveRole(myRoles) || null +export type AccessScope = 'silo' | 'project' + +export type ScopedRoleEntry = { + roleName: RoleKey + scope: AccessScope + 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 — across the given policies. Each + * entry is tagged with the scope of the policy it came from. Since the API + * permits multiple assignments for the same identity in one policy, each entry + * collapses those to the strongest role (see `getEffectiveRole`). + * Callers are responsible for sorting and any display-layer merging. + */ +export function userScopedRoleEntries( + userId: string, + userGroups: { id: string; displayName: string }[], + siloPolicy: Policy, + projectPolicy?: Policy +): ScopedRoleEntry[] { + const entries = policyRoleEntries('silo', siloPolicy, userId, userGroups) + if (projectPolicy) { + entries.push(...policyRoleEntries('project', projectPolicy, userId, userGroups)) + } + return entries +} + +function policyRoleEntries( + scope: AccessScope, + policy: Policy, + userId: string, + userGroups: { id: string; displayName: string }[] +): ScopedRoleEntry[] { + const roleById = rolesByIdFromPolicy(policy) + const entries: ScopedRoleEntry[] = [] + const direct = roleById.get(userId) + if (direct) { + entries.push({ roleName: direct, scope, source: { type: 'direct' } }) + } + for (const group of userGroups) { + const via = roleById.get(group.id) + if (via) { + entries.push({ roleName: via, scope, source: { type: 'group', group } }) + } + } + return entries +} + +/** Sort role entries strongest first (see `roleOrder`), so the effective role leads. */ +export const sortRoleEntries = (entries: ScopedRoleEntry[]) => + R.sortBy(entries, (e) => roleOrder[e.roleName]) + +/** + * Members of a group. Shared so loaders and readers use the same query key. + * Loaders prefetch this per group without awaiting, so readers use `useQuery` + * and handle the pending state. + * + * Long staleTime: the Users tab and the user detail route both observe every + * group's list, so the default 2s would refetch all of them on every detail + * open. Membership only changes in the IdP (JIT or SCIM), never from a console + * action, so there is nothing to invalidate on. Matches the current user's + * group list in AuthenticatedLayout. + */ +export const groupMemberList = ({ groupId }: PP.SiloGroup) => + q(api.userList, { query: { group: groupId, limit: ALL_ISH } }, { staleTime: 60000 }) + +/** Async state of data derived from one or more queries. */ +export type Loadable = + | { status: 'pending' } + | { status: 'error' } + | { status: 'success'; data: T } + +/** + * 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. + * Pending until every group's members have loaded, since a partial map would + * make users look like they have fewer groups (and roles) than they do. For + * the same reason, one failed member fetch makes the whole map an error. + * Queries don't retry, so without the error state a single failure would + * leave callers loading forever. + */ +export function useGroupsByUserId(groups: Group[]): Loadable> { + const { isError, pages } = useQueries({ + queries: groups.map((g) => groupMemberList({ groupId: g.id })), + // useQueries returns a new array each render; combine structurally shares + // the selected data so the Map only changes with groups or memberships + combine: (results) => ({ + isError: results.some((result) => result.isError), + pages: results.map((result) => result.data), + }), + }) + + return useMemo(() => { + if (isError) return { status: 'error' } + const map = new Map() + for (const [i, group] of groups.entries()) { + const page = pages[i] + if (!page) return { status: 'pending' } + for (const member of page.items) { + const existing = map.get(member.id) + if (existing) existing.push(group) + else map.set(member.id, [group]) + } + } + return { status: 'success', data: map } + }, [groups, isError, pages]) +} + +/** One user's groups out of the `useGroupsByUserId` result. */ +export function groupsOfUser( + groupsByUserId: Loadable>, + userId: string +): Loadable { + return groupsByUserId.status === 'success' + ? { status: 'success', data: groupsByUserId.data.get(userId) ?? [] } + : groupsByUserId } diff --git a/app/api/selectors.ts b/app/api/selectors.ts index 73131242ba..4e771ad2f5 100644 --- a/app/api/selectors.ts +++ b/app/api/selectors.ts @@ -32,6 +32,8 @@ export type SystemUpdate = Readonly<{ version: string }> export type SshKey = Readonly<{ sshKey: string }> export type Sled = Readonly<{ sledId?: string }> export type SupportBundle = Readonly<{ bundleId?: string }> +export type SiloUser = Readonly<{ userId?: string }> +export type SiloGroup = Readonly<{ groupId?: string }> export type IpPool = Readonly<{ pool?: string }> export type SubnetPool = Readonly<{ subnetPool?: string }> export type AlertReceiver = Readonly<{ receiver?: string }> diff --git a/app/components/ConfirmActionModal.tsx b/app/components/ConfirmActionModal.tsx index a979d06fe5..ab6f107077 100644 --- a/app/components/ConfirmActionModal.tsx +++ b/app/components/ConfirmActionModal.tsx @@ -9,6 +9,7 @@ import { useState } from 'react' import { type ApiError } from '@oxide/api' +import { TypeToConfirmField, typeToConfirmReason } from '~/components/TypeToConfirmField' import { clearConfirmAction, useConfirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' import { Modal } from '~/ui/lib/Modal' @@ -21,16 +22,33 @@ export function ConfirmActionModal() { // and would require us to hook this up in a way that re-renders whenever the // loading state changes const [loading, setLoading] = useState(false) + const [typedText, setTypedText] = useState('') if (!actionConfig) return null - const { doAction, modalContent, errorTitle, modalTitle, actionType } = actionConfig + const { doAction, modalContent, errorTitle, modalTitle, actionType, confirmText } = + actionConfig + + // this component stays mounted between opens, so clear the typed text on close + const close = () => { + setTypedText('') + clearConfirmAction() + } return ( - - {modalContent} + + + {modalContent} + {confirmText && ( + + )} + { setLoading(true) try { @@ -46,12 +64,14 @@ export function ConfirmActionModal() { setLoading(false) // do this regardless of success or error // TODO: generic success toast? - clearConfirmAction() + close() }} cancelText="Cancel" actionText="Confirm" actionType={actionType} actionLoading={loading} + disabled={!!confirmText && typedText !== confirmText} + disabledReason={confirmText && typeToConfirmReason(confirmText, typedText)} /> ) diff --git a/app/components/ErrorBoundary.tsx b/app/components/ErrorBoundary.tsx index b2f67d86d3..e2f792025c 100644 --- a/app/components/ErrorBoundary.tsx +++ b/app/components/ErrorBoundary.tsx @@ -11,6 +11,7 @@ import { useRouteError } from 'react-router' import { api, q } from '~/api' import { type ApiError } from '~/api/errors' +import { myGroupsQuery } from '~/hooks/use-current-user' import { Message } from '~/ui/lib/Message' import { links } from '~/util/links' @@ -43,7 +44,7 @@ function useDetectNoSiloRole(enabled: boolean): boolean { // this is kind of a hail mary, so if any of this goes wrong we need to ignore it const options = { enabled, throwOnError: false } const { data: me } = useQuery(q(api.currentUserView, {}, options)) - const { data: myGroups } = useQuery(q(api.currentUserGroups, {}, options)) + const { data: myGroups } = useQuery(myGroupsQuery(options)) const { data: siloPolicy } = useQuery(q(api.policyView, {}, options)) if (!me || !myGroups || !siloPolicy) return false diff --git a/app/components/TypeToConfirmField.tsx b/app/components/TypeToConfirmField.tsx new file mode 100644 index 0000000000..2c58eb0c6b --- /dev/null +++ b/app/components/TypeToConfirmField.tsx @@ -0,0 +1,47 @@ +/* + * 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 { useId } from 'react' + +import { FieldLabel } from '~/ui/lib/FieldLabel' +import { TextInput } from '~/ui/lib/TextInput' + +/** Text field the user must fill with `confirmText` before a risky action */ +export function TypeToConfirmField({ + confirmText, + value, + onChange, +}: { + confirmText: string + value: string + onChange: (value: string) => void +}) { + const inputId = useId() + return ( +
+ + + Type{' '} + + {confirmText} + {' '} + to confirm + + + onChange(e.target.value)} + autoComplete="off" + /> +
+ ) +} + +/** Disabled reason for a submit button until `value` matches `confirmText` */ +export const typeToConfirmReason = (confirmText: string, value: string) => + value === confirmText ? undefined : `Type ${confirmText} to confirm` diff --git a/app/components/access/AccessGroupsTab.tsx b/app/components/access/AccessGroupsTab.tsx new file mode 100644 index 0000000000..32b25f371a --- /dev/null +++ b/app/components/access/AccessGroupsTab.tsx @@ -0,0 +1,110 @@ +/* + * 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 { useCallback, useMemo } from 'react' +import { Outlet } from 'react-router' +import * as R from 'remeda' + +import { + sortRoleEntries, + usePrefetchedQuery, + userScopedRoleEntries, + type Group, +} from '@oxide/api' +import { PersonGroup24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { EmptyCell } from '~/table/cells/EmptyCell' +import { LinkCell } from '~/table/cells/LinkCell' +import { MemberCountCell } from '~/table/cells/MemberCountCell' +import { Columns } from '~/table/columns/common' +import { Table } from '~/table/Table' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { TableEmptyBox } from '~/ui/lib/Table' +import { roleColor } from '~/util/access' + +import { groupListAll, type PolicyScope } from './policy-scope' + +const colHelper = createColumnHelper() + +const memberCountCol = colHelper.display({ + id: 'memberCount', + header: 'Users', + cell: ({ row }) => , +}) + +const timeCreatedCol = colHelper.accessor('timeCreated', Columns.timeCreated) + +const GroupEmptyState = () => ( + + } + title="No groups" + body="No groups have been added to this silo" + /> + +) + +export function AccessGroupsTab({ scope }: { scope: PolicyScope }) { + const { siloPolicy, projectPolicy, groupPath } = scope + + const { data: groups } = usePrefetchedQuery(groupListAll) + const sortedGroups = useMemo( + () => R.sortBy(groups.items, (g) => g.displayName.toLowerCase()), + [groups] + ) + + // groups can't be in other groups, so passing no groups yields only the + // group's direct roles in each scope + const effectiveRole = useCallback( + (group: Group) => + sortRoleEntries(userScopedRoleEntries(group.id, [], siloPolicy, projectPolicy)).at(0), + [siloPolicy, projectPolicy] + ) + + const columns = useMemo( + () => [ + colHelper.accessor('displayName', { + header: 'Name', + cell: (info) => ( + {info.getValue()} + ), + }), + colHelper.display({ + id: 'role', + header: 'Role', + cell: ({ row }) => { + const effective = effectiveRole(row.original) + if (!effective) return + return ( + + {effective.scope}.{effective.roleName} + + ) + }, + }), + memberCountCol, + timeCreatedCol, + ], + [groupPath, effectiveRole] + ) + + const table = useReactTable({ + columns, + data: sortedGroups, + getRowId: (group) => group.id, + getCoreRowModel: getCoreRowModel(), + }) + + return ( + <> + {sortedGroups.length === 0 ? : } + + + ) +} diff --git a/app/components/access/AccessUsersTab.tsx b/app/components/access/AccessUsersTab.tsx new file mode 100644 index 0000000000..0525450b42 --- /dev/null +++ b/app/components/access/AccessUsersTab.tsx @@ -0,0 +1,155 @@ +/* + * 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 } from 'react' +import { Outlet } from 'react-router' +import * as R from 'remeda' +import { match } from 'ts-pattern' + +import { + groupsOfUser, + sortRoleEntries, + useGroupsByUserId, + usePrefetchedQuery, + userScopedRoleEntries, + type User, +} from '@oxide/api' +import { Person24Icon, PersonGroup16Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { ListPlusCell } from '~/components/ListPlusCell' +import { EmptyCell, ErrorCell, SkeletonCell } from '~/table/cells/EmptyCell' +import { LinkCell } from '~/table/cells/LinkCell' +import { Columns } from '~/table/columns/common' +import { Table } from '~/table/Table' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { TableEmptyBox } from '~/ui/lib/Table' +import { TipIcon } from '~/ui/lib/TipIcon' +import { roleColor } from '~/util/access' + +import { groupListAll, userListAll, type PolicyScope } from './policy-scope' + +const colHelper = createColumnHelper() + +const timeCreatedCol = colHelper.accessor('timeCreated', Columns.timeCreated) + +const EmptyState = () => ( + + } + title="No users" + body="No users have been added to this silo" + /> + +) + +export function AccessUsersTab({ scope }: { scope: PolicyScope }) { + const { siloPolicy, projectPolicy, userPath } = scope + + const { data: users } = usePrefetchedQuery(userListAll) + const sortedUsers = useMemo( + () => R.sortBy(users.items, (u) => u.displayName.toLowerCase()), + [users] + ) + + const { data: groups } = usePrefetchedQuery(groupListAll) + const groupsByUserId = useGroupsByUserId(groups.items) + + const nameCol = useMemo( + () => + colHelper.accessor('displayName', { + header: 'Name', + cell: (info) => ( + {info.getValue()} + ), + }), + [userPath] + ) + + const roleCol = useMemo( + () => + colHelper.display({ + id: 'role', + header: 'Role', + cell: ({ row }) => + match(groupsOfUser(groupsByUserId, row.original.id)) + .with({ status: 'pending' }, () => ) + .with({ status: 'error' }, () => ) + .with({ status: 'success' }, ({ data: userGroups }) => { + // strongest assignment is the effective role; any others are + // shown in the user detail side modal, reached by clicking the name + const effective = sortRoleEntries( + userScopedRoleEntries( + row.original.id, + userGroups, + siloPolicy, + projectPolicy + ) + ).at(0) + if (!effective) return + return ( +
+ + {effective.scope}.{effective.roleName} + + {/* call out when the effective role is inherited rather than direct */} + {effective.source.type === 'group' && ( + } + > + via {effective.source.group.displayName} + + )} +
+ ) + }) + .exhaustive(), + }), + [groupsByUserId, siloPolicy, projectPolicy] + ) + + const groupsCol = useMemo( + () => + colHelper.display({ + id: 'groups', + header: 'Groups', + cell: ({ row }) => + match(groupsOfUser(groupsByUserId, row.original.id)) + .with({ status: 'pending' }, () => ) + .with({ status: 'error' }, () => ) + .with({ status: 'success' }, ({ data: userGroups }) => ( + + {userGroups.map((g) => ( + {g.displayName} + ))} + + )) + .exhaustive(), + }), + [groupsByUserId] + ) + + const columns = useMemo( + () => [nameCol, roleCol, groupsCol, timeCreatedCol], + [nameCol, roleCol, groupsCol] + ) + + const table = useReactTable({ + columns, + data: sortedUsers, + getRowId: (user) => user.id, + getCoreRowModel: getCoreRowModel(), + }) + + return ( + <> + {sortedUsers.length === 0 ? :
} + + + ) +} diff --git a/app/components/access/DetailTables.tsx b/app/components/access/DetailTables.tsx new file mode 100644 index 0000000000..d018eebacd --- /dev/null +++ b/app/components/access/DetailTables.tsx @@ -0,0 +1,206 @@ +/* + * 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 { match } from 'ts-pattern' + +import { sortRoleEntries, type Loadable, type ScopedRoleEntry } from '@oxide/api' +import { Badge } from '@oxide/design-system/ui' + +import { EmptyCell, ErrorCell, SkeletonCell } from '~/table/cells/EmptyCell' +import { RowActions, type MenuAction } from '~/table/columns/action-col' +import { SideModal } from '~/ui/lib/SideModal' +import { Table } from '~/ui/lib/Table' +import { Truncate } from '~/ui/lib/Truncate' +import { roleColor } from '~/util/access' + +/** The strongest of the entries in `RoleAssignmentsTable`, for a properties row */ +export const EffectiveRole = ({ entries }: { entries: Loadable }) => + match(entries) + .with({ status: 'pending' }, () => ) + .with({ status: 'error' }, () => ) + .with({ status: 'success' }, ({ data }) => { + const effective = sortRoleEntries(data).at(0) + return effective ? ( + + {effective.scope}.{effective.roleName} + + ) : ( + + ) + }) + .exhaustive() + +/** + * Role/Source table shared by the user and group detail side modals. Entries + * come from `userScopedRoleEntries` (groups pass an empty groups list, so their + * entries are all direct). Sorted strongest-first. A user's entries depend on + * group memberships, so they can be pending or failed. + */ +export function RoleAssignmentsTable({ + entries, + makeActions, + addRole, +}: { + entries: Loadable + makeActions: (entry: ScopedRoleEntry) => MenuAction[] + /** + * A row with a menu for adding a direct role: "No roles assigned" when there + * are no entries, otherwise `text` below them. Omit it once the identity has + * a direct role. With no entries there is never a direct role, so callers + * always pass it then. + */ + addRole?: { text: string; actions: MenuAction[] } +}) { + return ( +
+ Assignments +
+ + + Role + Source + + + + + {match(entries) + .with({ status: 'pending' }, () => ) + .with({ status: 'error' }, () => ( + + Could not load group memberships + + )) + .with({ status: 'success' }, ({ data }) => ( + <> + {sortRoleEntries(data).map((entry) => ( + // stable across refetches so an open row menu isn't remounted + // onto a different entry when rows shift; unique because + // entries collapse to one per identity per scope + + + + {entry.scope}.{entry.roleName} + + + {/* takes the remaining width. The group name is positioned + absolutely so its length doesn't widen the table, which + lets it truncate. max-width on a cell would be simpler, + but Firefox ignores it. */} + + {entry.source.type === 'direct' && 'Direct'} + {entry.source.type === 'group' && ( +
+ +
+ )} +
+ + + +
+ ))} + {addRole && ( + + + {data.length === 0 ? 'No roles assigned' : addRole.text} + + + + + + )} + + )) + .exhaustive()} +
+
+ + ) +} + +type Identity = { id: string; displayName: string } + +/** + * List of related identities (a group's members, or a user's groups) with a + * Copy ID row action, plus any from `makeActions`. Shared by the user and group + * detail side modals. + */ +export function IdentityListTable({ + label, + items, + emptyText, + makeActions, +}: { + label: string + items: Loadable + emptyText: string + makeActions?: (item: Identity) => MenuAction[] +}) { + return ( + + + + {label} + + + + + {match(items) + .with({ status: 'pending' }, () => ) + .with({ status: 'error' }, () => ( + + Could not load {label.toLowerCase()} + + )) + .with({ status: 'success' }, ({ data }) => + data.length === 0 ? ( + {emptyText} + ) : ( + data.map((item) => ( + + {item.displayName} + + + + + )) + ) + ) + .exhaustive()} + +
+ ) +} + +const LoadingRow = ({ colSpan }: { colSpan: number }) => ( + + + + + +) + +const MessageRow = ({ + colSpan, + error, + children, +}: { + colSpan: number + error?: boolean + children: React.ReactNode +}) => ( + + + {children} + + +) diff --git a/app/components/access/GroupMembersSideModal.tsx b/app/components/access/GroupMembersSideModal.tsx new file mode 100644 index 0000000000..6e77ea8bf1 --- /dev/null +++ b/app/components/access/GroupMembersSideModal.tsx @@ -0,0 +1,160 @@ +/* + * 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 { useState } from 'react' + +import { + groupMemberList, + userScopedRoleEntries, + type Group, + type Loadable, + type RoleKey, + type ScopedRoleEntry, + type User, +} from '@oxide/api' +import { PersonGroup16Icon } from '@oxide/design-system/icons/react' + +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +import { useCurrentUser } from '~/hooks/use-current-user' +import { type MenuAction } from '~/table/columns/action-col' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { ResourceLabel } from '~/ui/lib/SideModal' +import { docLinks } from '~/util/links' + +import { EffectiveRole, IdentityListTable, RoleAssignmentsTable } from './DetailTables' +import { type PolicyScope } from './policy-scope' +import { roleActions, siloPolicyReason } from './roleActions' +import { canEditScope, useCanEditPolicy } from './use-can-edit-policy' + +type Props = { + group: Group + onDismiss: () => void + scope: PolicyScope +} + +export function GroupMembersSideModal({ group, onDismiss, scope }: Props) { + const { managedScope, siloPolicy, projectPolicy, managedPolicy, EditModal } = scope + const { removeRole, userPath } = scope + const canEdit = useCanEditPolicy(siloPolicy, projectPolicy) + const { me, myGroups } = useCurrentUser() + // edit modal state; it stacks over this side modal. No default role means + // adding one to a group with no role in the managed scope. + const [editing, setEditing] = useState<{ defaultRole?: RoleKey } | null>(null) + + // not awaited by every loader that leads here, so it may still be loading + const membersQuery = useQuery(groupMemberList({ groupId: group.id })) + const members: Loadable = + membersQuery.status === 'success' + ? { status: 'success', data: membersQuery.data.items } + : { status: membersQuery.status } + + // groups never inherit, so passing no groups yields the group's direct roles + const roleEntries = { + status: 'success', + data: userScopedRoleEntries(group.id, [], siloPolicy, projectPolicy), + } as const + + const addRoleAction = roleActions(managedScope, canEdit).add(() => setEditing({})) + const hasManagedRole = roleEntries.data.some((e) => e.scope === managedScope) + + // Removing this group's role takes it from the current user too if they're a + // member, so warn them like removing their own direct role does. They keep + // edit access if their other roles still grant it. For the silo this misses + // fleet-derived access (see canEditScope), so it can warn when it needn't. + const isSelf = myGroups.items.some((g) => g.id === group.id) + const keepsEditAccess = + isSelf && + canEditScope( + userScopedRoleEntries(me.id, myGroups.items, siloPolicy, projectPolicy).filter( + (e) => + !( + e.scope === managedScope && + e.source.type === 'group' && + e.source.group.id === group.id + ) + ), + managedScope + ) + + const makeRoleActions = ({ roleName, scope: entryScope }: ScopedRoleEntry) => { + const actions = roleActions(entryScope, canEdit) + // a silo role shown on a project page can only be edited on the silo page + const isOtherScope = entryScope !== managedScope + return [ + actions.change( + () => setEditing({ defaultRole: roleName }), + isOtherScope ? siloPolicyReason : undefined + ), + actions.remove({ + name: group.displayName, + directRole: isOtherScope ? undefined : roleName, + isSelf, + keepsEditAccess, + inheritedReason: siloPolicyReason, + doRemove: () => removeRole(group.id), + }), + ] + } + + const makeMemberActions = (member: { id: string }): MenuAction[] => [ + { label: 'View user', to: userPath(member.id) }, + ] + + return ( + + {group.displayName} + + } + onDismiss={onDismiss} + animate + > + + + + + + + +
+ +
+
+ +
+ + {editing && ( + setEditing(null)} + policy={managedPolicy} + name={group.displayName} + identityId={group.id} + identityType="silo_group" + defaultValues={{ roleName: editing.defaultRole }} + /> + )} +
+ ) +} diff --git a/app/components/access/PolicyModal.tsx b/app/components/access/PolicyModal.tsx new file mode 100644 index 0000000000..7c93ef1d58 --- /dev/null +++ b/app/components/access/PolicyModal.tsx @@ -0,0 +1,150 @@ +/* + * 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 { useMemo } from 'react' + +import { byGroupThenName, usePrefetchedQuery } from '@oxide/api' +import { Person16Icon, PersonGroup16Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { RowActions } from '~/table/columns/action-col' +import { Modal } from '~/ui/lib/Modal' +import { TipIcon } from '~/ui/lib/TipIcon' +import { roleColor } from '~/util/access' +import { roleDiv } from '~/util/classed' +import { capitalize } from '~/util/str' + +import { groupListAll, userListAll, type PolicyScope } from './policy-scope' +import { roleActions } from './roleActions' +import { useCanEditPolicy } from './use-can-edit-policy' + +// Log-style list like the Alerts tab: rows with only a bottom border, so divs +// with explicit table roles rather than table elements. A grid with subgrid +// rows (like MiniTable) so both columns size to their widest content and the +// header lines up. The empty third track absorbs the leftover width, so rows +// (and their borders) still span the modal. Rows bleed past the modal section +// padding so the borders run edge to edge, and the cells pad back in by the +// same amount. The negative vertical margins pull the list up under the title +// bar, since the header row already has its own height. +const Table = roleDiv( + 'table', + 'text-sans-md -mx-4 -mt-4 -mb-2 grid grid-cols-[auto_auto_minmax(0,1fr)]' +) +const Row = roleDiv( + 'row', + 'border-secondary col-span-full grid h-9 grid-cols-subgrid items-center gap-8 border-b px-4' +) +const HeadCell = roleDiv('columnheader', 'text-mono-sm text-tertiary') +const Cell = roleDiv('cell', '') + +/** + * The raw role assignments in a policy, one row each. Unlike the Users and + * Groups tabs, nothing is merged or collapsed here, so this is the policy + * exactly as the API holds it, including any duplicate assignments for the + * same identity. The identity lists are prefetched by the Access page loader. + * + * Assignments whose identity can't be resolved never appear in the Users and + * Groups tabs, so this is the only place they can be removed. + */ +export function PolicyModal({ + policyScope, + onDismiss, +}: { + policyScope: PolicyScope + onDismiss: () => void +}) { + const { + managedScope: scope, + managedPolicy: policy, + siloPolicy, + projectPolicy, + removeRole, + } = policyScope + const canEdit = useCanEditPolicy(siloPolicy, projectPolicy) + const { data: users } = usePrefetchedQuery(userListAll) + const { data: groups } = usePrefetchedQuery(groupListAll) + + const rows = useMemo(() => { + // Identity types never share IDs, so one map covers both. An ID can go + // unresolved if the identity was deleted after the assignment was made + // (local IdP user deletion leaves role assignments behind), if it falls + // past the ALL_ISH cap on the lists, or if the policy was written through + // the API with an ID from outside the silo. We fall back to showing the ID. + const nameById = new Map( + [...users.items, ...groups.items].map((i) => [i.id, i.displayName]) + ) + return policy.roleAssignments + .map((ra) => ({ ...ra, name: nameById.get(ra.identityId) })) + .sort((a, b) => + byGroupThenName( + { identityType: a.identityType, name: a.name ?? a.identityId }, + { identityType: b.identityType, name: b.name ?? b.identityId } + ) + ) + }, [policy, users, groups]) + + return ( + + + + + + Actor + Role + + {rows.length === 0 ? ( + + No roles assigned + + ) : ( + rows.map((row, i) => ( + + + {row.identityType === 'silo_group' ? ( + + ) : ( + + )} + {row.name ?? ( + <> + {row.identityId} + + Can't find this{' '} + {row.identityType === 'silo_user' ? 'user' : 'group'} in the silo + + + )} + + + + {scope}.{row.roleName} + + + {/* resolved identities are managed from the Users and Groups tabs */} + {!row.name && ( + + removeRole(row.identityId), + }), + ]} + /> + + )} + + )) + )} +
+
+
+
+ ) +} diff --git a/app/components/access/UserDetailsSideModal.tsx b/app/components/access/UserDetailsSideModal.tsx new file mode 100644 index 0000000000..f220d42561 --- /dev/null +++ b/app/components/access/UserDetailsSideModal.tsx @@ -0,0 +1,159 @@ +/* + * 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 { useState } from 'react' + +import { + userScopedRoleEntries, + type Group, + type Loadable, + type RoleKey, + type ScopedRoleEntry, + type User, +} from '@oxide/api' +import { Person16Icon } from '@oxide/design-system/icons/react' + +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +import { useCurrentUser } from '~/hooks/use-current-user' +import { type MenuAction } from '~/table/columns/action-col' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { ResourceLabel } from '~/ui/lib/SideModal' + +import { EffectiveRole, IdentityListTable, RoleAssignmentsTable } from './DetailTables' +import { type PolicyScope } from './policy-scope' +import { roleActions, siloPolicyReason } from './roleActions' +import { canEditScope, useCanEditPolicy } from './use-can-edit-policy' + +type Props = { + user: User + onDismiss: () => void + userGroups: Loadable + scope: PolicyScope +} + +export function UserDetailsSideModal({ user, onDismiss, userGroups, scope }: Props) { + const { managedScope, siloPolicy, projectPolicy, managedPolicy, EditModal } = scope + const { removeRole, groupPath } = scope + const canEdit = useCanEditPolicy(siloPolicy, projectPolicy) + const { me } = useCurrentUser() + // edit modal state; it stacks over this side modal. No default role means + // adding a direct role. + const [editing, setEditing] = useState<{ defaultRole?: RoleKey } | null>(null) + + const roleEntries = + userGroups.status === 'success' + ? { + status: userGroups.status, + data: userScopedRoleEntries(user.id, userGroups.data, siloPolicy, projectPolicy), + } + : userGroups + + const addRoleAction = roleActions(managedScope, canEdit).add(() => setEditing({})) + const hasDirectRole = + roleEntries.status === 'success' && + roleEntries.data.some((e) => e.scope === managedScope && e.source.type === 'direct') + + // whether the user could still edit this scope's roles without their direct + // role in it, e.g., a silo admin removing or lowering their own project admin + // role. Only matters when the user is the current user. + const keepsEditAccess = + roleEntries.status === 'success' && + canEditScope( + roleEntries.data.filter( + (e) => !(e.scope === managedScope && e.source.type === 'direct') + ), + managedScope + ) + + const makeActions = ({ + roleName, + scope: entryScope, + source, + }: ScopedRoleEntry): MenuAction[] => { + if (source.type === 'group') { + return [{ label: 'View group', to: groupPath(source.group.id) }] + } + const actions = roleActions(entryScope, canEdit) + // a direct silo role shown on a project page can only be edited on the silo page + if (entryScope !== managedScope) { + return [ + actions.change(() => {}, siloPolicyReason), + actions.remove({ + name: user.displayName, + directRole: undefined, + inheritedReason: siloPolicyReason, + doRemove: () => removeRole(user.id), + }), + ] + } + return [ + actions.change(() => setEditing({ defaultRole: roleName })), + actions.remove({ + name: user.displayName, + directRole: roleName, + isSelf: user.id === me.id, + keepsEditAccess, + doRemove: () => removeRole(user.id), + }), + ] + } + + return ( + + {user.displayName} + + } + onDismiss={onDismiss} + animate + > + + + + + + + +
+ +
+
+ [{ label: 'View group', to: groupPath(group.id) }]} + /> +
+ + {editing && ( + setEditing(null)} + policy={managedPolicy} + name={user.displayName} + identityId={user.id} + identityType="silo_user" + defaultValues={{ roleName: editing.defaultRole }} + keepsEditAccess={keepsEditAccess} + /> + )} +
+ ) +} diff --git a/app/components/access/UsersGroupsLayout.tsx b/app/components/access/UsersGroupsLayout.tsx new file mode 100644 index 0000000000..1f70d0183e --- /dev/null +++ b/app/components/access/UsersGroupsLayout.tsx @@ -0,0 +1,62 @@ +/* + * 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 { useState } from 'react' + +import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' + +import { DocsPopover } from '~/components/DocsPopover' +import { RouteTabs, Tab } from '~/components/RouteTabs' +import { Button } from '~/ui/lib/Button' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { docLinks } from '~/util/links' + +import { type PolicyScope } from './policy-scope' +import { PolicyModal } from './PolicyModal' + +/** Header and Users/Groups tabs shared by the silo and project pages */ +export function UsersGroupsLayout({ + title, + policyScope, + usersPath, + groupsPath, + summary, +}: { + title: string + /** The policy this page manages, shown raw in the Show policy modal */ + policyScope: PolicyScope + usersPath: string + groupsPath: string + summary: string +}) { + const [policyOpen, setPolicyOpen] = useState(false) + return ( + <> + + }>{title} +
+ + } + summary={summary} + links={[docLinks.keyConceptsIam, docLinks.access, docLinks.identityProviders]} + /> +
+
+ + Groups + Users + + {policyOpen && ( + setPolicyOpen(false)} /> + )} + + ) +} diff --git a/app/components/access/policy-scope.ts b/app/components/access/policy-scope.ts new file mode 100644 index 0000000000..728aeb1186 --- /dev/null +++ b/app/components/access/policy-scope.ts @@ -0,0 +1,142 @@ +/* + * 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 { useCallback, type ComponentType } from 'react' + +import { + api, + deleteRole, + groupMemberList, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type AccessScope, + type Policy, +} from '@oxide/api' + +import { type EditRoleModalProps } from '~/forms/access-util' +import { ProjectAccessEditRoleModal } from '~/forms/project-access' +import { invalidateSiloPolicy, SiloAccessEditRoleModal } from '~/forms/silo-access' +import { useProjectSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { ALL_ISH } from '~/util/consts' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +// The API only sorts users and groups by id, so fetch the full sets and sort by +// name client-side. ALL_ISH is the practical ceiling; a silo with more than +// that would have its tail dropped in (arbitrary) id order. +export const userListAll = q(api.userList, { query: { limit: ALL_ISH } }) +export const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) +export const siloPolicyView = q(api.policyView, {}) +export const projectPolicyView = ({ project }: PP.Project) => + q(api.projectPolicyView, { path: { project } }) + +/** + * Prefetch everything the Users and Groups tabs (and their detail modals) need, + * so switching tabs doesn't trigger a fetch. Per-group member lists are fired + * but not awaited: readers use `useQuery`/`useQueries`, so member counts and + * per-user group lists fill in as they resolve instead of blocking the page on + * one request per group. + */ +export async function prefetchUsersAndGroups(project?: string) { + await Promise.all([ + queryClient.fetchQuery(groupListAll).then((groups) => { + groups.items.forEach((g) => + queryClient.prefetchQuery(groupMemberList({ groupId: g.id })) + ) + }), + queryClient.prefetchQuery(siloPolicyView), + queryClient.prefetchQuery(userListAll), + project && queryClient.prefetchQuery(projectPolicyView({ project })), + ]) +} + +/** + * The policy an Access page manages, plus what the shared tabs need to + * act on it. Silo roles contribute to effective roles on both silo and project + * pages; only the managed scope's direct roles can be edited. + */ +export type PolicyScope = { + managedScope: AccessScope + siloPolicy: Policy + /** Present on project pages only */ + projectPolicy?: Policy + managedPolicy: Policy + EditModal: ComponentType + /** Remove any direct role for the identity in the managed policy */ + removeRole: (identityId: string) => Promise + userPath: (userId: string) => string + groupPath: (groupId: string) => string + /** Docs on the managed scope's roles */ + rolesDocLink: { href: string; linkText: string } +} + +const siloUserPath = (userId: string) => pb.siloUser({ userId }) +const siloGroupPath = (groupId: string) => pb.siloGroup({ groupId }) + +export function useSiloPolicyScope(): PolicyScope { + const { data: siloPolicy } = usePrefetchedQuery(siloPolicyView) + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { + onSuccess: () => { + invalidateSiloPolicy() + addToast({ content: 'Role removed' }) + }, + }) + const removeRole = useCallback( + (identityId: string) => updatePolicy({ body: deleteRole(identityId, siloPolicy) }), + [updatePolicy, siloPolicy] + ) + return { + managedScope: 'silo', + siloPolicy, + managedPolicy: siloPolicy, + EditModal: SiloAccessEditRoleModal, + removeRole, + userPath: siloUserPath, + groupPath: siloGroupPath, + rolesDocLink: docLinks.siloRoles, + } +} + +export function useProjectPolicyScope(): PolicyScope { + const { project } = useProjectSelector() + const { data: siloPolicy } = usePrefetchedQuery(siloPolicyView) + const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView({ project })) + const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('projectPolicyView') + addToast({ content: 'Role removed' }) + }, + }) + const removeRole = useCallback( + (identityId: string) => + updatePolicy({ path: { project }, body: deleteRole(identityId, projectPolicy) }), + [updatePolicy, project, projectPolicy] + ) + const userPath = useCallback( + (userId: string) => pb.projectUser({ project, userId }), + [project] + ) + const groupPath = useCallback( + (groupId: string) => pb.projectGroup({ project, groupId }), + [project] + ) + return { + managedScope: 'project', + siloPolicy, + projectPolicy, + managedPolicy: projectPolicy, + EditModal: ProjectAccessEditRoleModal, + removeRole, + userPath, + groupPath, + rolesDocLink: docLinks.projectRoles, + } +} diff --git a/app/components/access/roleActions.tsx b/app/components/access/roleActions.tsx new file mode 100644 index 0000000000..c562b020ae --- /dev/null +++ b/app/components/access/roleActions.tsx @@ -0,0 +1,119 @@ +/* + * 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 AccessScope, type RoleKey } from '@oxide/api' + +import { HL } from '~/components/HL' +import { confirmAction } from '~/stores/confirm-action' +import { type MenuAction } from '~/table/columns/action-col' +import { Message } from '~/ui/lib/Message' +import { capitalize } from '~/util/str' + +/** Scopes whose role assignments are editable from a row action. */ +type RoleScope = AccessScope | 'fleet' +type RoleVerb = 'add' | 'change' | 'remove' + +/** Row-action label, scoped so it reads e.g. "Change project role". */ +const roleActionLabel = (scope: RoleScope, verb: RoleVerb) => + `${capitalize(verb)} ${scope} role` + +/** Disabled-action reason shown when the user can't edit roles in this scope. */ +const noRolePermissionReason = (scope: RoleScope, verb: RoleVerb) => + `You don't have permission to ${verb} ${scope} roles` + +/** Disabled reason for a silo role shown on a project page */ +export const siloPolicyReason = 'This role comes from the silo policy' + +/** + * Builds consistently labeled actions for one scope. Callers choose add versus + * change because the meaning of direct and inherited roles varies by surface. + */ +export const roleActions = (scope: RoleScope, canEdit: boolean) => { + const edit = ( + verb: Exclude, + onActivate: () => void, + disabledReason?: string + ): MenuAction => ({ + label: roleActionLabel(scope, verb), + onActivate, + disabled: !canEdit ? noRolePermissionReason(scope, verb) : disabledReason, + }) + + return { + add: (onActivate: () => void) => edit('add', onActivate), + /** `disabledReason` applies only when the user otherwise has permission */ + change: (onActivate: () => void, disabledReason?: string) => + edit('change', onActivate, disabledReason), + + /** A destructive remove action with standard confirmation copy. */ + remove({ + name, + directRole, + isSelf = false, + keepsEditAccess = false, + inheritedReason, + doRemove, + }: { + name: string + /** Direct assignment to remove, absent when the displayed role is inherited. */ + directRole: RoleKey | undefined + /** The current user holds this assignment, directly or through a group */ + isSelf?: boolean + /** + * Whether the current user would still be able to edit this scope's + * roles through their other roles, in which case removing their own + * admin role needs no extra confirmation + */ + keepsEditAccess?: boolean + /** Explains where an inherited role must be removed instead. */ + inheritedReason?: string + doRemove: () => Promise + }): MenuAction { + const disabled = !canEdit + ? noRolePermissionReason(scope, 'remove') + : // if role is not direct, it is inherited from a silo or group, so + // we use inheritedReason to direct them to the appropriate spot to + // remove it + !directRole + ? inheritedReason + : undefined + + // only admin grants permission to edit roles in the scope it's removed from + const isRemovingOwnAdmin = isSelf && directRole === 'admin' && !keepsEditAccess + + return { + // Match RowActions' Delete behavior: destructive styling only while enabled. + label: roleActionLabel(scope, 'remove'), + className: disabled ? undefined : 'destructive', + onActivate: () => + confirmAction({ + doAction: doRemove, + modalTitle: 'Remove role assignment', + modalContent: ( +
+

+ Are you sure you want to remove the {directRole} role for{' '} + {name}? +

+ {/* "could" because the same access may also come from a group */} + {isRemovingOwnAdmin && ( + + )} +
+ ), + errorTitle: 'Could not remove role assignment', + actionType: 'danger', + confirmText: isRemovingOwnAdmin ? name || 'delete my own role' : undefined, + }), + disabled, + } + }, + } +} diff --git a/app/components/access/use-can-edit-policy.ts b/app/components/access/use-can-edit-policy.ts new file mode 100644 index 0000000000..92b4a4e930 --- /dev/null +++ b/app/components/access/use-can-edit-policy.ts @@ -0,0 +1,56 @@ +/* + * 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 { + getEffectiveRole, + userScopedRoleEntries, + type AccessScope, + type Policy, + type ScopedRoleEntry, +} from '@oxide/api' + +import { useCurrentUser } from '~/hooks/use-current-user' + +/** + * Whether the current user can add, change, or remove role assignments in the + * managed scope: the project when a project policy is given, otherwise the + * silo. Editing a scope's policy requires `modify` on that resource. + * + * Silo modify can come from fleet roles, including ones conferred through a + * silo's `mapped_fleet_roles`, which aren't visible in the silo policy. Nexus + * computes `siloAdmin` on /v1/me as exactly this permission, so use it rather + * than deriving it from policies. + * + * Project modify comes from the project admin role or a silo collaborator/admin + * role (a silo collaborator is an admin on every project in the silo). Fleet + * roles grant silo modify as a permission, not a silo role, so they don't + * cascade to projects, and both policies are in hand to compute this. + * https://github.com/oxidecomputer/omicron/blob/26f5e91/nexus/auth/src/authz/omicron.polar#L140-L223 + */ +export function useCanEditPolicy(siloPolicy: Policy, projectPolicy?: Policy): boolean { + const { me, myGroups } = useCurrentUser() + if (!projectPolicy) return me.siloAdmin + const entries = userScopedRoleEntries(me.id, myGroups.items, siloPolicy, projectPolicy) + return canEditScope(entries, 'project') +} + +/** + * Whether these role entries alone grant permission to edit the scope's roles. + * For the silo this misses fleet-derived permission (see above), so it can + * return false for a user who can in fact edit. + */ +export function canEditScope(entries: ScopedRoleEntry[], scope: AccessScope): boolean { + const roleInScope = (s: AccessScope) => + getEffectiveRole(entries.filter((e) => e.scope === s).map((e) => e.roleName)) + const siloRole = roleInScope('silo') + if (scope === 'silo') return siloRole === 'admin' + return ( + roleInScope('project') === 'admin' || + siloRole === 'admin' || + siloRole === 'collaborator' + ) +} diff --git a/app/forms/access-util.tsx b/app/forms/access-util.tsx index de4a93b94a..ee669fd7d5 100644 --- a/app/forms/access-util.tsx +++ b/app/forms/access-util.tsx @@ -5,37 +5,32 @@ * * Copyright Oxide Computer Company */ +import { useState } from 'react' import type { Control, FieldPath, FieldValues } from 'react-hook-form' import * as R from 'remeda' import { allRoles, fleetRoles, + rolesByIdFromPolicy, type Actor, type FleetRole, type IdentityType, type Policy, type RoleKey, } from '@oxide/api' +import { Access16Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' import { RadioFieldDyn } from '~/components/form/fields/RadioField' -import { type ListboxItem } from '~/ui/lib/Listbox' +import { TypeToConfirmField, typeToConfirmReason } from '~/components/TypeToConfirmField' +import { useCurrentUser } from '~/hooks/use-current-user' +import { type ComboboxItem } from '~/ui/lib/Combobox' import { Message } from '~/ui/lib/Message' import { Radio } from '~/ui/lib/Radio' import { docLinks } from '~/util/links' import { capitalize } from '~/util/str' -type AddUserValues = { - identityId: string - roleName: RoleKey -} - -export const defaultValues: AddUserValues = { - identityId: '', - roleName: 'viewer', -} - // Role descriptions for project-level roles const projectRoleDescriptions: Record = { admin: 'Control all aspects of the project', @@ -59,7 +54,7 @@ const fleetRoleDescriptions: Record = { viewer: 'View fleet-level resources', } -export const actorToItem = (actor: Actor): ListboxItem => ({ +export const actorToItem = (actor: Actor): ComboboxItem => ({ value: actor.id, label: ( <> @@ -83,9 +78,83 @@ export type EditRoleModalProps = AddRoleModalPro name?: string identityId: string identityType: IdentityType - defaultValues: { roleName: Role } + defaultValues: { roleName?: Role } + /** + * Whether the current user would still be able to edit this scope's roles + * through their other roles if their own direct role were lowered. Skips the + * self-demotion confirmation when true. + */ + keepsEditAccess?: boolean } +/** Names the user or group whose role a role modal edits, since the modal title doesn't */ +export const RoleModalIdentity = ({ name }: { name?: string }) => + name ? ( +
+ {name} +
+ ) : null + +/** + * Admin is the only role that grants permission to edit a scope's roles, so a + * user changing their own direct admin role to another role could lose that + * permission, unless another of their roles grants it. If it could, the edit + * form asks them to type their name first. + */ +export function useOwnAdminDemotion({ + policy, + identityId, + name, + roleName, + keepsEditAccess = false, +}: { + policy: Policy + identityId: string + name?: string + /** Role currently selected in the form */ + roleName: Role | undefined + keepsEditAccess?: boolean +}) { + const { me } = useCurrentUser() + const [typedText, setTypedText] = useState('') + const isDemotingSelf = + identityId === me.id && + rolesByIdFromPolicy(policy).get(me.id) === 'admin' && + roleName !== 'admin' && + !keepsEditAccess + const confirmText = name || 'change my own role' + return { + isDemotingSelf, + confirmText, + typedText, + setTypedText, + submitDisabled: isDemotingSelf + ? typeToConfirmReason(confirmText, typedText) + : undefined, + } +} + +export const OwnAdminDemotionConfirm = ({ + scope, + demotion, +}: { + scope: 'fleet' | 'silo' | 'project' + demotion: ReturnType +}) => + demotion.isDemotingSelf ? ( + <> + + + + ) : null + const AccessDocs = () => ( {docLinks.access.linkText} diff --git a/app/forms/fleet-access.tsx b/app/forms/fleet-access.tsx index 9b5ed04470..a4907ff319 100644 --- a/app/forms/fleet-access.tsx +++ b/app/forms/fleet-access.tsx @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { api, @@ -17,7 +17,7 @@ import { } from '@oxide/api' import { Access16Icon } from '@oxide/design-system/icons/react' -import { ListboxField } from '~/components/form/fields/ListboxField' +import { ComboboxField } from '~/components/form/fields/ComboboxField' import { SideModalForm } from '~/components/form/SideModalForm' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' import { ResourceLabel } from '~/ui/lib/SideModal' @@ -25,7 +25,9 @@ import { docLinks } from '~/util/links' import { actorToItem, + OwnAdminDemotionConfirm, RoleRadioField, + useOwnAdminDemotion, type AddRoleModalProps, type EditRoleModalProps, } from './access-util' @@ -69,7 +71,8 @@ export function FleetAccessAddUserSideModal({ loading={updatePolicy.isPending || updatePolicy.isSuccess} submitError={updatePolicy.error} > - {name} } onSubmit={({ roleName }) => { + if (!roleName) return updatePolicy.mutate({ body: updateRole({ identityId, identityType, roleName }, policy), }) @@ -122,6 +129,7 @@ export function FleetAccessEditUserSideModal({ }} > + ) diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx index 45be3fd2c2..9a112b3b7b 100644 --- a/app/forms/project-access.tsx +++ b/app/forms/project-access.tsx @@ -5,114 +5,58 @@ * * Copyright Oxide Computer Company */ -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' -import { - api, - queryClient, - updateRole, - useActorsNotInPolicy, - useApiMutation, -} from '@oxide/api' -import { Access16Icon } from '@oxide/design-system/icons/react' +import { api, queryClient, updateRole, useApiMutation } from '@oxide/api' -import { ListboxField } from '~/components/form/fields/ListboxField' -import { SideModalForm } from '~/components/form/SideModalForm' +import { ModalForm } from '~/components/form/ModalForm' import { useProjectSelector } from '~/hooks/use-params' -import { addToast } from '~/stores/toast' -import { SideModalFormDocs } from '~/ui/lib/ModalLinks' -import { ResourceLabel } from '~/ui/lib/SideModal' -import { docLinks } from '~/util/links' import { - actorToItem, - defaultValues, + OwnAdminDemotionConfirm, + RoleModalIdentity, RoleRadioField, - type AddRoleModalProps, + useOwnAdminDemotion, type EditRoleModalProps, } from './access-util' -export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalProps) { - const { project } = useProjectSelector() - - const actors = useActorsNotInPolicy(policy) - - const updatePolicy = useApiMutation(api.projectPolicyUpdate, { - onSuccess: () => { - queryClient.invalidateEndpoint('projectPolicyView') - // We don't have the name of the user or group, so we'll just have a generic message - addToast({ content: 'Role assigned' }) - onDismiss() - }, - }) - - const form = useForm({ defaultValues }) - - return ( - { - // actor is guaranteed to be in the list because it came from there - const identityType = actors.find((a) => a.id === identityId)!.identityType - - updatePolicy.mutate({ - path: { project }, - body: updateRole({ identityId, identityType, roleName }, policy), - }) - }} - loading={updatePolicy.isPending || updatePolicy.isSuccess} - submitError={updatePolicy.error} - onDismiss={onDismiss} - > - - - - - ) -} - -export function ProjectAccessEditUserSideModal({ +export function ProjectAccessEditRoleModal({ onDismiss, name, identityId, identityType, policy, defaultValues, + keepsEditAccess, }: EditRoleModalProps) { const { project } = useProjectSelector() + const isAssigning = !defaultValues.roleName const updatePolicy = useApiMutation(api.projectPolicyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('projectPolicyView') - addToast({ content: 'Role updated' }) onDismiss() }, }) const form = useForm({ defaultValues }) + const roleName = useWatch({ control: form.control, name: 'roleName' }) + const demotion = useOwnAdminDemotion({ + policy, + identityId, + name, + roleName, + keepsEditAccess, + }) return ( - - {name} - - } + title={isAssigning ? 'Add project role' : 'Edit project role'} + submitLabel={isAssigning ? 'Add project role' : 'Update role'} + submitDisabled={demotion.submitDisabled} onSubmit={({ roleName }) => { + if (!roleName) return updatePolicy.mutate({ path: { project }, body: updateRole({ identityId, identityType, roleName }, policy), @@ -120,10 +64,14 @@ export function ProjectAccessEditUserSideModal({ }} loading={updatePolicy.isPending || updatePolicy.isSuccess} submitError={updatePolicy.error} - onDismiss={onDismiss} + onDismiss={() => { + updatePolicy.reset() // clear API error state so it doesn't persist on next open + onDismiss() + }} > + - - + + ) } diff --git a/app/forms/silo-access.tsx b/app/forms/silo-access.tsx index bb0d6c09e0..260a7c8eee 100644 --- a/app/forms/silo-access.tsx +++ b/app/forms/silo-access.tsx @@ -5,136 +5,66 @@ * * Copyright Oxide Computer Company */ -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' -import { - api, - queryClient, - updateRole, - useActorsNotInPolicy, - useApiMutation, -} from '@oxide/api' -import { Access16Icon } from '@oxide/design-system/icons/react' +import { api, queryClient, updateRole, useApiMutation } from '@oxide/api' -import { ListboxField } from '~/components/form/fields/ListboxField' -import { SideModalForm } from '~/components/form/SideModalForm' -import { HL } from '~/components/HL' -import { useCurrentUser } from '~/hooks/use-current-user' -import { confirmAction } from '~/stores/confirm-action' -import { SideModalFormDocs } from '~/ui/lib/ModalLinks' -import { ResourceLabel } from '~/ui/lib/SideModal' -import { docLinks } from '~/util/links' +import { ModalForm } from '~/components/form/ModalForm' import { - actorToItem, - defaultValues, + OwnAdminDemotionConfirm, + RoleModalIdentity, RoleRadioField, - type AddRoleModalProps, + useOwnAdminDemotion, type EditRoleModalProps, } from './access-util' -export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalProps) { - const actors = useActorsNotInPolicy(policy) - - const updatePolicy = useApiMutation(api.policyUpdate, { - onSuccess: () => { - queryClient.invalidateEndpoint('policyView') - onDismiss() - }, - }) - - const form = useForm({ defaultValues }) - - return ( - { - updatePolicy.reset() // clear API error state so it doesn't persist on next open - onDismiss() - }} - onSubmit={({ identityId, roleName }) => { - // TODO: DRY logic - // actor is guaranteed to be in the list because it came from there - const identityType = actors.find((a) => a.id === identityId)!.identityType - - updatePolicy.mutate({ - body: updateRole({ identityId, identityType, roleName }, policy), - }) - }} - loading={updatePolicy.isPending || updatePolicy.isSuccess} - submitError={updatePolicy.error} - > - - - - - ) +/** + * Silo policy changes can change the current user's `siloAdmin` on /v1/me, + * which gates role actions, so refresh both. + */ +export function invalidateSiloPolicy() { + queryClient.invalidateEndpoint('policyView') + queryClient.invalidateEndpoint('currentUserView') } -export function SiloAccessEditUserSideModal({ +export function SiloAccessEditRoleModal({ onDismiss, name, identityId, identityType, policy, defaultValues, + keepsEditAccess, }: EditRoleModalProps) { - const { me } = useCurrentUser() + const isAssigning = !defaultValues.roleName const updatePolicy = useApiMutation(api.policyUpdate, { onSuccess: () => { - queryClient.invalidateEndpoint('policyView') + invalidateSiloPolicy() onDismiss() }, }) const form = useForm({ defaultValues }) + const roleName = useWatch({ control: form.control, name: 'roleName' }) + const demotion = useOwnAdminDemotion({ + policy, + identityId, + name, + roleName, + keepsEditAccess, + }) return ( - - {name} - - } + title={isAssigning ? 'Add silo role' : 'Edit silo role'} + submitLabel={isAssigning ? 'Add silo role' : 'Update role'} + submitDisabled={demotion.submitDisabled} onSubmit={({ roleName }) => { - const body = updateRole({ identityId, identityType, roleName }, policy) - // Only silo admins can edit the policy, so an admin who removes their - // own admin role may not be able to undo the change. "May" because - // they could still be an admin through a group. - if ( - identityId === me.id && - defaultValues.roleName === 'admin' && - roleName !== 'admin' - ) { - confirmAction({ - actionType: 'danger', - doAction: () => updatePolicy.mutateAsync({ body }), - modalTitle: 'Remove your own admin role', - modalContent: ( -

- You are removing the admin role from your own account. You may lose - the ability to manage access to this silo, including restoring your own - role. Are you sure? -

- ), - errorTitle: 'Could not update role', - }) - return - } - updatePolicy.mutate({ body }) + if (!roleName) return + updatePolicy.mutate({ + body: updateRole({ identityId, identityType, roleName }, policy), + }) }} loading={updatePolicy.isPending || updatePolicy.isSuccess} submitError={updatePolicy.error} @@ -143,8 +73,9 @@ export function SiloAccessEditUserSideModal({ onDismiss() }} > + - -
+ + ) } diff --git a/app/hooks/use-current-user.ts b/app/hooks/use-current-user.ts index 2f07b82c09..96c48008ee 100644 --- a/app/hooks/use-current-user.ts +++ b/app/hooks/use-current-user.ts @@ -7,6 +7,18 @@ */ import { api, q, usePrefetchedQuery } from '~/api/client' +import { ALL_ISH } from '~/util/consts' + +/** + * The current user's groups. ALL_ISH because permission checks derive roles + * from these memberships, and a group past the first page could be the one that + * grants access. Shared so every caller hits the cache key the loader prefetches. + */ +export const myGroupsQuery = (options?: { + staleTime?: number + enabled?: boolean + throwOnError?: boolean +}) => q(api.currentUserGroups, { query: { limit: ALL_ISH } }, options) /** * Access all the data fetched by the loader. Because of the `shouldRevalidate` @@ -16,6 +28,6 @@ import { api, q, usePrefetchedQuery } from '~/api/client' */ export function useCurrentUser() { const { data: me } = usePrefetchedQuery(q(api.currentUserView, {})) - const { data: myGroups } = usePrefetchedQuery(q(api.currentUserGroups, {})) + const { data: myGroups } = usePrefetchedQuery(myGroupsQuery()) return { me, myGroups } } diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index 5906e7de4f..47a06f0097 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -44,6 +44,8 @@ export const getVpcSubnetSelector = requireParams('project', 'vpc', 'subnet') export const getInternetGatewaySelector = requireParams('project', 'vpc', 'gateway') export const getSiloSelector = requireParams('silo') export const getSiloImageSelector = requireParams('image') +export const getSiloUserSelector = requireParams('userId') +export const getSiloGroupSelector = requireParams('groupId') export const getSshKeySelector = requireParams('sshKey') export const getIdpSelector = requireParams('silo', 'provider') export const getProjectImageSelector = requireParams('project', 'image') @@ -101,6 +103,8 @@ export const useInternetGatewaySelector = () => export const useFirewallRuleSelector = () => useSelectedParams(getFirewallRuleSelector) export const useSiloSelector = () => useSelectedParams(getSiloSelector) export const useSiloImageSelector = () => useSelectedParams(getSiloImageSelector) +export const useSiloUserSelector = () => useSelectedParams(getSiloUserSelector) +export const useSiloGroupSelector = () => useSelectedParams(getSiloGroupSelector) export const useIdpSelector = () => useSelectedParams(getIdpSelector) export const useSledParams = () => useSelectedParams(requireSledParams) export const useUpdateParams = () => useSelectedParams(requireUpdateParams) diff --git a/app/layouts/AuthenticatedLayout.tsx b/app/layouts/AuthenticatedLayout.tsx index b610138264..d95993f031 100644 --- a/app/layouts/AuthenticatedLayout.tsx +++ b/app/layouts/AuthenticatedLayout.tsx @@ -10,6 +10,7 @@ import { Outlet } from 'react-router' import { api, q, queryClient } from '@oxide/api' import { RouterDataErrorBoundary } from '~/components/ErrorBoundary' +import { myGroupsQuery } from '~/hooks/use-current-user' import { QuickActions } from '~/hooks/use-quick-actions' /** very important. see `currentUserLoader` and `useCurrentUser` */ @@ -27,7 +28,7 @@ export async function clientLoader() { const staleTime = 60000 await Promise.all([ queryClient.prefetchQuery(q(api.currentUserView, {}, { staleTime })), - queryClient.prefetchQuery(q(api.currentUserGroups, {}, { staleTime })), + queryClient.prefetchQuery(myGroupsQuery({ staleTime })), ]) return null } diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx index d0cb34a039..a7d4d54fe6 100644 --- a/app/layouts/ProjectLayoutBase.tsx +++ b/app/layouts/ProjectLayoutBase.tsx @@ -71,7 +71,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { { value: 'Floating IPs', path: pb.floatingIps(projectSelector) }, { value: 'External Subnets', path: pb.externalSubnets(projectSelector) }, { value: 'Affinity Groups', path: pb.affinity(projectSelector) }, - { value: 'Project Access', path: pb.projectAccess(projectSelector) }, + { value: 'Project Access', path: pb.projectGroups(projectSelector) }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -120,7 +120,10 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { Affinity Groups - + Project Access diff --git a/app/layouts/SiloLayout.tsx b/app/layouts/SiloLayout.tsx index 13d1e49dce..c695f8b2da 100644 --- a/app/layouts/SiloLayout.tsx +++ b/app/layouts/SiloLayout.tsx @@ -33,7 +33,7 @@ export default function SiloLayout() { { value: 'Projects', path: pb.projects() }, { value: 'Images', path: pb.siloImages() }, { value: 'Utilization', path: pb.siloUtilization() }, - { value: 'Silo Access', path: pb.siloAccess() }, + { value: 'Silo Access', path: pb.siloGroups() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -63,7 +63,7 @@ export default function SiloLayout() { Utilization - + Silo Access diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx deleted file mode 100644 index afed88b1f8..0000000000 --- a/app/pages/SiloAccessPage.tsx +++ /dev/null @@ -1,219 +0,0 @@ -/* - * 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 } from 'react' - -import { - api, - byGroupThenName, - deleteRole, - q, - queryClient, - useApiMutation, - usePrefetchedQuery, - useUserRows, - type IdentityType, - type RoleKey, -} from '@oxide/api' -import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' -import { Badge } from '@oxide/design-system/ui' - -import { DocsPopover } from '~/components/DocsPopover' -import { HL } from '~/components/HL' -import { - SiloAccessAddUserSideModal, - SiloAccessEditUserSideModal, -} from '~/forms/silo-access' -import { useCurrentUser } from '~/hooks/use-current-user' -import { useQuickActions } from '~/hooks/use-quick-actions' -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 { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' -import { TableActions, TableEmptyBox } from '~/ui/lib/Table' -import { identityTypeLabel, roleColor } from '~/util/access' -import { groupBy } from '~/util/array' -import { ALL_ISH } from '~/util/consts' -import { docLinks } from '~/util/links' - -const EmptyState = ({ onClick }: { onClick: () => void }) => ( - - } - title="No authorized users" - body="Give permission to view, edit, or administer this silo" - buttonText="Add user or group" - onClick={onClick} - /> - -) - -const policyView = q(api.policyView, {}) -const userList = q(api.userList, { query: { limit: ALL_ISH } }) -const groupList = q(api.groupList, { query: { limit: ALL_ISH } }) - -export async function clientLoader() { - await Promise.all([ - queryClient.prefetchQuery(policyView), - // used to resolve user names - queryClient.prefetchQuery(userList), - queryClient.prefetchQuery(groupList), - ]) - return null -} - -export const handle = { crumb: 'Silo Access' } - -type UserRow = { - id: string - identityType: IdentityType - name: string - siloRole: RoleKey | undefined -} - -const colHelper = createColumnHelper() - -export default function SiloAccessPage() { - const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) - - const { me } = useCurrentUser() - const { data: siloPolicy } = usePrefetchedQuery(policyView) - const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') - - const rows = useMemo(() => { - return groupBy(siloRows, (u) => u.id) - .map(([userId, userAssignments]) => { - const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName - - const { name, identityType } = userAssignments[0] - - const row: UserRow = { - id: userId, - identityType, - name, - siloRole, - } - - return row - }) - .sort(byGroupThenName) - }, [siloRows]) - - const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { - onSuccess: () => { - queryClient.invalidateEndpoint('policyView') - addToast({ content: 'Access removed' }) - }, - // TODO: handle 403 - }) - - // TODO: checkboxes and bulk delete? not sure - // TODO: disable delete on permissions you can't delete - - const columns = useMemo( - () => [ - colHelper.accessor('name', { header: 'Name' }), - colHelper.accessor('identityType', { - header: 'Type', - cell: (info) => identityTypeLabel[info.getValue()], - }), - colHelper.accessor('siloRole', { - header: 'Role', - cell: (info) => { - const role = info.getValue() - return role ? silo.{role} : null - }, - }), - // TODO: tooltips on disabled elements explaining why - getActionsCol((row: UserRow) => [ - { - label: 'Change role', - onActivate: () => setEditingUserRow(row), - disabled: !row.siloRole && "You don't have permission to change this user's role", - }, - // TODO: only show if you have permission to do this - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => updatePolicy({ body: deleteRole(row.id, siloPolicy) }), - label: ( - - the {row.siloRole} role for {row.name} - - ), - resourceKind: 'role assignment', - extraContent: - row.id === me.id ? 'This will remove your own silo access.' : undefined, - }), - disabled: !row.siloRole && "You don't have permission to delete this user", - }, - ]), - ], - [siloPolicy, updatePolicy, me] - ) - - const tableInstance = useReactTable({ - columns, - data: rows, - getCoreRowModel: getCoreRowModel(), - }) - - useQuickActions( - () => [ - { - value: 'Add user or group', - navGroup: 'Actions', - action: () => setAddModalOpen(true), - }, - ], - [] - ) - - return ( - <> - - }>Silo Access - } - summary="Roles determine who can view, edit, or administer this silo and the projects within it. If a user or group has both a silo and project role, the stronger role takes precedence." - links={[docLinks.keyConceptsIam, docLinks.access]} - /> - - - - setAddModalOpen(true)}>Add user or group - - {addModalOpen && ( - setAddModalOpen(false)} - policy={siloPolicy} - /> - )} - {editingUserRow?.siloRole && ( - setEditingUserRow(null)} - policy={siloPolicy} - name={editingUserRow.name} - identityId={editingUserRow.id} - identityType={editingUserRow.identityType} - defaultValues={{ roleName: editingUserRow.siloRole }} - /> - )} - {rows.length === 0 ? ( - setAddModalOpen(true)} /> - ) : ( - - )} - - ) -} diff --git a/app/pages/SiloGroupDetail.tsx b/app/pages/SiloGroupDetail.tsx new file mode 100644 index 0000000000..d79dd90a46 --- /dev/null +++ b/app/pages/SiloGroupDetail.tsx @@ -0,0 +1,45 @@ +/* + * 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 { useNavigate } from 'react-router' + +import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api' + +import { GroupMembersSideModal } from '~/components/access/GroupMembersSideModal' +import { useSiloPolicyScope } from '~/components/access/policy-scope' +import { titleCrumb } from '~/hooks/use-crumbs' +import { useSiloGroupSelector } from '~/hooks/use-params' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +import type { Route } from './+types/SiloGroupDetail' + +const groupView = ({ groupId }: PP.SiloGroup) => q(api.groupView, { path: { groupId } }) + +export async function clientLoader({ params }: Route.ClientLoaderArgs) { + const { groupId } = params + // fetchQuery throws on a bad ID so the error boundary renders Not Found + await queryClient.fetchQuery(groupView({ groupId })) + return null +} + +export const handle = titleCrumb('Group') + +export default function SiloGroupDetail() { + const selector = useSiloGroupSelector() + const navigate = useNavigate() + const { data: group } = usePrefetchedQuery(groupView(selector)) + const scope = useSiloPolicyScope() + + return ( + navigate(pb.siloGroups())} + scope={scope} + /> + ) +} diff --git a/app/pages/SiloGroupsTab.tsx b/app/pages/SiloGroupsTab.tsx new file mode 100644 index 0000000000..566e585c11 --- /dev/null +++ b/app/pages/SiloGroupsTab.tsx @@ -0,0 +1,16 @@ +/* + * 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 { AccessGroupsTab } from '~/components/access/AccessGroupsTab' +import { useSiloPolicyScope } from '~/components/access/policy-scope' +import { titleCrumb } from '~/hooks/use-crumbs' + +export const handle = titleCrumb('Groups') + +export default function SiloGroupsTab() { + return +} diff --git a/app/pages/SiloUserDetail.tsx b/app/pages/SiloUserDetail.tsx new file mode 100644 index 0000000000..da699e9db8 --- /dev/null +++ b/app/pages/SiloUserDetail.tsx @@ -0,0 +1,58 @@ +/* + * 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 { useNavigate } from 'react-router' + +import { + api, + groupsOfUser, + q, + queryClient, + useGroupsByUserId, + usePrefetchedQuery, +} from '@oxide/api' + +import { useSiloPolicyScope } from '~/components/access/policy-scope' +import { UserDetailsSideModal } from '~/components/access/UserDetailsSideModal' +import { titleCrumb } from '~/hooks/use-crumbs' +import { useSiloUserSelector } from '~/hooks/use-params' +import { ALL_ISH } from '~/util/consts' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +import type { Route } from './+types/SiloUserDetail' + +const userView = ({ userId }: PP.SiloUser) => q(api.userView, { path: { userId } }) +// prefetched by the parent Access loader +const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) + +export async function clientLoader({ params }: Route.ClientLoaderArgs) { + const { userId } = params + // fetchQuery throws on a bad ID so the error boundary renders Not Found + await queryClient.fetchQuery(userView({ userId })) + return null +} + +export const handle = titleCrumb('User') + +export default function SiloUserDetail() { + const selector = useSiloUserSelector() + const navigate = useNavigate() + const { data: user } = usePrefetchedQuery(userView(selector)) + const scope = useSiloPolicyScope() + const { data: groups } = usePrefetchedQuery(groupListAll) + const groupsByUserId = useGroupsByUserId(groups.items) + + return ( + navigate(pb.siloUsers())} + scope={scope} + userGroups={groupsOfUser(groupsByUserId, user.id)} + /> + ) +} diff --git a/app/pages/SiloUsersGroupsPage.tsx b/app/pages/SiloUsersGroupsPage.tsx new file mode 100644 index 0000000000..2a09b556fb --- /dev/null +++ b/app/pages/SiloUsersGroupsPage.tsx @@ -0,0 +1,35 @@ +/* + * 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 { + prefetchUsersAndGroups, + useSiloPolicyScope, +} from '~/components/access/policy-scope' +import { UsersGroupsLayout } from '~/components/access/UsersGroupsLayout' +import { makeCrumb } from '~/hooks/use-crumbs' +import { pb } from '~/util/path-builder' + +// This loader runs once on entry; react-router won't re-run it when navigating +// between sibling tab routes. +export async function clientLoader() { + await prefetchUsersAndGroups() + return null +} + +export const handle = makeCrumb('Silo Access', pb.siloGroups()) + +export default function SiloUsersGroupsPage() { + return ( + + ) +} diff --git a/app/pages/SiloUsersTab.tsx b/app/pages/SiloUsersTab.tsx new file mode 100644 index 0000000000..f8401a0e2e --- /dev/null +++ b/app/pages/SiloUsersTab.tsx @@ -0,0 +1,16 @@ +/* + * 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 { AccessUsersTab } from '~/components/access/AccessUsersTab' +import { useSiloPolicyScope } from '~/components/access/policy-scope' +import { titleCrumb } from '~/hooks/use-crumbs' + +export const handle = titleCrumb('Users') + +export default function SiloUsersTab() { + return +} diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx deleted file mode 100644 index 4cfb1f8da7..0000000000 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ /dev/null @@ -1,261 +0,0 @@ -/* - * 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 } from 'react' -import * as R from 'remeda' - -import { - api, - byGroupThenName, - deleteRole, - q, - queryClient, - roleOrder, - useApiMutation, - usePrefetchedQuery, - useUserRows, - type IdentityType, - type RoleKey, -} from '@oxide/api' -import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' -import { Badge } from '@oxide/design-system/ui' - -import { DocsPopover } from '~/components/DocsPopover' -import { HL } from '~/components/HL' -import { ListPlusCell } from '~/components/ListPlusCell' -import { - ProjectAccessAddUserSideModal, - ProjectAccessEditUserSideModal, -} from '~/forms/project-access' -import { useProjectSelector } from '~/hooks/use-params' -import { useQuickActions } from '~/hooks/use-quick-actions' -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 { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' -import { TableActions, TableEmptyBox } from '~/ui/lib/Table' -import { TipIcon } from '~/ui/lib/TipIcon' -import { identityTypeLabel, roleColor } from '~/util/access' -import { groupBy } from '~/util/array' -import { ALL_ISH } from '~/util/consts' -import { docLinks } from '~/util/links' -import type * as PP from '~/util/path-params' - -import type { Route } from './+types/ProjectAccessPage' - -const policyView = q(api.policyView, {}) -const projectPolicyView = ({ project }: PP.Project) => - q(api.projectPolicyView, { path: { project } }) -const userList = q(api.userList, { query: { limit: ALL_ISH } }) -const groupList = q(api.groupList, { query: { limit: ALL_ISH } }) - -const EmptyState = ({ onClick }: { onClick: () => void }) => ( - - } - title="No authorized users" - body="Give permission to view, edit, or administer this project" - buttonText="Add user or group to project" - onClick={onClick} - /> - -) - -export async function clientLoader({ params }: Route.ClientLoaderArgs) { - const { project } = params - await Promise.all([ - queryClient.prefetchQuery(policyView), - queryClient.prefetchQuery(projectPolicyView({ project })), - // used to resolve user names - queryClient.prefetchQuery(userList), - queryClient.prefetchQuery(groupList), - ]) - return null -} - -export const handle = { crumb: 'Project Access' } - -type UserRow = { - id: string - identityType: IdentityType - name: string - projectRole: RoleKey | undefined - roleBadges: { roleSource: string; roleName: RoleKey }[] -} - -const colHelper = createColumnHelper() - -export default function ProjectAccessPage() { - const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) - const projectSelector = useProjectSelector() - - const { data: siloPolicy } = usePrefetchedQuery(policyView) - const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') - - const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) - const projectRows = useUserRows(projectPolicy.roleAssignments, 'project') - - const rows: UserRow[] = useMemo(() => { - return 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') - - const roleBadges = R.sortBy( - [siloAccessRow, projectAccessRow].filter((r) => !!r), - (r) => roleOrder[r.roleName] // sorts strongest role first - ) - - return { - id: userId, - identityType, - name, - projectRole: projectAccessRow?.roleName, - roleBadges, - } - }) - .sort(byGroupThenName) - }, [siloRows, projectRows]) - - const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { - onSuccess: () => { - queryClient.invalidateEndpoint('projectPolicyView') - addToast({ content: 'Access removed' }) - }, - // TODO: handle 403 - }) - - // TODO: checkboxes and bulk delete? not sure - // TODO: disable delete on permissions you can't delete - - const columns = useMemo( - () => [ - colHelper.accessor('name', { header: 'Name' }), - colHelper.accessor('identityType', { - header: 'Type', - cell: (info) => identityTypeLabel[info.getValue()], - }), - colHelper.accessor('roleBadges', { - header: () => ( - - Role - - A user or group's effective role for this project is the strongest role - on either the silo or project - - - ), - cell: (info) => ( - - {info.getValue().map(({ roleName, roleSource }) => ( - - {roleSource}.{roleName} - - ))} - - ), - }), - - // TODO: tooltips on disabled elements explaining why - getActionsCol((row: UserRow) => [ - { - label: 'Change role', - onActivate: () => setEditingUserRow(row), - disabled: - !row.projectRole && "You don't have permission to change this user's role", - }, - // TODO: only show if you have permission to do this - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => - updatePolicy({ - path: { project: projectSelector.project }, - body: deleteRole(row.id, projectPolicy), - }), - // TODO: explain that this 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. - label: ( - - the {row.projectRole} role for {row.name} - - ), - resourceKind: 'role assignment', - }), - disabled: !row.projectRole && "You don't have permission to delete this user", - }, - ]), - ], - [projectPolicy, projectSelector.project, updatePolicy] - ) - - const tableInstance = useReactTable({ - columns, - data: rows, - getCoreRowModel: getCoreRowModel(), - }) - - useQuickActions( - () => [ - { - value: 'Add user or group', - navGroup: 'Actions', - action: () => setAddModalOpen(true), - }, - ], - [] - ) - - return ( - <> - - }>Project Access - } - summary="Roles determine who can view, edit, or administer this project. Silo roles are inherited from the silo. If a user or group has both a silo and project role, the stronger role takes precedence." - links={[docLinks.keyConceptsIam, docLinks.access]} - /> - - - - setAddModalOpen(true)}>Add user or group - - {projectPolicy && addModalOpen && ( - setAddModalOpen(false)} - policy={projectPolicy} - /> - )} - {projectPolicy && editingUserRow?.projectRole && ( - setEditingUserRow(null)} - policy={projectPolicy} - name={editingUserRow.name} - identityId={editingUserRow.id} - identityType={editingUserRow.identityType} - defaultValues={{ roleName: editingUserRow.projectRole }} - /> - )} - {rows.length === 0 ? ( - setAddModalOpen(true)} /> - ) : ( -
- )} - - ) -} diff --git a/app/pages/project/access/ProjectGroupDetail.tsx b/app/pages/project/access/ProjectGroupDetail.tsx new file mode 100644 index 0000000000..baa87803cc --- /dev/null +++ b/app/pages/project/access/ProjectGroupDetail.tsx @@ -0,0 +1,47 @@ +/* + * 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 { useNavigate } from 'react-router' + +import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api' + +import { GroupMembersSideModal } from '~/components/access/GroupMembersSideModal' +import { useProjectPolicyScope } from '~/components/access/policy-scope' +import { titleCrumb } from '~/hooks/use-crumbs' +import { useProjectSelector, useSiloGroupSelector } from '~/hooks/use-params' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +import type { Route } from './+types/ProjectGroupDetail' + +const groupView = ({ groupId }: PP.SiloGroup) => q(api.groupView, { path: { groupId } }) + +export async function clientLoader({ params }: Route.ClientLoaderArgs) { + const { groupId } = params + // fetchQuery throws on a bad ID so the error boundary renders Not Found. + // Policies are prefetched by the parent Access loader. + await queryClient.fetchQuery(groupView({ groupId })) + return null +} + +export const handle = titleCrumb('Group') + +export default function ProjectGroupDetail() { + const projectSelector = useProjectSelector() + const groupSelector = useSiloGroupSelector() + const navigate = useNavigate() + const { data: group } = usePrefetchedQuery(groupView(groupSelector)) + const scope = useProjectPolicyScope() + + return ( + navigate(pb.projectGroups(projectSelector))} + scope={scope} + /> + ) +} diff --git a/app/pages/project/access/ProjectGroupsTab.tsx b/app/pages/project/access/ProjectGroupsTab.tsx new file mode 100644 index 0000000000..a13d74e570 --- /dev/null +++ b/app/pages/project/access/ProjectGroupsTab.tsx @@ -0,0 +1,16 @@ +/* + * 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 { AccessGroupsTab } from '~/components/access/AccessGroupsTab' +import { useProjectPolicyScope } from '~/components/access/policy-scope' +import { titleCrumb } from '~/hooks/use-crumbs' + +export const handle = titleCrumb('Groups') + +export default function ProjectGroupsTab() { + return +} diff --git a/app/pages/project/access/ProjectUserDetail.tsx b/app/pages/project/access/ProjectUserDetail.tsx new file mode 100644 index 0000000000..453ee5b242 --- /dev/null +++ b/app/pages/project/access/ProjectUserDetail.tsx @@ -0,0 +1,57 @@ +/* + * 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 { useNavigate } from 'react-router' + +import { + api, + groupsOfUser, + q, + queryClient, + useGroupsByUserId, + usePrefetchedQuery, +} from '@oxide/api' + +import { groupListAll, useProjectPolicyScope } from '~/components/access/policy-scope' +import { UserDetailsSideModal } from '~/components/access/UserDetailsSideModal' +import { titleCrumb } from '~/hooks/use-crumbs' +import { useProjectSelector, useSiloUserSelector } from '~/hooks/use-params' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +import type { Route } from './+types/ProjectUserDetail' + +const userView = ({ userId }: PP.SiloUser) => q(api.userView, { path: { userId } }) + +export async function clientLoader({ params }: Route.ClientLoaderArgs) { + const { userId } = params + // fetchQuery throws on a bad ID so the error boundary renders Not Found. + // Policies and groups are prefetched by the parent Access loader. + await queryClient.fetchQuery(userView({ userId })) + return null +} + +export const handle = titleCrumb('User') + +export default function ProjectUserDetail() { + const projectSelector = useProjectSelector() + const userSelector = useSiloUserSelector() + const navigate = useNavigate() + const { data: user } = usePrefetchedQuery(userView(userSelector)) + const scope = useProjectPolicyScope() + const { data: groups } = usePrefetchedQuery(groupListAll) + const groupsByUserId = useGroupsByUserId(groups.items) + + return ( + navigate(pb.projectUsers(projectSelector))} + scope={scope} + userGroups={groupsOfUser(groupsByUserId, user.id)} + /> + ) +} diff --git a/app/pages/project/access/ProjectUsersGroupsPage.tsx b/app/pages/project/access/ProjectUsersGroupsPage.tsx new file mode 100644 index 0000000000..b36d8f8e2a --- /dev/null +++ b/app/pages/project/access/ProjectUsersGroupsPage.tsx @@ -0,0 +1,43 @@ +/* + * 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 { + prefetchUsersAndGroups, + useProjectPolicyScope, +} from '~/components/access/policy-scope' +import { UsersGroupsLayout } from '~/components/access/UsersGroupsLayout' +import { makeCrumb } from '~/hooks/use-crumbs' +import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' +import { pb } from '~/util/path-builder' + +import type { Route } from './+types/ProjectUsersGroupsPage' + +// This loader runs once on entry; react-router won't re-run it when navigating +// between sibling tab routes. +export async function clientLoader({ params }: Route.ClientLoaderArgs) { + const { project } = params + await prefetchUsersAndGroups(project) + return null +} + +export const handle = makeCrumb('Project Access', (p) => + pb.projectGroups(getProjectSelector(p)) +) + +export default function ProjectUsersGroupsPage() { + const projectSelector = useProjectSelector() + const policyScope = useProjectPolicyScope() + return ( + + ) +} diff --git a/app/pages/project/access/ProjectUsersTab.tsx b/app/pages/project/access/ProjectUsersTab.tsx new file mode 100644 index 0000000000..f4a153b769 --- /dev/null +++ b/app/pages/project/access/ProjectUsersTab.tsx @@ -0,0 +1,16 @@ +/* + * 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 { AccessUsersTab } from '~/components/access/AccessUsersTab' +import { useProjectPolicyScope } from '~/components/access/policy-scope' +import { titleCrumb } from '~/hooks/use-crumbs' + +export const handle = titleCrumb('Users') + +export default function ProjectUsersTab() { + return +} diff --git a/app/pages/system/FleetAccessPage.tsx b/app/pages/system/FleetAccessPage.tsx index da6620e86b..6dd16cffcc 100644 --- a/app/pages/system/FleetAccessPage.tsx +++ b/app/pages/system/FleetAccessPage.tsx @@ -19,22 +19,20 @@ import { queryClient, useApiMutation, usePrefetchedQuery, - useUserRows, type FleetRole, type IdentityType, } from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' +import { roleActions } from '~/components/access/roleActions' import { DocsPopover } from '~/components/DocsPopover' -import { HL } from '~/components/HL' import { FleetAccessAddUserSideModal, FleetAccessEditUserSideModal, } from '~/forms/fleet-access' import { useCurrentUser } from '~/hooks/use-current-user' import { useQuickActions } from '~/hooks/use-quick-actions' -import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' @@ -107,17 +105,34 @@ export default function FleetAccessPage() { const navigate = useNavigate() const { me } = useCurrentUser() const { data: fleetPolicy } = usePrefetchedQuery(systemPolicyView) + const { data: users } = usePrefetchedQuery(userList) + const { data: groups } = usePrefetchedQuery(groupList) const { data: silos } = usePrefetchedQuery(siloList) - const fleetRows = useUserRows(fleetPolicy.roleAssignments, 'fleet') const rows: AccessRow[] = useMemo(() => { - const assignmentRows: AssignmentRow[] = groupBy(fleetRows, (u) => u.id) - .map(([userId, userAssignments]) => { - const { name, identityType } = userAssignments[0] - // non-null: userAssignments is non-empty (groupBy only creates groups for existing items) + // A user might not appear here if they're not in the current user's silo + // (can happen for cross-silo fleet assignments), so fall back to the raw ID. + // The name column detects the fallback and shows an explanatory tooltip. + const nameById = new Map( + [...users.items, ...groups.items].map((u) => [u.id, u.displayName]) + ) + + const assignmentRows: AssignmentRow[] = groupBy( + fleetPolicy.roleAssignments, + (ra) => ra.identityId + ) + .map(([userId, assignments]) => { + const { identityType } = assignments[0] + // non-null: assignments is non-empty (groupBy only creates groups for existing items) // getEffectiveRole needed because API allows multiple fleet role assignments for the same user, even though that's probably rare - const fleetRole = getEffectiveRole(userAssignments.map((a) => a.roleName))! - return { kind: 'assignment' as const, id: userId, identityType, name, fleetRole } + const fleetRole = getEffectiveRole(assignments.map((a) => a.roleName))! + return { + kind: 'assignment' as const, + id: userId, + identityType, + name: nameById.get(userId) ?? userId, + fleetRole, + } }) .sort(byGroupThenName) @@ -135,7 +150,7 @@ export default function FleetAccessPage() { ) return [...assignmentRows, ...mappingRows] - }, [fleetRows, silos]) + }, [fleetPolicy, users, groups, silos]) const { mutateAsync: updatePolicy } = useApiMutation(api.systemPolicyUpdate, { onSuccess: () => { @@ -214,26 +229,21 @@ export default function FleetAccessPage() { onActivate: () => navigate(pb.siloFleetRoles({ silo: row.siloName })), }, ]) - .with({ kind: 'assignment' }, (row) => [ - { - label: 'Change role', - onActivate: () => setEditingUserRow(row), - }, - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => updatePolicy({ body: deleteRole(row.id, fleetPolicy) }), - label: ( - - the {row.fleetRole} role for {row.name} - - ), - resourceKind: 'role assignment', - extraContent: - row.id === me.id ? 'This will remove your own fleet access.' : undefined, + .with({ kind: 'assignment' }, (row) => { + // Fleet admin can come from a silo's mapped_fleet_roles, which the + // client can't cheaply determine, so leave actions enabled and let + // the API reject unauthorized changes + const actions = roleActions('fleet', true) + return [ + actions.change(() => setEditingUserRow(row)), + actions.remove({ + name: row.name, + directRole: row.fleetRole, + isSelf: row.id === me.id, + doRemove: () => updatePolicy({ body: deleteRole(row.id, fleetPolicy) }), }), - }, - ]) + ] + }) .exhaustive() ), ], diff --git a/app/routes.ts b/app/routes.ts index f6168f9841..67dba95726 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -149,7 +149,16 @@ export default [ route('projects-new', 'forms/project-create.tsx'), route('projects/:project/edit', 'forms/project-edit.tsx'), ]), - route('access', 'pages/SiloAccessPage.tsx'), + route('access', 'routes/silo-access-redirect.tsx'), + // Users and Groups are sibling tabs sharing one Access page + layout('pages/SiloUsersGroupsPage.tsx', [ + route('access/users', 'pages/SiloUsersTab.tsx', [ + route(':userId', 'pages/SiloUserDetail.tsx'), + ]), + route('access/groups', 'pages/SiloGroupsTab.tsx', [ + route(':groupId', 'pages/SiloGroupDetail.tsx'), + ]), + ]), ]), route('projects', 'routes/projects.tsx', [ route(':project', 'layouts/SerialConsoleLayout.tsx', [ @@ -260,7 +269,16 @@ export default [ id: 'projects/project/images/image/edit', }), ]), - route('access', 'pages/project/access/ProjectAccessPage.tsx'), + route('access', 'routes/project-access-redirect.tsx'), + // Users and Groups are sibling tabs sharing one Access page + layout('pages/project/access/ProjectUsersGroupsPage.tsx', [ + route('access/users', 'pages/project/access/ProjectUsersTab.tsx', [ + route(':userId', 'pages/project/access/ProjectUserDetail.tsx'), + ]), + route('access/groups', 'pages/project/access/ProjectGroupsTab.tsx', [ + route(':groupId', 'pages/project/access/ProjectGroupDetail.tsx'), + ]), + ]), layout('routes/affinity-create.tsx', [ route('affinity-new', 'forms/anti-affinity-group-create.tsx'), ]), diff --git a/app/routes/project-access-redirect.tsx b/app/routes/project-access-redirect.tsx new file mode 100644 index 0000000000..59311a2dda --- /dev/null +++ b/app/routes/project-access-redirect.tsx @@ -0,0 +1,22 @@ +/* + * 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 { redirect } from 'react-router' + +import { getProjectSelector } from '~/hooks/use-params' +import { pb } from '~/util/path-builder' + +import type { Route } from './+types/project-access-redirect' + +// /access has no content of its own; land on the Groups tab +export function clientLoader({ params }: Route.ClientLoaderArgs) { + return redirect(pb.projectGroups(getProjectSelector(params))) +} + +export default function ProjectAccessRedirect() { + return null +} diff --git a/app/routes/silo-access-redirect.tsx b/app/routes/silo-access-redirect.tsx new file mode 100644 index 0000000000..ff12fb6a47 --- /dev/null +++ b/app/routes/silo-access-redirect.tsx @@ -0,0 +1,19 @@ +/* + * 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 { redirect } from 'react-router' + +import { pb } from '~/util/path-builder' + +// /access has no content of its own; land on the Groups tab +export function clientLoader() { + return redirect(pb.siloGroups()) +} + +export default function SiloAccessRedirect() { + return null +} diff --git a/app/stores/confirm-action.ts b/app/stores/confirm-action.ts index a8d2f0d90f..6859240fcd 100644 --- a/app/stores/confirm-action.ts +++ b/app/stores/confirm-action.ts @@ -17,6 +17,8 @@ type ActionConfig = { /** Title of error toast */ errorTitle: string actionType: 'primary' | 'danger' + /** If present, the user must type this text before confirming */ + confirmText?: string } type ConfirmActionStore = { diff --git a/app/table/cells/EmptyCell.tsx b/app/table/cells/EmptyCell.tsx index 372c960b4f..6ae533a280 100644 --- a/app/table/cells/EmptyCell.tsx +++ b/app/table/cells/EmptyCell.tsx @@ -10,4 +10,6 @@ import { classed } from '~/util/classed' export const EmptyCell = () => — +export const ErrorCell = () => Error + export const SkeletonCell = classed.div`h-4 w-12 rounded-md bg-tertiary motion-safe:animate-pulse` diff --git a/app/table/cells/MemberCountCell.tsx b/app/table/cells/MemberCountCell.tsx new file mode 100644 index 0000000000..97e3cfb6bb --- /dev/null +++ b/app/table/cells/MemberCountCell.tsx @@ -0,0 +1,20 @@ +/* + * 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 { groupMemberList } from '~/api' +import { ErrorCell, SkeletonCell } from '~/table/cells/EmptyCell' + +export function MemberCountCell({ groupId }: { groupId: string }) { + // The loader prefetches member lists without awaiting them, so they may not + // be in cache at render time + const { data, isError } = useQuery(groupMemberList({ groupId })) + if (isError) return + if (!data) return + return <>{data.items.length} +} diff --git a/app/ui/lib/DropdownMenu.tsx b/app/ui/lib/DropdownMenu.tsx index 066d43c6f6..75d1c42535 100644 --- a/app/ui/lib/DropdownMenu.tsx +++ b/app/ui/lib/DropdownMenu.tsx @@ -14,6 +14,7 @@ import { Link } from 'react-router' import { OpenLink12Icon } from '@oxide/design-system/icons/react' import { Wrap } from '../util/wrap' +import { useIsInModal, useIsInSideModal } from './modal-context' import { Tooltip } from './Tooltip' // Re-export Root with modal={false} default to prevent scroll locking @@ -62,6 +63,7 @@ type ContentProps = { anchor?: AnchorProp /** Spacing in px between trigger and menu */ gap?: 8 + /** Overrides the default, which is derived from modal context */ zIndex?: ZIndex collisionPadding?: React.ComponentProps['collisionPadding'] } @@ -71,15 +73,22 @@ export function Content({ children, anchor = 'bottom end', gap, - zIndex = 'dropdown', + zIndex, // keep menus off the viewport edge, mainly for small screens collisionPadding = 12, }: ContentProps) { const { side, align, sideOffset, alignOffset } = parseAnchor(anchor, gap) + const isInModal = useIsInModal() + const isInSideModal = useIsInSideModal() + const contextZIndex: ZIndex = isInModal + ? 'modal' + : isInSideModal + ? 'sideModal' + : 'dropdown' return ( ) diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 721e74ad56..61c4a37f58 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -566,16 +566,40 @@ exports[`breadcrumbs 2`] = ` "label": "p", "path": "/projects/p/instances", }, + ], + "projectEdit (/projects/p/edit)": [ + { + "label": "Projects", + "path": "/projects", + }, + ], + "projectGroup (/projects/p/access/groups/c2e7b1d4-8f3a-4c6e-b9d1-0a5f7e3c2b8d)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, { "label": "Project Access", - "path": "/projects/p/access", + "path": "/projects/p/access/groups", }, ], - "projectEdit (/projects/p/edit)": [ + "projectGroups (/projects/p/access/groups)": [ { "label": "Projects", "path": "/projects", }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "Project Access", + "path": "/projects/p/access/groups", + }, ], "projectImage (/projects/p/images/im)": [ { @@ -619,6 +643,34 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/images", }, ], + "projectUser (/projects/p/access/users/a9d4e8f2-3c1b-4f7a-9e2d-6b8c0f1a2d3e)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "Project Access", + "path": "/projects/p/access/groups", + }, + ], + "projectUsers (/projects/p/access/users)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "Project Access", + "path": "/projects/p/access/groups", + }, + ], "projects (/projects)": [ { "label": "Projects", @@ -681,12 +733,7 @@ exports[`breadcrumbs 2`] = ` "path": "/system/silos/s/idps", }, ], - "siloAccess (/access)": [ - { - "label": "Silo Access", - "path": "/access", - }, - ], + "siloAccess (/access)": [], "siloFleetRoles (/system/silos/s/fleet-roles)": [ { "label": "Silos", @@ -701,6 +748,18 @@ exports[`breadcrumbs 2`] = ` "path": "/system/silos/s/fleet-roles", }, ], + "siloGroup (/access/groups/c2e7b1d4-8f3a-4c6e-b9d1-0a5f7e3c2b8d)": [ + { + "label": "Silo Access", + "path": "/access/groups", + }, + ], + "siloGroups (/access/groups)": [ + { + "label": "Silo Access", + "path": "/access/groups", + }, + ], "siloIdps (/system/silos/s/idps)": [ { "label": "Silos", @@ -797,6 +856,18 @@ exports[`breadcrumbs 2`] = ` "path": "/system/silos/s/subnet-pools", }, ], + "siloUser (/access/users/a9d4e8f2-3c1b-4f7a-9e2d-6b8c0f1a2d3e)": [ + { + "label": "Silo Access", + "path": "/access/groups", + }, + ], + "siloUsers (/access/users)": [ + { + "label": "Silo Access", + "path": "/access/groups", + }, + ], "siloUtilization (/utilization)": [ { "label": "Utilization", diff --git a/app/util/links.ts b/app/util/links.ts index d1ea70f95b..05c5a58916 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -73,6 +73,18 @@ export const docLinks = { href: 'https://docs.oxide.computer/guides/configuring-access#_silo_admin_group_and_mapped_fleet_roles', linkText: 'Fleet Role Mappings', }, + managingGroups: { + href: 'https://docs.oxide.computer/guides/configuring-access#_managing_users_and_groups', + linkText: 'Managing Groups', + }, + projectRoles: { + href: 'https://docs.oxide.computer/guides/configuring-access#_project', + linkText: 'Project Roles', + }, + siloRoles: { + href: 'https://docs.oxide.computer/guides/configuring-access#_silo', + linkText: 'Silo Roles', + }, floatingIps: { href: 'https://docs.oxide.computer/guides/managing-floating-ips', linkText: 'Floating IPs', diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index e1feb23286..068223d176 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -57,6 +57,8 @@ const params = { router: 'r', route: 'rr', receiver: 'rc', + userId: 'a9d4e8f2-3c1b-4f7a-9e2d-6b8c0f1a2d3e', + groupId: 'c2e7b1d4-8f3a-4c6e-b9d1-0a5f7e3c2b8d', } test('path builder', () => { @@ -105,9 +107,13 @@ test('path builder', () => { "project": "/projects/p/instances", "projectAccess": "/projects/p/access", "projectEdit": "/projects/p/edit", + "projectGroup": "/projects/p/access/groups/c2e7b1d4-8f3a-4c6e-b9d1-0a5f7e3c2b8d", + "projectGroups": "/projects/p/access/groups", "projectImage": "/projects/p/images/im", "projectImages": "/projects/p/images", "projectImagesNew": "/projects/p/images-new", + "projectUser": "/projects/p/access/users/a9d4e8f2-3c1b-4f7a-9e2d-6b8c0f1a2d3e", + "projectUsers": "/projects/p/access/users", "projects": "/projects", "projectsNew": "/projects-new", "samlIdp": "/system/silos/s/idps/saml/pr", @@ -115,6 +121,8 @@ test('path builder', () => { "silo": "/system/silos/s/idps", "siloAccess": "/access", "siloFleetRoles": "/system/silos/s/fleet-roles", + "siloGroup": "/access/groups/c2e7b1d4-8f3a-4c6e-b9d1-0a5f7e3c2b8d", + "siloGroups": "/access/groups", "siloIdps": "/system/silos/s/idps", "siloIdpsNew": "/system/silos/s/idps-new", "siloImage": "/images/im", @@ -123,6 +131,8 @@ test('path builder', () => { "siloQuotas": "/system/silos/s/quotas", "siloScim": "/system/silos/s/scim", "siloSubnetPools": "/system/silos/s/subnet-pools", + "siloUser": "/access/users/a9d4e8f2-3c1b-4f7a-9e2d-6b8c0f1a2d3e", + "siloUsers": "/access/users", "siloUtilization": "/utilization", "silos": "/system/silos", "silosNew": "/system/silos-new", @@ -187,6 +197,7 @@ test('breadcrumbs', () => { .map(([key]) => key) expect(zeroCrumbKeys).toMatchInlineSnapshot(` [ + "siloAccess (/access)", "deviceSuccess (/device/success)", ] `) diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index abe51e79ec..5dc3818286 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -21,6 +21,12 @@ export const pb = { projectEdit: (params: PP.Project) => href('/projects/:project/edit', params), projectAccess: (params: PP.Project) => href('/projects/:project/access', params), + projectUsers: (params: PP.Project) => href('/projects/:project/access/users', params), + projectUser: (params: PP.Project & PP.SiloUser) => + href('/projects/:project/access/users/:userId', params), + projectGroups: (params: PP.Project) => href('/projects/:project/access/groups', params), + projectGroup: (params: PP.Project & PP.SiloGroup) => + href('/projects/:project/access/groups/:groupId', params), projectImages: (params: PP.Project) => href('/projects/:project/images', params), projectImagesNew: (params: PP.Project) => href('/projects/:project/images-new', params), projectImage: (params: PP.Image) => href('/projects/:project/images/:image', params), @@ -124,6 +130,10 @@ export const pb = { siloUtilization: () => href('/utilization'), siloAccess: () => href('/access'), + siloUsers: () => href('/access/users'), + siloUser: (params: PP.SiloUser) => href('/access/users/:userId', params), + siloGroups: () => href('/access/groups'), + siloGroup: (params: PP.SiloGroup) => href('/access/groups/:groupId', params), siloImages: () => href('/images'), siloImage: (params: PP.SiloImage) => href('/images/:image', params), diff --git a/app/util/path-params.ts b/app/util/path-params.ts index 7e16ba201f..47c1f5300c 100644 --- a/app/util/path-params.ts +++ b/app/util/path-params.ts @@ -32,4 +32,6 @@ export type AntiAffinityGroup = Required export type SubnetPool = Required export type AlertReceiver = Required export type SupportBundle = Required +export type SiloUser = Required +export type SiloGroup = Required export type Disk = Required diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 0c38ed297b..39bbfdafaa 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -102,6 +102,7 @@ export const handlers = makeHandlers({ loginLocal: ({ body: { password } }) => (password === 'bad' ? 401 : 200), groupList: (params) => paginated(params.query, db.userGroups), groupView: (params) => lookupById(db.userGroups, params.path.groupId), + userView: (params) => lookupById(db.users, params.path.userId), projectList: ({ query, cookies }) => { // this is used to test for the IdP misconfig situation where the user has // no role on the silo (see error-pages.e2e.ts). requireRole checks for _at @@ -3157,5 +3158,4 @@ export const handlers = makeHandlers({ userLogout: NotImplemented, userSessionList: NotImplemented, userTokenList: NotImplemented, - userView: NotImplemented, }) diff --git a/mock-api/role-assignment.ts b/mock-api/role-assignment.ts index 933f02b246..2dfa3d1ef5 100644 --- a/mock-api/role-assignment.ts +++ b/mock-api/role-assignment.ts @@ -44,6 +44,12 @@ type DbRoleAssignment = { export const crossSiloUserId = 'd4e5f6a7-b8c9-4d0e-a1f2-b3c4d5e6f7a8' export const crossSiloGroupId = 'e5f6a7b8-c9d0-4e1f-b2a3-c4d5e6f7a8b9' +// A local IdP user deleted while holding a silo role. Nexus soft-deletes the +// user but leaves their role assignments, so this ID is in the silo policy but +// not in userList. +// https://github.com/oxidecomputer/omicron/blob/26f5e91/nexus/db-queries/src/db/datastore/silo_user.rs#L476-L568 +export const deletedSiloUserId = '480332fd-4516-403a-809d-e9fc67963b5a' + export const roleAssignments: DbRoleAssignment[] = [ { resource_type: 'fleet', @@ -87,6 +93,24 @@ export const roleAssignments: DbRoleAssignment[] = [ identity_type: 'silo_user', role_name: 'admin', }, + { + // Jane also inherits the stronger silo.collaborator via real-estate-devs, so + // she has both a direct and a group-derived role (exercises the "+N" roles + // display on the Users tab). Kept below admin so she stays a non-silo-admin, + // which other tests rely on. + resource_type: 'silo', + resource_id: defaultSilo.id, + identity_id: user5.id, // Jane Austen + identity_type: 'silo_user', + role_name: 'viewer', + }, + { + resource_type: 'silo', + resource_id: defaultSilo.id, + identity_id: deletedSiloUserId, + identity_type: 'silo_user', + role_name: 'viewer', + }, { resource_type: 'project', resource_id: project.id, @@ -108,6 +132,13 @@ export const roleAssignments: DbRoleAssignment[] = [ identity_type: 'silo_user', role_name: 'limited_collaborator', }, + { + resource_type: 'project', + resource_id: project.id, + identity_id: deletedSiloUserId, + identity_type: 'silo_user', + role_name: 'viewer', + }, // Role assignments for test silos (IP pool configuration testing) // myriad silo: v4-only default pool { diff --git a/mock-api/user-group.ts b/mock-api/user-group.ts index e3ad27dec1..2dd6353b52 100644 --- a/mock-api/user-group.ts +++ b/mock-api/user-group.ts @@ -35,7 +35,8 @@ export const userGroup3: Json = { time_modified: new Date(2021, 3, 1).toISOString(), } -export const userGroups = [userGroup1, userGroup2, userGroup3] +// ordered by display_name +export const userGroups = [userGroup3, userGroup2, userGroup1] type GroupMembership = { userId: string @@ -55,4 +56,8 @@ export const groupMemberships: GroupMembership[] = [ userId: user5.id, groupId: userGroup3.id, }, + { + userId: user1.id, + groupId: userGroup2.id, + }, ] diff --git a/test/e2e/fleet-access.e2e.ts b/test/e2e/fleet-access.e2e.ts index 0e6685600b..d495f850ac 100644 --- a/test/e2e/fleet-access.e2e.ts +++ b/test/e2e/fleet-access.e2e.ts @@ -56,13 +56,14 @@ test('Click through fleet access page', async ({ page }) => { await page.getByRole('button', { name: 'Add user or group' }).click() await expect(page.getByRole('heading', { name: /Add user or group/ })).toBeVisible() - await page.getByRole('button', { name: /User or group/ }).click() + await page.getByRole('combobox', { name: 'User or group' }).click() // users already assigned should not be in the list await expect(page.getByRole('option', { name: 'Hannah Arendt' })).toBeHidden() await expect(page.getByRole('option', { name: 'Jacob Klein' })).toBeVisible() await expect(page.getByRole('option', { name: 'Hans Jonas' })).toBeVisible() await expect(page.getByRole('option', { name: 'Simone de Beauvoir' })).toBeVisible() + await page.getByRole('combobox', { name: 'User or group' }).fill('Jacob Klein') await page.getByRole('option', { name: 'Jacob Klein' }).click() await page.getByRole('radio', { name: /^Collaborator / }).click() await page.getByRole('button', { name: 'Assign role' }).click() @@ -79,9 +80,9 @@ test('Click through fleet access page', async ({ page }) => { .getByRole('row', { name: user3.display_name, exact: false }) .getByRole('button', { name: 'Row actions' }) .click() - await page.getByRole('menuitem', { name: 'Change role' }).click() + await page.getByRole('menuitem', { name: 'Change fleet role' }).click() - await expect(page.getByRole('heading', { name: /Edit role/ })).toBeVisible() + await expect(page.getByRole('heading', { name: /Edit fleet role/ })).toBeVisible() await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() await page.getByRole('radio', { name: /^Viewer / }).click() @@ -89,11 +90,11 @@ test('Click through fleet access page', async ({ page }) => { await expectRowVisible(table, { Name: user3.display_name, 'Fleet role': 'fleet.viewer' }) - // delete user 3 + // remove user 3 const user3Row = page.getByRole('row', { name: user3.display_name, exact: false }) await expect(user3Row).toBeVisible() await user3Row.getByRole('button', { name: 'Row actions' }).click() - await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByRole('menuitem', { name: 'Remove fleet role' }).click() await page.getByRole('button', { name: 'Confirm' }).click() await expectToast(page, 'Access removed') await expect(user3Row).toBeHidden() @@ -108,7 +109,7 @@ test('Add a group to fleet access', async ({ page }) => { await expect(page.getByRole('cell', { name: 'web-devs' })).toBeHidden() await page.getByRole('button', { name: 'Add user or group' }).click() - await page.getByRole('button', { name: /User or group/ }).click() + await page.getByRole('combobox', { name: 'User or group' }).click() // groups appear before users in the picker, with a "Group" badge await expect(page.getByRole('option', { name: /web-devs/ })).toBeVisible() @@ -131,33 +132,44 @@ test('Self-removal warning on delete', async ({ page }) => { // Hannah Arendt is the logged-in user with fleet admin const hannahRow = page.getByRole('row', { name: 'Hannah Arendt', exact: false }) await hannahRow.getByRole('button', { name: 'Row actions' }).click() - await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByRole('menuitem', { name: 'Remove fleet role' }).click() // confirm dialog should show the self-removal warning - await expect(page.getByText('This will remove your own fleet access.')).toBeVisible() + await expect(page.getByText('This could remove your own fleet access.')).toBeVisible() + + // confirming requires typing your own name + const dialog = page.getByRole('dialog', { name: 'Remove role assignment' }) + const confirm = dialog.getByRole('button', { name: 'Confirm' }) + await expect(confirm).toBeDisabled() + await dialog.getByRole('textbox').fill('Hannah') + await expect(confirm).toBeDisabled() + await dialog.getByRole('textbox').fill('Hannah Arendt') + await expect(confirm).toBeEnabled() // cancel instead of confirming await page.getByRole('button', { name: 'Cancel' }).click() }) -test('Fleet viewer cannot modify fleet access', async ({ browser }) => { +test('Fleet viewer sees an error when changing fleet roles', async ({ browser }) => { + // Jane Austen is only a fleet viewer, so she lacks `modify` on the fleet + // policy. The actions stay enabled because fleet admin can also come from a + // silo's mapped_fleet_roles, which the client can't see, so the API rejects + // the change and the form shows the error. const page = await getPageAsUser(browser, 'Jane Austen') await page.goto('/system/access') - const table = page.getByRole('table') await expect(page.getByRole('heading', { name: /Fleet Access/ })).toBeVisible() - await expectRowVisible(table, { Name: 'Hannah Arendt', 'Fleet role': 'fleet.admin' }) - // attempt to add a user — the submit should fail with 403 - await page.getByRole('button', { name: 'Add user or group' }).click() - await page.getByRole('button', { name: /User or group/ }).click() - await page.getByRole('option', { name: 'Jacob Klein' }).click() - await page.getByRole('button', { name: 'Assign role' }).click() - await expect(page.getByText('Action not authorized')).toBeVisible() + await page + .getByRole('row', { name: 'Hannah Arendt', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'Change fleet role' }).click() - // dismiss the modal and confirm the table is unchanged - await page.getByRole('button', { name: 'Cancel' }).click() - await expect(page.getByRole('cell', { name: 'Jacob Klein' })).toBeHidden() + const dialog = page.getByRole('dialog', { name: 'Edit fleet role' }) + await dialog.getByRole('radio', { name: /^Viewer / }).click() + await dialog.getByRole('button', { name: 'Update role' }).click() + await expect(dialog.getByText('Action not authorized')).toBeVisible() }) test('Cross-silo user shows UUID with tooltip', async ({ page }) => { diff --git a/test/e2e/project-access.e2e.ts b/test/e2e/project-access.e2e.ts index d4f34d8ce5..0f419e1f82 100644 --- a/test/e2e/project-access.e2e.ts +++ b/test/e2e/project-access.e2e.ts @@ -5,114 +5,442 @@ * * Copyright Oxide Computer Company */ -import { user3, user4 } from '@oxide/api-mocks' +import { deletedSiloUserId } from '@oxide/api-mocks' -import { expect, expectNotVisible, expectRowVisible, expectVisible, test } from './utils' +import { expect, expectRowVisible, getPageAsUser, test, type Page } from './utils' -test('Click through project access page', async ({ page }) => { +const usersUrl = '/projects/mock-project/access/users' +const groupsUrl = '/projects/mock-project/access/groups' + +const closeSideModal = (page: Page) => + page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + +test('Project Users tab shows effective roles across silo, project, and groups', async ({ + page, +}) => { await page.goto('/projects/mock-project') - await page.click('role=link[name*="Access"]') + await page.getByRole('link', { name: 'Project Access' }).click() + await expect(page).toHaveURL(/\/projects\/mock-project\/access\/groups$/) + await expect(page.getByRole('heading', { name: 'Project Access' })).toBeVisible() + await page.getByRole('tab', { name: 'Users' }).click() + await expect(page).toHaveURL(/\/projects\/mock-project\/access\/users$/) + + const table = page.getByRole('table') - // we see groups and users 1, 3, 6 but not users 2, 4, 5 - await expectVisible(page, ['role=heading[name*="Access"]']) - const table = page.locator('table') + // the strongest role from any scope or group is shown, labeled with its scope await expectRowVisible(table, { Name: 'Hannah Arendt', - Type: 'User', Role: 'silo.admin', + Groups: 'kernel-devs+1', }) - await expectRowVisible(table, { - Name: 'Jacob Klein', - Type: 'User', - Role: 'project.collaborator', - }) + await expectRowVisible(table, { Name: 'Jacob Klein', Role: 'project.collaborator' }) await expectRowVisible(table, { Name: 'Herbert Marcuse', - Type: 'User', Role: 'project.limited_collaborator', }) await expectRowVisible(table, { - Name: 'real-estate-devs', - Type: 'Group', + Name: 'Hans Jonas', Role: 'silo.collaborator', + Groups: 'real-estate-devs', }) - await expectRowVisible(table, { - Name: 'kernel-devs', - Type: 'Group', - Role: 'project.viewer', - }) + // every silo user is listed, including those with no access + await expectRowVisible(table, { Name: 'Simone de Beauvoir', Role: '—', Groups: '—' }) + + // inherited role names its source group + const hansRow = table.getByRole('row', { name: 'Hans Jonas', exact: false }) + await hansRow.getByRole('button', { name: 'Tip' }).hover() + await expect(page.getByRole('tooltip')).toContainText('via real-estate-devs') +}) + +test('Add, change, and remove a project role from the user details side modal', async ({ + page, +}) => { + await page.goto(usersUrl) + const table = page.getByRole('table') + + await page.getByRole('link', { name: 'Simone de Beauvoir' }).click() + const sideModal = page.getByRole('dialog', { name: 'User' }) + + // Simone has no role anywhere, so only Add is offered + const emptyRow = sideModal.getByRole('row').filter({ hasText: 'No roles assigned' }) + await emptyRow.getByRole('button', { name: 'Row actions' }).click() + await expect(page.getByRole('menuitem', { name: 'Change project role' })).toBeHidden() + await expect(page.getByRole('menuitem', { name: 'Remove project role' })).toBeHidden() + await page.getByRole('menuitem', { name: 'Add project role' }).click() + const addModal = page.getByRole('dialog', { name: 'Add project role' }) + await expect(addModal).toContainText('Simone de Beauvoir') + await addModal.getByRole('radio', { name: /^Collaborator / }).click() + await addModal.getByRole('button', { name: 'Add project role' }).click() + const directRow = sideModal.getByRole('row').filter({ hasText: 'Direct' }) + await expect(directRow).toContainText('project.collaborator') + + await directRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Change project role' }).click() + const editModal = page.getByRole('dialog', { name: 'Edit project role' }) + await expect(editModal.getByRole('radio', { name: /^Collaborator / })).toBeChecked() + await editModal.getByRole('radio', { name: /^Viewer / }).click() + await editModal.getByRole('button', { name: 'Update role' }).click() + await expect(directRow).toContainText('project.viewer') + + // the Users table reflects the change once the side modal closes + await closeSideModal(page) + await expectRowVisible(table, { Name: 'Simone de Beauvoir', Role: 'project.viewer' }) + + await page.getByRole('link', { name: 'Simone de Beauvoir' }).click() + await directRow.getByRole('button', { name: 'Row actions' }).click() + const removeRole = page.getByRole('menuitem', { name: 'Remove project role' }) + await expect(removeRole).toHaveClass(/destructive/) + await removeRole.click() + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(emptyRow).toBeVisible() + await closeSideModal(page) + await expectRowVisible(table, { Name: 'Simone de Beauvoir', Role: '—' }) +}) + +test('Roles from the silo or a group cannot be edited on the project page', async ({ + page, +}) => { + await page.goto(usersUrl) + + // Hannah has a direct silo role + await page.getByRole('link', { name: 'Hannah Arendt' }).click() + const sideModal = page.getByRole('dialog', { name: 'User' }) + const siloRow = sideModal.getByRole('row').filter({ hasText: 'silo.admin' }) + await siloRow.getByRole('button', { name: 'Row actions' }).click() + await expect(page.getByRole('menuitem', { name: 'Change silo role' })).toBeDisabled() + const removeSilo = page.getByRole('menuitem', { name: 'Remove silo role' }) + await expect(removeSilo).toBeDisabled() + await expect(removeSilo).not.toHaveClass(/destructive/) + await removeSilo.hover() + await expect(page.getByRole('tooltip')).toHaveText('This role comes from the silo policy') + await page.keyboard.press('Escape') + + // a direct project role can still be added, from a row below the others + const missingRow = sideModal + .getByRole('row') + .filter({ hasText: 'No project role assigned' }) + await expect(sideModal.getByRole('table').first().getByRole('row').last()).toContainText( + 'No project role assigned' + ) + await missingRow.getByRole('button', { name: 'Row actions' }).click() + await expect(page.getByRole('menuitem', { name: 'Add project role' })).toBeEnabled() + await page.keyboard.press('Escape') + await closeSideModal(page) + + // Hans's only role comes from a group + await page.getByRole('link', { name: 'Hans Jonas' }).click() + const viaRow = sideModal.getByRole('row').filter({ hasText: 'via real-estate-devs' }) + await viaRow.getByRole('button', { name: 'Row actions' }).click() + await expect(page.getByRole('menuitem', { name: 'View group' })).toBeVisible() + await expect(page.getByRole('menuitem', { name: 'Remove project role' })).toBeHidden() + await page.keyboard.press('Escape') + await missingRow.getByRole('button', { name: 'Row actions' }).click() + await expect(page.getByRole('menuitem', { name: 'Add project role' })).toBeEnabled() +}) + +test('User details side modal offers role actions by scope and source', async ({ + page, +}) => { + await page.goto(usersUrl) + await page.getByRole('link', { name: 'Hannah Arendt' }).click() + const sideModal = page.getByRole('dialog', { name: 'User' }) + await expect(sideModal.getByRole('link', { name: 'Project Roles' })).toHaveAttribute( + 'href', + 'https://docs.oxide.computer/guides/configuring-access#_project' + ) + + // a direct silo role can't be removed from the project page + const siloRow = sideModal.getByRole('row').filter({ hasText: 'silo.admin' }) + await siloRow.getByRole('button', { name: 'Row actions' }).click() + await expect(page.getByRole('menuitem', { name: 'Change silo role' })).toBeDisabled() + const removeSilo = page.getByRole('menuitem', { name: 'Remove silo role' }) + await expect(removeSilo).toBeDisabled() + await page.keyboard.press('Escape') + + // give Hannah a direct project role + const missingRow = sideModal + .getByRole('row') + .filter({ hasText: 'No project role assigned' }) + await missingRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Add project role' }).click() + const addModal = page.getByRole('dialog', { name: 'Add project role' }) + await addModal.getByRole('radio', { name: /^Viewer / }).click() + await addModal.getByRole('button', { name: 'Add project role' }).click() + await expect(addModal).toBeHidden() + await expect(missingRow).toBeHidden() + + const projectRow = sideModal + .getByRole('row') + .filter({ hasText: 'project.' }) + .filter({ hasText: 'Direct' }) + await expect(projectRow).toContainText('project.viewer') + await projectRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Change project role' }).click() + const editModal = page.getByRole('dialog', { name: 'Edit project role' }) + await editModal.getByRole('radio', { name: /^Admin / }).click() + await editModal.getByRole('button', { name: 'Update role' }).click() + await expect(editModal).toBeHidden() + await expect(projectRow).toContainText('project.admin') + + await projectRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Remove project role' }).click() + // Hannah's silo admin role still lets her manage project roles, so removing + // her own project admin role skips the warning and the name field + const confirmDialog = page.getByRole('dialog', { name: 'Remove role assignment' }) + await expect(confirmDialog).toBeVisible() + await expect(confirmDialog).not.toContainText('remove your own') + await expect(confirmDialog.getByRole('textbox')).toBeHidden() + await confirmDialog.getByRole('button', { name: 'Confirm' }).click() + await expect(projectRow).toBeHidden() + await expect(sideModal).toBeVisible() + + // group memberships link to their group details + await sideModal + .getByRole('row') + .filter({ has: page.getByRole('cell', { name: 'kernel-devs', exact: true }) }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'View group' }).click() + await expect(page).toHaveURL(/\/projects\/mock-project\/access\/groups\/[0-9a-f-]+$/) + await expect(page.getByRole('dialog', { name: 'Group' })).toContainText('kernel-devs') +}) + +test('Add a project role from the group details side modal', async ({ page }) => { + await page.goto(groupsUrl) + + // web-devs has no role anywhere, so the empty row offers Add + await page.getByRole('link', { name: 'web-devs' }).click() + const sideModal = page.getByRole('dialog', { name: 'Group' }) + const emptyRow = sideModal.getByRole('row').filter({ hasText: 'No roles assigned' }) + await emptyRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Add project role' }).click() + const addModal = page.getByRole('dialog', { name: 'Add project role' }) + await addModal.getByRole('radio', { name: /^Viewer / }).click() + await addModal.getByRole('button', { name: 'Add project role' }).click() + await expect(addModal).toBeHidden() + await expect( + sideModal.getByRole('row').filter({ hasText: 'project.viewer' }) + ).toBeVisible() + await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + + // real-estate-devs has only a silo role, which can't be edited here + await page.getByRole('link', { name: 'real-estate-devs' }).click() + const siloRow = sideModal.getByRole('row').filter({ hasText: 'silo.collaborator' }) + await siloRow.getByRole('button', { name: 'Row actions' }).click() + await expect(page.getByRole('menuitem', { name: 'Change silo role' })).toBeDisabled() + await expect(page.getByRole('menuitem', { name: 'Remove silo role' })).toBeDisabled() + await expect(page.getByRole('menuitem', { name: 'Add project role' })).toBeHidden() + await page.keyboard.press('Escape') - await expectNotVisible(page, [ - `role=cell[name="Hans Jonas"]`, - `role=cell[name="Simone de Beauvoir"]`, - ]) - - // Add user 4 as collab - await page.click('role=button[name="Add user or group"]') - await expectVisible(page, ['role=heading[name*="Add user or group"]']) - - await page.click('role=button[name*="User or group"]') - // only users not already on the project should be visible - await expectNotVisible(page, [ - 'role=option[name="Jacob Klein"]', - 'role=option[name="Herbert Marcuse"]', - ]) - - await expectVisible(page, [ - 'role=option[name="Hannah Arendt"]', - 'role=option[name="Hans Jonas"]', - 'role=option[name="Simone de Beauvoir"]', - ]) - - await page.click('role=option[name="Simone de Beauvoir"]') - await page.getByRole('radio', { name: /^Collaborator / }).click() - await page.click('role=button[name="Assign role"]') - - // User 4 shows up in the table + // a row below it offers to add a project role + await sideModal + .getByRole('row') + .filter({ hasText: 'No project role assigned' }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'Add project role' }).click() + await expect(page.getByRole('dialog', { name: 'Add project role' })).toBeVisible() +}) + +test('Removing your own non-admin role skips the self-removal warning', async ({ + page, +}) => { + await page.goto(usersUrl) + const table = page.getByRole('table') + + // Hannah is the current user; give her a project viewer role, then remove it + await page.getByRole('link', { name: 'Hannah Arendt' }).click() + const sideModal = page.getByRole('dialog', { name: 'User' }) + await sideModal + .getByRole('row') + .filter({ hasText: 'No project role assigned' }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'Add project role' }).click() + const addModal = page.getByRole('dialog', { name: 'Add project role' }) + await addModal.getByRole('radio', { name: /^Viewer / }).click() + await addModal.getByRole('button', { name: 'Add project role' }).click() + await expect(addModal).toBeHidden() + + const projectRow = sideModal + .getByRole('row') + .filter({ hasText: 'project.' }) + .filter({ hasText: 'Direct' }) + await projectRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Remove project role' }).click() + const confirmDialog = page.getByRole('dialog', { name: 'Remove role assignment' }) + await expect(confirmDialog).toBeVisible() + await expect(confirmDialog).not.toContainText('remove your own') + await expect(confirmDialog.getByRole('textbox')).toBeHidden() + await confirmDialog.getByRole('button', { name: 'Confirm' }).click() + await expect(confirmDialog).toBeHidden() + await expect(projectRow).toBeHidden() + + // the stronger silo role stays effective throughout + await closeSideModal(page) + await expectRowVisible(table, { Name: 'Hannah Arendt', Role: 'silo.admin' }) +}) + +test('Lowering your own admin role skips the warning if you keep edit access', async ({ + page, +}) => { + await page.goto(usersUrl) + + // Hannah is the current user and a silo admin; give her project admin too + await page.getByRole('link', { name: 'Hannah Arendt' }).click() + const sideModal = page.getByRole('dialog', { name: 'User' }) + await sideModal + .getByRole('row') + .filter({ hasText: 'No project role assigned' }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'Add project role' }).click() + const addModal = page.getByRole('dialog', { name: 'Add project role' }) + await addModal.getByRole('radio', { name: /^Admin / }).click() + await addModal.getByRole('button', { name: 'Add project role' }).click() + await expect(addModal).toBeHidden() + + // her silo admin role still lets her manage project roles, so lowering her + // project admin role needs no extra confirmation + const projectRow = sideModal + .getByRole('row') + .filter({ hasText: 'project.' }) + .filter({ hasText: 'Direct' }) + await projectRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Change project role' }).click() + const editModal = page.getByRole('dialog', { name: 'Edit project role' }) + await editModal.getByRole('radio', { name: /^Viewer / }).click() + await expect(editModal).not.toContainText('remove your ability') + await expect(editModal.getByRole('textbox')).toBeHidden() + await editModal.getByRole('button', { name: 'Update role' }).click() + await expect(editModal).toBeHidden() + await expect(projectRow).toContainText('project.viewer') +}) + +test('Non-admin cannot change or remove project roles', async ({ browser }) => { + // Jacob Klein is only a project collaborator (not project admin or silo + // collaborator/admin), so he lacks `modify` on the project and can't edit role + // assignments. Both side modal actions should be disabled with an explanation. + const page = await getPageAsUser(browser, 'Jacob Klein') + await page.goto(usersUrl) + await expect(page.getByRole('heading', { name: 'Project Access' })).toBeVisible() + + await page.getByRole('link', { name: 'Herbert Marcuse' }).click() + await page + .getByRole('dialog', { name: 'User' }) + .getByRole('row') + .filter({ hasText: 'Direct' }) + .getByRole('button', { name: 'Row actions' }) + .click() + + const changeRole = page.getByRole('menuitem', { name: 'Change project role' }) + await expect(changeRole).toBeDisabled() + await changeRole.hover() + await expect(page.getByRole('tooltip')).toHaveText( + "You don't have permission to change project roles" + ) + + const removeRole = page.getByRole('menuitem', { name: 'Remove project role' }) + await expect(removeRole).toBeDisabled() + await removeRole.hover() + await expect(page.getByRole('tooltip')).toHaveText( + "You don't have permission to remove project roles" + ) +}) + +test('Project user details side modal shows silo and project roles', async ({ page }) => { + await page.goto(usersUrl) + + await page.getByRole('link', { name: 'Hannah Arendt' }).click() + await expect(page).toHaveURL(/\/projects\/mock-project\/access\/users\/[0-9a-f-]+$/) + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + + // the silo admin role outranks her project role from kernel-devs + await expect(modal.getByLabel('Properties table')).toContainText( + 'Effective rolesilo.admin' + ) + await expect(modal.getByRole('row').filter({ hasText: 'silo.admin' })).toContainText( + 'Direct' + ) + await expect(modal.getByRole('row').filter({ hasText: 'project.viewer' })).toContainText( + 'via kernel-devs' + ) + // exact, since a role row also reads "via kernel-devs" + await expect(modal.getByRole('cell', { name: 'kernel-devs', exact: true })).toBeVisible() + await expect(modal.getByRole('cell', { name: 'web-devs', exact: true })).toBeVisible() + + await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + await expect(modal).toBeHidden() + await expect(page).toHaveURL(/\/projects\/mock-project\/access\/users$/) +}) + +test('Project Groups tab shows and edits group roles', async ({ page }) => { + await page.goto(usersUrl) + await page.getByRole('tab', { name: 'Groups' }).click() + await expect(page).toHaveURL(/\/projects\/mock-project\/access\/groups$/) + + const table = page.getByRole('table') await expectRowVisible(table, { - Name: 'Simone de Beauvoir', - Type: 'User', - Role: 'project.collaborator', + Name: 'real-estate-devs', + Role: 'silo.collaborator', + Users: '2', }) + await expectRowVisible(table, { Name: 'kernel-devs', Role: 'project.viewer', Users: '1' }) + await expectRowVisible(table, { Name: 'web-devs', Role: '—', Users: '1' }) - // now change user 4 role from collab to viewer + // change kernel-devs' direct project role + await page.getByRole('link', { name: 'kernel-devs' }).click() await page - .locator('role=row', { hasText: user4.display_name }) - .locator('role=button[name="Row actions"]') + .getByRole('dialog', { name: 'Group' }) + .getByRole('row') + .filter({ hasText: 'project.viewer' }) + .getByRole('button', { name: 'Row actions' }) .click() - await page.click('role=menuitem[name="Change role"]') + await page.getByRole('menuitem', { name: 'Change project role' }).click() + const editModal = page.getByRole('dialog', { name: 'Edit project role' }) + await expect(editModal.getByRole('radio', { name: /^Viewer / })).toBeChecked() + await editModal.getByRole('radio', { name: /^Collaborator / }).click() + await editModal.getByRole('button', { name: 'Update role' }).click() + await expect(editModal).toBeHidden() + await closeSideModal(page) + await expectRowVisible(table, { Name: 'kernel-devs', Role: 'project.collaborator' }) - await expectVisible(page, ['role=heading[name*="Edit role"]']) + // group detail modal + await page.getByRole('link', { name: 'real-estate-devs' }).click() + await expect(page).toHaveURL(/\/projects\/mock-project\/access\/groups\/[0-9a-f-]+$/) + const modal = page.getByRole('dialog') + await expect( + modal.getByRole('row').filter({ hasText: 'silo.collaborator' }) + ).toBeVisible() + await expect(modal.getByRole('cell', { name: 'Hans Jonas' })).toBeVisible() + await expect(modal.getByRole('cell', { name: 'Jane Austen' })).toBeVisible() + await closeSideModal(page) + await expect(page).toHaveURL(new RegExp(`${groupsUrl}$`)) +}) - // Verify Collaborator is currently selected - await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() +test('Project Access root redirects to the Groups tab', async ({ page }) => { + await page.goto('/projects/mock-project/access') + await expect(page).toHaveURL(/\/projects\/mock-project\/access\/groups$/) + await expect(page.getByRole('heading', { name: 'Project Access' })).toBeVisible() +}) - // Select Viewer role - await page.getByRole('radio', { name: /^Viewer / }).click() - await page.click('role=button[name="Update role"]') +test('Show policy modal lists raw project role assignments', async ({ page }) => { + await page.goto(groupsUrl) + await page.getByRole('button', { name: 'Show policy' }).click() - await expectRowVisible(table, { Name: user4.display_name, Role: 'project.viewer' }) + const dialog = page.getByRole('dialog', { name: 'Project policy' }) + await expect(dialog).toBeVisible() + const table = dialog.getByRole('table') - // now delete user 3. has to be 3 or 4 because they're the only ones that come - // from the project policy - const user3Row = page.getByRole('row', { name: user3.display_name, exact: false }) - await expect(user3Row).toBeVisible() - await user3Row.getByRole('button', { name: 'Row actions' }).click() - await page.getByRole('menuitem', { name: 'Delete' }).click() - await page.getByRole('button', { name: 'Confirm' }).click() - await expect(user3Row).toBeHidden() - - // now add a project role to user 1, who currently only has silo role - await page.click('role=button[name="Add user or group"]') - await page.click('role=button[name*="User or group"]') - await page.click('role=option[name="Hannah Arendt"]') - // Select Viewer role - await page.getByRole('radio', { name: /^Viewer / }).click() - await page.click('role=button[name="Assign role"]') - // because we only show the "effective" role, we should still see the silo admin role, but should now have an additional count value + await expectRowVisible(table, { Actor: 'kernel-devs', Role: 'project.viewer' }) + await expectRowVisible(table, { Actor: 'Jacob Klein', Role: 'project.collaborator' }) await expectRowVisible(table, { - Name: 'Hannah Arendt', - Type: 'User', - Role: 'silo.admin+1', + Actor: 'Herbert Marcuse', + Role: 'project.limited_collaborator', }) + // deleted user: name can't be resolved, so the ID is shown + await expectRowVisible(table, { Actor: deletedSiloUserId, Role: 'project.viewer' }) + // silo roles are not part of the project policy + await expect(table.getByRole('row')).toHaveCount(5) }) diff --git a/test/e2e/silo-access.e2e.ts b/test/e2e/silo-access.e2e.ts index 3713a40828..31fd771eb1 100644 --- a/test/e2e/silo-access.e2e.ts +++ b/test/e2e/silo-access.e2e.ts @@ -5,116 +5,578 @@ * * Copyright Oxide Computer Company */ -import { user3, user4 } from '@oxide/api-mocks' +import { deletedSiloUserId } from '@oxide/api-mocks' -import { expect, expectNotVisible, expectRowVisible, expectVisible, test } from './utils' +import { expect, expectRowVisible, getPageAsUser, test } from './utils' -test('Click through silo access page', async ({ page }) => { +test('Non-admin cannot change or remove silo roles', async ({ browser }) => { + // Hans Jonas is only a silo collaborator (via the real-estate-devs group), so + // he lacks `modify` on the silo and can't edit role assignments. Both side + // modal actions should be disabled with an explanation. + const page = await getPageAsUser(browser, 'Hans Jonas') + await page.goto('/access/users') + + await expect(page.getByRole('heading', { name: 'Silo Access' })).toBeVisible() + + await page.getByRole('link', { name: 'Hannah Arendt' }).click() + await page + .getByRole('dialog', { name: 'User' }) + .getByRole('row') + .filter({ hasText: 'Direct' }) + .getByRole('button', { name: 'Row actions' }) + .click() + + const changeRole = page.getByRole('menuitem', { name: 'Change silo role' }) + await expect(changeRole).toBeDisabled() + await changeRole.hover() + await expect(page.getByRole('tooltip')).toHaveText( + "You don't have permission to change silo roles" + ) + + const removeRole = page.getByRole('menuitem', { name: 'Remove silo role' }) + await expect(removeRole).toBeDisabled() + await removeRole.hover() + await expect(page.getByRole('tooltip')).toHaveText( + "You don't have permission to remove silo roles" + ) +}) + +test('Silo Access page lands on Groups tab; Users tab shows direct + via-group silo roles', async ({ + page, +}) => { await page.goto('/') + await page.getByRole('link', { name: 'Silo Access' }).click() + await expect(page).toHaveURL(/\/access\/groups$/) + await expect(page.getByRole('heading', { name: 'Silo Access' })).toBeVisible() - const table = page.locator('role=table') + // Users tab comes second + await page.getByRole('tab', { name: 'Users' }).click() + await expect(page).toHaveURL(/\/access\/users$/) - // page is there; we see user 1 and 2 but not 3 - await page.click('role=link[name*="Access"]') + const table = page.getByRole('table') - await expectVisible(page, ['role=heading[name*="Access"]']) + // Hannah has a direct silo.admin assignment; Groups column shows the first + // group (kernel-devs) and "+1" for web-devs await expectRowVisible(table, { - Name: 'real-estate-devs', - Type: 'Group', + Name: 'Hannah Arendt', + Role: 'silo.admin', + Groups: 'kernel-devs+1', + }) + + // Hans Jonas has no direct role but inherits silo.collaborator from real-estate-devs + await expectRowVisible(table, { + Name: 'Hans Jonas', Role: 'silo.collaborator', + Groups: 'real-estate-devs', }) + + // Jacob Klein has no silo role and no groups + await expectRowVisible(table, { Name: 'Jacob Klein', Role: '—', Groups: '—' }) + + // Jane Austen has a direct silo.viewer role but inherits the stronger + // silo.collaborator via real-estate-devs, so her effective role is + // collaborator; the other role is only shown in the detail side modal await expectRowVisible(table, { - Name: 'Hannah Arendt', - Type: 'User', - Role: 'silo.admin', + Name: 'Jane Austen', + Role: 'silo.collaborator', + Groups: 'real-estate-devs', + }) + + // the effective role is inherited, so an info icon names the source group + const janeRow = table.getByRole('row', { name: 'Jane Austen', exact: false }) + await janeRow.getByRole('button', { name: 'Tip' }).hover() + await expect(page.getByRole('tooltip')).toContainText('via real-estate-devs') +}) + +test('User details side modal shows assigned + via-group roles and group list', async ({ + page, +}) => { + await page.goto('/access/users') + + // Open Hannah's details + await page.getByRole('link', { name: 'Hannah Arendt' }).click() + await expect(page).toHaveURL(/\/access\/users\/[0-9a-f-]+$/) + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + await expect(modal.getByText('Hannah Arendt')).toBeVisible() + + await expect(modal.getByLabel('Properties table')).toContainText( + 'Effective rolesilo.admin' + ) + await expect(modal.getByText('Assignments', { exact: true })).toBeVisible() + + // Direct silo.admin assignment + const roleRow = modal.getByRole('row').filter({ hasText: 'silo.admin' }) + await expect(roleRow).toBeVisible() + await expect(roleRow).toContainText('Direct') + + // Group memberships + await expect(modal.getByRole('cell', { name: 'kernel-devs' })).toBeVisible() + await expect(modal.getByRole('cell', { name: 'web-devs' })).toBeVisible() + + await expect(modal.getByRole('link', { name: 'Silo Roles' })).toHaveAttribute( + 'href', + 'https://docs.oxide.computer/guides/configuring-access#_silo' + ) + await expect(modal.getByRole('link', { name: 'Managing Groups' })).toBeHidden() + + await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + await expect(modal).toBeHidden() + await expect(page).toHaveURL(/\/access\/users$/) + + // Hans Jonas inherits silo.collaborator via real-estate-devs + await page.getByRole('link', { name: 'Hans Jonas' }).click() + await expect(modal).toBeVisible() + const viaRow = modal.getByRole('row').filter({ hasText: 'silo.collaborator' }) + await expect(viaRow).toContainText('via real-estate-devs') +}) + +test('Change and remove a role from the user details side modal', async ({ page }) => { + await page.goto('/access/users') + await page.getByRole('link', { name: 'Jane Austen' }).click() + const sideModal = page.getByRole('dialog', { name: 'User' }) + await expect(sideModal).toBeVisible() + + // Jane has a direct silo.viewer role and silo.collaborator via real-estate-devs. + // The stronger one is her effective role. + await expect(sideModal.getByLabel('Properties table')).toContainText( + 'Effective rolesilo.collaborator' + ) + const directRow = sideModal.getByRole('row').filter({ hasText: 'Direct' }) + await expect(directRow).toContainText('silo.viewer') + + // the edit modal opens over the side modal, and the side modal stays open after + await directRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Change silo role' }).click() + const editModal = page.getByRole('dialog', { name: 'Edit silo role' }) + await expect(editModal).toBeVisible() + await expect(editModal.getByRole('radio', { name: /^Viewer / })).toBeChecked() + await editModal.getByRole('radio', { name: /^Admin / }).click() + await editModal.getByRole('button', { name: 'Update role' }).click() + await expect(editModal).toBeHidden() + await expect(sideModal).toBeVisible() + await expect(directRow).toContainText('silo.admin') + + await directRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Remove silo role' }).click() + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(directRow).toBeHidden() + await expect(sideModal).toBeVisible() + + // an inherited role links to its source group + const viaRow = sideModal.getByRole('row').filter({ hasText: 'via real-estate-devs' }) + await viaRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'View group' }).click() + await expect(page).toHaveURL(/\/access\/groups\/[0-9a-f-]+$/) + await expect(page.getByRole('dialog', { name: 'Group' })).toContainText( + 'real-estate-devs' + ) +}) + +test('Change a role and view members from the group details side modal', async ({ + page, +}) => { + await page.goto('/access/groups') + await page.getByRole('link', { name: 'real-estate-devs' }).click() + const sideModal = page.getByRole('dialog', { name: 'Group' }) + await expect(sideModal.getByLabel('Properties table')).toContainText( + 'Effective rolesilo.collaborator' + ) + const docLinks = sideModal.getByRole('link', { name: /Roles|Managing/ }) + await expect(docLinks).toHaveText(['Silo Roles', 'Managing Groups']) + + const roleRow = sideModal.getByRole('row').filter({ hasText: 'silo.collaborator' }) + await roleRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Change silo role' }).click() + const editModal = page.getByRole('dialog', { name: 'Edit silo role' }) + await expect(editModal).toContainText('real-estate-devs') + await expect(editModal.getByRole('radio', { name: /^Collaborator / })).toBeChecked() + await editModal.getByRole('radio', { name: /^Viewer / }).click() + await editModal.getByRole('button', { name: 'Update role' }).click() + await expect(editModal).toBeHidden() + const viewerRow = sideModal.getByRole('row').filter({ hasText: 'silo.viewer' }) + await expect(viewerRow).toBeVisible() + + await viewerRow.getByRole('button', { name: 'Row actions' }).click() + const removeRole = page.getByRole('menuitem', { name: 'Remove silo role' }) + await expect(removeRole).toHaveClass(/destructive/) + await removeRole.click() + await page.getByRole('button', { name: 'Confirm' }).click() + await expect( + sideModal.getByRole('row').filter({ hasText: 'No roles assigned' }) + ).toBeVisible() + + // the Groups table reflects the change once the side modal closes + await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + await expectRowVisible(page.getByRole('table'), { Name: 'real-estate-devs', Role: '—' }) + await page.getByRole('link', { name: 'real-estate-devs' }).click() + + // members link to their user details + await sideModal + .getByRole('row', { name: 'Hans Jonas', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await expect(page.getByRole('menuitem', { name: 'Copy ID' })).toBeVisible() + await page.getByRole('menuitem', { name: 'View user' }).click() + await expect(page).toHaveURL(/\/access\/users\/[0-9a-f-]+$/) + await expect(page.getByRole('dialog', { name: 'User' })).toContainText('Hans Jonas') +}) + +test('Add a role from the group details side modal when none is assigned', async ({ + page, +}) => { + await page.goto('/access/groups') + await page.getByRole('link', { name: 'kernel-devs' }).click() + const sideModal = page.getByRole('dialog', { name: 'Group' }) + + // kernel-devs has no silo role + const emptyRow = sideModal.getByRole('row').filter({ hasText: 'No roles assigned' }) + await emptyRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Add silo role' }).click() + const addModal = page.getByRole('dialog', { name: 'Add silo role' }) + await addModal.getByRole('radio', { name: /^Viewer / }).click() + await addModal.getByRole('button', { name: 'Add silo role' }).click() + await expect(addModal).toBeHidden() + await expect(sideModal.getByRole('row').filter({ hasText: 'silo.viewer' })).toBeVisible() + await expect(emptyRow).toBeHidden() + + await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + await expectRowVisible(page.getByRole('table'), { + Name: 'kernel-devs', + Role: 'silo.viewer', }) - await expectNotVisible(page, [`role=cell[name="${user4.display_name}"]`]) - - // Add user 2 as collab - await page.click('role=button[name="Add user or group"]') - await expectVisible(page, ['role=heading[name*="Add user or group"]']) - - await page.click('role=button[name*="User or group"]') - // only users not already on the org should be visible - await expectNotVisible(page, ['role=option[name="Hannah Arendt"]']) - await expectVisible(page, [ - 'role=option[name="Hans Jonas"]', - 'role=option[name="Jacob Klein"]', - 'role=option[name="Simone de Beauvoir"]', - ]) - - await page.click('role=option[name="Jacob Klein"]') - await page.getByRole('radio', { name: /^Collaborator / }).click() - await page.click('role=button[name="Assign role"]') - - // User 3 shows up in the table +}) + +test('Add, change, and remove a user role from the user details side modal', async ({ + page, +}) => { + await page.goto('/access/users') + const table = page.getByRole('table') + + // Act on Jacob rather than the current user (Hannah, silo admin): demoting or + // removing our own silo role would revoke our permission to keep editing + await page.getByRole('link', { name: 'Jacob Klein' }).click() + const sideModal = page.getByRole('dialog', { name: 'User' }) + + await expect(sideModal.getByLabel('Properties table')).toContainText('Effective role—') + + // Jacob has no silo role and no groups, so only Add is offered + const emptyRow = sideModal.getByRole('row').filter({ hasText: 'No roles assigned' }) + await emptyRow.getByRole('button', { name: 'Row actions' }).click() + await expect(page.getByRole('menuitem', { name: 'Change silo role' })).toBeHidden() + await expect(page.getByRole('menuitem', { name: 'Remove silo role' })).toBeHidden() + await page.getByRole('menuitem', { name: 'Add silo role' }).click() + + // the modal targets Jacob, with no identity picker and no role preselected + const addModal = page.getByRole('dialog', { name: 'Add silo role' }) + await expect(addModal).toContainText('Jacob Klein') + await expect(addModal.getByRole('combobox')).toBeHidden() + await expect(addModal.getByRole('radio', { name: /^Collaborator / })).not.toBeChecked() + await addModal.getByRole('radio', { name: /^Collaborator / }).click() + await addModal.getByRole('button', { name: 'Add silo role' }).click() + await expect(addModal).toBeHidden() + await expect(emptyRow).toBeHidden() + const directRow = sideModal.getByRole('row').filter({ hasText: 'Direct' }) + await expect(directRow).toContainText('silo.collaborator') + + await directRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Change silo role' }).click() + const editModal = page.getByRole('dialog', { name: 'Edit silo role' }) + await expect(editModal.getByRole('radio', { name: /^Collaborator / })).toBeChecked() + await editModal.getByRole('radio', { name: /^Viewer / }).click() + await editModal.getByRole('button', { name: 'Update role' }).click() + await expect(directRow).toContainText('silo.viewer') + + // the Users table reflects the change once the side modal closes + await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + await expectRowVisible(table, { Name: 'Jacob Klein', Role: 'silo.viewer' }) + + // remove Jacob's direct role; he has no group-inherited role, so it goes to — + await page.getByRole('link', { name: 'Jacob Klein' }).click() + await directRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Remove silo role' }).click() + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(emptyRow).toBeVisible() + await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + await expectRowVisible(table, { Name: 'Jacob Klein', Role: '—' }) +}) + +test('A user with only inherited roles can get a direct role', async ({ page }) => { + await page.goto('/access/users') + await page.getByRole('link', { name: 'Hans Jonas' }).click() + const sideModal = page.getByRole('dialog', { name: 'User' }) + + // Hans has no direct silo role but inherits silo.collaborator via + // real-estate-devs, which can't be changed or removed from here + const viaRow = sideModal.getByRole('row').filter({ hasText: 'via real-estate-devs' }) + await viaRow.getByRole('button', { name: 'Row actions' }).click() + await expect(page.getByRole('menuitem', { name: 'View group' })).toBeVisible() + await expect(page.getByRole('menuitem', { name: 'Change silo role' })).toBeHidden() + await expect(page.getByRole('menuitem', { name: 'Remove silo role' })).toBeHidden() + await expect(page.getByRole('menuitem', { name: 'Add silo role' })).toBeHidden() + await page.keyboard.press('Escape') + + // a row below the inherited roles offers to add a direct one + const missingRow = sideModal.getByRole('row').filter({ hasText: 'No silo role assigned' }) + await expect(sideModal.getByRole('table').first().getByRole('row').last()).toContainText( + 'No silo role assigned' + ) + await missingRow.getByRole('button', { name: 'Row actions' }).click() + + // Add opens with no role preselected, since there's no direct role to change + await page.getByRole('menuitem', { name: 'Add silo role' }).click() + const addModal = page.getByRole('dialog', { name: 'Add silo role' }) + await expect(addModal.getByRole('radio', { name: /^Collaborator / })).not.toBeChecked() + await addModal.getByRole('radio', { name: /^Viewer / }).click() + await addModal.getByRole('button', { name: 'Add silo role' }).click() + await expect(addModal).toBeHidden() + await expect(sideModal.getByRole('row').filter({ hasText: 'Direct' })).toContainText( + 'silo.viewer' + ) + + // with a direct role in place, the row goes away + await expect(missingRow).toBeHidden() +}) + +test('User and group detail URLs open the side modal directly', async ({ page }) => { + await page.goto('/access/users') + await page.getByRole('link', { name: 'Hannah Arendt' }).click() + await expect(page.getByRole('dialog')).toContainText('Hannah Arendt') + + // a fresh load of the URL lands on the same user's modal over the Users tab + // (navigate instead of page.reload(), which doesn't work in Firefox) + await page.goto(page.url()) + await expect(page.getByRole('dialog')).toContainText('Hannah Arendt') + await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + await expect(page).toHaveURL(/\/access\/users$/) + await expect(page.getByRole('tab', { name: 'Users', selected: true })).toBeVisible() + + await page.goto('/access/groups') + await page.getByRole('link', { name: 'real-estate-devs' }).click() + await expect(page.getByRole('dialog')).toContainText('real-estate-devs') + await page.goto(page.url()) + await expect(page.getByRole('dialog')).toContainText('real-estate-devs') + await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + await expect(page).toHaveURL(/\/access\/groups$/) + await expect(page.getByRole('tab', { name: 'Groups', selected: true })).toBeVisible() +}) + +test('Unknown user or group ID shows Not Found', async ({ page }) => { + await page.goto('/access/users/7e5a4f1c-2b3d-4e6f-8a9b-0c1d2e3f4a5b') + await expect(page.getByText('Page not found')).toBeVisible() + await page.goto('/access/groups/7e5a4f1c-2b3d-4e6f-8a9b-0c1d2e3f4a5b') + await expect(page.getByText('Page not found')).toBeVisible() +}) + +test('Groups tab shows roles and member counts; modal lists members', async ({ page }) => { + await page.goto('/access/users') + + await page.getByRole('tab', { name: 'Groups' }).click() + await expect(page).toHaveURL(/\/access\/groups$/) + + const table = page.getByRole('table') + await expectRowVisible(table, { - Name: 'Jacob Klein', + Name: 'real-estate-devs', Role: 'silo.collaborator', - Type: 'User', + Users: '2', }) + await expectRowVisible(table, { Name: 'kernel-devs', Role: '—', Users: '1' }) + await expectRowVisible(table, { Name: 'web-devs', Role: '—', Users: '1' }) - // now change user 3's role from collab to viewer + // Open the real-estate-devs group modal + await page.getByRole('link', { name: 'real-estate-devs' }).click() + await expect(page).toHaveURL(/\/access\/groups\/[0-9a-f-]+$/) + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + await expect( + modal.getByRole('row').filter({ hasText: 'silo.collaborator' }) + ).toBeVisible() + await expect(modal.getByRole('cell', { name: 'Hans Jonas' })).toBeVisible() + await expect(modal.getByRole('cell', { name: 'Jane Austen' })).toBeVisible() +}) + +test('Removing your last admin role requires typing your name', async ({ page }) => { + await page.goto('/access/users') + + // Hannah is the mock session user, and her direct silo admin role is her only + // source of silo admin + await page.getByRole('link', { name: 'Hannah Arendt' }).click() await page - .locator('role=row', { hasText: user3.display_name }) - .locator('role=button[name="Row actions"]') + .getByRole('dialog', { name: 'User' }) + .getByRole('row') + .filter({ hasText: 'Direct' }) + .getByRole('button', { name: 'Row actions' }) .click() - await page.click('role=menuitem[name="Change role"]') + await page.getByRole('menuitem', { name: 'Remove silo role' }).click() - await expectVisible(page, ['role=heading[name*="Edit role"]']) + const confirmDialog = page.getByRole('dialog', { name: 'Remove role assignment' }) + await expect(confirmDialog).toContainText( + 'Are you sure you want to remove the admin role for Hannah Arendt?' + ) + await expect(confirmDialog).toContainText('This could remove your own silo access.') + const confirm = confirmDialog.getByRole('button', { name: 'Confirm' }) + await expect(confirm).toBeDisabled() + await confirmDialog.getByRole('textbox').fill('Hannah Arendt') + await expect(confirm).toBeEnabled() + await confirmDialog.getByRole('button', { name: 'Cancel' }).click() + await expect(confirmDialog).toBeHidden() +}) - // Verify Collaborator is currently selected - await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() +test('Removing a group role you hold through membership warns when it is your last admin role', async ({ + page, +}) => { + await page.goto('/access/groups') + // Hannah, the mock session user, is a member of web-devs + await page.getByRole('link', { name: 'web-devs' }).click() + const groupModal = page.getByRole('dialog', { name: 'Group' }) + await groupModal + .getByRole('row') + .filter({ hasText: 'No roles assigned' }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'Add silo role' }).click() + const addModal = page.getByRole('dialog', { name: 'Add silo role' }) + await addModal.getByRole('radio', { name: /^Admin / }).click() + await addModal.getByRole('button', { name: 'Add silo role' }).click() + await expect(addModal).toBeHidden() - // Select Viewer role - await page.getByRole('radio', { name: /^Viewer / }).click() - await page.click('role=button[name="Update role"]') + const groupRoleRow = groupModal.getByRole('row').filter({ hasText: 'silo.admin' }) + const confirmDialog = page.getByRole('dialog', { name: 'Remove role assignment' }) - await expectRowVisible(table, { Name: user3.display_name, Role: 'silo.viewer' }) + // her direct silo admin role still grants edit access, so no warning + await groupRoleRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Remove silo role' }).click() + await expect(confirmDialog).toBeVisible() + await expect(confirmDialog).not.toContainText('remove your own') + await expect(confirmDialog.getByRole('textbox')).toBeHidden() + await confirmDialog.getByRole('button', { name: 'Cancel' }).click() + await expect(confirmDialog).toBeHidden() - // now delete user 3 - const user3Row = page.getByRole('row', { name: user3.display_name, exact: false }) - await expect(user3Row).toBeVisible() - await user3Row.getByRole('button', { name: 'Row actions' }).click() - await page.getByRole('menuitem', { name: 'Delete' }).click() - await page.getByRole('button', { name: 'Confirm' }).click() - await expect(user3Row).toBeHidden() + // remove her direct role, leaving web-devs as her only source of silo admin + await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + await page.getByRole('tab', { name: 'Users' }).click() + await page.getByRole('link', { name: 'Hannah Arendt' }).click() + const userModal = page.getByRole('dialog', { name: 'User' }) + const directRow = userModal.getByRole('row').filter({ hasText: 'Direct' }) + await directRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Remove silo role' }).click() + // web-devs keeps her edit access, so this one needs no warning either + await expect(confirmDialog).not.toContainText('remove your own') + await confirmDialog.getByRole('button', { name: 'Confirm' }).click() + await expect(directRow).toBeHidden() + + // now removing the group role warns and requires typing the group name + await userModal + .getByRole('row') + .filter({ hasText: 'via web-devs' }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'View group' }).click() + await groupRoleRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Remove silo role' }).click() + await expect(confirmDialog).toContainText('This could remove your own silo access.') + const confirm = confirmDialog.getByRole('button', { name: 'Confirm' }) + await expect(confirm).toBeDisabled() + await confirmDialog.getByRole('textbox').fill('web-devs') + await expect(confirm).toBeEnabled() }) // changing your own role away from admin requires confirmation because you // may not be able to undo it -test('Removing your own admin role requires confirmation', async ({ page }) => { - await page.goto('/access') - - const table = page.getByRole('table') +test('Changing your own admin role requires typing your name', async ({ page }) => { + await page.goto('/access/users') // Hannah Arendt is the mock session user and a silo admin - const openEditForm = async () => { - await page - .getByRole('row', { name: 'Hannah Arendt' }) - .getByRole('button', { name: 'Row actions' }) - .click() - await page.getByRole('menuitem', { name: 'Change role' }).click() - await page.getByRole('radio', { name: /^Viewer / }).click() - await page.getByRole('button', { name: 'Update role' }).click() - } - const editForm = page.getByRole('dialog', { name: 'Edit role' }) - const confirmModal = page.getByRole('dialog', { name: 'Remove your own admin role' }) - - // confirmation modal pops up over the side modal. cancel out of it, then - // close the form and check nothing changed - await openEditForm() - await expect(confirmModal).toBeVisible() - await confirmModal.getByRole('button', { name: 'Cancel' }).click() - await expect(confirmModal).toBeHidden() - await expect(editForm).toBeVisible() - await editForm.getByRole('button', { name: 'Cancel' }).click() - await expectRowVisible(table, { Name: 'Hannah Arendt', Role: 'silo.admin' }) - - // this time, confirm - await openEditForm() - await confirmModal.getByRole('button', { name: 'Confirm' }).click() + await page.getByRole('link', { name: 'Hannah Arendt' }).click() + const sideModal = page.getByRole('dialog', { name: 'User' }) + const directRow = sideModal.getByRole('row').filter({ hasText: 'Direct' }) + await directRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Change silo role' }).click() + const editForm = page.getByRole('dialog', { name: 'Edit silo role' }) + const update = editForm.getByRole('button', { name: 'Update role' }) + const warning = editForm.getByText('This could remove your ability to manage silo roles.') + + // nothing extra while admin stays selected + await expect(warning).toBeHidden() + await expect(editForm.getByRole('textbox')).toBeHidden() + + // picking a lower role adds the warning and the name field + await editForm.getByRole('radio', { name: /^Viewer / }).click() + await expect(warning).toBeVisible() + await expect(update).toBeDisabled() + await editForm.getByRole('textbox').fill('Hannah') + await expect(update).toBeDisabled() + await editForm.getByRole('textbox').fill('Hannah Arendt') + await expect(update).toBeEnabled() + await update.click() await expect(editForm).toBeHidden() - await expectRowVisible(table, { Name: 'Hannah Arendt', Role: 'silo.viewer' }) + await expect(directRow).toContainText('silo.viewer') + + // no longer a silo admin, so role actions are disabled without a navigation + await directRow.getByRole('button', { name: 'Row actions' }).click() + await expect(page.getByRole('menuitem', { name: 'Change silo role' })).toBeDisabled() +}) + +test('Silo Access root redirects to the Groups tab', async ({ page }) => { + await page.goto('/access') + await expect(page).toHaveURL(/\/access\/groups$/) + await expect(page.getByRole('heading', { name: 'Silo Access' })).toBeVisible() +}) + +test('Show policy modal lists raw silo role assignments', async ({ page }) => { + await page.goto('/access/groups') + await page.getByRole('button', { name: 'Show policy' }).click() + + const dialog = page.getByRole('dialog', { name: 'Silo policy' }) + await expect(dialog).toBeVisible() + const table = dialog.getByRole('table') + + // groups first, then users, each by name + await expectRowVisible(table, { Actor: 'real-estate-devs', Role: 'silo.collaborator' }) + await expectRowVisible(table, { Actor: 'Hannah Arendt', Role: 'silo.admin' }) + await expectRowVisible(table, { Actor: 'Jane Austen', Role: 'silo.viewer' }) + // a deleted user's assignment outlives them, so it falls back to the ID + await expectRowVisible(table, { Actor: deletedSiloUserId, Role: 'silo.viewer' }) + // only direct assignments: Hans Jonas is a silo collaborator via a group + await expect(table.getByRole('row')).toHaveCount(5) + + // resolved identities are managed from the tabs, so they get no row actions + await expect(table.getByRole('button', { name: 'Row actions' })).toHaveCount(1) + + await dialog.getByRole('button', { name: 'Close' }).click() + await expect(dialog).toBeHidden() +}) + +test('Show policy modal removes an unresolved role assignment', async ({ page }) => { + await page.goto('/access/users') + // the deleted user never shows up in the tabs + await expect(page.getByRole('cell', { name: deletedSiloUserId })).toBeHidden() + + await page.getByRole('button', { name: 'Show policy' }).click() + const dialog = page.getByRole('dialog', { name: 'Silo policy' }) + const row = dialog.getByRole('row').filter({ hasText: deletedSiloUserId }) + + await row.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Remove silo role' }).click() + const confirmDialog = page.getByRole('dialog', { name: 'Remove role assignment' }) + await expect(confirmDialog).toContainText( + `Are you sure you want to remove the viewer role for ${deletedSiloUserId}?` + ) + await confirmDialog.getByRole('button', { name: 'Confirm' }).click() + + await expect(row).toBeHidden() + await expect(dialog.getByRole('table').getByRole('row')).toHaveCount(4) +}) + +test('Show policy modal disables removal for non-admins', async ({ browser }) => { + const page = await getPageAsUser(browser, 'Jane Austen') + await page.goto('/access/users') + await page.getByRole('button', { name: 'Show policy' }).click() + const dialog = page.getByRole('dialog', { name: 'Silo policy' }) + const row = dialog.getByRole('row').filter({ hasText: deletedSiloUserId }) + + await row.getByRole('button', { name: 'Row actions' }).click() + await expect(page.getByRole('menuitem', { name: 'Remove silo role' })).toBeDisabled() }) diff --git a/test/e2e/utilization.e2e.ts b/test/e2e/utilization.e2e.ts index 10dc62d68f..1ec2604028 100644 --- a/test/e2e/utilization.e2e.ts +++ b/test/e2e/utilization.e2e.ts @@ -124,6 +124,10 @@ test.describe('System utilization', () => { if (name === 'all-zeros') continue await clickRowAction(page, name, 'Delete') await confirm.click() + // The open modal hides the table from the accessibility tree, so the + // link check alone would pass before the delete lands. The list refetch + // would then shift rows under the next row's menu as we click it. + await expect(page.getByRole('dialog')).toBeHidden() await expect(table.getByRole('link', { name })).toBeHidden() } diff --git a/test/visual/regression.e2e.ts b/test/visual/regression.e2e.ts index aa25774140..4426f7b99a 100644 --- a/test/visual/regression.e2e.ts +++ b/test/visual/regression.e2e.ts @@ -60,7 +60,7 @@ const pages = [ heading: 'Image details', exact: true, }, - { name: 'silo access', url: '/access', heading: 'Silo Access' }, + { name: 'silo users', url: '/access/users', heading: 'Silo Access' }, // Project - Instances { name: 'instances list', url: `${p}/instances`, heading: 'Instances' }, @@ -111,7 +111,7 @@ const pages = [ { name: 'external subnets', url: `${p}/external-subnets`, heading: 'External Subnets' }, // Project - Other - { name: 'project access', url: `${p}/access`, heading: 'Project Access' }, + { name: 'project users', url: `${p}/access/users`, heading: 'Project Access' }, { name: 'affinity groups', url: `${p}/affinity`, heading: 'Affinity Groups' }, { name: 'anti-affinity group detail',