diff --git a/package.json b/package.json index b3c8a92f39..d775bb0b9b 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@playwright/test": "1.41.0", "@types/convert-source-map": "2.0.3", "@types/cytoscape-popper": "2.0.4", + "@types/lodash.isequal": "^4.5.8", "@types/node-forge": "1.3.11", "@types/react": "18.2.48", "@types/react-cytoscapejs": "1.2.5", diff --git a/public/assets/icon/contextual-menu.svg b/public/assets/icon/contextual-menu.svg new file mode 100644 index 0000000000..47325d8c99 --- /dev/null +++ b/public/assets/icon/contextual-menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/App.tsx b/src/App.tsx index 99873c2f9f..aeb63d68ec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,7 +43,9 @@ const ProfileList = lazy(() => import("pages/profiles/ProfileList")); const ProjectConfig = lazy(() => import("pages/projects/ProjectConfiguration")); const ProtectedRoute = lazy(() => import("components/ProtectedRoute")); const Settings = lazy(() => import("pages/settings/Settings")); -const Storage = lazy(() => import("pages/storage/Storage")); +const StoragePools = lazy(() => import("pages/storage/StoragePools")); +const StorageVolumes = lazy(() => import("pages/storage/StorageVolumes")); +const CustomIsoList = lazy(() => import("pages/storage/CustomIsoList")); const StoragePoolDetail = lazy(() => import("pages/storage/StoragePoolDetail")); const StorageVolumeCreate = lazy( () => import("pages/storage/forms/StorageVolumeCreate"), @@ -52,6 +54,15 @@ const StorageVolumeDetail = lazy( () => import("pages/storage/StorageVolumeDetail"), ); const WarningList = lazy(() => import("pages/warnings/WarningList")); +const PermissionIdentities = lazy( + () => import("pages/permissions/PermissionIdentities"), +); +const PermissionGroups = lazy( + () => import("pages/permissions/PermissionGroups"), +); +const PermissionIdpGroups = lazy( + () => import("pages/permissions/PermissionIdpGroups"), +); const HOME_REDIRECT_PATHS = ["/", "/ui", "/ui/project"]; @@ -261,13 +272,15 @@ const App: FC = () => { element={} />} /> } />} /> + } />} + /> } /> } />} @@ -275,9 +288,11 @@ const App: FC = () => { } /> } />} /> + } />} + /> } /> { /> } /> + } />} + /> + } + /> { path="/ui/warnings" element={} />} /> + } />} + /> + } />} + /> + } />} + /> } />} diff --git a/src/api/auth-groups.tsx b/src/api/auth-groups.tsx new file mode 100644 index 0000000000..3231a9771d --- /dev/null +++ b/src/api/auth-groups.tsx @@ -0,0 +1,82 @@ +import { handleResponse, handleSettledResult } from "util/helpers"; +import { LxdApiResponse } from "types/apiResponse"; +import { LxdGroup } from "types/permissions"; + +export const fetchGroups = (): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/auth/groups?recursion=1`) + .then(handleResponse) + .then((data: LxdApiResponse) => resolve(data.metadata)) + .catch(reject); + }); +}; + +export const fetchGroup = (name: string): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/auth/groups/${name}`) + .then(handleResponse) + .then((data: LxdApiResponse) => resolve(data.metadata)) + .catch(reject); + }); +}; + +export const createGroup = (group: Partial): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/auth/groups`, { + method: "POST", + body: JSON.stringify(group), + }) + .then(handleResponse) + .then(resolve) + .catch(reject); + }); +}; + +export const deleteGroup = (group: string): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/auth/groups/${group}`, { + method: "DELETE", + }) + .then(handleResponse) + .then(resolve) + .catch(reject); + }); +}; + +export const deleteGroups = (groups: string[]): Promise => { + return new Promise((resolve, reject) => { + void Promise.allSettled(groups.map((group) => deleteGroup(group))) + .then(handleSettledResult) + .then(resolve) + .catch(reject); + }); +}; + +export const updateGroup = (group: Partial): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/auth/groups/${group.name}`, { + method: "PUT", + body: JSON.stringify(group), + }) + .then(handleResponse) + .then(resolve) + .catch(reject); + }); +}; + +export const renameGroup = ( + oldName: string, + newName: string, +): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/auth/groups/${oldName}`, { + method: "POST", + body: JSON.stringify({ + name: newName, + }), + }) + .then(handleResponse) + .then(resolve) + .catch(reject); + }); +}; diff --git a/src/api/auth-identities.tsx b/src/api/auth-identities.tsx new file mode 100644 index 0000000000..d0501ea819 --- /dev/null +++ b/src/api/auth-identities.tsx @@ -0,0 +1,52 @@ +import { handleResponse, handleSettledResult } from "util/helpers"; +import { LxdApiResponse } from "types/apiResponse"; +import { LxdIdentity } from "types/permissions"; + +export const fetchIdentities = (): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/auth/identities?recursion=1`) + .then(handleResponse) + .then((data: LxdApiResponse) => resolve(data.metadata)) + .catch(reject); + }); +}; + +export const fetchIdentity = ( + id: string, + authMethod: string, +): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/auth/identities/${authMethod}/${id}`) + .then(handleResponse) + .then((data: LxdApiResponse) => resolve(data.metadata)) + .catch(reject); + }); +}; + +export const updateIdentity = (identity: Partial) => { + return new Promise((resolve, reject) => { + fetch( + `/1.0/auth/identities/${identity.authentication_method}/${identity.id}`, + { + method: "PUT", + body: JSON.stringify(identity), + }, + ) + .then(handleResponse) + .then(resolve) + .catch(reject); + }); +}; + +export const updateIdentities = ( + identities: Partial[], +): Promise => { + return new Promise((resolve, reject) => { + void Promise.allSettled( + identities.map((identity) => updateIdentity(identity)), + ) + .then(handleSettledResult) + .then(resolve) + .catch(reject); + }); +}; diff --git a/src/api/auth-idp-groups.tsx b/src/api/auth-idp-groups.tsx new file mode 100644 index 0000000000..776cd0b004 --- /dev/null +++ b/src/api/auth-idp-groups.tsx @@ -0,0 +1,88 @@ +import { handleResponse, handleSettledResult } from "util/helpers"; +import { LxdApiResponse } from "types/apiResponse"; +import { IdpGroup } from "types/permissions"; + +export const fetchIdpGroups = (): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/auth/identity-provider-groups?recursion=1`) + .then(handleResponse) + .then((data: LxdApiResponse) => resolve(data.metadata)) + .catch(reject); + }); +}; + +export const fetchIdpGroup = (name: string): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/auth/identity-provider-groups/${name}`) + .then(handleResponse) + .then((data: LxdApiResponse) => resolve(data.metadata)) + .catch(reject); + }); +}; + +export const createIdpGroup = (group: Partial): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/auth/identity-provider-groups`, { + method: "POST", + body: JSON.stringify({ name: group.name }), + }) + .then(() => + fetch(`/1.0/auth/identity-provider-groups/${group.name}`, { + method: "PUT", + body: JSON.stringify(group), + }), + ) + .then(handleResponse) + .then(resolve) + .catch(reject); + }); +}; + +export const updateIdpGroup = (group: IdpGroup): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/auth/identity-provider-groups/${group.name}`, { + method: "PUT", + body: JSON.stringify(group), + }) + .then(handleResponse) + .then(resolve) + .catch(reject); + }); +}; + +export const renameIdpGroup = ( + oldName: string, + newName: string, +): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/auth/identity-provider-groups/${oldName}`, { + method: "POST", + body: JSON.stringify({ + name: newName, + }), + }) + .then(handleResponse) + .then(resolve) + .catch(reject); + }); +}; + +export const deleteIdpGroup = (group: string): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/auth/identity-provider-groups/${group}`, { + method: "DELETE", + }) + .then(handleResponse) + .then(resolve) + .catch(reject); + }); +}; + +export const deleteIdpGroups = (groups: string[]): Promise => { + return new Promise((resolve, reject) => { + void Promise.allSettled(groups.map((group) => deleteIdpGroup(group))) + .then(handleSettledResult) + .then(resolve) + .catch(reject); + }); +}; diff --git a/src/api/auth-permissions.tsx b/src/api/auth-permissions.tsx new file mode 100644 index 0000000000..9613d61a17 --- /dev/null +++ b/src/api/auth-permissions.tsx @@ -0,0 +1,21 @@ +import { handleResponse } from "util/helpers"; +import { LxdApiResponse } from "types/apiResponse"; +import { LxdPermission } from "types/permissions"; + +export const fetchPermissions = (args: { + resourceType: string; + project?: string; +}): Promise => { + const { resourceType, project } = args; + let url = `/1.0/auth/permissions?entity-type=${resourceType}`; + if (project) { + url += `&project=${project}`; + } + + return new Promise((resolve, reject) => { + fetch(url) + .then(handleResponse) + .then((data: LxdApiResponse) => resolve(data.metadata)) + .catch(reject); + }); +}; diff --git a/src/api/images.tsx b/src/api/images.tsx index e8c442e966..f1d56f09ed 100644 --- a/src/api/images.tsx +++ b/src/api/images.tsx @@ -22,9 +22,11 @@ export const fetchImage = ( }); }; -export const fetchImageList = (project: string): Promise => { +export const fetchImageList = (project?: string): Promise => { + const url = + "/1.0/images?recursion=1" + (project ? `&project=${project}` : ""); return new Promise((resolve, reject) => { - fetch(`/1.0/images?recursion=1&project=${project}`) + fetch(url) .then(handleResponse) .then((data: LxdApiResponse) => resolve(data.metadata)) .catch(reject); diff --git a/src/components/NavAccordion.tsx b/src/components/NavAccordion.tsx new file mode 100644 index 0000000000..7f618b8a97 --- /dev/null +++ b/src/components/NavAccordion.tsx @@ -0,0 +1,52 @@ +import { Icon } from "@canonical/react-components"; +import { FC, ReactNode } from "react"; +import { useLocation } from "react-router-dom"; + +export type AccordionNavMenu = "permissions" | "storage"; + +interface Props { + baseUrl: string; + title: string; + children: ReactNode; + iconName: string; + label: string; + open: boolean; + onOpen: () => void; +} + +const NavAccordion: FC = ({ + baseUrl, + title, + children, + iconName, + label, + open, + onOpen, +}) => { + const location = useLocation(); + const isActive = location.pathname.includes(baseUrl); + + return ( + <> +
+ {" "} + {label} + +
+
    + {children} +
+ + ); +}; + +export default NavAccordion; diff --git a/src/components/NavLink.tsx b/src/components/NavLink.tsx index 121d5d7b69..a4fc9b6189 100644 --- a/src/components/NavLink.tsx +++ b/src/components/NavLink.tsx @@ -1,25 +1,43 @@ -import { FC, ReactNode } from "react"; +import { FC, LinkHTMLAttributes, ReactNode } from "react"; import { Link, useLocation } from "react-router-dom"; +import classnames from "classnames"; interface Props { to: string; title: string; children: ReactNode; + className?: string; + activeUrlMatches?: string[]; } -const NavLink: FC = ({ to, title, children }) => { +const NavLink: FC> = ({ + to, + title, + children, + className, + activeUrlMatches = [], + ...linkProps +}) => { const location = useLocation(); // ignore last char to consider /instances and /instance as active const matchPart = to.substring(0, to.length - 1); - const isActive = location.pathname.startsWith(matchPart); + let isActive = location.pathname.startsWith(matchPart); + + // this caters for more custom url matches for link to be active e.g. /identities & /identity/oidc etc + for (const match of activeUrlMatches) { + if (location.pathname.includes(match)) { + isActive = true; + } + } return ( {children} diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index 147676a69f..9936d4cbda 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -4,11 +4,15 @@ import { useAuth } from "context/auth"; import classnames from "classnames"; import Logo from "./Logo"; import ProjectSelector from "pages/projects/ProjectSelector"; -import { isWidthBelow, logout } from "util/helpers"; +import { getElementAbsoluteHeight, isWidthBelow, logout } from "util/helpers"; import { useProject } from "context/project"; import { useMenuCollapsed } from "context/menuCollapsed"; import { useDocs } from "context/useDocs"; import NavLink from "components/NavLink"; +import { useSupportedFeatures } from "context/useSupportedFeatures"; +import NavAccordion, { AccordionNavMenu } from "./NavAccordion"; +import useEventListener from "@use-it/event-listener"; +import { enablePermissionsFeature } from "util/permissions"; const isSmallScreen = () => isWidthBelow(620); @@ -20,11 +24,34 @@ const Navigation: FC = () => { const [projectName, setProjectName] = useState( project && !isLoading ? project.name : "default", ); + const { hasCustomVolumeIso } = useSupportedFeatures(); + const enablePermissions = enablePermissionsFeature(); + const [scroll, setScroll] = useState(false); + const [openNavMenus, setOpenNavMenus] = useState([]); useEffect(() => { project && project.name !== projectName && setProjectName(project.name); }, [project?.name]); + useEffect(() => { + if (!menuCollapsed) { + adjustNavigationScrollForOverflow(); + return; + } + + if (scroll) { + setScroll(false); + } + + if (openNavMenus.length) { + setOpenNavMenus([]); + } + }, [menuCollapsed, scroll, openNavMenus]); + + useEffect(() => { + adjustNavigationScrollForOverflow(); + }, [openNavMenus]); + const { isAuthenticated } = useAuth(); const softToggleMenu = () => { if (isSmallScreen()) { @@ -37,6 +64,56 @@ const Navigation: FC = () => { e.stopPropagation(); }; + const adjustNavigationScrollForOverflow = () => { + const navHeader = document.querySelector(".l-navigation .p-panel__header"); + const navContent = document.querySelector( + ".l-navigation .p-panel__content", + ); + const navFooter = document.querySelector( + ".l-navigation .sidenav-bottom-ul", + ); + const navToggle = document.querySelector( + ".l-navigation .sidenav-toggle-wrapper", + ); + const navHeaderHeight = getElementAbsoluteHeight(navHeader as HTMLElement); + const navContentHeight = getElementAbsoluteHeight( + navContent as HTMLElement, + ); + const navFooterHeight = getElementAbsoluteHeight(navFooter as HTMLElement); + const navToggleHeight = getElementAbsoluteHeight(navToggle as HTMLElement); + + let totalNavHeight = navHeaderHeight + navContentHeight + navFooterHeight; + + // when the continer is in scrolling mode, p-panel__content includes the footer height already since it's not absolutely positioned + // also need to take into account the toggle height + if (scroll) { + const footerOffset = navToggleHeight - navFooterHeight; + totalNavHeight = totalNavHeight + footerOffset; + } + + const isNavigationPanelOverflow = totalNavHeight >= window.innerHeight; + + if (isNavigationPanelOverflow) { + setScroll(true); + } else { + setScroll(false); + } + }; + + const toggleAccordionNav = (feature: AccordionNavMenu) => { + if (menuCollapsed) { + setMenuCollapsed(false); + } + + const newOpenMenus = openNavMenus.includes(feature) + ? openNavMenus.filter((navMenu) => navMenu !== feature) + : [...openNavMenus, feature]; + + setOpenNavMenus(newOpenMenus); + }; + + useEventListener("resize", adjustNavigationScrollForOverflow); + return ( <>
@@ -60,8 +137,8 @@ const Navigation: FC = () => { className={classnames("l-navigation", { "is-collapsed": menuCollapsed, "is-pinned": !menuCollapsed, + "is-scroll": scroll, })} - onClick={softToggleMenu} >
@@ -90,10 +167,11 @@ const Navigation: FC = () => { activeProject={projectName} /> -
  • +
  • { Instances
  • -
  • +
  • { Profiles
  • -
  • +
  • { Networks
  • -
  • - + toggleAccordionNav("storage")} + open={openNavMenus.includes("storage")} > - {" "} - Storage - + {[ +
  • + + Pools + +
  • , +
  • + + Volumes + +
  • , + ...(hasCustomVolumeIso + ? [ +
  • + + Custom ISOs + +
  • , + ] + : []), + ]} + -
  • +
  • { Images
  • -
  • +
  • {

  • - + { {
  • {!isRestricted && (
  • - + {
  • )} + {enablePermissions && ( +
  • + toggleAccordionNav("permissions")} + open={openNavMenus.includes("permissions")} + > + {[ +
  • + + Identities + +
  • , +
  • + + Groups + +
  • , +
  • + + IDP groups + +
  • , + ]} + + + )}
  • - + { { + logout(); + softToggleMenu(); + }} > { {!isAuthenticated && ( <>
  • - + { +interface Props { + className?: string; +} + +const NotificationRow: FC = ({ className }) => { return ( - + ); diff --git a/src/components/RenameHeader.tsx b/src/components/RenameHeader.tsx index 22ed2930d1..e497e37c9c 100644 --- a/src/components/RenameHeader.tsx +++ b/src/components/RenameHeader.tsx @@ -21,7 +21,7 @@ interface Props { controls?: ReactNode; isLoaded: boolean; renameDisabledReason?: string; - formik: FormikProps; + formik?: FormikProps; } const RenameHeader: FC = ({ @@ -36,7 +36,7 @@ const RenameHeader: FC = ({ }) => { const canRename = renameDisabledReason === undefined; const enableRename = () => { - if (!canRename) { + if (!canRename || !formik) { return; } void formik.setValues({ @@ -61,7 +61,7 @@ const RenameHeader: FC = ({ {item}
  • ))} - {formik.values.isRenaming ? ( + {formik?.values.isRenaming ? (
  • = ({ )} - {!formik.values.isRenaming && centerControls} + {!formik?.values.isRenaming && centerControls}
  • ) : (

    {name}

    )} - {isLoaded && !formik.values.isRenaming && ( + {isLoaded && !formik?.values.isRenaming && (
    {controls}
    )}
    diff --git a/src/components/ScrollableContainer.tsx b/src/components/ScrollableContainer.tsx index aacd8be562..3b35547a66 100644 --- a/src/components/ScrollableContainer.tsx +++ b/src/components/ScrollableContainer.tsx @@ -1,17 +1,26 @@ import { DependencyList, FC, ReactNode, useEffect, useRef } from "react"; import useEventListener from "@use-it/event-listener"; -import { getAbsoluteHeightBelow, getParentsBottomSpacing } from "util/helpers"; +import { + getAbsoluteHeightBelowById, + getAbsoluteHeightBelowBySelector, + getParentsBottomSpacing, +} from "util/helpers"; +import classnames from "classnames"; interface Props { children: ReactNode; dependencies: DependencyList; - belowId?: string; + belowIds?: string[]; + belowSelectors?: string[]; + className?: string; } const ScrollableContainer: FC = ({ dependencies, children, - belowId = "", + belowIds = ["status-bar"], + belowSelectors = [], + className, }) => { const ref = useRef(null); @@ -20,11 +29,18 @@ const ScrollableContainer: FC = ({ if (!childContainer) { return; } + const above = childContainer.getBoundingClientRect().top + 1; - const below = getAbsoluteHeightBelow(belowId); - const parentsBottomSpacing = - getParentsBottomSpacing(childContainer) + - getAbsoluteHeightBelow("status-bar"); + const below = + belowIds.reduce( + (acc, belowId) => acc + getAbsoluteHeightBelowById(belowId), + 0, + ) + + belowSelectors.reduce( + (acc, belowId) => acc + getAbsoluteHeightBelowBySelector(belowId), + 0, + ); + const parentsBottomSpacing = getParentsBottomSpacing(childContainer); const offset = Math.ceil(above + below + parentsBottomSpacing); const style = `height: calc(100vh - ${offset}px); min-height: calc(100vh - ${offset}px)`; childContainer.setAttribute("style", style); @@ -34,7 +50,7 @@ const ScrollableContainer: FC = ({ useEffect(updateChildContainerHeight, [...dependencies, ref]); return ( -
    +
    {children}
    ); diff --git a/src/components/ScrollableForm.tsx b/src/components/ScrollableForm.tsx index d92dfc6467..a5d402b315 100644 --- a/src/components/ScrollableForm.tsx +++ b/src/components/ScrollableForm.tsx @@ -11,7 +11,7 @@ const ScrollableForm: FC = ({ children }) => { return ( {children} diff --git a/src/components/ScrollableTable.tsx b/src/components/ScrollableTable.tsx index a69975db76..642debfc15 100644 --- a/src/components/ScrollableTable.tsx +++ b/src/components/ScrollableTable.tsx @@ -1,29 +1,33 @@ import { DependencyList, FC, ReactNode, useEffect } from "react"; import useEventListener from "@use-it/event-listener"; -import { getAbsoluteHeightBelow, getParentsBottomSpacing } from "util/helpers"; +import { + getAbsoluteHeightBelowById, + getParentsBottomSpacing, +} from "util/helpers"; interface Props { children: ReactNode; dependencies: DependencyList; - belowIds?: string[]; tableId: string; + belowIds?: string[]; } const ScrollableTable: FC = ({ dependencies, children, - belowIds = [], tableId, + belowIds = [], }) => { const updateTBodyHeight = () => { const table = document.getElementById(tableId); if (!table || table.children.length !== 2) { return; } + const tBody = table.children[1]; const above = tBody.getBoundingClientRect().top + 1; const below = belowIds.reduce( - (acc, belowId) => acc + getAbsoluteHeightBelow(belowId), + (acc, belowId) => acc + getAbsoluteHeightBelowById(belowId), 0, ); const parentsBottomSpacing = getParentsBottomSpacing(table); diff --git a/src/components/SelectableMainTable.tsx b/src/components/SelectableMainTable.tsx index b17b3fee17..8eaabc7e14 100644 --- a/src/components/SelectableMainTable.tsx +++ b/src/components/SelectableMainTable.tsx @@ -11,15 +11,20 @@ import { } from "@canonical/react-components"; import classnames from "classnames"; import useEventListener from "@use-it/event-listener"; +import { pluralize } from "util/instanceBulkActions"; interface SelectableMainTableProps { filteredNames: string[]; itemName: string; parentName: string; selectedNames: string[]; - setSelectedNames: (val: string[]) => void; + setSelectedNames: (val: string[], isUnselectAll?: boolean) => void; processingNames: string[]; rows: MainTableRow[]; + indeterminateNames?: string[]; + disableSelect?: boolean; + onToggleRow?: (rowName: string) => void; + hideContextualMenu?: boolean; } type Props = SelectableMainTableProps & MainTableProps; @@ -33,12 +38,16 @@ const SelectableMainTable: FC = ({ processingNames, rows, headers, + indeterminateNames = [], + disableSelect = false, + onToggleRow, + hideContextualMenu, ...props }: Props) => { const [currentSelectedIndex, setCurrentSelectedIndex] = useState(); const isAllSelected = selectedNames.length === filteredNames.length && selectedNames.length > 0; - const isSomeSelected = selectedNames.length > 0; + const isSomeSelected = selectedNames.length + indeterminateNames.length > 0; const isCheckBoxTarget = (target: HTMLElement) => { return target.className === "p-checkbox__label"; @@ -57,12 +66,16 @@ const SelectableMainTable: FC = ({ }; const selectPage = () => { - setSelectedNames(rows.map((row) => row.name ?? "")); + const allNames = rows + .filter((row) => !!row.name) + .map((row) => row.name ?? ""); + setSelectedNames(allNames); setCurrentSelectedIndex(undefined); }; const selectNone = () => { - setSelectedNames([]); + const isUnselectAll = true; + setSelectedNames([], isUnselectAll); setCurrentSelectedIndex(undefined); }; @@ -76,41 +89,56 @@ const SelectableMainTable: FC = ({ checked={isAllSelected} indeterminate={isSomeSelected && !isAllSelected} onChange={isSomeSelected ? selectNone : selectPage} + disabled={disableSelect} /> - } - toggleProps={{ - "aria-label": "multiselect rows", - }} - links={[ - { - children: `Select all ${itemName}s on this page`, - onClick: selectPage, - }, - { - children: `Select all ${parentName} ${itemName}s`, - onClick: selectAll, - }, - ]} - /> + {!hideContextualMenu && ( + } + toggleProps={{ + "aria-label": "multiselect rows", + disabled: disableSelect, + }} + links={[ + { + children: `Select all ${pluralize(itemName, 2)} on this page`, + onClick: selectPage, + }, + { + children: `Select all ${parentName} ${pluralize(itemName, 2)}`, + onClick: selectAll, + }, + ]} + /> + )} ), - className: "select select-header", + className: classnames("select select-header", { + "no-menu": hideContextualMenu, + }), "aria-label": "select", }, ...(headers ?? []), ]; + const selectedNamesLookup = new Set(selectedNames); + const processingNamesLookup = new Set(processingNames); + const indeterminateNamesLookup = new Set(indeterminateNames); const rowsWithCheckbox = rows.map((row, rowIndex) => { - const isRowSelected = selectedNames.includes(row.name ?? ""); - const isRowProcessing = processingNames.includes(row.name ?? ""); + const isRowSelected = selectedNamesLookup.has(row.name ?? ""); + const isRowProcessing = processingNamesLookup.has(row.name ?? ""); + const isRowIndeterminate = indeterminateNamesLookup.has(row.name ?? ""); const toggleRow = (event: PointerEvent) => { + if (onToggleRow) { + onToggleRow(row.name ?? ""); + return; + } + if ( event.nativeEvent.shiftKey && currentSelectedIndex !== undefined && @@ -153,7 +181,8 @@ const SelectableMainTable: FC = ({ labelClassName="u-no-margin--bottom" checked={isRowSelected} onChange={toggleRow} - disabled={isRowProcessing || !row.name} + disabled={isRowProcessing || !row.name || disableSelect} + indeterminate={isRowIndeterminate && !isRowSelected} /> ), role: "rowheader", @@ -167,7 +196,7 @@ const SelectableMainTable: FC = ({ "processing-row": isRowProcessing, }); - const key = row.name; + const key = row.key ?? row.name; return { ...row, diff --git a/src/components/SelectedTableNotification.tsx b/src/components/SelectedTableNotification.tsx index f39d91a967..af1f087529 100644 --- a/src/components/SelectedTableNotification.tsx +++ b/src/components/SelectedTableNotification.tsx @@ -6,9 +6,10 @@ interface Props { totalCount: number; filteredNames: string[]; itemName: string; - parentName: string; + parentName?: string; selectedNames: string[]; setSelectedNames: (val: string[]) => void; + hideActions?: boolean; } const SelectedTableNotification: FC = ({ @@ -18,6 +19,7 @@ const SelectedTableNotification: FC = ({ parentName, selectedNames, setSelectedNames, + hideActions, }: Props) => { const isAllSelected = selectedNames.length === filteredNames.length; @@ -39,31 +41,36 @@ const SelectedTableNotification: FC = ({ ) : ( <> - All {filteredNames.length} {itemName}s selected.{" "} + All {filteredNames.length}{" "} + {pluralize(itemName, filteredNames.length)} selected.{" "} )} - + {!hideActions && ( + + )} ) : ( <> {selectedNames.length}{" "} {pluralize(itemName, selectedNames.length)} selected.{" "} - + {!hideActions && ( + + )} )}
    diff --git a/src/components/SidePanel.tsx b/src/components/SidePanel.tsx index 913e471737..4c3655e19a 100644 --- a/src/components/SidePanel.tsx +++ b/src/components/SidePanel.tsx @@ -69,7 +69,7 @@ const Footer: FC = ({ className, }) => { return ( -
    + diff --git a/src/components/Tag.tsx b/src/components/Tag.tsx new file mode 100644 index 0000000000..416b453838 --- /dev/null +++ b/src/components/Tag.tsx @@ -0,0 +1,19 @@ +import { FC, PropsWithChildren } from "react"; +import classnames from "classnames"; + +interface Props { + isVisible: boolean; + className?: string; +} + +const Tag: FC> = ({ + isVisible, + children, + className, +}) => { + return isVisible ? ( + {children} + ) : null; +}; + +export default Tag; diff --git a/src/context/menuCollapsed.tsx b/src/context/menuCollapsed.tsx index 3d66c6d2c7..3b20d83dee 100644 --- a/src/context/menuCollapsed.tsx +++ b/src/context/menuCollapsed.tsx @@ -5,6 +5,8 @@ import { isWidthBelow } from "util/helpers"; const isSmallScreen = () => isWidthBelow(620); const isMediumScreen = () => isWidthBelow(820); +const noCollapseEvents = new Set(["search-and-filter"]); + export const useMenuCollapsed = () => { const [menuCollapsed, setMenuCollapsed] = useState(isMediumScreen()); @@ -12,7 +14,7 @@ export const useMenuCollapsed = () => { if (isSmallScreen()) { return; } - if (!("detail" in e) || e.detail !== "search-and-filter") { + if (!("detail" in e) || !noCollapseEvents.has(e.detail)) { setMenuCollapsed(isMediumScreen()); } }; diff --git a/src/context/useDeleteIcon.tsx b/src/context/useSmallScreen.tsx similarity index 89% rename from src/context/useDeleteIcon.tsx rename to src/context/useSmallScreen.tsx index 5d52275be4..50eed5920c 100644 --- a/src/context/useDeleteIcon.tsx +++ b/src/context/useSmallScreen.tsx @@ -2,7 +2,7 @@ import useEventListener from "@use-it/event-listener"; import { useState } from "react"; import { isWidthBelow } from "util/helpers"; -export const useDeleteIcon = (): boolean => { +export const useSmallScreen = (): boolean => { const [isSmallScreen, setIsSmallScreen] = useState(isWidthBelow(620)); const handleResize = () => { const newSmall = isWidthBelow(620); diff --git a/src/context/useSupportedFeatures.tsx b/src/context/useSupportedFeatures.tsx index 67a8f5b641..af7f678146 100644 --- a/src/context/useSupportedFeatures.tsx +++ b/src/context/useSupportedFeatures.tsx @@ -21,5 +21,6 @@ export const useSupportedFeatures = () => { !!serverVersion && serverMajor >= 5 && serverMinor >= 19, hasDocumentationObject: !!serverVersion && serverMajor >= 5 && serverMinor >= 20, + hasAccessManagement: apiExtensions.has("access_management"), }; }; diff --git a/src/pages/instances/actions/DeleteInstanceBtn.tsx b/src/pages/instances/actions/DeleteInstanceBtn.tsx index 5a229a10ea..a33f85342a 100644 --- a/src/pages/instances/actions/DeleteInstanceBtn.tsx +++ b/src/pages/instances/actions/DeleteInstanceBtn.tsx @@ -4,7 +4,7 @@ import { LxdInstance } from "types/instance"; import { useNavigate } from "react-router-dom"; import ItemName from "components/ItemName"; import { deletableStatuses } from "util/instanceDelete"; -import { useDeleteIcon } from "context/useDeleteIcon"; +import { useSmallScreen } from "context/useSmallScreen"; import { ConfirmationButton, Icon } from "@canonical/react-components"; import classnames from "classnames"; import { useEventQueue } from "context/eventQueue"; @@ -19,7 +19,7 @@ interface Props { const DeleteInstanceBtn: FC = ({ instance }) => { const eventQueue = useEventQueue(); - const isDeleteIcon = useDeleteIcon(); + const isDeleteIcon = useSmallScreen(); const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const [isLoading, setLoading] = useState(false); diff --git a/src/pages/networks/NetworkDetailOverview.tsx b/src/pages/networks/NetworkDetailOverview.tsx index 48dafa5880..a7ee8b5444 100644 --- a/src/pages/networks/NetworkDetailOverview.tsx +++ b/src/pages/networks/NetworkDetailOverview.tsx @@ -49,8 +49,8 @@ const NetworkDetailOverview: FC = ({ network }) => { } const data: Record = { - instances: filterUsedByType("instances", network.used_by), - profiles: filterUsedByType("profiles", network.used_by), + instances: filterUsedByType("instance", network.used_by), + profiles: filterUsedByType("profile", network.used_by), }; return ( diff --git a/src/pages/permissions/PermissionGroups.tsx b/src/pages/permissions/PermissionGroups.tsx new file mode 100644 index 0000000000..209174f503 --- /dev/null +++ b/src/pages/permissions/PermissionGroups.tsx @@ -0,0 +1,303 @@ +import { + Button, + EmptyState, + Icon, + Row, + TablePagination, + useNotify, +} from "@canonical/react-components"; +import { useQuery } from "@tanstack/react-query"; +import Loader from "components/Loader"; +import ScrollableTable from "components/ScrollableTable"; +import SelectableMainTable from "components/SelectableMainTable"; +import SelectedTableNotification from "components/SelectedTableNotification"; +import { FC, useEffect, useState } from "react"; +import { queryKeys } from "util/queryKeys"; +import useSortTableData from "util/useSortTableData"; +import { getIdentityIdsForGroup } from "util/permissionIdentities"; +import usePanelParams, { panels } from "util/usePanelParams"; +import CustomLayout from "components/CustomLayout"; +import PageHeader from "components/PageHeader"; +import NotificationRow from "components/NotificationRow"; +import HelpLink from "components/HelpLink"; +import { useDocs } from "context/useDocs"; +import { fetchGroups } from "api/auth-groups"; +import GroupActions from "./actions/GroupActions"; +import CreateGroupPanel from "./panels/CreateGroupPanel"; +import EditGroupPanel from "./panels/EditGroupPanel"; +import PermissionGroupsFilter from "./PermissionGroupsFilter"; +import EditGroupIdentitiesBtn from "./actions/EditGroupIdentitiesBtn"; +import EditGroupIdentitiesPanel from "./panels/EditGroupIdentitiesPanel"; +import BulkDeleteGroupsBtn from "./actions/BulkDeleteGroupsBtn"; +import EditGroupPermissionsPanel from "./panels/EditGroupPermissionsPanel"; + +const PermissionGroups: FC = () => { + const notify = useNotify(); + const { + data: groups = [], + error, + isLoading, + } = useQuery({ + queryKey: [queryKeys.authGroups], + queryFn: fetchGroups, + }); + const docBaseLink = useDocs(); + const panelParams = usePanelParams(); + const [search, setSearch] = useState(""); + const [selectedGroupNames, setSelectedGroupNames] = useState([]); + + if (error) { + notify.failure("Loading groups failed", error); + } + + useEffect(() => { + if (panelParams.group) { + setSelectedGroupNames([panelParams.group]); + } + }, [panelParams.group]); + + const headers = [ + { content: "Name", className: "name", sortKey: "name" }, + { + content: "Description", + className: "description", + sortKey: "description", + }, + { + content: "Identities", + sortKey: "identities", + className: "u-align--right identities", + }, + { + content: "Permissions", + sortKey: "permissions", + className: "u-align--right permissions", + }, + { "aria-label": "Actions", className: "u-align--right actions" }, + ]; + + const filteredGroups = groups.filter( + (group) => + !search || + group.name.toLowerCase().includes(search) || + group.description.toLowerCase().includes(search), + ); + + const selectedGroups = groups.filter((group) => + selectedGroupNames.includes(group.name), + ); + + const rows = filteredGroups.map((group) => { + const allIdentityIds = getIdentityIdsForGroup(group); + return { + name: group.name, + className: "u-row", + columns: [ + { + content: group.name, + role: "cell", + "aria-label": "Name", + className: "u-truncate name", + title: group.name, + }, + { + content: {group.description}, + role: "cell", + "aria-label": "Description", + className: "description", + title: group.description, + }, + { + content: allIdentityIds.length, + role: "cell", + className: "u-align--right identities", + "aria-label": "Identities in this group", + }, + { + content: group.permissions?.length || 0, + role: "cell", + className: "u-align--right permissions", + "aria-label": "Permissions for this group", + }, + { + className: "actions u-align--right", + content: , + role: "cell", + "aria-label": "Actions", + }, + ], + sortData: { + name: group.name.toLowerCase(), + description: group.description.toLowerCase(), + permissions: group.permissions?.length || 0, + identities: allIdentityIds.length, + }, + }; + }); + + const { rows: sortedRows, updateSort } = useSortTableData({ + rows, + defaultSort: "name", + }); + + if (isLoading) { + return ; + } + + const getTablePaginationDescription = () => { + if (selectedGroupNames.length > 0) { + return ( + item.name)} + hideActions={!!panelParams.panel} + /> + ); + } + }; + + const hasGroups = groups.length > 0; + const content = hasGroups ? ( + + + item.name)} + disableSelect={!!panelParams.panel} + /> + + + ) : ( + } + title="No groups" + > +

    + Groups are an easy way to manage the structured assignment of + permissions +

    +

    + + Learn more about permissions + + +

    + + + ); + + return ( + <> + + + + + Groups + + + {!selectedGroupNames.length && hasGroups && ( + + + + )} + {selectedGroupNames.length > 0 && !panelParams.panel && ( + <> + setSelectedGroupNames([])} + /> + + + )} + + {hasGroups && ( + + {!selectedGroupNames.length && ( + + )} + + )} + + } + > + {!panelParams.panel && } + {content} + + + {panelParams.panel === panels.createGroup && } + + {panelParams.panel === panels.editGroup && !!selectedGroups.length && ( + + )} + + {panelParams.panel === panels.groupIdentities && + !!selectedGroups.length && ( + + )} + + {panelParams.panel === panels.groupPermissions && + !!selectedGroups.length && ( + + )} + + ); +}; + +export default PermissionGroups; diff --git a/src/pages/permissions/PermissionGroupsFilter.tsx b/src/pages/permissions/PermissionGroupsFilter.tsx new file mode 100644 index 0000000000..f3bd0b2786 --- /dev/null +++ b/src/pages/permissions/PermissionGroupsFilter.tsx @@ -0,0 +1,31 @@ +import { Input } from "@canonical/react-components"; +import { ChangeEvent, FC } from "react"; + +interface Props { + onChange: (val: string) => void; + value: string; + disabled?: boolean; +} + +const PermissionGroupsFilter: FC = ({ onChange, value, disabled }) => { + const handleSearchChange = (e: ChangeEvent) => { + onChange(e.target.value.toLowerCase()); + }; + + return ( +
    + +
    + ); +}; + +export default PermissionGroupsFilter; diff --git a/src/pages/permissions/PermissionIdentities.tsx b/src/pages/permissions/PermissionIdentities.tsx new file mode 100644 index 0000000000..3a02138a2d --- /dev/null +++ b/src/pages/permissions/PermissionIdentities.tsx @@ -0,0 +1,282 @@ +import { + Button, + Icon, + Row, + TablePagination, + useNotify, +} from "@canonical/react-components"; +import { useQuery } from "@tanstack/react-query"; +import { fetchIdentities } from "api/auth-identities"; +import Loader from "components/Loader"; +import ScrollableTable from "components/ScrollableTable"; +import SelectableMainTable from "components/SelectableMainTable"; +import SelectedTableNotification from "components/SelectedTableNotification"; +import { FC, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { queryKeys } from "util/queryKeys"; +import useSortTableData from "util/useSortTableData"; +import PermissionIdentitiesFilter, { + AUTH_METHOD, + PermissionIdentitiesFilterType, + QUERY, +} from "./PermissionIdentitiesFilter"; +import { useSettings } from "context/useSettings"; +import EditIdentityGroupsBtn from "./actions/EditIdentityGroupsBtn"; +import usePanelParams, { panels } from "util/usePanelParams"; +import CustomLayout from "components/CustomLayout"; +import PageHeader from "components/PageHeader"; +import NotificationRow from "components/NotificationRow"; +import HelpLink from "components/HelpLink"; +import { useDocs } from "context/useDocs"; +import EditIdentityGroupsPanel from "./panels/EditIdentityGroupsPanel"; +import Tag from "components/Tag"; + +const PermissionIdentities: FC = () => { + const notify = useNotify(); + const { + data: identities = [], + error, + isLoading, + } = useQuery({ + queryKey: [queryKeys.identities], + queryFn: fetchIdentities, + }); + const { data: settings } = useSettings(); + const docBaseLink = useDocs(); + const panelParams = usePanelParams(); + const [searchParams] = useSearchParams(); + const [selectedIdentityIds, setSelectedIdentityIds] = useState([]); + + if (error) { + notify.failure("Loading identities failed", error); + } + + const headers = [ + { content: "Name", className: "name", sortKey: "name" }, + { content: "ID", sortKey: "id" }, + { content: "Auth method", sortKey: "authmethod" }, + { content: "Type", sortKey: "type" }, + { + content: "Groups", + sortKey: "groups", + className: "u-align--right", + }, + { "aria-label": "Actions", className: "u-align--right actions" }, + ]; + + const filters: PermissionIdentitiesFilterType = { + queries: searchParams.getAll(QUERY), + authMethod: searchParams.getAll(AUTH_METHOD), + }; + + const filteredIdentities = identities.filter((identity) => { + if ( + !filters.queries.every( + (q) => + identity.name.toLowerCase().includes(q) || + identity.id.toLowerCase().includes(q), + ) + ) { + return false; + } + + if ( + filters.authMethod.length > 0 && + !filters.authMethod.includes(identity.authentication_method) + ) { + return false; + } + + return true; + }); + + const selectedIdentities = identities.filter((identity) => + selectedIdentityIds.includes(identity.id), + ); + + const rows = filteredIdentities.map((identity) => { + const isLoggedInIdentity = settings?.auth_user_name === identity.id; + const isTlsIdentity = identity.authentication_method === "tls"; + return { + name: isTlsIdentity ? "" : identity.id, + key: identity.id, + className: "u-row", + columns: [ + { + content: ( + <> + {identity.name} You + + ), + role: "cell", + "aria-label": "Name", + className: "u-truncate", + title: identity.name, + }, + { + content: identity.id, + role: "cell", + "aria-label": "ID", + className: "u-truncate", + title: identity.id, + }, + { + content: identity.authentication_method.toUpperCase(), + role: "cell", + "aria-label": "Auth method", + }, + { + content: identity.type, + role: "cell", + "aria-label": "Type", + className: "u-truncate", + title: identity.type, + }, + { + content: identity.groups?.length || 0, + role: "cell", + className: "u-align--right", + "aria-label": "Groups for this identity", + }, + { + content: !isTlsIdentity && ( + + ), + className: "actions u-align--right", + role: "cell", + "aria-label": "Actions", + }, + ], + sortData: { + id: identity.id, + name: identity.name.toLowerCase(), + authentication_method: identity.authentication_method, + type: identity.type, + groups: identity.groups?.length || 0, + }, + }; + }); + + const { rows: sortedRows, updateSort } = useSortTableData({ + rows, + defaultSort: "name", + }); + + // NOTE: tls user group membership cannot be modified, this will be supported in the future + const nonTlsUsers = identities.filter((identity) => { + const isTlsIdentity = identity.authentication_method === "tls"; + return !isTlsIdentity; + }); + + if (isLoading) { + return ; + } + + const getTablePaginationDescription = () => { + // This is needed because TablePagination does not cater for plural identity + const defaultPaginationDescription = + rows.length > 1 + ? `Showing all ${rows.length} identities` + : `Showing 1 out of 1 identity`; + + if (selectedIdentityIds.length > 0) { + return ( + item.id)} + hideActions={!!panelParams.panel} + /> + ); + } + + return defaultPaginationDescription; + }; + + return ( + <> + + + + + Identities + + + {!selectedIdentityIds.length && !panelParams.panel && ( + + + + )} + {!!selectedIdentityIds.length && ( + + )} + + + } + > + {!panelParams.panel && } + + + + identity.id)} + disableSelect={!!panelParams.panel} + /> + + + + + {panelParams.panel === panels.identityGroups && ( + + )} + + ); +}; + +export default PermissionIdentities; diff --git a/src/pages/permissions/PermissionIdentitiesFilter.tsx b/src/pages/permissions/PermissionIdentitiesFilter.tsx new file mode 100644 index 0000000000..2a5b836f16 --- /dev/null +++ b/src/pages/permissions/PermissionIdentitiesFilter.tsx @@ -0,0 +1,68 @@ +import { FC, memo } from "react"; +import { SearchAndFilter } from "@canonical/react-components"; +import { + SearchAndFilterData, + SearchAndFilterChip, +} from "@canonical/react-components/dist/components/SearchAndFilter/types"; +import { useSearchParams } from "react-router-dom"; +import { + paramsFromSearchData, + searchParamsToChips, +} from "util/searchAndFilter"; + +export interface PermissionIdentitiesFilterType { + queries: string[]; + authMethod: string[]; +} +export const QUERY = "query"; +export const AUTH_METHOD = "auth-method"; + +const authMethods: string[] = ["tls", "oidc"]; +const QUERY_PARAMS = [QUERY, AUTH_METHOD]; + +const PermissionIdentitiesFilter: FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); + + const searchAndFilterData: SearchAndFilterData[] = [ + { + id: 1, + heading: "Auth method", + chips: authMethods.map((method) => { + return { lead: AUTH_METHOD, value: method }; + }), + }, + ]; + + const onSearchDataChange = (searchData: SearchAndFilterChip[]) => { + const newParams = paramsFromSearchData( + searchData, + searchParams, + QUERY_PARAMS, + ); + + if (newParams.toString() !== searchParams.toString()) { + setSearchParams(newParams); + } + }; + + return ( + <> +

    Search and filter

    + { + window.dispatchEvent( + new CustomEvent("resize", { detail: "search-and-filter" }), + ); + }} + onPanelToggle={() => { + window.dispatchEvent(new CustomEvent("sfp-toggle")); + }} + /> + + ); +}; + +export default memo(PermissionIdentitiesFilter); diff --git a/src/pages/permissions/PermissionIdpGroups.tsx b/src/pages/permissions/PermissionIdpGroups.tsx new file mode 100644 index 0000000000..bb4719ce9f --- /dev/null +++ b/src/pages/permissions/PermissionIdpGroups.tsx @@ -0,0 +1,302 @@ +import { + Button, + Card, + EmptyState, + Icon, + List, + Row, + TablePagination, + useNotify, +} from "@canonical/react-components"; +import { useQuery } from "@tanstack/react-query"; +import Loader from "components/Loader"; +import ScrollableTable from "components/ScrollableTable"; +import SelectableMainTable from "components/SelectableMainTable"; +import SelectedTableNotification from "components/SelectedTableNotification"; +import { FC, useEffect, useState } from "react"; +import { queryKeys } from "util/queryKeys"; +import useSortTableData from "util/useSortTableData"; +import usePanelParams, { panels } from "util/usePanelParams"; +import CustomLayout from "components/CustomLayout"; +import PageHeader from "components/PageHeader"; +import NotificationRow from "components/NotificationRow"; +import HelpLink from "components/HelpLink"; +import { useDocs } from "context/useDocs"; +import PermissionGroupsFilter from "./PermissionGroupsFilter"; +import { fetchIdpGroups } from "api/auth-idp-groups"; +import CreateIdpGroupPanel from "./panels/CreateIdpGroupPanel"; +import BulkDeleteIdpGroupsBtn from "./actions/BulkDeleteIdpGroupsBtn"; +import EditIdpGroupPanel from "./panels/EditIdpGroupPanel"; +import DeleteIdepGroupBtn from "./actions/DeleteIdpGroupBtn"; +import { useSettings } from "context/useSettings"; +import { Link } from "react-router-dom"; + +const PermissionIdpGroups: FC = () => { + const notify = useNotify(); + const { + data: groups = [], + error, + isLoading, + } = useQuery({ + queryKey: [queryKeys.idpGroups], + queryFn: fetchIdpGroups, + }); + const docBaseLink = useDocs(); + const panelParams = usePanelParams(); + const [search, setSearch] = useState(""); + const [selectedGroupNames, setSelectedGroupNames] = useState([]); + const { data: settings } = useSettings(); + const hasCustomClaim = settings?.config?.["oidc.groups.claim"]; + + if (error) { + notify.failure("Loading provider groups failed", error); + } + + useEffect(() => { + if (panelParams.idpGroup) { + setSelectedGroupNames([panelParams.idpGroup]); + } + }, [panelParams.idpGroup]); + + const headers = [ + { content: "Name", className: "name", sortKey: "name" }, + { + content: "Mapped groups", + sortKey: "groups", + className: "u-align--right", + }, + { "aria-label": "Actions", className: "u-align--right actions" }, + ]; + + const filteredGroups = groups.filter( + (idpGroup) => !search || idpGroup.name.toLowerCase().includes(search), + ); + + const selectedGroups = groups.filter((group) => + selectedGroupNames.includes(group.name), + ); + + const rows = filteredGroups.map((idpGroup) => { + return { + name: idpGroup.name, + className: "u-row", + columns: [ + { + content: idpGroup.name, + role: "cell", + "aria-label": "Name", + className: "u-truncate", + title: idpGroup.name, + }, + { + content: idpGroup.groups.length, + role: "cell", + className: "u-align--right", + "aria-label": "Number of mapped groups", + }, + { + className: "actions u-align--right", + content: ( + panelParams.openEditIdpGroup(idpGroup.name)} + type="button" + aria-label="Edit IDP group details" + title="Edit details" + > + + , + , + ]} + /> + ), + role: "cell", + "aria-label": "Actions", + }, + ], + sortData: { + name: idpGroup.name.toLowerCase(), + groups: idpGroup.groups.length, + }, + }; + }); + + const { rows: sortedRows, updateSort } = useSortTableData({ rows }); + + if (isLoading) { + return ; + } + + const getTablePaginationDescription = () => { + if (selectedGroupNames.length > 0) { + return ( + item.name)} + hideActions={!!panelParams.panel} + /> + ); + } + }; + + const hasGroups = groups.length > 0; + const infoStyle = hasGroups ? "u-text--muted u-no-max-width" : ""; + const idpGroupsInfo = ( + <> +

    + Identity provider groups map authentication entities from your identity + provider to groups within LXD. +

    + {!hasCustomClaim ? ( +

    + You need to set your server{" "} + + configuration (oidc.groups.claim) + {" "} + to the name of the custom claim that provides the IDP groups. +

    + ) : ( + "" + )} +

    + + Learn more about IDP groups + + +

    + + ); + + const content = hasGroups ? ( + <> + {idpGroupsInfo} + + + item.name)} + disableSelect={!!panelParams.panel} + /> + + + + ) : ( + } + title="No IDP group mappings" + > + {idpGroupsInfo} + + + ); + + return ( + <> + + + + + Identity provider groups + + + {!selectedGroupNames.length && hasGroups && ( + + + + )} + {selectedGroupNames.length > 0 && !panelParams.panel && ( + <> + setSelectedGroupNames([])} + /> + + )} + + {hasGroups && ( + + {!selectedGroupNames.length && ( + + )} + + )} + + } + > + {!panelParams.panel && } + {content} + + + {panelParams.panel === panels.createIdpGroup && } + + {panelParams.panel === panels.editIdpGroup && selectedGroups.length && ( + + )} + + ); +}; + +export default PermissionIdpGroups; diff --git a/src/pages/permissions/actions/BulkDeleteGroupsBtn.tsx b/src/pages/permissions/actions/BulkDeleteGroupsBtn.tsx new file mode 100644 index 0000000000..a02d885613 --- /dev/null +++ b/src/pages/permissions/actions/BulkDeleteGroupsBtn.tsx @@ -0,0 +1,45 @@ +import { FC, useState } from "react"; +import { Button, Icon } from "@canonical/react-components"; +import { LxdGroup } from "types/permissions"; +import DeleteGroupModal from "./DeleteGroupModal"; +import { pluralize } from "util/instanceBulkActions"; + +interface Props { + groups: LxdGroup[]; + onDelete: () => void; + className?: string; +} + +const BulkDeleteGroupsBtn: FC = ({ groups, className, onDelete }) => { + const [confirming, setConfirming] = useState(false); + + const handleConfirmDelete = () => { + setConfirming(true); + }; + + const handleCloseConfirm = () => { + onDelete(); + setConfirming(false); + }; + + return ( + <> + + {confirming && ( + + )} + + ); +}; + +export default BulkDeleteGroupsBtn; diff --git a/src/pages/permissions/actions/BulkDeleteIdpGroupsBtn.tsx b/src/pages/permissions/actions/BulkDeleteIdpGroupsBtn.tsx new file mode 100644 index 0000000000..8bcce73771 --- /dev/null +++ b/src/pages/permissions/actions/BulkDeleteIdpGroupsBtn.tsx @@ -0,0 +1,52 @@ +import { FC, useState } from "react"; +import { Button, Icon } from "@canonical/react-components"; +import { IdpGroup } from "types/permissions"; +import { pluralize } from "util/instanceBulkActions"; +import DeleteIdpGroupsModal from "./DeleteIdpGroupsModal"; + +interface Props { + idpGroups: IdpGroup[]; + onDelete: () => void; + className?: string; +} + +const BulkDeleteIdpGroupsBtn: FC = ({ + idpGroups, + className, + onDelete, +}) => { + const [confirming, setConfirming] = useState(false); + + const handleConfirmDelete = () => { + setConfirming(true); + }; + + const handleCloseConfirm = () => { + onDelete(); + setConfirming(false); + }; + + return ( + <> + + {confirming && ( + + )} + + ); +}; + +export default BulkDeleteIdpGroupsBtn; diff --git a/src/pages/permissions/actions/DeleteGroupModal.tsx b/src/pages/permissions/actions/DeleteGroupModal.tsx new file mode 100644 index 0000000000..9764516b67 --- /dev/null +++ b/src/pages/permissions/actions/DeleteGroupModal.tsx @@ -0,0 +1,122 @@ +import { + ActionButton, + Input, + Modal, + useNotify, +} from "@canonical/react-components"; +import { useQueryClient } from "@tanstack/react-query"; +import { deleteGroup, deleteGroups } from "api/auth-groups"; +import { useToastNotification } from "context/toastNotificationProvider"; +import { ChangeEvent, FC, useState } from "react"; +import { LxdGroup } from "types/permissions"; +import { pluralize } from "util/instanceBulkActions"; +import { queryKeys } from "util/queryKeys"; + +interface Props { + groups: LxdGroup[]; + close: () => void; +} + +const DeleteGroupModal: FC = ({ groups, close }) => { + const queryClient = useQueryClient(); + const notify = useNotify(); + const toastNotify = useToastNotification(); + const [confirmInput, setConfirmInput] = useState(""); + const [disableConfirm, setDisableConfirm] = useState(true); + const [submitting, setSubmitting] = useState(false); + const hasOneGroup = groups.length === 1; + const confirmText = "confirm-delete-group"; + + const handleConfirmInputChange = (e: ChangeEvent) => { + if (e.target.value === confirmText) { + setDisableConfirm(false); + } else { + setDisableConfirm(true); + } + + setConfirmInput(e.target.value); + }; + + const handleDeleteGroups = () => { + setSubmitting(true); + const hasSingleGroup = groups.length === 1; + const mutationPromise = hasSingleGroup + ? deleteGroup(groups[0].name) + : deleteGroups(groups.map((group) => group.name)); + + const successMessage = hasSingleGroup + ? `Group ${groups[0].name} deleted.` + : `${groups.length} groups deleted.`; + + mutationPromise + .then(() => { + void queryClient.invalidateQueries({ + predicate: (query) => { + return [queryKeys.identities, queryKeys.authGroups].includes( + query.queryKey[0] as string, + ); + }, + }); + toastNotify.success(successMessage); + close(); + }) + .catch((e) => { + notify.failure( + `${pluralize("group", groups.length)} deletion failed`, + e, + ); + }) + .finally(() => { + setSubmitting(false); + }); + }; + + return ( + + + , + + {`Permanently delete ${groups.length} ${pluralize("group", groups.length)}`} + , + ]} + > +

    + Are you sure you want to permanently delete{" "} + + {hasOneGroup ? groups[0].name : `${groups.length} groups`} + + ? +

    +

    + This action cannot be undone and may result in users losing access to + LXD, including the possibility that all users lose admin access. +

    +

    To continue, please type the confirmation text below.

    +

    + {confirmText} +

    +
    + ); +}; + +export default DeleteGroupModal; diff --git a/src/pages/permissions/actions/DeleteIdpGroupBtn.tsx b/src/pages/permissions/actions/DeleteIdpGroupBtn.tsx new file mode 100644 index 0000000000..b458c34037 --- /dev/null +++ b/src/pages/permissions/actions/DeleteIdpGroupBtn.tsx @@ -0,0 +1,36 @@ +import { FC } from "react"; +import { Button, Icon } from "@canonical/react-components"; +import { IdpGroup } from "types/permissions"; +import DeleteIdpGroupsModal from "./DeleteIdpGroupsModal"; +import usePortal from "react-useportal"; + +interface Props { + idpGroup: IdpGroup; +} + +const DeleteIdepGroupBtn: FC = ({ idpGroup }) => { + const { openPortal, closePortal, isOpen, Portal } = usePortal(); + + return ( + <> + + {isOpen && ( + + + + )} + + ); +}; + +export default DeleteIdepGroupBtn; diff --git a/src/pages/permissions/actions/DeleteIdpGroupsModal.tsx b/src/pages/permissions/actions/DeleteIdpGroupsModal.tsx new file mode 100644 index 0000000000..5150b4adc5 --- /dev/null +++ b/src/pages/permissions/actions/DeleteIdpGroupsModal.tsx @@ -0,0 +1,73 @@ +import { ConfirmationModal, useNotify } from "@canonical/react-components"; +import { useQueryClient } from "@tanstack/react-query"; +import { deleteIdpGroup, deleteIdpGroups } from "api/auth-idp-groups"; +import { useToastNotification } from "context/toastNotificationProvider"; +import { FC, useState } from "react"; +import { IdpGroup } from "types/permissions"; +import { pluralize } from "util/instanceBulkActions"; +import { queryKeys } from "util/queryKeys"; + +interface Props { + idpGroups: IdpGroup[]; + close: () => void; +} + +const DeleteIdpGroupsModal: FC = ({ idpGroups, close }) => { + const queryClient = useQueryClient(); + const notify = useNotify(); + const toastNotify = useToastNotification(); + const [submitting, setSubmitting] = useState(false); + const hasOneGroup = idpGroups.length === 1; + + const handleDeleteIdpGroups = () => { + setSubmitting(true); + const mutationPromise = hasOneGroup + ? deleteIdpGroup(idpGroups[0].name) + : deleteIdpGroups(idpGroups.map((group) => group.name)); + + const successMessage = hasOneGroup + ? `IDP group ${idpGroups[0].name} deleted.` + : `${idpGroups.length} IDP groups deleted.`; + + mutationPromise + .then(() => { + void queryClient.invalidateQueries({ + queryKey: [queryKeys.idpGroups], + }); + toastNotify.success(successMessage); + close(); + }) + .catch((e) => { + notify.failure( + `${pluralize("IDP group", idpGroups.length)} deletion failed`, + e, + ); + }) + .finally(() => { + setSubmitting(false); + }); + }; + + return ( + +

    + Are you sure you want to delete{" "} + + {hasOneGroup + ? `"${idpGroups[0].name}"` + : `${idpGroups.length} IDP groups`} + + ? This action is permanent and can not be undone. +

    +
    + ); +}; + +export default DeleteIdpGroupsModal; diff --git a/src/pages/permissions/actions/EditGroupIdentitiesBtn.tsx b/src/pages/permissions/actions/EditGroupIdentitiesBtn.tsx new file mode 100644 index 0000000000..621720df8b --- /dev/null +++ b/src/pages/permissions/actions/EditGroupIdentitiesBtn.tsx @@ -0,0 +1,30 @@ +import { FC } from "react"; +import { Button, Icon } from "@canonical/react-components"; +import { LxdGroup } from "types/permissions"; +import usePanelParams from "util/usePanelParams"; + +interface Props { + groups: LxdGroup[]; + className?: string; +} + +const EditGroupIdentitiesBtn: FC = ({ groups, className }) => { + const panelParams = usePanelParams(); + return ( + <> + + + ); +}; + +export default EditGroupIdentitiesBtn; diff --git a/src/pages/permissions/actions/EditIdentityGroupsBtn.tsx b/src/pages/permissions/actions/EditIdentityGroupsBtn.tsx new file mode 100644 index 0000000000..90aee3d1b9 --- /dev/null +++ b/src/pages/permissions/actions/EditIdentityGroupsBtn.tsx @@ -0,0 +1,38 @@ +import { FC } from "react"; +import { Button, ButtonProps } from "@canonical/react-components"; +import { LxdIdentity } from "types/permissions"; +import usePanelParams from "util/usePanelParams"; + +interface Props { + identities: LxdIdentity[]; + className?: string; +} + +const EditIdentityGroupsBtn: FC = ({ + identities, + className, + ...buttonProps +}) => { + const panelParams = usePanelParams(); + const buttonText = + identities.length > 1 + ? `Modify groups for ${identities.length} identities` + : "Modify groups"; + + return ( + <> + + + ); +}; + +export default EditIdentityGroupsBtn; diff --git a/src/pages/permissions/actions/GroupActions.tsx b/src/pages/permissions/actions/GroupActions.tsx new file mode 100644 index 0000000000..b4bf1f3339 --- /dev/null +++ b/src/pages/permissions/actions/GroupActions.tsx @@ -0,0 +1,114 @@ +import { FC, useRef, useState } from "react"; +import { ContextualMenu, Icon } from "@canonical/react-components"; +import { LxdGroup } from "types/permissions"; +import usePanelParams from "util/usePanelParams"; +import DeleteGroupModal from "./DeleteGroupModal"; +import usePortal from "react-useportal"; +import classnames from "classnames"; + +interface Props { + group: LxdGroup; +} + +const GroupActions: FC = ({ group }) => { + const panelParams = usePanelParams(); + const { openPortal, closePortal, isOpen, Portal } = usePortal(); + const [displayAbove, setDisplayAbove] = useState(false); + const menuRef = useRef(null); + + const adjustDisplayPosition = () => { + const menu = menuRef.current; + if (!menu) { + return; + } + + const menuPosition = menu.getBoundingClientRect().top; + const showMenuAbove = menuPosition > window.innerHeight / 1.5; + setDisplayAbove(showMenuAbove); + }; + + return ( + <> + + } + toggleAppearance="base" + title="Actions" + onClick={adjustDisplayPosition} + links={[ + { + appearance: "base", + hasIcon: true, + onClick: () => panelParams.openEditGroup(group.name), + type: "button", + "aria-label": "Edit group details", + title: "Edit group details", + children: ( + <> + + Edit details + + ), + }, + { + appearance: "base", + hasIcon: true, + onClick: () => panelParams.openGroupIdentities(group.name), + type: "button", + "aria-label": "Manage identities", + title: "Manage identities", + children: ( + <> + + Manage identities + + ), + }, + { + appearance: "base", + hasIcon: true, + onClick: () => panelParams.openGroupPermissions(group.name), + type: "button", + "aria-label": "Manage permissions", + title: "Manage permissions", + children: ( + <> + + Manage permissions + + ), + }, + { + appearance: "base", + hasIcon: true, + onClick: openPortal, + type: "button", + "aria-label": "Delete group", + title: "Delete group", + children: ( + <> + + Delete + + ), + }, + ]} + /> + {isOpen && ( + + + + )} + + ); +}; + +export default GroupActions; diff --git a/src/pages/permissions/actions/GroupSelectionActions.tsx b/src/pages/permissions/actions/GroupSelectionActions.tsx new file mode 100644 index 0000000000..c100e50b7b --- /dev/null +++ b/src/pages/permissions/actions/GroupSelectionActions.tsx @@ -0,0 +1,59 @@ +import React, { FC } from "react"; +import ModifiedStatusAction from "./ModifiedStatusAction"; +import { ActionButton, Button } from "@canonical/react-components"; +import { pluralize } from "util/instanceBulkActions"; + +interface Props { + modifiedGroups: Set; + undoChange: () => void; + closePanel: () => void; + onSubmit: () => void; + loading?: boolean; + disabled?: boolean; + actionText?: string; +} + +const GroupSelectionActions: FC = ({ + modifiedGroups, + undoChange, + closePanel, + onSubmit, + loading, + disabled, + actionText, +}) => { + const confirmButtonText = modifiedGroups.size + ? `Apply ${modifiedGroups.size} group ${pluralize("change", modifiedGroups.size)}` + : "Modify groups"; + + return ( + <> + {modifiedGroups.size ? ( + + ) : null} + + + {actionText ? "Confirm" : confirmButtonText} + + + ); +}; + +export default GroupSelectionActions; diff --git a/src/pages/permissions/actions/ModifiedStatusAction.tsx b/src/pages/permissions/actions/ModifiedStatusAction.tsx new file mode 100644 index 0000000000..65c592e9e4 --- /dev/null +++ b/src/pages/permissions/actions/ModifiedStatusAction.tsx @@ -0,0 +1,42 @@ +import { Button, Icon } from "@canonical/react-components"; +import { FC } from "react"; +import { getClientOS } from "util/helpers"; +import { pluralize } from "util/instanceBulkActions"; + +interface Props { + modifiedCount: number; + onUndoChange: () => void; + itemName: string; + actionText?: string; +} + +const ModifiedStatusAction: FC = ({ + modifiedCount, + onUndoChange, + itemName, + actionText, +}) => { + const controlKey = + getClientOS(navigator.userAgent) === "macos" ? "\u2318" : "ctrl"; + + return ( +
    +
    + + {`${modifiedCount} ${pluralize(itemName, modifiedCount)} will be ${actionText ?? "modified"}`} +
    + +
    + ); +}; + +export default ModifiedStatusAction; diff --git a/src/pages/permissions/forms/GroupForm.tsx b/src/pages/permissions/forms/GroupForm.tsx new file mode 100644 index 0000000000..3d468da392 --- /dev/null +++ b/src/pages/permissions/forms/GroupForm.tsx @@ -0,0 +1,48 @@ +import { FC, ReactNode } from "react"; +import { Form, Input } from "@canonical/react-components"; +import { FormikProps } from "formik/dist/types"; +import AutoExpandingTextArea from "components/AutoExpandingTextArea"; + +export interface GroupFormValues { + name: string; + description: string; +} + +interface Props { + formik: FormikProps; +} + +const GroupForm: FC = ({ formik }) => { + const getFormProps = (id: "name" | "description") => { + return { + id: id, + name: id, + onBlur: formik.handleBlur, + onChange: formik.handleChange, + value: formik.values[id] ?? "", + error: formik.touched[id] ? (formik.errors[id] as ReactNode) : null, + placeholder: `Enter ${id.replaceAll("_", " ")}`, + }; + }; + + return ( +
    + {/* hidden submit to enable enter key in inputs */} + + + + + ); +}; + +export default GroupForm; diff --git a/src/pages/permissions/forms/IdpGroupForm.tsx b/src/pages/permissions/forms/IdpGroupForm.tsx new file mode 100644 index 0000000000..31984ed330 --- /dev/null +++ b/src/pages/permissions/forms/IdpGroupForm.tsx @@ -0,0 +1,35 @@ +import { FC } from "react"; +import { Form, Input } from "@canonical/react-components"; +import { FormikProps } from "formik/dist/types"; + +export interface IdpGroupFormValues { + name: string; +} + +interface Props { + formik: FormikProps; +} + +const IdpGroupForm: FC = ({ formik }) => { + return ( +
    + {/* hidden submit to enable enter key in inputs */} + + +
    + ); +}; + +export default IdpGroupForm; diff --git a/src/pages/permissions/panels/CreateGroupPanel.tsx b/src/pages/permissions/panels/CreateGroupPanel.tsx new file mode 100644 index 0000000000..9c6ccd490b --- /dev/null +++ b/src/pages/permissions/panels/CreateGroupPanel.tsx @@ -0,0 +1,99 @@ +import { ActionButton, Button, useNotify } from "@canonical/react-components"; +import { useQueryClient } from "@tanstack/react-query"; +import SidePanel from "components/SidePanel"; +import { FC, useState } from "react"; +import usePanelParams from "util/usePanelParams"; +import { useToastNotification } from "context/toastNotificationProvider"; +import * as Yup from "yup"; +import { useFormik } from "formik"; +import GroupForm, { GroupFormValues } from "../forms/GroupForm"; +import { createGroup } from "api/auth-groups"; +import { queryKeys } from "util/queryKeys"; +import { testDuplicateGroupName } from "util/permissionGroups"; +import NotificationRow from "components/NotificationRow"; +import ScrollableContainer from "components/ScrollableContainer"; + +const CreateGroupPanel: FC = () => { + const panelParams = usePanelParams(); + const notify = useNotify(); + const toastNotify = useToastNotification(); + const queryClient = useQueryClient(); + const controllerState = useState(null); + + const closePanel = () => { + panelParams.clear(); + notify.clear(); + }; + + const groupSchema = Yup.object().shape({ + name: Yup.string() + .test(...testDuplicateGroupName(controllerState)) + .required("Group name is required"), + }); + + const formik = useFormik({ + initialValues: { + name: "", + description: "", + }, + validationSchema: groupSchema, + onSubmit: (values) => { + createGroup({ + name: values.name, + description: values.description, + }) + .then(() => { + toastNotify.success(`Group ${values.name} created.`); + closePanel(); + }) + .catch((e) => { + notify.failure(`Group creation failed`, e); + }) + .finally(() => { + formik.setSubmitting(false); + void queryClient.invalidateQueries({ + queryKey: [queryKeys.authGroups], + }); + }); + }, + }); + + return ( + <> + + + Create group + + + + + + + + + + void formik.submitForm()} + className="u-no-margin--bottom" + disabled={!formik.isValid || !formik.values.name} + > + Create group + + + + + ); +}; + +export default CreateGroupPanel; diff --git a/src/pages/permissions/panels/CreateIdpGroupPanel.tsx b/src/pages/permissions/panels/CreateIdpGroupPanel.tsx new file mode 100644 index 0000000000..4d1d38ed35 --- /dev/null +++ b/src/pages/permissions/panels/CreateIdpGroupPanel.tsx @@ -0,0 +1,152 @@ +import { useNotify } from "@canonical/react-components"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import SidePanel from "components/SidePanel"; +import { FC, useState } from "react"; +import usePanelParams from "util/usePanelParams"; +import { useToastNotification } from "context/toastNotificationProvider"; +import * as Yup from "yup"; +import { useFormik } from "formik"; +import { queryKeys } from "util/queryKeys"; +import NotificationRow from "components/NotificationRow"; +import { createIdpGroup } from "api/auth-idp-groups"; +import { testDuplicateIdpGroupName } from "util/permissionIdpGroups"; +import { fetchGroups } from "api/auth-groups"; +import IdpGroupForm, { IdpGroupFormValues } from "../forms/IdpGroupForm"; +import GroupSelection from "./GroupSelection"; +import useEditHistory from "util/useEditHistory"; +import GroupSelectionActions from "../actions/GroupSelectionActions"; + +type GroupEditHistory = { + groupsAdded: Set; +}; + +const CreateIdpGroupPanel: FC = () => { + const panelParams = usePanelParams(); + const notify = useNotify(); + const toastNotify = useToastNotification(); + const queryClient = useQueryClient(); + const controllerState = useState(null); + + const { + data: groups = [], + error, + isLoading, + } = useQuery({ + queryKey: [queryKeys.authGroups], + queryFn: fetchGroups, + }); + + const { + desiredState, + save: saveToPanelHistory, + undo: undoMappingChanges, + } = useEditHistory({ + initialState: { + groupsAdded: new Set(), + }, + }); + + if (error) { + notify.failure("Loading panel details failed", error); + } + + const modifyGroups = (newGroups: string[], isUnselectAll?: boolean) => { + if (isUnselectAll) { + saveToPanelHistory({ + groupsAdded: new Set(), + }); + } else { + saveToPanelHistory({ + groupsAdded: new Set(newGroups), + }); + } + }; + + const closePanel = () => { + panelParams.clear(); + notify.clear(); + }; + + const saveIdpGroup = (values: IdpGroupFormValues) => { + const newGroup = { + name: values.name, + groups: Array.from(desiredState.groupsAdded), + }; + + formik.setSubmitting(true); + createIdpGroup(newGroup) + .then(() => { + toastNotify.success(`IDP group ${values.name} created.`); + void queryClient.invalidateQueries({ + queryKey: [queryKeys.idpGroups], + }); + closePanel(); + }) + .catch((e) => { + notify.failure(`IDP group creation failed`, e); + }) + .finally(() => { + formik.setSubmitting(false); + }); + }; + + const groupSchema = Yup.object().shape({ + name: Yup.string() + .test(...testDuplicateIdpGroupName(controllerState)) + .required("IDP group name is required"), + }); + + const formik = useFormik({ + initialValues: { + name: "", + }, + validationSchema: groupSchema, + onSubmit: saveIdpGroup, + }); + + return ( + <> + + + Create IDP group + + + +

    Map groups to this idp group

    + + + + + void formik.submitForm()} + actionText="mapped" + loading={formik.isSubmitting} + disabled={ + !formik.isValid || + (!formik.values.name && + !desiredState.groupsAdded.size && + !formik.touched.name) + } + /> + +
    + + ); +}; + +export default CreateIdpGroupPanel; diff --git a/src/pages/permissions/panels/EditGroupIdentitiesPanel.tsx b/src/pages/permissions/panels/EditGroupIdentitiesPanel.tsx new file mode 100644 index 0000000000..1b95184dc4 --- /dev/null +++ b/src/pages/permissions/panels/EditGroupIdentitiesPanel.tsx @@ -0,0 +1,338 @@ +import { + ActionButton, + Button, + Icon, + useNotify, +} from "@canonical/react-components"; +import { useQuery } from "@tanstack/react-query"; +import SidePanel from "components/SidePanel"; +import { FC, useEffect, useState } from "react"; +import { queryKeys } from "util/queryKeys"; +import usePanelParams from "util/usePanelParams"; +import ScrollableTable from "components/ScrollableTable"; +import SelectableMainTable from "components/SelectableMainTable"; +import { useSearchParams } from "react-router-dom"; +import useEditHistory from "util/useEditHistory"; +import ModifiedStatusAction from "../actions/ModifiedStatusAction"; +import { pluralize } from "util/instanceBulkActions"; +import { fetchIdentities } from "api/auth-identities"; +import { LxdGroup } from "types/permissions"; +import { getCurrentIdentitiesForGroups } from "util/permissionGroups"; +import GroupIdentitiesPanelConfirmModal from "./GroupIdentitiesPanelConfirmModal"; +import PermissionIdentitiesFilter, { + AUTH_METHOD, + PermissionIdentitiesFilterType, + QUERY, +} from "../PermissionIdentitiesFilter"; +import NotificationRow from "components/NotificationRow"; +import ScrollableContainer from "components/ScrollableContainer"; + +type IdentityEditHistory = { + identitiesAdded: Set; + identitiesRemoved: Set; +}; + +interface Props { + groups: LxdGroup[]; +} + +const EditGroupIdentitiesPanel: FC = ({ groups }) => { + const panelParams = usePanelParams(); + const [searchParams] = useSearchParams(); + const notify = useNotify(); + const [confirming, setConfirming] = useState(false); + + const { + data: identities = [], + error, + isLoading, + } = useQuery({ + queryKey: [queryKeys.identities], + queryFn: fetchIdentities, + }); + + const { + desiredState, + save: saveToPanelHistory, + undo: undoIdentitiesChange, + } = useEditHistory({ + initialState: { + identitiesAdded: new Set(), + identitiesRemoved: new Set(), + }, + }); + + if (error) { + notify.failure("Loading panel details failed", error); + } + + // in case if user refresh the browser while the panel is open + useEffect(() => { + if (!groups.length) { + panelParams.clear(); + return; + } + }, [groups]); + + const nonTlsIdentities = identities.filter( + (identity) => identity.authentication_method !== "tls", + ); + + const { + identityIdsInAllGroups, + identityIdsInNoGroups, + identityIdsInSomeGroups, + } = getCurrentIdentitiesForGroups(groups, nonTlsIdentities); + + const selectedIdentities = new Set(desiredState.identitiesAdded); + for (const identity of identityIdsInAllGroups) { + if (!desiredState.identitiesRemoved.has(identity)) { + selectedIdentities.add(identity); + } + } + + const indeterminateIdentities = new Set( + identityIdsInSomeGroups.filter( + (id) => + !selectedIdentities.has(id) && !desiredState.identitiesRemoved.has(id), + ), + ); + + const calculatedModifiedIdentities = () => { + const modifiedIdentities = new Set(); + + for (const identity of identityIdsInAllGroups) { + if (!selectedIdentities.has(identity)) { + modifiedIdentities.add(identity); + } + } + + for (const identity of identityIdsInSomeGroups) { + if (!indeterminateIdentities.has(identity)) { + modifiedIdentities.add(identity); + } + } + + for (const identity of identityIdsInNoGroups) { + if (selectedIdentities.has(identity)) { + modifiedIdentities.add(identity); + } + } + + return modifiedIdentities; + }; + + const modifyIdentities = ( + newIdentities: string[], + isUnselectAll?: boolean, + ) => { + if (isUnselectAll) { + saveToPanelHistory({ + identitiesAdded: new Set(), + identitiesRemoved: new Set( + nonTlsIdentities.map((identity) => identity.id), + ), + }); + } else { + saveToPanelHistory({ + identitiesAdded: new Set(newIdentities), + identitiesRemoved: new Set(), + }); + } + }; + + const toggleRow = (rowName: string) => { + const isRowSelected = selectedIdentities.has(rowName); + const isRowIndeterminate = indeterminateIdentities.has(rowName); + + const identitiesAdded = new Set(desiredState.identitiesAdded); + const identitiesRemoved = new Set(desiredState.identitiesRemoved); + + if (isRowSelected || isRowIndeterminate) { + identitiesAdded.delete(rowName); + identitiesRemoved.add(rowName); + } else { + identitiesAdded.add(rowName); + identitiesRemoved.delete(rowName); + } + + saveToPanelHistory({ + identitiesAdded, + identitiesRemoved, + }); + }; + + const closePanel = () => { + panelParams.clear(); + notify.clear(); + setConfirming(false); + }; + + const closeModal = () => { + notify.clear(); + setConfirming(false); + }; + + const modifiedIdentities = calculatedModifiedIdentities(); + + const headers = [ + { content: "Identity", sortKey: "name", role: "rowheader" }, + { + content: "", + role: "rowheader", + "aria-label": "Modified status", + className: "modified-status", + }, + ]; + + const filters: PermissionIdentitiesFilterType = { + queries: searchParams.getAll(QUERY), + authMethod: searchParams.getAll(AUTH_METHOD), + }; + + const filteredIdentities = nonTlsIdentities.filter((identity) => { + if ( + !filters.queries.every( + (q) => + identity.name.toLowerCase().includes(q) || + identity.id.toLowerCase().includes(q), + ) + ) { + return false; + } + + if ( + filters.authMethod.length > 0 && + !filters.authMethod.includes(identity.authentication_method) + ) { + return false; + } + + return true; + }); + + const rows = filteredIdentities.map((identity) => { + const selectedGroupText = + groups.length > 1 ? "all selected groups" : `group ${groups[0].name}`; + const modifiedTitle = desiredState.identitiesAdded.has(identity.id) + ? `Identity will be added to ${selectedGroupText}` + : desiredState.identitiesRemoved.has(identity.id) + ? `Identity will be removed from ${selectedGroupText}` + : ""; + + return { + name: identity.id, + className: "u-row", + columns: [ + { + content: identity.name, + role: "cell", + "aria-label": "Identity", + title: identity.name, + }, + { + content: modifiedIdentities.has(identity.id) && ( + + ), + role: "cell", + "aria-label": "Modified status", + className: "modified-status u-align--right", + title: modifiedTitle, + }, + ], + sortData: { + name: identity.name.toLowerCase(), + }, + }; + }); + + const content = ( + + identity.id)} + indeterminateNames={Array.from(indeterminateIdentities)} + onToggleRow={toggleRow} + hideContextualMenu + /> + + ); + + const panelTitle = + groups.length > 1 + ? `Change identities for ${groups.length} groups` + : `Change identities for ${groups[0]?.name}`; + + const confirmButtonText = modifiedIdentities.size + ? `Apply ${modifiedIdentities.size} identity ${pluralize("change", modifiedIdentities.size)}` + : "Modify identities"; + + return ( + <> + + + {panelTitle} + + + + + + {content} + + + + {modifiedIdentities.size ? ( + + ) : null} + + setConfirming(true)} + className="u-no-margin--bottom" + disabled={modifiedIdentities.size === 0} + > + {confirmButtonText} + + + + {confirming && ( + + )} + + ); +}; + +export default EditGroupIdentitiesPanel; diff --git a/src/pages/permissions/panels/EditGroupPanel.tsx b/src/pages/permissions/panels/EditGroupPanel.tsx new file mode 100644 index 0000000000..5516071337 --- /dev/null +++ b/src/pages/permissions/panels/EditGroupPanel.tsx @@ -0,0 +1,118 @@ +import { ActionButton, Button, useNotify } from "@canonical/react-components"; +import { useQueryClient } from "@tanstack/react-query"; +import SidePanel from "components/SidePanel"; +import { FC, useState } from "react"; +import usePanelParams from "util/usePanelParams"; +import { useToastNotification } from "context/toastNotificationProvider"; +import * as Yup from "yup"; +import { useFormik } from "formik"; +import GroupForm, { GroupFormValues } from "../forms/GroupForm"; +import { renameGroup, updateGroup } from "api/auth-groups"; +import { queryKeys } from "util/queryKeys"; +import { testDuplicateGroupName } from "util/permissionGroups"; +import NotificationRow from "components/NotificationRow"; +import { LxdGroup } from "types/permissions"; +import ScrollableContainer from "components/ScrollableContainer"; + +interface Props { + group: LxdGroup; +} + +const EditGroupPanel: FC = ({ group }) => { + const panelParams = usePanelParams(); + const notify = useNotify(); + const toastNotify = useToastNotification(); + const queryClient = useQueryClient(); + const controllerState = useState(null); + + const groupSchema = Yup.object().shape({ + name: Yup.string() + .test(...testDuplicateGroupName(controllerState, panelParams.group ?? "")) + .required("Group name is required"), + }); + + const formik = useFormik({ + initialValues: { + name: group?.name ?? "", + description: group?.description ?? "", + }, + enableReinitialize: true, + validationSchema: groupSchema, + onSubmit: (values) => { + const isNameChanged = values.name !== group?.name; + const mutationPromise = isNameChanged + ? renameGroup(group?.name ?? "", values.name).then(() => + updateGroup({ + ...group, + name: values.name, + description: values.description, + }), + ) + : updateGroup({ + ...group, + name: values.name, + description: values.description, + }); + + mutationPromise + .then(() => { + panelParams.clear(); + toastNotify.success(`LXD group ${values.name} updated.`); + notify.clear(); + }) + .catch((e) => { + notify.failure(`LXD group update failed`, e); + }) + .finally(() => { + formik.setSubmitting(false); + void queryClient.invalidateQueries({ + queryKey: [queryKeys.authGroups], + }); + }); + }, + }); + + const closePanel = () => { + panelParams.clear(); + notify.clear(); + }; + + return ( + <> + + + {`Edit group ${group?.name}`} + + + + + + + + + + void formik.submitForm()} + className="u-no-margin--bottom" + disabled={!formik.isValid || !formik.values.name} + > + Confirm + + + + + ); +}; + +export default EditGroupPanel; diff --git a/src/pages/permissions/panels/EditGroupPermissionsPanel.tsx b/src/pages/permissions/panels/EditGroupPermissionsPanel.tsx new file mode 100644 index 0000000000..f8dc70389a --- /dev/null +++ b/src/pages/permissions/panels/EditGroupPermissionsPanel.tsx @@ -0,0 +1,447 @@ +import { + ActionButton, + Button, + Card, + ConfirmationModal, + EmptyState, + Icon, + MainTable, + SearchBox, + useNotify, +} from "@canonical/react-components"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import SidePanel from "components/SidePanel"; +import { FC, useMemo, useState } from "react"; +import usePanelParams from "util/usePanelParams"; +import { updateGroup } from "api/auth-groups"; +import { queryKeys } from "util/queryKeys"; +import PermissionSelector, { LxdPermissionWithID } from "./PermissionSelector"; +import { LxdGroup } from "types/permissions"; +import { + constructResourceSelectorLabel, + generateIdentityNamesLookup, + generateImageNamesLookup, + generatePermissionSort, + getPermissionId, +} from "util/permissions"; +import { useToastNotification } from "context/toastNotificationProvider"; +import NotificationRow from "components/NotificationRow"; +import ScrollableContainer from "components/ScrollableContainer"; +import { fetchImageList } from "api/images"; +import { fetchIdentities } from "api/auth-identities"; +import useEditHistory from "util/useEditHistory"; +import classnames from "classnames"; +import ModifiedStatusAction from "../actions/ModifiedStatusAction"; +import { useSettings } from "context/useSettings"; +import { getIdentityIdsForGroup } from "util/permissionIdentities"; +import LoggedInUserNotification from "./LoggedInUserNotification"; +import { extractResourceDetailsFromUrl } from "util/resourceDetails"; + +type PermissionsEditHistory = { + permissionsAdded: LxdPermissionWithID[]; + permissionsRemoved: Set; +}; + +interface Props { + group: LxdGroup; +} + +const EditGroupPermissionsPanel: FC = ({ group }) => { + const { data: settings } = useSettings(); + const panelParams = usePanelParams(); + const notify = useNotify(); + const toastNotify = useToastNotification(); + const queryClient = useQueryClient(); + const [submitting, setSubmitting] = useState(false); + const [search, setSearch] = useState(""); + const [confirming, setConfirming] = useState(false); + const loggedInUserId = settings?.auth_user_name; + const allIdentityIds = getIdentityIdsForGroup(group); + const groupAllocatedToLoggedInUser = allIdentityIds.some( + (identity) => identity === loggedInUserId, + ); + + const { + desiredState, + save: saveToPermissionHistory, + undo: undoPermissionChange, + } = useEditHistory({ + initialState: { + permissionsAdded: [], + permissionsRemoved: new Set(), + }, + }); + + const { data: images = [], isLoading: isImagesLoading } = useQuery({ + queryKey: [queryKeys.images], + queryFn: () => fetchImageList(), + }); + + const { data: identities = [], isLoading: isIdentitiesLoading } = useQuery({ + queryKey: [queryKeys.identities], + queryFn: fetchIdentities, + }); + + const getPermissions = () => { + const existingPermissions = group.permissions ?? []; + const permissions: LxdPermissionWithID[] = []; + for (const permission of existingPermissions) { + permissions.push({ + ...permission, + id: getPermissionId(permission), + }); + } + + for (const permission of desiredState.permissionsAdded) { + permissions.push(permission); + } + + return permissions; + }; + + const permissions = getPermissions(); + + // a modified permission is either: + // new permission added that is not marked as removed + // an existing permission being removed + const getModifiedPermissions = () => { + const modifiedPermissions = new Set(); + const addedPermissionsLookup = new Set( + desiredState.permissionsAdded.map((permission) => permission.id), + ); + for (const permission of permissions) { + const permissionAddedAndNotRemoved = + addedPermissionsLookup.has(permission.id) && + !desiredState.permissionsRemoved.has(permission.id); + + const existingPermissionRemoved = + !addedPermissionsLookup.has(permission.id) && + desiredState.permissionsRemoved.has(permission.id); + + if (permissionAddedAndNotRemoved) { + modifiedPermissions.add(permission.id); + } + + if (existingPermissionRemoved) { + modifiedPermissions.add(permission.id); + } + } + + return modifiedPermissions; + }; + + const restorePermission = (permissionId: string) => { + const permissionsRemoved = new Set(desiredState.permissionsRemoved); + permissionsRemoved.delete(permissionId); + saveToPermissionHistory({ + permissionsAdded: [...desiredState.permissionsAdded], + permissionsRemoved, + }); + }; + + const addPermission = (newPermission: LxdPermissionWithID) => { + // we should prevent user from adding the same permission again + const permissionExists = permissions.some( + (permission) => permission.id === newPermission.id, + ); + + if (permissionExists) { + if (desiredState.permissionsRemoved.has(newPermission.id)) { + restorePermission(newPermission.id); + } + return; + } + + saveToPermissionHistory({ + permissionsAdded: [...desiredState.permissionsAdded, newPermission], + permissionsRemoved: new Set(desiredState.permissionsRemoved), + }); + }; + + const deletePermission = (permissionId: string) => { + const permissionsRemoved = new Set(desiredState.permissionsRemoved); + permissionsRemoved.add(permissionId); + saveToPermissionHistory({ + permissionsAdded: [...desiredState.permissionsAdded], + permissionsRemoved, + }); + }; + + const savePermissionsWithConfirm = (confirm: boolean) => { + if (confirm) { + setConfirming(true); + return; + } + + const effectivePermissions = permissions.filter( + (permission) => !desiredState.permissionsRemoved.has(permission.id), + ); + + const groupPayload = { + name: group?.name, + description: group?.description, + permissions: effectivePermissions, + }; + + setSubmitting(true); + updateGroup(groupPayload) + .then(() => { + void queryClient.invalidateQueries({ + queryKey: [queryKeys.authGroups], + }); + toastNotify.success(`Permissions for group ${group?.name} updated.`); + panelParams.clear(); + notify.clear(); + }) + .catch((e) => { + notify.failure("Failed to update permissions", e); + }) + .finally(() => { + setSubmitting(false); + setConfirming(false); + }); + }; + + const closePanel = () => { + panelParams.clear(); + notify.clear(); + }; + const imageNamesLookup = generateImageNamesLookup(images); + const identityNamesLookup = generateIdentityNamesLookup(identities); + + const filteredPermissions = search + ? permissions.filter((permission) => { + const resource = extractResourceDetailsFromUrl( + permission.entity_type, + permission.url, + imageNamesLookup, + identityNamesLookup, + ); + const resourceLabel = constructResourceSelectorLabel(resource); + return ( + permission.entitlement.includes(search) || + permission.entity_type.includes(search) || + resourceLabel.toLowerCase().includes(search) + ); + }) + : permissions; + + const sortedPermissions = useMemo( + () => + filteredPermissions.sort( + generatePermissionSort(imageNamesLookup, identityNamesLookup), + ), + [filteredPermissions, imageNamesLookup, identityNamesLookup], + ); + + const modifiedPermissions = getModifiedPermissions(); + + const headers = [ + { + content: "Resource type", + sortKey: "resourceType", + className: "resource-type", + }, + { content: "Resource", sortKey: "resource", className: "resource" }, + { + content: "Entitlement", + sortKey: "entitlement", + className: "entitlement", + }, + { "aria-label": "Actions", className: "u-align--right actions" }, + ]; + + const rows = sortedPermissions.map((permission) => { + const resource = extractResourceDetailsFromUrl( + permission.entity_type, + permission.url, + imageNamesLookup, + identityNamesLookup, + ); + + const resourceLabel = constructResourceSelectorLabel(resource); + + const isPermissionModified = modifiedPermissions.has(permission.id); + const isPermissionRemoved = desiredState.permissionsRemoved.has( + permission.id, + ); + + return { + name: permission.id, + className: classnames("u-row", { + strikeout: isPermissionRemoved, + }), + columns: [ + { + content: permission.entity_type, + role: "cell", + "aria-label": "Resource type", + className: "resource-type", + }, + { + content: resourceLabel, + role: "cell", + "aria-label": "Resource", + className: "u-truncate resource", + title: resourceLabel, + }, + { + content: permission.entitlement, + role: "cell", + "aria-label": "Entitlement", + className: "u-truncate entitlement", + title: permission.entitlement, + }, + { + className: "actions u-align--right", + content: ( + <> + {isPermissionRemoved ? ( + + ) : ( + + )} + + + ), + role: "cell", + "aria-label": "Delete permission", + }, + ], + sortData: { + resourceType: permission.entity_type, + resource: resourceLabel.toLowerCase(), + entitlement: permission.entitlement, + }, + }; + }); + + return ( + <> + + + {`Change permissions for ${group?.name}`} + + + + + + + Entitlements need to be given in relation to a specific + resource. Select the appropriate resource and entitlement below + and add it to the list of permissions for this group. + + + + + {!permissions.length ? ( + } + title="No permissions" + > +

    Select a permission above and add to the group

    +
    + ) : ( + + )} +
    +
    + + {modifiedPermissions.size ? ( + + ) : null} + + + savePermissionsWithConfirm(groupAllocatedToLoggedInUser) + } + className="u-no-margin--bottom" + disabled={!modifiedPermissions.size} + > + Save changes + + +
    + {confirming && ( + savePermissionsWithConfirm(false)} + close={() => setConfirming(false)} + title="Confirm permission modification" + className="permission-confirm-modal" + confirmButtonLoading={submitting} + > + + + )} + + ); +}; + +export default EditGroupPermissionsPanel; diff --git a/src/pages/permissions/panels/EditIdentityGroupsPanel.tsx b/src/pages/permissions/panels/EditIdentityGroupsPanel.tsx new file mode 100644 index 0000000000..293f32f38c --- /dev/null +++ b/src/pages/permissions/panels/EditIdentityGroupsPanel.tsx @@ -0,0 +1,209 @@ +import { useNotify } from "@canonical/react-components"; +import { useQuery } from "@tanstack/react-query"; +import { fetchGroups } from "api/auth-groups"; +import SidePanel from "components/SidePanel"; +import { FC, useEffect, useState } from "react"; +import { queryKeys } from "util/queryKeys"; +import usePanelParams from "util/usePanelParams"; +import { getGroupsForIdentities } from "util/permissionIdentities"; +import useEditHistory from "util/useEditHistory"; +import IdentityGroupsPanelConfirmModal from "./IdentityGroupsPanelConfirmModal"; +import { LxdIdentity } from "types/permissions"; +import NotificationRow from "components/NotificationRow"; +import GroupSelection from "./GroupSelection"; +import GroupSelectionActions from "../actions/GroupSelectionActions"; + +type GroupEditHistory = { + groupsAdded: Set; + groupsRemoved: Set; +}; + +interface Props { + identities: LxdIdentity[]; +} + +const EditIdentityGroupsPanel: FC = ({ identities }) => { + const panelParams = usePanelParams(); + const notify = useNotify(); + const [confirming, setConfirming] = useState(false); + + const { + data: groups = [], + error, + isLoading, + } = useQuery({ + queryKey: [queryKeys.authGroups], + queryFn: fetchGroups, + }); + + const { + desiredState, + save: saveToPanelHistory, + undo: undoGroupChange, + } = useEditHistory({ + initialState: { + groupsAdded: new Set(), + groupsRemoved: new Set(), + }, + }); + + if (error) { + notify.failure("Loading panel details failed", error); + } + + // in case if user refresh the browser while the panel is open + useEffect(() => { + if (!identities.length) { + panelParams.clear(); + return; + } + }, [identities]); + + const { + groupsForAllIdentities, + groupsForSomeIdentities, + groupsForNoIdentities, + } = getGroupsForIdentities(groups, identities); + + const selectedGroups = new Set(desiredState.groupsAdded); + + for (const group of groupsForAllIdentities) { + if (!desiredState.groupsRemoved.has(group)) { + selectedGroups.add(group); + } + } + + const indeterminateGroups = new Set( + groupsForSomeIdentities.filter( + (groupName) => + !selectedGroups.has(groupName) && + !desiredState.groupsRemoved.has(groupName), + ), + ); + + const calculatedModifiedGroups = () => { + const modifiedGroups = new Set(); + + for (const group of groupsForAllIdentities) { + if (!selectedGroups.has(group)) { + modifiedGroups.add(group); + } + } + + for (const group of groupsForSomeIdentities) { + if (!indeterminateGroups.has(group)) { + modifiedGroups.add(group); + } + } + + for (const group of groupsForNoIdentities) { + if (selectedGroups.has(group)) { + modifiedGroups.add(group); + } + } + + return modifiedGroups; + }; + + const modifyGroups = ( + newselectedGroups: string[], + isUnselectAll?: boolean, + ) => { + if (isUnselectAll) { + saveToPanelHistory({ + groupsAdded: new Set(), + groupsRemoved: new Set(groups.map((group) => group.name)), + }); + } else { + saveToPanelHistory({ + groupsAdded: new Set(newselectedGroups), + groupsRemoved: new Set(), + }); + } + }; + + // need to do this outside of SelectableMainTable so we can cater for the case of + // unselecting a checkbox when initially a checkbox is indeterminate + const toggleRow = (rowName: string) => { + const isRowSelected = selectedGroups.has(rowName); + const isRowIndeterminate = indeterminateGroups.has(rowName); + // always create new sets to avoid mutation by reference + const groupsAdded = new Set(desiredState.groupsAdded); + const groupsRemoved = new Set(desiredState.groupsRemoved); + + if (isRowSelected || isRowIndeterminate) { + groupsAdded.delete(rowName); + groupsRemoved.add(rowName); + } else { + groupsAdded.add(rowName); + groupsRemoved.delete(rowName); + } + + saveToPanelHistory({ + groupsAdded, + groupsRemoved, + }); + }; + + const closePanel = () => { + panelParams.clear(); + notify.clear(); + setConfirming(false); + }; + + const closeModal = () => { + notify.clear(); + setConfirming(false); + }; + + const modifiedGroups = calculatedModifiedGroups(); + + const panelTitle = + identities.length > 1 + ? `Change groups for ${identities.length} identities` + : `Change groups for ${identities[0]?.name}`; + + return ( + <> + + + {panelTitle} + + + + + + + setConfirming(true)} + disabled={modifiedGroups.size === 0} + /> + + + {confirming && ( + + )} + + ); +}; + +export default EditIdentityGroupsPanel; diff --git a/src/pages/permissions/panels/EditIdpGroupPanel.tsx b/src/pages/permissions/panels/EditIdpGroupPanel.tsx new file mode 100644 index 0000000000..6c3100f576 --- /dev/null +++ b/src/pages/permissions/panels/EditIdpGroupPanel.tsx @@ -0,0 +1,235 @@ +import { useNotify } from "@canonical/react-components"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import SidePanel from "components/SidePanel"; +import { FC, useState } from "react"; +import usePanelParams from "util/usePanelParams"; +import { useToastNotification } from "context/toastNotificationProvider"; +import * as Yup from "yup"; +import { useFormik } from "formik"; +import { queryKeys } from "util/queryKeys"; +import NotificationRow from "components/NotificationRow"; +import { IdpGroup } from "types/permissions"; +import { renameIdpGroup, updateIdpGroup } from "api/auth-idp-groups"; +import { testDuplicateIdpGroupName } from "util/permissionIdpGroups"; +import { fetchGroups } from "api/auth-groups"; +import useEditHistory from "util/useEditHistory"; +import IdpGroupForm, { IdpGroupFormValues } from "../forms/IdpGroupForm"; +import GroupSelection from "./GroupSelection"; +import GroupSelectionActions from "../actions/GroupSelectionActions"; + +type GroupEditHistory = { + groupsAdded: Set; + groupsRemoved: Set; +}; + +interface Props { + idpGroup: IdpGroup; +} + +const EditIdpGroupPanel: FC = ({ idpGroup }) => { + const panelParams = usePanelParams(); + const notify = useNotify(); + const toastNotify = useToastNotification(); + const queryClient = useQueryClient(); + const controllerState = useState(null); + + const { + data: groups = [], + error, + isLoading, + } = useQuery({ + queryKey: [queryKeys.authGroups], + queryFn: fetchGroups, + }); + + const { + desiredState, + save: saveToPanelHistory, + undo: undoMappingChanges, + } = useEditHistory({ + initialState: { + groupsAdded: new Set(), + groupsRemoved: new Set(), + }, + }); + + if (error) { + notify.failure("Loading panel details failed", error); + } + + const selectedGroups = new Set(desiredState.groupsAdded); + for (const group of idpGroup.groups) { + if (!desiredState.groupsRemoved.has(group)) { + selectedGroups.add(group); + } + } + + const calculateModifiedGroups = () => { + const modifiedGroups = new Set(); + + for (const group of idpGroup.groups) { + if (!selectedGroups.has(group)) { + modifiedGroups.add(group); + } + } + + for (const group of groups) { + if ( + !idpGroup.groups.includes(group.name) && + selectedGroups.has(group.name) + ) { + modifiedGroups.add(group.name); + } + } + + return modifiedGroups; + }; + + const modifyGroups = (newGroups: string[], isUnselectAll?: boolean) => { + if (isUnselectAll) { + saveToPanelHistory({ + groupsAdded: new Set(), + groupsRemoved: new Set(groups.map((group) => group.name)), + }); + } else { + saveToPanelHistory({ + groupsAdded: new Set(newGroups), + groupsRemoved: new Set(), + }); + } + }; + + const toggleRow = (rowName: string) => { + const isRowSelected = selectedGroups.has(rowName); + + const groupsAdded = new Set(desiredState.groupsAdded); + const groupsRemoved = new Set(desiredState.groupsRemoved); + + if (isRowSelected) { + groupsAdded.delete(rowName); + groupsRemoved.add(rowName); + } else { + groupsAdded.add(rowName); + groupsRemoved.delete(rowName); + } + + saveToPanelHistory({ + groupsAdded, + groupsRemoved, + }); + }; + + const closePanel = () => { + panelParams.clear(); + notify.clear(); + }; + + const saveIdpGroup = (values: IdpGroupFormValues) => { + const newGroupMappings = new Set(idpGroup.groups); + for (const group of desiredState.groupsAdded) { + newGroupMappings.add(group); + } + + for (const group of desiredState.groupsRemoved) { + newGroupMappings.delete(group); + } + + let mutationPromise = updateIdpGroup({ + ...idpGroup, + groups: Array.from(newGroupMappings), + }); + + const nameChanged = idpGroup.name !== values.name; + + if (nameChanged) { + mutationPromise = mutationPromise.then(() => + renameIdpGroup(idpGroup?.name ?? "", values.name), + ); + } + + formik.setSubmitting(true); + mutationPromise + .then(() => { + toastNotify.success(`IDP group ${values.name} updated.`); + void queryClient.invalidateQueries({ + queryKey: [queryKeys.idpGroups], + }); + closePanel(); + }) + .catch((e) => { + notify.failure(`IDP group update failed`, e); + }) + .finally(() => { + formik.setSubmitting(false); + }); + }; + + const groupSchema = Yup.object().shape({ + name: Yup.string() + .test( + ...testDuplicateIdpGroupName( + controllerState, + panelParams.idpGroup ?? "", + ), + ) + .required("IDP group name is required"), + }); + + const formik = useFormik({ + initialValues: { + name: idpGroup?.name ?? "", + }, + enableReinitialize: true, + validationSchema: groupSchema, + onSubmit: saveIdpGroup, + }); + + const modifiedGroups = calculateModifiedGroups(); + const nameModified = !!formik.touched.name; + const nameIsValid = formik.isValid && formik.values.name; + const groupsModified = !!modifiedGroups.size; + const enableSubmission = + (nameModified && nameIsValid) || (nameIsValid && groupsModified); + + return ( + <> + + + {`Edit IDP group ${idpGroup?.name}`} + + + +

    Map groups to this idp group

    + + + + + void formik.submitForm()} + loading={formik.isSubmitting} + disabled={!enableSubmission} + /> + +
    + + ); +}; + +export default EditIdpGroupPanel; diff --git a/src/pages/permissions/panels/GroupIdentitiesPanelConfirmModal.tsx b/src/pages/permissions/panels/GroupIdentitiesPanelConfirmModal.tsx new file mode 100644 index 0000000000..a2c3608b17 --- /dev/null +++ b/src/pages/permissions/panels/GroupIdentitiesPanelConfirmModal.tsx @@ -0,0 +1,121 @@ +import { ConfirmationModal, useNotify } from "@canonical/react-components"; +import { FC, useState } from "react"; +import { LxdGroup, LxdIdentity } from "types/permissions"; +import { pivotIdentityGroupsChangeSummary } from "util/permissionIdentities"; +import GroupsOrIdentityChangesTable from "./GroupOrIdentityChangesTable"; +import { + generateGroupAllocationsForIdentities, + getChangesInGroupsForIdentities, + getAddedOrRemovedIdentities, +} from "util/permissionGroups"; +import usePanelParams from "util/usePanelParams"; +import { useQueryClient } from "@tanstack/react-query"; +import { useToastNotification } from "context/toastNotificationProvider"; +import { updateIdentities } from "api/auth-identities"; +import { queryKeys } from "util/queryKeys"; + +interface Props { + onConfirm: () => void; + close: () => void; + addedIdentities: Set; + removedIdentities: Set; + selectedGroups: LxdGroup[]; + allIdentities: LxdIdentity[]; +} + +const GroupIdentitiesPanelConfirmModal: FC = ({ + onConfirm, + close, + addedIdentities, + removedIdentities, + selectedGroups, + allIdentities, +}) => { + const [submitting, setSubmitting] = useState(false); + const notify = useNotify(); + const panelParams = usePanelParams(); + const queryClient = useQueryClient(); + const toastNotify = useToastNotification(); + + const identityGroupsChangeSummary = getChangesInGroupsForIdentities( + allIdentities, + selectedGroups, + addedIdentities, + removedIdentities, + ); + + const groupIdentitiesChangeSummary = pivotIdentityGroupsChangeSummary( + identityGroupsChangeSummary, + ); + + const handleSaveGroupsForIdentities = () => { + setSubmitting(true); + const modifiedIdentities = getAddedOrRemovedIdentities( + allIdentities, + addedIdentities, + removedIdentities, + ); + + const newGroupsForIdentities = generateGroupAllocationsForIdentities( + addedIdentities, + removedIdentities, + selectedGroups, + modifiedIdentities, + ); + + const payload = modifiedIdentities.map((identity) => ({ + ...identity, + groups: newGroupsForIdentities[identity.id], + })); + + updateIdentities(payload) + .then(() => { + // modifying groups should invalidate both identities and groups api queries + void queryClient.invalidateQueries({ + predicate: (query) => { + return [queryKeys.identities, queryKeys.authGroups].includes( + query.queryKey[0] as string, + ); + }, + }); + + const modifiedGroupNames = Object.keys(groupIdentitiesChangeSummary); + const successMessage = + modifiedGroupNames.length > 1 + ? `Updated identities for ${modifiedGroupNames.length} groups` + : `Updated identities for ${modifiedGroupNames[0]}`; + + toastNotify.success(successMessage); + panelParams.clear(); + notify.clear(); + }) + .catch((e) => { + notify.failure("Update groups failed", e); + }) + .finally(() => { + setSubmitting(false); + onConfirm(); + }); + }; + + return ( + + + + ); +}; + +export default GroupIdentitiesPanelConfirmModal; diff --git a/src/pages/permissions/panels/GroupOrIdentityChangesTable.tsx b/src/pages/permissions/panels/GroupOrIdentityChangesTable.tsx new file mode 100644 index 0000000000..3ee2dba70f --- /dev/null +++ b/src/pages/permissions/panels/GroupOrIdentityChangesTable.tsx @@ -0,0 +1,217 @@ +import { Button, Icon } from "@canonical/react-components"; +import { FC, useEffect, useRef, useState } from "react"; +import { ChangeSummary } from "util/permissionIdentities"; +import Tag from "components/Tag"; +import { LxdIdentity } from "types/permissions"; +import LoggedInUserNotification from "./LoggedInUserNotification"; +import { useSettings } from "context/useSettings"; +import { + getAbsoluteHeightBelowById, + getAbsoluteHeightBelowBySelector, + getElementAbsoluteHeight, +} from "util/helpers"; +import useEventListener from "@use-it/event-listener"; + +interface Props { + identityGroupsChangeSummary: ChangeSummary; + groupIdentitiesChangeSummary: ChangeSummary; + identities: LxdIdentity[]; + initialGroupBy: "identity" | "group"; +} + +const generateRowsFromIdentityGroupChanges = ( + identityGroupsChangeSummary: ChangeSummary, + loggedInIdentityID: string, +) => { + const identityIDs = Object.keys(identityGroupsChangeSummary); + + const rows: JSX.Element[] = []; + for (const id of identityIDs) { + const groupChangesForIdentity = identityGroupsChangeSummary[id]; + const identityLoggedIn = id === loggedInIdentityID; + const addedGroups: JSX.Element[] = []; + const removedGroups: JSX.Element[] = []; + + for (const group of groupChangesForIdentity.added) { + addedGroups.push( +

    + + {group} +

    , + ); + } + + for (const group of groupChangesForIdentity.removed) { + removedGroups.push( +

    + - {group} +

    , + ); + } + + rows.push( + + +

    + {groupChangesForIdentity.name} + You +

    + + {addedGroups.concat(removedGroups)} + , + ); + } + + return rows; +}; + +const generateRowsFromGroupIdentityChanges = ( + groupIdentitiesChangeSummary: ChangeSummary, + loggedInIdentityID: string, + identities: LxdIdentity[], +) => { + const groups = Object.keys(groupIdentitiesChangeSummary); + const identityNameLookup: Record = {}; + identities.forEach( + (identity) => (identityNameLookup[identity.id] = identity.name), + ); + + const rows: JSX.Element[] = []; + for (const group of groups) { + const identityChangesForGroup = groupIdentitiesChangeSummary[group]; + const addedIdentities: JSX.Element[] = []; + const removedIdentities: JSX.Element[] = []; + + for (const identity of identityChangesForGroup.added) { + const identityLoggedIn = identity === loggedInIdentityID; + addedIdentities.push( +

    + + {identityNameLookup[identity]} + You +

    , + ); + } + + for (const identity of identityChangesForGroup.removed) { + const identityLoggedIn = identity === loggedInIdentityID; + removedIdentities.push( +

    + - {identityNameLookup[identity]} + You +

    , + ); + } + + rows.push( + + +

    {group}

    + + {addedIdentities.concat(removedIdentities)} + , + ); + } + + return rows; +}; + +const GroupsOrIdentityChangesTable: FC = ({ + groupIdentitiesChangeSummary, + identityGroupsChangeSummary, + identities, + initialGroupBy, +}) => { + const { data: settings } = useSettings(); + const containerRef = useRef(null); + const [groupBy, setGroupBy] = useState(initialGroupBy); + const loggedInIdentityID = settings?.auth_user_name ?? ""; + const loggedInIdentityModified = + loggedInIdentityID in identityGroupsChangeSummary; + + const updateModalTableHeight = () => { + const tableContainer = containerRef.current; + if (!tableContainer) { + return; + } + // first let the table grow vertically as much as needed + tableContainer.setAttribute("style", "height: auto;"); + const modalMaxHeight = window.innerHeight - 64; + const headerHeight = getAbsoluteHeightBelowBySelector(".p-modal__header"); + const notificationHeight = getAbsoluteHeightBelowById( + "current-user-warning", + ); + const footerHeight = getAbsoluteHeightBelowBySelector(".p-modal__footer"); + const tableHeight = getElementAbsoluteHeight(tableContainer); + + const remainingTableHeight = + modalMaxHeight - notificationHeight - headerHeight - footerHeight; + if (tableHeight >= remainingTableHeight) { + const style = `height: ${remainingTableHeight}px;`; + tableContainer.setAttribute("style", style); + } + }; + + useEventListener("resize", updateModalTableHeight); + useEffect(updateModalTableHeight, [groupBy]); + + const handleChangeGroupBy = () => { + setGroupBy((prev) => { + if (prev === "identity") { + return "group"; + } + + return "identity"; + }); + }; + + let rows: JSX.Element[] = []; + if (groupBy === "identity") { + rows = generateRowsFromIdentityGroupChanges( + identityGroupsChangeSummary, + loggedInIdentityID, + ); + } + + if (groupBy === "group") { + rows = generateRowsFromGroupIdentityChanges( + groupIdentitiesChangeSummary, + loggedInIdentityID, + identities, + ); + } + + return ( + <> +
    + + + + + + + + {rows} +
    + {groupBy === "identity" ? "Identity" : "Group"} + + {groupBy === "identity" ? "Group" : "Identity"}
    +
    + + + ); +}; + +export default GroupsOrIdentityChangesTable; diff --git a/src/pages/permissions/panels/GroupSelection.tsx b/src/pages/permissions/panels/GroupSelection.tsx new file mode 100644 index 0000000000..46ec0c0938 --- /dev/null +++ b/src/pages/permissions/panels/GroupSelection.tsx @@ -0,0 +1,165 @@ +import { DependencyList, FC, useState } from "react"; +import PermissionGroupsFilter from "../PermissionGroupsFilter"; +import ScrollableContainer from "components/ScrollableContainer"; +import { LxdGroup } from "types/permissions"; +import { EmptyState, Icon } from "@canonical/react-components"; +import ScrollableTable from "components/ScrollableTable"; +import SelectableMainTable from "components/SelectableMainTable"; +import { Link } from "react-router-dom"; +import { pluralize } from "util/instanceBulkActions"; +import useSortTableData from "util/useSortTableData"; + +interface Props { + groups: LxdGroup[]; + modifiedGroups: Set; + parentItemName: string; + parentItems?: { name: string }[]; + selectedGroups: Set; + setSelectedGroups: (val: string[], isUnselectAll?: boolean) => void; + indeterminateGroups?: Set; + toggleGroup?: (rowName: string) => void; + scrollDependencies: DependencyList; +} + +const GroupSelection: FC = ({ + groups, + modifiedGroups, + parentItemName, + parentItems, + selectedGroups, + indeterminateGroups, + setSelectedGroups, + toggleGroup, + scrollDependencies, +}) => { + const [search, setSearch] = useState(""); + + const headers = [ + { + content: "Group name", + sortKey: "name", + className: "name", + role: "rowheader", + }, + { + content: "Description", + sortKey: "description", + className: "description", + role: "rowheader", + }, + { + content: "", + role: "rowheader", + "aria-label": "Modified status", + className: "modified-status", + }, + ]; + + const filteredGroups = groups.filter((group) => { + return group.name.toLowerCase().includes(search) || !search; + }); + + const rows = filteredGroups.map((group) => { + const groupAdded = + selectedGroups.has(group.name) && modifiedGroups.has(group.name); + const groupRemoved = + !selectedGroups.has(group.name) && modifiedGroups.has(group.name); + + const selectedParentsText = + (parentItems?.length || 0) > 1 + ? `all selected ${pluralize(parentItemName, 2)}` + : `${parentItemName} ${parentItems?.[0].name}`; + const modifiedTitle = groupAdded + ? `Group will be added to ${selectedParentsText}` + : groupRemoved + ? `Group will be removed from ${selectedParentsText}` + : ""; + + return { + name: group.name, + className: "u-row", + columns: [ + { + content: group.name, + role: "cell", + className: "name u-truncate", + "aria-label": "Name", + }, + { + content: {group.description || ""}, + role: "cell", + className: "description", + "aria-label": "Description", + title: group.description, + }, + { + content: modifiedGroups.has(group.name) && ( + + ), + role: "cell", + "aria-label": "Modified status", + className: "modified-status u-align--right", + title: parentItemName ? modifiedTitle : undefined, + }, + ], + sortData: { + name: group.name.toLowerCase(), + description: group.description.toLowerCase(), + }, + }; + }); + + const { rows: sortedRows } = useSortTableData({ + rows, + defaultSort: "name", + }); + + return ( + + + {groups.length ? ( + + group.name)} + indeterminateNames={Array.from(indeterminateGroups ?? new Set())} + onToggleRow={toggleGroup} + hideContextualMenu + /> + + ) : ( + } + title="No groups" + > +

    No groups found.

    + + Create groups + + +
    + )} +
    + ); +}; + +export default GroupSelection; diff --git a/src/pages/permissions/panels/IdentityGroupsPanelConfirmModal.tsx b/src/pages/permissions/panels/IdentityGroupsPanelConfirmModal.tsx new file mode 100644 index 0000000000..6a7c2c3828 --- /dev/null +++ b/src/pages/permissions/panels/IdentityGroupsPanelConfirmModal.tsx @@ -0,0 +1,111 @@ +import { ConfirmationModal, useNotify } from "@canonical/react-components"; +import { FC, useState } from "react"; +import { LxdIdentity } from "types/permissions"; +import { + generateGroupAllocationsForIdentities, + getChangesInGroupsForIdentities, + pivotIdentityGroupsChangeSummary, +} from "util/permissionIdentities"; +import GroupsOrIdentityChangesTable from "./GroupOrIdentityChangesTable"; +import { updateIdentities } from "api/auth-identities"; +import { useQueryClient } from "@tanstack/react-query"; +import { queryKeys } from "util/queryKeys"; +import { useToastNotification } from "context/toastNotificationProvider"; +import usePanelParams from "util/usePanelParams"; + +interface Props { + onConfirm: () => void; + close: () => void; + selectedIdentities: LxdIdentity[]; + addedGroups: Set; + removedGroups: Set; +} + +const IdentityGroupsPanelConfirmModal: FC = ({ + onConfirm, + close, + addedGroups, + removedGroups, + selectedIdentities, +}) => { + const [submitting, setSubmitting] = useState(false); + const notify = useNotify(); + const panelParams = usePanelParams(); + const queryClient = useQueryClient(); + const toastNotify = useToastNotification(); + + const identityGroupsChangeSummary = getChangesInGroupsForIdentities( + selectedIdentities, + addedGroups, + removedGroups, + ); + + const groupIdentitiesChangeSummary = pivotIdentityGroupsChangeSummary( + identityGroupsChangeSummary, + ); + + const handleSaveGroupsForIdentities = () => { + setSubmitting(true); + + const newGroupsForIdentities = generateGroupAllocationsForIdentities( + addedGroups, + removedGroups, + selectedIdentities, + ); + + const payload = selectedIdentities.map((identity) => ({ + ...identity, + groups: newGroupsForIdentities[identity.id], + })); + + updateIdentities(payload) + .then(() => { + // modifying groups should invalidate both identities and groups api queries + void queryClient.invalidateQueries({ + predicate: (query) => { + return [queryKeys.identities, queryKeys.authGroups].includes( + query.queryKey[0] as string, + ); + }, + }); + + const modifiedGroupNames = Object.keys(identityGroupsChangeSummary); + const successMessage = + modifiedGroupNames.length > 1 + ? `Updated groups for ${modifiedGroupNames.length} identities` + : `Updated groups for ${modifiedGroupNames[0]}`; + + toastNotify.success(successMessage); + panelParams.clear(); + notify.clear(); + }) + .catch((e) => { + notify.failure("Update groups failed", e); + }) + .finally(() => { + setSubmitting(false); + onConfirm(); + }); + }; + + return ( + + + + ); +}; + +export default IdentityGroupsPanelConfirmModal; diff --git a/src/pages/permissions/panels/LoggedInUserNotification.tsx b/src/pages/permissions/panels/LoggedInUserNotification.tsx new file mode 100644 index 0000000000..88f7365229 --- /dev/null +++ b/src/pages/permissions/panels/LoggedInUserNotification.tsx @@ -0,0 +1,35 @@ +import { Notification } from "@canonical/react-components"; +import Tag from "components/Tag"; +import { FC } from "react"; + +interface Props { + isVisible: boolean; +} + +const LoggedInUserNotification: FC = ({ isVisible }) => { + if (!isVisible) { + return null; + } + + return ( + + + This action will modify the permissions of the current logged-in + identity. + +

    + + You + {" "} + might not be able to reverse this change once you’ve made it. +

    +
    + ); +}; + +export default LoggedInUserNotification; diff --git a/src/pages/permissions/panels/PermissionSelector.tsx b/src/pages/permissions/panels/PermissionSelector.tsx new file mode 100644 index 0000000000..24915b590c --- /dev/null +++ b/src/pages/permissions/panels/PermissionSelector.tsx @@ -0,0 +1,178 @@ +import { Button, Select, useNotify } from "@canonical/react-components"; +import { useQuery } from "@tanstack/react-query"; +import { fetchPermissions } from "api/auth-permissions"; +import { ChangeEvent, FC, useEffect, useState } from "react"; +import { LxdPermission } from "types/permissions"; +import { + generateEntitlementOptions, + generateResourceOptions, + noneAvailableOption, + resourceTypeOptions, +} from "util/permissions"; +import { queryKeys } from "util/queryKeys"; + +export type LxdPermissionWithID = LxdPermission & { id: string }; + +interface Props { + imageNamesLookup: Record; + identityNamesLookup: Record; + onAddPermission: (permission: LxdPermissionWithID) => void; +} + +const PermissionSelector: FC = ({ + imageNamesLookup, + identityNamesLookup, + onAddPermission, +}) => { + const notify = useNotify(); + const [resourceType, setResourceType] = useState(""); + const [resource, setResource] = useState(""); + const [entitlement, setEntitlement] = useState(""); + + const { + data: permissions, + isLoading: isPermissionsLoading, + error: permissionsError, + } = useQuery({ + queryKey: [queryKeys.permissions, resourceType], + queryFn: () => fetchPermissions({ resourceType }), + enabled: !!resourceType, + }); + + useEffect(() => { + document.getElementById("resourceType")?.focus(); + }, []); + + useEffect(() => { + if (!resourceType) { + document.getElementById("resourceType")?.focus(); + return; + } + + if (resourceType === "server") { + document.getElementById("entitlement")?.focus(); + return; + } + document.getElementById("resource")?.focus(); + }, [resourceType, permissions]); + + useEffect(() => { + document.getElementById("entitlement")?.focus(); + }, [resource]); + + useEffect(() => { + document.getElementById("add-entitlement")?.focus(); + }, [entitlement]); + + const handleResourceTypeChange = (e: ChangeEvent) => { + const resourceType = e.target.value; + setEntitlement(""); + setResourceType(resourceType); + + // for server resource type we can select a resource, so automatically set it + // label for resource will show "server" if resource type is set to server + if (resourceType === "server") { + setResource("/1.0"); + } else { + setResource(""); + } + }; + + const handleResourceChange = (e: ChangeEvent) => { + setResource(e.target.value); + }; + + const handleEntitlementChange = (e: ChangeEvent) => { + setEntitlement(e.target.value); + }; + + const handleAddPermission = () => { + const newPermissionId = resourceType + resource + entitlement; + const newPermission = { + id: newPermissionId, + entity_type: resourceType, + url: resource, + entitlement: entitlement, + }; + + onAddPermission(newPermission); + + // after adding a permission, only reset the entitlement selector + setEntitlement(""); + document.getElementById("entitlement")?.focus(); + }; + + if (permissionsError) { + notify.failure("Loading permissions failed", permissionsError); + } + + const resourceOptions = generateResourceOptions( + resourceType, + permissions ?? [], + imageNamesLookup, + identityNamesLookup, + ); + + const entitlementOptions = generateEntitlementOptions( + resourceType, + permissions, + ); + + const isServerResourceType = resourceType === "server"; + const hasResourceOptions = resourceOptions.length; + + return ( +
    + Resource} + options={!hasResourceOptions ? [noneAvailableOption] : resourceOptions} + className="u-no-margin--bottom" + aria-label="Resource" + onChange={handleResourceChange} + value={resource} + disabled={ + isPermissionsLoading || + !resourceType || + isServerResourceType || + !hasResourceOptions + } + /> +