+
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 ? (
+
+
+
+ ) : (
+ }
+ 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 && }
+
+
+
+
+
+
+ {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}
+
+
+
+ >
+ ) : (
+ }
+ 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 (
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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 (
+ <>
+
+
+
+
+
+ {groupBy === "identity" ? "Identity" : "Group"}
+
+ |
+ {groupBy === "identity" ? "Group" : "Identity"} |
+
+
+ {rows}
+
+
+
+ >
+ );
+};
+
+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 (
+
+
+ );
+};
+
+export default PermissionSelector;
diff --git a/src/pages/profiles/actions/DeleteProfileBtn.tsx b/src/pages/profiles/actions/DeleteProfileBtn.tsx
index e0dd6a661e..2e589b4df5 100644
--- a/src/pages/profiles/actions/DeleteProfileBtn.tsx
+++ b/src/pages/profiles/actions/DeleteProfileBtn.tsx
@@ -3,7 +3,7 @@ import { deleteProfile } from "api/profiles";
import { useNavigate } from "react-router-dom";
import { LxdProfile } from "types/profile";
import ItemName from "components/ItemName";
-import { useDeleteIcon } from "context/useDeleteIcon";
+import { useSmallScreen } from "context/useSmallScreen";
import {
ConfirmationButton,
Icon,
@@ -25,7 +25,7 @@ const DeleteProfileBtn: FC = ({
project,
featuresProfiles,
}) => {
- const isDeleteIcon = useDeleteIcon();
+ const isDeleteIcon = useSmallScreen();
const notify = useNotify();
const toastNotify = useToastNotification();
const queryClient = useQueryClient();
diff --git a/src/pages/projects/ProjectSelectorList.tsx b/src/pages/projects/ProjectSelectorList.tsx
index 12412124bf..da458a0656 100644
--- a/src/pages/projects/ProjectSelectorList.tsx
+++ b/src/pages/projects/ProjectSelectorList.tsx
@@ -18,7 +18,7 @@ const ProjectSelectorList: FC = ({ projects, onMount }): JSX.Element => {
const targetSection = getSubpageFromUrl(location.pathname) ?? "instances";
function getInstanceCount(project: LxdProject) {
- const count = filterUsedByType("instances", project.used_by).length;
+ const count = filterUsedByType("instance", project.used_by).length;
return count === 1 ? "1 instance" : `${count} instances`;
}
diff --git a/src/pages/projects/actions/DeleteProjectBtn.tsx b/src/pages/projects/actions/DeleteProjectBtn.tsx
index aa5d27f3ea..feae7e7589 100644
--- a/src/pages/projects/actions/DeleteProjectBtn.tsx
+++ b/src/pages/projects/actions/DeleteProjectBtn.tsx
@@ -6,7 +6,7 @@ import { queryKeys } from "util/queryKeys";
import { useQueryClient } from "@tanstack/react-query";
import ItemName from "components/ItemName";
import { isProjectEmpty } from "util/projects";
-import { useDeleteIcon } from "context/useDeleteIcon";
+import { useSmallScreen } from "context/useSmallScreen";
import {
ConfirmationButton,
Icon,
@@ -20,7 +20,7 @@ interface Props {
}
const DeleteProjectBtn: FC = ({ project }) => {
- const isDeleteIcon = useDeleteIcon();
+ const isDeleteIcon = useSmallScreen();
const notify = useNotify();
const toastNotify = useToastNotification();
const queryClient = useQueryClient();
diff --git a/src/pages/settings/Settings.tsx b/src/pages/settings/Settings.tsx
index 7c27229eab..5def15cdff 100644
--- a/src/pages/settings/Settings.tsx
+++ b/src/pages/settings/Settings.tsx
@@ -31,6 +31,7 @@ const Settings: FC = () => {
settings,
isSettingsLoading,
settingsError,
+ hasAccessManagement,
} = useSupportedFeatures();
const { data: configOptions, isLoading: isConfigOptionsLoading } = useQuery({
@@ -77,6 +78,17 @@ const Settings: FC = () => {
type: "string",
});
+ if (hasAccessManagement) {
+ configFields.push({
+ key: "user.show_permissions",
+ category: "user",
+ default: "false",
+ shortdesc:
+ "Show the permissions feature. If oidc configs are set, the permissions feature is available in the UI independent of this setting.",
+ type: "bool",
+ });
+ }
+
let lastCategory = "";
const rows = configFields
.filter((configField) => {
diff --git a/src/pages/storage/CustomIsoList.tsx b/src/pages/storage/CustomIsoList.tsx
index be7bc7f515..3cf33d8116 100644
--- a/src/pages/storage/CustomIsoList.tsx
+++ b/src/pages/storage/CustomIsoList.tsx
@@ -4,6 +4,7 @@ import {
Icon,
List,
MainTable,
+ Row,
SearchBox,
TablePagination,
} from "@canonical/react-components";
@@ -16,21 +17,28 @@ import Loader from "components/Loader";
import CreateInstanceFromImageBtn from "pages/images/actions/CreateInstanceFromImageBtn";
import UploadCustomIsoBtn from "pages/images/actions/UploadCustomIsoBtn";
import ScrollableTable from "components/ScrollableTable";
-import { Link } from "react-router-dom";
+import { Link, useParams } from "react-router-dom";
import { useDocs } from "context/useDocs";
import useSortTableData from "util/useSortTableData";
import { useToastNotification } from "context/toastNotificationProvider";
import { useSupportedFeatures } from "context/useSupportedFeatures";
+import CustomLayout from "components/CustomLayout";
+import PageHeader from "components/PageHeader";
+import HelpLink from "components/HelpLink";
+import NotificationRow from "components/NotificationRow";
-interface Props {
- project: string;
-}
-
-const CustomIsoList: FC = ({ project }) => {
+const CustomIsoList: FC = () => {
const docBaseLink = useDocs();
const { hasStorageVolumesAll } = useSupportedFeatures();
const toastNotify = useToastNotification();
const [query, setQuery] = useState("");
+ const { project } = useParams<{
+ project: string;
+ }>();
+
+ if (!project) {
+ return <>Missing project>;
+ }
const { data: images = [], isLoading } = useQuery({
queryKey: [queryKeys.isoVolumes, project],
@@ -140,7 +148,9 @@ const CustomIsoList: FC = ({ project }) => {
return ;
}
- return images.length === 0 ? (
+ const hasImages = images.length !== 0;
+
+ const content = !hasImages ? (
}
@@ -161,22 +171,6 @@ const CustomIsoList: FC = ({ project }) => {
) : (
-
-
- {
- setQuery(value);
- }}
- placeholder="Search for custom ISOs"
- value={query}
- aria-label="Search for custom ISOs"
- />
-
-
-
= ({ project }) => {
);
+
+ return (
+
+
+
+
+ Custom ISOs
+
+
+ {hasImages && (
+
+
+ {
+ setQuery(value);
+ }}
+ placeholder="Search for custom ISOs"
+ value={query}
+ aria-label="Search for custom ISOs"
+ />
+
+
+ )}
+
+ {hasImages && (
+
+
+
+ )}
+
+ }
+ >
+
+ {content}
+
+ );
};
export default CustomIsoList;
diff --git a/src/pages/storage/Storage.tsx b/src/pages/storage/Storage.tsx
deleted file mode 100644
index 917007a820..0000000000
--- a/src/pages/storage/Storage.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import { FC } from "react";
-import { Row } from "@canonical/react-components";
-import BaseLayout from "components/BaseLayout";
-import NotificationRow from "components/NotificationRow";
-import { useParams } from "react-router-dom";
-import CustomIsoList from "pages/storage/CustomIsoList";
-import StoragePools from "pages/storage/StoragePools";
-import StorageVolumes from "pages/storage/StorageVolumes";
-import HelpLink from "components/HelpLink";
-import TabLinks from "components/TabLinks";
-import { useDocs } from "context/useDocs";
-import { storageTabs } from "util/projects";
-import { useSupportedFeatures } from "context/useSupportedFeatures";
-
-const Storage: FC = () => {
- const docBaseLink = useDocs();
- const { project, activeTab } = useParams<{
- project: string;
- activeTab?: string;
- }>();
- const { hasCustomVolumeIso } = useSupportedFeatures();
-
- if (!project) {
- return <>Missing project>;
- }
-
- return (
-
- Storage
-
- }
- contentClassName="detail-page"
- >
-
-
- tab !== "Custom ISOs")
- }
- activeTab={activeTab}
- tabUrl={`/ui/project/${project}/storage`}
- />
-
- {!activeTab && (
-
-
-
- )}
-
- {activeTab === "volumes" && (
-
-
-
- )}
-
- {activeTab === "custom-isos" && hasCustomVolumeIso && (
-
-
-
- )}
-
-
- );
-};
-
-export default Storage;
diff --git a/src/pages/storage/StoragePoolHeader.tsx b/src/pages/storage/StoragePoolHeader.tsx
index b46790294b..4f0b70c0e1 100644
--- a/src/pages/storage/StoragePoolHeader.tsx
+++ b/src/pages/storage/StoragePoolHeader.tsx
@@ -59,7 +59,7 @@ const StoragePoolHeader: FC = ({ name, pool, project }) => {
+
Storage pools
,
]}
diff --git a/src/pages/storage/StoragePools.tsx b/src/pages/storage/StoragePools.tsx
index bb94853470..74447cb015 100644
--- a/src/pages/storage/StoragePools.tsx
+++ b/src/pages/storage/StoragePools.tsx
@@ -17,6 +17,10 @@ import CreateStoragePoolBtn from "pages/storage/actions/CreateStoragePoolBtn";
import ScrollableTable from "components/ScrollableTable";
import StorageVolumesInPoolBtn from "pages/storage/actions/StorageVolumesInPoolBtn";
import { useDocs } from "context/useDocs";
+import HelpLink from "components/HelpLink";
+import NotificationRow from "components/NotificationRow";
+import CustomLayout from "components/CustomLayout";
+import PageHeader from "components/PageHeader";
const StoragePools: FC = () => {
const docBaseLink = useDocs();
@@ -157,44 +161,74 @@ const StoragePools: FC = () => {
return ;
}
- return pools.length > 0 ? (
-
-
-
-
- 0 ? (
+
+
+
+
+
+ ) : (
+ }
+ title="No pools found in this project"
>
- Storage pools will appear here.
+
+
+ Learn more about storage
+
+
+
+
-
-
- ) : (
- }
- title="No pools found in this project"
+
+ );
+
+ return (
+
+
+
+
+ Pools
+
+
+
+
+
+
+
+ }
>
- Storage pools will appear here.
-
-
- Learn more about storage
-
-
-
-
-
+
+ {content}
+
);
};
diff --git a/src/pages/storage/StorageUsedBy.tsx b/src/pages/storage/StorageUsedBy.tsx
index f1ead00096..f16aa6277f 100644
--- a/src/pages/storage/StorageUsedBy.tsx
+++ b/src/pages/storage/StorageUsedBy.tsx
@@ -19,11 +19,11 @@ const CUSTOM_VOLUMES = "Custom volumes";
const StorageUsedBy: FC = ({ storage, project }) => {
const data: Record = {
- [INSTANCES]: filterUsedByType("instances", storage.used_by),
- [PROFILES]: filterUsedByType("profiles", storage.used_by),
- [IMAGES]: filterUsedByType("images", storage.used_by),
- [SNAPSHOTS]: filterUsedByType("snapshots", storage.used_by),
- [CUSTOM_VOLUMES]: filterUsedByType("volumes", storage.used_by),
+ [INSTANCES]: filterUsedByType("instance", storage.used_by),
+ [PROFILES]: filterUsedByType("profile", storage.used_by),
+ [IMAGES]: filterUsedByType("image", storage.used_by),
+ [SNAPSHOTS]: filterUsedByType("snapshot", storage.used_by),
+ [CUSTOM_VOLUMES]: filterUsedByType("volume", storage.used_by),
};
return (
diff --git a/src/pages/storage/StorageVolumes.tsx b/src/pages/storage/StorageVolumes.tsx
index 356a286d11..33bf5d2e7a 100644
--- a/src/pages/storage/StorageVolumes.tsx
+++ b/src/pages/storage/StorageVolumes.tsx
@@ -6,6 +6,7 @@ import {
EmptyState,
Icon,
MainTable,
+ Row,
TablePagination,
useNotify,
} from "@canonical/react-components";
@@ -48,6 +49,10 @@ import CustomStorageVolumeActions from "./actions/CustomStorageVolumeActions";
import useEventListener from "@use-it/event-listener";
import useSortTableData from "util/useSortTableData";
import { useSupportedFeatures } from "context/useSupportedFeatures";
+import CustomLayout from "components/CustomLayout";
+import PageHeader from "components/PageHeader";
+import HelpLink from "components/HelpLink";
+import NotificationRow from "components/NotificationRow";
const StorageVolumes: FC = () => {
const docBaseLink = useDocs();
@@ -358,7 +363,9 @@ const StorageVolumes: FC = () => {
const defaultPoolForVolumeCreate =
filters.pools.length === 1 ? filters.pools[0] : "";
- return volumes.length === 0 ? (
+ const hasVolumes = volumes.length !== 0;
+
+ const content = !hasVolumes ? (
}
@@ -379,17 +386,6 @@ const StorageVolumes: FC = () => {
) : (
);
+
+ return (
+
+
+
+
+ Volumes
+
+
+ {hasVolumes && (
+
+
+
+ )}
+
+ {hasVolumes && (
+
+
+
+ )}
+
+ }
+ >
+
+ {content}
+
+ );
};
export default StorageVolumes;
diff --git a/src/pages/storage/actions/CreateStoragePoolBtn.tsx b/src/pages/storage/actions/CreateStoragePoolBtn.tsx
index 1cee8b30b9..103aafbf7b 100644
--- a/src/pages/storage/actions/CreateStoragePoolBtn.tsx
+++ b/src/pages/storage/actions/CreateStoragePoolBtn.tsx
@@ -15,7 +15,7 @@ const CreateStoragePoolBtn: FC = ({ project, className }) => {
appearance="positive"
className={className}
hasIcon
- onClick={() => navigate(`/ui/project/${project}/storage/create`)}
+ onClick={() => navigate(`/ui/project/${project}/storage/pools/create`)}
>
Create pool
diff --git a/src/pages/storage/actions/DeleteStoragePoolBtn.tsx b/src/pages/storage/actions/DeleteStoragePoolBtn.tsx
index 8a342cf0f3..55e1ad5a78 100644
--- a/src/pages/storage/actions/DeleteStoragePoolBtn.tsx
+++ b/src/pages/storage/actions/DeleteStoragePoolBtn.tsx
@@ -8,7 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { deleteStoragePool } from "api/storage-pools";
import classnames from "classnames";
import ItemName from "components/ItemName";
-import { useDeleteIcon } from "context/useDeleteIcon";
+import { useSmallScreen } from "context/useSmallScreen";
import { useNavigate } from "react-router-dom";
import { LxdStoragePool } from "types/storage";
import { queryKeys } from "util/queryKeys";
@@ -25,7 +25,7 @@ const DeleteStoragePoolBtn: FC = ({
project,
shouldExpand = false,
}) => {
- const isSmallScreen = useDeleteIcon();
+ const isSmallScreen = useSmallScreen();
const navigate = useNavigate();
const notify = useNotify();
const toastNotify = useToastNotification();
diff --git a/src/sass/_empty_state.scss b/src/sass/_empty_state.scss
index cfab11153e..98d1e26d2b 100644
--- a/src/sass/_empty_state.scss
+++ b/src/sass/_empty_state.scss
@@ -1,13 +1,7 @@
.empty-state {
- margin-left: auto;
- margin-right: auto;
- margin-top: min(5vh, $spv--strip-regular);
+ margin: min(5vh, $spv--strip-regular) auto;
width: 67%;
- @include desktop {
- width: 50%;
- }
-
.empty-state-icon {
height: 2.5rem;
margin-bottom: $spv--large;
@@ -16,6 +10,10 @@
width: 2.5rem;
}
+ @include desktop {
+ width: 50%;
+ }
+
h4 {
margin-bottom: $spv--x-small;
}
diff --git a/src/sass/_helpers.scss b/src/sass/_helpers.scss
new file mode 100644
index 0000000000..44f71f0236
--- /dev/null
+++ b/src/sass/_helpers.scss
@@ -0,0 +1,17 @@
+// Truncate text after overflow at specified line count
+// Reverts to single line text truncation if -webkit-line-clamp is not supported
+@mixin u-line-clamp-truncate($lines) {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ @supports (-webkit-line-clamp: $lines) {
+ -webkit-box-orient: vertical;
+ display: -webkit-box;
+ -webkit-line-clamp: $lines;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: initial;
+ word-wrap: break-word;
+ }
+}
diff --git a/src/sass/_modified_actions.scss b/src/sass/_modified_actions.scss
new file mode 100644
index 0000000000..91ea1038c0
--- /dev/null
+++ b/src/sass/_modified_actions.scss
@@ -0,0 +1,17 @@
+.modified-actions {
+ align-items: center;
+ display: flex;
+ gap: $sph--large;
+ justify-content: flex-end;
+ margin-bottom: calc(0.75 * $spv--large);
+
+ .modified-status {
+ align-items: center;
+ display: flex;
+ gap: $sp-unit;
+
+ span {
+ color: $color-mid-dark;
+ }
+ }
+}
diff --git a/src/sass/_pattern_navigation.scss b/src/sass/_pattern_navigation.scss
index 92236ef1a6..7f1469a0c4 100644
--- a/src/sass/_pattern_navigation.scss
+++ b/src/sass/_pattern_navigation.scss
@@ -51,6 +51,29 @@
}
}
+.sidenav-toggle-wrapper {
+ background: $colors--dark-theme--background-default;
+ bottom: 0;
+ padding: $spv--small $sph--x-large $spv--large $sph--large;
+ position: absolute;
+ text-align: right;
+ width: 100%;
+
+ .sidenav-toggle {
+ background-color: #444 !important;
+ }
+}
+
+.l-navigation.is-collapsed {
+ .sidenav-toggle-wrapper {
+ text-align: left;
+
+ .sidenav-toggle {
+ rotate: 180deg;
+ }
+ }
+}
+
.l-navigation.is-collapsed:focus-within {
transform: translateX(-100%);
}
@@ -68,6 +91,20 @@
padding-bottom: 0;
}
+.l-navigation.is-scroll {
+ .sidenav-bottom-ul.authenticated-nav {
+ bottom: initial;
+ margin-bottom: 0;
+ position: initial;
+ }
+
+ .sidenav-toggle-wrapper.authenticated-nav {
+ bottom: initial;
+ position: initial;
+ width: initial;
+ }
+}
+
.l-navigation .p-panel__header {
z-index: 1001;
}
@@ -87,42 +124,6 @@
width: 15rem;
}
-.sidenav-toggle-wrapper {
- background: $colors--dark-theme--background-default;
- bottom: 0;
- padding: $spv--small $sph--x-large $spv--large $sph--large;
- position: absolute;
- text-align: right;
- width: 100%;
-
- .sidenav-toggle {
- background-color: #444 !important;
- }
-}
-
-.l-navigation.is-collapsed .sidenav-toggle-wrapper {
- text-align: left;
-
- .sidenav-toggle {
- rotate: 180deg;
- }
-}
-
-@media screen and (max-width: $breakpoint-x-large) and (height <= 680px),
- screen and (min-width: $breakpoint-x-large) and (height <= 810px) {
- .sidenav-bottom-ul.authenticated-nav {
- bottom: initial;
- margin-bottom: 0;
- position: initial;
- }
-
- .sidenav-toggle-wrapper.authenticated-nav {
- bottom: initial;
- position: initial;
- width: initial;
- }
-}
-
.p-side-navigation__item--title {
white-space: nowrap;
}
@@ -144,6 +145,32 @@
}
}
+.accordion-nav-menu {
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+
+ .p-icon--chevron-up {
+ margin-right: $sph--small;
+ }
+
+ .closed {
+ rotate: 90deg;
+ }
+
+ .open {
+ rotate: 180deg;
+ }
+
+ &:hover {
+ cursor: pointer;
+ }
+}
+
+.accordion-nav-secondary {
+ padding-left: 4rem !important;
+}
+
@include mobile {
.navigation-hr {
margin-left: $sph--large;
diff --git a/src/sass/_permission_confirm_modal.scss b/src/sass/_permission_confirm_modal.scss
new file mode 100644
index 0000000000..6cb0ba6e32
--- /dev/null
+++ b/src/sass/_permission_confirm_modal.scss
@@ -0,0 +1,50 @@
+.permission-confirm-modal {
+ .confirm-table {
+ overflow-y: auto;
+
+ .modified-row {
+ border-top: $border-thin;
+ }
+
+ .removed {
+ text-decoration: line-through;
+ }
+
+ tr {
+ border: none;
+ }
+
+ thead {
+ background-color: white;
+ border-bottom: $border-thin;
+ position: sticky;
+ top: 0;
+ }
+
+ .display-by-header {
+ position: relative;
+ }
+
+ .display-by-button {
+ bottom: 0.45rem;
+ position: absolute;
+ right: 0;
+ }
+ }
+
+ // fixes issue on safari with table content appearing through margin of header when scrolling
+ .p-modal__header {
+ margin-bottom: 0;
+ }
+
+ // modal styling
+ @include large {
+ .p-modal__dialog {
+ width: 45rem;
+ }
+ }
+
+ .p-notification--caution {
+ margin-top: $spv--large;
+ }
+}
diff --git a/src/sass/_permission_group_selection.scss b/src/sass/_permission_group_selection.scss
new file mode 100644
index 0000000000..a3b33b4504
--- /dev/null
+++ b/src/sass/_permission_group_selection.scss
@@ -0,0 +1,25 @@
+@import "./helpers";
+
+.group-selection {
+ .group-selection-table {
+ .selected-row {
+ background-color: white;
+ }
+
+ .modified-status {
+ width: 50px;
+ }
+
+ .name {
+ width: 30%;
+ }
+
+ .description {
+ width: 50%;
+
+ span {
+ @include u-line-clamp-truncate(2);
+ }
+ }
+ }
+}
diff --git a/src/sass/_permission_groups.scss b/src/sass/_permission_groups.scss
new file mode 100644
index 0000000000..8d7b2550c7
--- /dev/null
+++ b/src/sass/_permission_groups.scss
@@ -0,0 +1,115 @@
+@import "./helpers";
+
+.permission-groups {
+ .groups-table {
+ .name {
+ width: 15%;
+ }
+
+ .description {
+ width: 45%;
+
+ span {
+ @include u-line-clamp-truncate(2);
+ }
+ }
+
+ .identities,
+ .permissions {
+ width: 10%;
+ }
+ }
+}
+
+.permission-groups-filter {
+ margin-bottom: -0.3rem; // needed to align button with search filter
+ width: 100%;
+}
+
+.show-menu-above {
+ bottom: 1.8rem;
+}
+
+#group-identities-table {
+ .selected-row {
+ background-color: white;
+ }
+
+ .modified-status {
+ width: 50px;
+ }
+}
+
+.delete-group-confirm-modal {
+ .p-modal__footer {
+ display: flex;
+ gap: $sph--large;
+
+ .confirm-input {
+ flex-grow: 1;
+ }
+ }
+}
+
+.edit-permissions-panel {
+ width: 46rem !important;
+
+ .p-card__title {
+ font-size: #{map-get($font-sizes, h3)}rem;
+ }
+
+ .permission-selector {
+ align-items: flex-end;
+ display: flex;
+ gap: 1rem;
+ justify-content: space-evenly;
+
+ .p-form__group {
+ width: 30%;
+ }
+
+ .add-entitlement {
+ flex-grow: 1;
+
+ button {
+ width: 100%;
+ }
+ }
+ }
+
+ .hide-modified-status {
+ opacity: 0;
+ }
+
+ .permissions-table {
+ td.resource-type,
+ th.resource-type {
+ width: 20%;
+ }
+
+ td.resource,
+ th.resource {
+ width: 35%;
+ }
+
+ td.entitlement,
+ th.entitlement {
+ width: 30%;
+ }
+
+ tr.strikeout {
+ td {
+ position: relative;
+ }
+
+ td:not(.actions)::before {
+ border-bottom: 1px solid black;
+ content: "";
+ left: 0;
+ position: absolute;
+ top: 1.2rem;
+ width: 100%;
+ }
+ }
+ }
+}
diff --git a/src/sass/_selectable_main_table.scss b/src/sass/_selectable_main_table.scss
index 585b49a770..f90f7194e3 100644
--- a/src/sass/_selectable_main_table.scss
+++ b/src/sass/_selectable_main_table.scss
@@ -5,6 +5,10 @@
.multiselect-checkbox {
display: inline-block;
}
+
+ &.no-menu {
+ padding-top: 0.75rem;
+ }
}
// fix for safari https://warthogs.atlassian.net/browse/WD-7486
diff --git a/src/sass/_side_panel.scss b/src/sass/_side_panel.scss
index 20b50963b6..6b29fd7320 100644
--- a/src/sass/_side_panel.scss
+++ b/src/sass/_side_panel.scss
@@ -1,5 +1,21 @@
-.is-overlay {
+.l-aside.is-overlay {
+ // mobile
+ background-color: #fff;
height: 100vh;
+ padding: 0 $sph--x-large;
+ padding-bottom: $spv--medium;
+ padding-top: $spv--small;
position: absolute;
+ right: 0;
z-index: 103 !important;
+
+ .p-panel__header {
+ padding: 0;
+ }
+
+ .panel-footer {
+ hr {
+ margin-bottom: $spv--medium;
+ }
+ }
}
diff --git a/src/sass/_tag.scss b/src/sass/_tag.scss
new file mode 100644
index 0000000000..837cb788b7
--- /dev/null
+++ b/src/sass/_tag.scss
@@ -0,0 +1,11 @@
+.tag {
+ border: 1px solid #666;
+ border-radius: 4px;
+ color: #666;
+ display: inline-block;
+ font-size: calc($sp-unit * 1.875);
+ font-weight: 500;
+ margin-left: $sph--small;
+ padding: 0 $sph--small;
+ text-transform: capitalize;
+}
diff --git a/src/sass/styles.scss b/src/sass/styles.scss
index 34d155d25f..caf8d324e0 100644
--- a/src/sass/styles.scss
+++ b/src/sass/styles.scss
@@ -10,6 +10,7 @@
@include vf-p-icon-begin-downloading;
@include vf-p-icon-canvas;
@include vf-p-icon-change-version;
+@include vf-p-icon-change-version;
@include vf-p-icon-close;
@include vf-p-icon-connected;
@include vf-p-icon-containers;
@@ -21,10 +22,12 @@
@include vf-p-icon-fullscreen;
@include vf-p-icon-get-link;
@include vf-p-icon-import;
+@include vf-p-icon-lock-locked;
@include vf-p-icon-machines;
@include vf-p-icon-mount;
@include vf-p-icon-open-terminal;
@include vf-p-icon-pause;
+@include vf-p-icon-plans;
@include vf-p-icon-play;
@include vf-p-icon-pods;
@include vf-p-icon-power-off;
@@ -43,6 +46,7 @@
@include vf-p-icon-switcher-environments;
@include vf-p-icon-task-outstanding;
@include vf-p-icon-units;
+@include vf-p-icon-user-group;
@include vf-p-icon-video-play;
@include vf-p-icon-warning-grey;
@@ -69,6 +73,7 @@ $border-thin: 1px solid $color-mid-light !default;
@import "instance_list";
@import "login";
@import "meter";
+@import "modified_actions";
@import "network_detail_overview";
@import "network_form";
@import "network_forwards_form";
@@ -78,6 +83,9 @@ $border-thin: 1px solid $color-mid-light !default;
@import "page_header";
@import "pattern_navigation";
@import "pattern_terminal";
+@import "permission_confirm_modal";
+@import "permission_group_selection";
+@import "permission_groups";
@import "profile_detail_overview";
@import "profile_detail_panel";
@import "profile_list";
@@ -96,6 +104,7 @@ $border-thin: 1px solid $color-mid-light !default;
@import "storage_pool_form";
@import "storage_volume_form";
@import "storage";
+@import "tag";
@import "toast";
@import "upper_controls_bar";
diff --git a/src/types/permissions.d.ts b/src/types/permissions.d.ts
new file mode 100644
index 0000000000..fb250d68c1
--- /dev/null
+++ b/src/types/permissions.d.ts
@@ -0,0 +1,32 @@
+export interface LxdIdentity {
+ id: string; // fingerprint for tls and email for oidc
+ type: string;
+ name: string;
+ authentication_method: "tls" | "oidc";
+ groups?: string[] | null;
+ effective_groups?: string[];
+ effective_permissions?: LxdPermission[];
+}
+
+export interface LxdGroup {
+ name: string;
+ description: string;
+ permissions?: LxdPermission[];
+ identities?: {
+ oidc?: string[];
+ tls?: string[];
+ };
+ identity_provider_groups?: string[];
+}
+
+export interface LxdPermission {
+ entity_type: string;
+ url: string;
+ entitlement: string;
+ groups?: LxdGroup[];
+}
+
+export interface IdpGroup {
+ name: string;
+ groups: string[]; // these should be names of lxd groups
+}
diff --git a/src/util/helpers.tsx b/src/util/helpers.tsx
index 6950e59df9..9dd1078231 100644
--- a/src/util/helpers.tsx
+++ b/src/util/helpers.tsx
@@ -248,8 +248,7 @@ export const logout = (): void =>
export const capitalizeFirstLetter = (val: string): string =>
val.charAt(0).toUpperCase() + val.slice(1);
-export const getAbsoluteHeightBelow = (belowId: string): number => {
- const element = belowId ? document.getElementById(belowId) : undefined;
+export const getElementAbsoluteHeight = (element: HTMLElement) => {
if (!element) {
return 0;
}
@@ -260,6 +259,34 @@ export const getAbsoluteHeightBelow = (belowId: string): number => {
return element.offsetHeight + margin + padding + 1;
};
+export const getAbsoluteHeightBelowById = (belowId: string): number => {
+ const element = belowId ? document.getElementById(belowId) : undefined;
+ if (!element) {
+ return 0;
+ }
+ return getElementAbsoluteHeight(element);
+};
+
+export const getAbsoluteHeightBelowBySelector = (selector: string): number => {
+ const element = selector ? document.querySelector(selector) : undefined;
+ if (!element) {
+ return 0;
+ }
+ return getElementAbsoluteHeight(element as HTMLElement);
+};
+
export const generateUUID = (): string => {
return crypto.randomBytes(16).toString("hex");
};
+
+export const getClientOS = (userAgent: string) => {
+ if (userAgent.includes("Windows")) {
+ return "windows";
+ } else if (userAgent.includes("Mac OS")) {
+ return "macos";
+ } else if (userAgent.includes("Linux")) {
+ return "linux";
+ }
+
+ return null;
+};
diff --git a/src/util/instanceBulkActions.tsx b/src/util/instanceBulkActions.tsx
index 82dab8125a..c607dac3d1 100644
--- a/src/util/instanceBulkActions.tsx
+++ b/src/util/instanceBulkActions.tsx
@@ -69,7 +69,17 @@ export const instanceActionLabel = (action: LxdInstanceAction): string => {
};
export const pluralize = (item: string, count: number): string => {
- return count === 1 ? item : `${item}s`;
+ const isSingular = count === 1;
+
+ if (isSingular) {
+ return item;
+ }
+
+ if (item.includes("identity")) {
+ return item.replace("identity", "identities");
+ }
+
+ return `${item}s`;
};
export const statusLabel = (status: LxdInstanceStatus): string | undefined => {
diff --git a/src/util/permissionGroups.spec.ts b/src/util/permissionGroups.spec.ts
new file mode 100644
index 0000000000..066eb7d994
--- /dev/null
+++ b/src/util/permissionGroups.spec.ts
@@ -0,0 +1,147 @@
+import { LxdGroup, LxdIdentity } from "types/permissions";
+import {
+ getCurrentIdentitiesForGroups,
+ generateGroupAllocationsForIdentities,
+ getChangesInGroupsForIdentities,
+} from "./permissionGroups";
+
+describe("Permissions util functions for groups page", () => {
+ it("getCurrentIdentitiesForGroups", () => {
+ const groups = [
+ {
+ name: "group-1",
+ },
+ {
+ name: "group-2",
+ },
+ {
+ name: "group-3",
+ },
+ ] as LxdGroup[];
+
+ const identities = [
+ {
+ id: "user-1",
+ groups: ["group-1", "group-2", "group-3"],
+ },
+ {
+ id: "user-2",
+ groups: ["group-1"],
+ },
+ {
+ id: "user-3",
+ groups: [],
+ },
+ ] as LxdIdentity[];
+
+ const {
+ identityIdsInAllGroups,
+ identityIdsInSomeGroups,
+ identityIdsInNoGroups,
+ } = getCurrentIdentitiesForGroups(groups, identities);
+
+ expect(identityIdsInAllGroups).toEqual(["user-1"]);
+ expect(identityIdsInSomeGroups).toEqual(["user-2"]);
+ expect(identityIdsInNoGroups).toEqual(["user-3"]);
+ });
+
+ it("generateGroupAllocationsForIdentities", () => {
+ const addedIdentities = new Set(["user-3"]);
+ const removedIdentities = new Set(["user-1", "user-2"]);
+ const selectedGroups = [
+ {
+ name: "group-1",
+ },
+ {
+ name: "group-2",
+ },
+ ] as LxdGroup[];
+ const addedOrRemovedIdentities = [
+ {
+ id: "user-1",
+ groups: ["group-1", "group-2"],
+ },
+ {
+ id: "user-2",
+ groups: ["group-2"],
+ },
+ {
+ id: "user-3",
+ groups: ["group-3"],
+ },
+ ] as LxdIdentity[];
+
+ const groupsForIdentities = generateGroupAllocationsForIdentities(
+ addedIdentities,
+ removedIdentities,
+ selectedGroups,
+ addedOrRemovedIdentities,
+ );
+
+ expect(groupsForIdentities).toEqual({
+ "user-1": [],
+ "user-2": [],
+ "user-3": ["group-3", "group-1", "group-2"],
+ });
+ });
+
+ it("getChangesInGroupsForIdentities", () => {
+ // user action:
+ // - remove user-1 from group-1 and group-2
+ // - add user-2 and user-3 to group-1 and group-2
+ const allIdentities = [
+ {
+ id: "user-1",
+ name: "user-1",
+ groups: ["group-1", "group-2"],
+ },
+ {
+ id: "user-2",
+ name: "user-2",
+ groups: ["group-3", "group-4"],
+ },
+ {
+ id: "user-3",
+ name: "user-3",
+ groups: ["group-2"],
+ },
+ ] as LxdIdentity[];
+
+ const addedIdentities = new Set(["user-2", "user-3"]);
+ const removedIdentities = new Set(["user-1"]);
+
+ const selectedGroups = [
+ {
+ name: "group-1",
+ },
+ {
+ name: "group-2",
+ },
+ ] as LxdGroup[];
+
+ const identityGroupsChangeSummary = getChangesInGroupsForIdentities(
+ allIdentities,
+ selectedGroups,
+ addedIdentities,
+ removedIdentities,
+ );
+
+ expect(identityGroupsChangeSummary).toEqual({
+ "user-1": {
+ added: new Set([]),
+ removed: new Set(["group-1", "group-2"]),
+ name: "user-1",
+ },
+ "user-2": {
+ added: new Set(["group-1", "group-2"]),
+ removed: new Set([]),
+ name: "user-2",
+ },
+ "user-3": {
+ added: new Set(["group-1"]),
+ removed: new Set([]),
+ name: "user-3",
+ },
+ });
+ });
+});
diff --git a/src/util/permissionGroups.tsx b/src/util/permissionGroups.tsx
new file mode 100644
index 0000000000..d5f8fb617e
--- /dev/null
+++ b/src/util/permissionGroups.tsx
@@ -0,0 +1,179 @@
+import * as Yup from "yup";
+import { AbortControllerState, checkDuplicateName } from "./helpers";
+import { LxdGroup, LxdIdentity } from "types/permissions";
+import { ChangeSummary } from "./permissionIdentities";
+
+export const testDuplicateGroupName = (
+ controllerState: AbortControllerState,
+ excludeName?: string,
+): [string, string, Yup.TestFunction] => {
+ return [
+ "deduplicate",
+ "A group with this name already exists",
+ (value?: string) => {
+ return (
+ (excludeName && value === excludeName) ||
+ checkDuplicateName(value, "", controllerState, "auth/groups")
+ );
+ },
+ ];
+};
+
+export const getCurrentIdentitiesForGroups = (
+ groups: LxdGroup[],
+ identities: LxdIdentity[],
+): {
+ identityIdsInAllGroups: string[];
+ identityIdsInSomeGroups: string[];
+ identityIdsInNoGroups: string[];
+} => {
+ const totalGroupsCount = groups.length;
+ const identityIdsInAllGroups: string[] = [];
+ const identityIdsInSomeGroups: string[] = [];
+ const identityIdsInNoGroups: string[] = [];
+ for (const identity of identities) {
+ let allocatedCount = 0;
+ const identityGroupsLookup = new Set(identity.groups || []);
+
+ for (const group of groups) {
+ if (identityGroupsLookup.has(group.name)) {
+ allocatedCount++;
+ }
+ }
+ const groupAllocatedToAll = allocatedCount === totalGroupsCount;
+ const groupAllocatedToSome = !groupAllocatedToAll && allocatedCount > 0;
+
+ if (groupAllocatedToAll) {
+ identityIdsInAllGroups.push(identity.id);
+ continue;
+ }
+
+ if (groupAllocatedToSome) {
+ identityIdsInSomeGroups.push(identity.id);
+ continue;
+ }
+
+ identityIdsInNoGroups.push(identity.id);
+ }
+
+ return {
+ identityIdsInAllGroups,
+ identityIdsInSomeGroups,
+ identityIdsInNoGroups,
+ };
+};
+
+export const getAddedOrRemovedIdentities = (
+ allIdentities: LxdIdentity[],
+ addedIdentities: Set,
+ removedIdentities: Set,
+) => {
+ const addedOrRemovedIdentities = allIdentities.filter(
+ (identity) =>
+ addedIdentities.has(identity.id) || removedIdentities.has(identity.id),
+ );
+
+ return addedOrRemovedIdentities;
+};
+
+// Given a set of identities that should be allocated to all groups and,
+// Given a set of identities that should be removed from all groups
+// Generate new groups to be assigned to each identity
+export const generateGroupAllocationsForIdentities = (
+ addedIdentities: Set,
+ removedIdentities: Set,
+ selectedGroups: LxdGroup[],
+ addedOrRemovedIdentities: LxdIdentity[],
+): Record => {
+ const addedIdentitiesLookup = new Set(addedIdentities);
+ const removedIdentitiesLookup = new Set(removedIdentities);
+ const selectedGroupsLookup = new Set(
+ selectedGroups.map((group) => group.name),
+ );
+
+ const newGroupsForIdentities: Record = {};
+ for (const identity of addedOrRemovedIdentities) {
+ const existingIdentityGroups = identity.groups || [];
+ const newIdentityGroups = new Set(existingIdentityGroups);
+
+ // see if any group should be removed from the identity
+ for (const group of existingIdentityGroups) {
+ if (
+ removedIdentitiesLookup.has(identity.id) &&
+ selectedGroupsLookup.has(group)
+ ) {
+ newIdentityGroups.delete(group);
+ }
+ }
+
+ // add groups to identity if necessary
+ for (const group of selectedGroups) {
+ if (addedIdentitiesLookup.has(identity.id)) {
+ newIdentityGroups.add(group.name);
+ }
+ }
+
+ newGroupsForIdentities[identity.id] = Array.from(newIdentityGroups);
+ }
+
+ return newGroupsForIdentities;
+};
+
+export const getChangesInGroupsForIdentities = (
+ allIdentities: LxdIdentity[],
+ selectedGroups: LxdGroup[],
+ addedIdentities: Set,
+ removedIdentities: Set,
+): ChangeSummary => {
+ const addedOrRemovedIdentities = getAddedOrRemovedIdentities(
+ allIdentities,
+ addedIdentities,
+ removedIdentities,
+ );
+
+ const newGroupsForIdentities = generateGroupAllocationsForIdentities(
+ addedIdentities,
+ removedIdentities,
+ selectedGroups,
+ addedOrRemovedIdentities,
+ );
+
+ const identityGroupsChangeSummary: ChangeSummary = {};
+
+ for (const identity of addedOrRemovedIdentities) {
+ const newIdentityGroups = newGroupsForIdentities[identity.id];
+ if (!newIdentityGroups) {
+ continue;
+ }
+
+ const groupsAddedForIdentity: Set = new Set();
+ const groupsRemovedForIdentity: Set = new Set();
+
+ // given a set of groups, for each identity check if each group is an addition
+ const existingIdentityGroupsLookup = new Set(identity.groups);
+ for (const newGroup of newIdentityGroups) {
+ if (!existingIdentityGroupsLookup.has(newGroup)) {
+ groupsAddedForIdentity.add(newGroup);
+ }
+ }
+
+ // Also check the reverse, if a group previously existed for the identity and is not part of the new groups, then that's a removal
+ const newIdentityGroupsLookup = new Set(newIdentityGroups);
+ for (const existingGroup of identity.groups || []) {
+ if (!newIdentityGroupsLookup.has(existingGroup)) {
+ groupsRemovedForIdentity.add(existingGroup);
+ }
+ }
+
+ // record the changes in groups for an identity, if there are changes
+ if (groupsAddedForIdentity.size || groupsRemovedForIdentity.size) {
+ identityGroupsChangeSummary[identity.id] = {
+ added: groupsAddedForIdentity,
+ removed: groupsRemovedForIdentity,
+ name: identity.name,
+ };
+ }
+ }
+
+ return identityGroupsChangeSummary;
+};
diff --git a/src/util/permissionIdentities.spec.ts b/src/util/permissionIdentities.spec.ts
new file mode 100644
index 0000000000..f4d3896b46
--- /dev/null
+++ b/src/util/permissionIdentities.spec.ts
@@ -0,0 +1,164 @@
+import { LxdGroup, LxdIdentity } from "types/permissions";
+import {
+ getGroupsForIdentities,
+ generateGroupAllocationsForIdentities,
+ getChangesInGroupsForIdentities,
+ pivotIdentityGroupsChangeSummary,
+} from "./permissionIdentities";
+
+describe("Permissions util functions for identities page", () => {
+ it("getGroupsForIdentities", () => {
+ const groups = [
+ {
+ name: "group-1",
+ identities: {
+ oidc: ["user-1"],
+ tls: ["user-2"],
+ },
+ },
+ {
+ name: "group-2",
+ identities: {
+ oidc: ["user-1", "user-3"],
+ tls: ["user-2"],
+ },
+ },
+ {
+ name: "group-3",
+ identities: {
+ oidc: [],
+ tls: [],
+ },
+ },
+ ] as LxdGroup[];
+
+ const identities = [
+ {
+ id: "user-1",
+ },
+ {
+ id: "user-2",
+ },
+ {
+ id: "user-3",
+ },
+ ] as LxdIdentity[];
+
+ const {
+ groupsForAllIdentities,
+ groupsForSomeIdentities,
+ groupsForNoIdentities,
+ } = getGroupsForIdentities(groups, identities);
+
+ expect(groupsForAllIdentities).toEqual(["group-2"]);
+ expect(groupsForSomeIdentities).toEqual(["group-1"]);
+ expect(groupsForNoIdentities).toEqual(["group-3"]);
+ });
+
+ it("generateGroupAllocationsForIdentities", () => {
+ const addedGroups = new Set(["group-1"]);
+ const removedGroups = new Set(["group-3"]);
+ const identities = [
+ {
+ id: "user-1",
+ groups: ["group-1", "group-2"],
+ },
+ {
+ id: "user-2",
+ groups: ["group-2"],
+ },
+ {
+ id: "user-3",
+ groups: ["group-3"],
+ },
+ ] as LxdIdentity[];
+ const groupsForIdentities = generateGroupAllocationsForIdentities(
+ addedGroups,
+ removedGroups,
+ identities,
+ );
+
+ expect(groupsForIdentities).toEqual({
+ "user-1": ["group-1", "group-2"],
+ "user-2": ["group-2", "group-1"],
+ "user-3": ["group-1"],
+ });
+ });
+
+ it("getChangesInGroupsForIdentities", () => {
+ // user action:
+ // - remove group-1 for user-1 and user-2
+ // - add group-3 and group-4 for user-1 and user-2
+ const identities = [
+ {
+ id: "user-1",
+ name: "user-1",
+ groups: ["group-1", "group-2"],
+ },
+ {
+ id: "user-2",
+ name: "user-2",
+ groups: ["group-1"],
+ },
+ ] as LxdIdentity[];
+
+ const addedGroups = new Set(["group-3", "group-4"]);
+ const removedGroups = new Set(["group-1"]);
+
+ const identityGroupsChangeSummary = getChangesInGroupsForIdentities(
+ identities,
+ addedGroups,
+ removedGroups,
+ );
+
+ expect(identityGroupsChangeSummary).toEqual({
+ "user-1": {
+ added: new Set(["group-3", "group-4"]),
+ removed: new Set(["group-1"]),
+ name: "user-1",
+ },
+ "user-2": {
+ added: new Set(["group-3", "group-4"]),
+ removed: new Set(["group-1"]),
+ name: "user-2",
+ },
+ });
+ });
+
+ it("pivotIdentityGroupsChangeSummary", () => {
+ const identityGroupsChangeSummary = {
+ "user-1": {
+ added: new Set(["group-3", "group-4"]),
+ removed: new Set(["group-1"]),
+ name: "user-1",
+ },
+ "user-2": {
+ added: new Set(["group-3", "group-4"]),
+ removed: new Set(["group-1"]),
+ name: "user-2",
+ },
+ };
+
+ const groupIdentitiesChangeSummary = pivotIdentityGroupsChangeSummary(
+ identityGroupsChangeSummary,
+ );
+
+ expect(groupIdentitiesChangeSummary).toEqual({
+ "group-1": {
+ added: new Set(),
+ removed: new Set(["user-1", "user-2"]),
+ name: "group-1",
+ },
+ "group-3": {
+ added: new Set(["user-1", "user-2"]),
+ removed: new Set(),
+ name: "group-3",
+ },
+ "group-4": {
+ added: new Set(["user-1", "user-2"]),
+ removed: new Set(),
+ name: "group-4",
+ },
+ });
+ });
+});
diff --git a/src/util/permissionIdentities.tsx b/src/util/permissionIdentities.tsx
new file mode 100644
index 0000000000..37570d28a9
--- /dev/null
+++ b/src/util/permissionIdentities.tsx
@@ -0,0 +1,176 @@
+import { LxdGroup, LxdIdentity } from "types/permissions";
+
+export type ChangeSummary = Record<
+ string,
+ { added: Set; removed: Set; name: string }
+>;
+
+export const getIdentityIdsForGroup = (group: LxdGroup): string[] => {
+ const oidcIdentityIds = group?.identities?.oidc || [];
+ const tlsIdentityIds = group?.identities?.tls || [];
+ const allIdentityIds = oidcIdentityIds.concat(tlsIdentityIds);
+ return allIdentityIds;
+};
+
+// Given a set of lxd groups and some identities
+// Generate a subset of those groups that's allocated to all identities
+// Generate a subset of those groups that's allocated to some identities
+export const getGroupsForIdentities = (
+ groups: LxdGroup[],
+ identities: LxdIdentity[],
+): {
+ groupsForAllIdentities: string[];
+ groupsForSomeIdentities: string[];
+ groupsForNoIdentities: string[];
+} => {
+ const totalIdentitiesCount = identities.length;
+ const groupsForAllIdentities: string[] = [];
+ const groupsForSomeIdentities: string[] = [];
+ const groupsForNoIdentities: string[] = [];
+ for (const group of groups) {
+ let allocatedCount = 0;
+ const allIdentityIds = getIdentityIdsForGroup(group);
+ const groupIdentitiesLookup = new Set(allIdentityIds);
+
+ for (const identity of identities) {
+ if (groupIdentitiesLookup.has(identity.id)) {
+ allocatedCount++;
+ }
+ }
+ const groupAllocatedToAll = allocatedCount === totalIdentitiesCount;
+ const groupAllocatedToSome = !groupAllocatedToAll && allocatedCount > 0;
+
+ if (groupAllocatedToAll) {
+ groupsForAllIdentities.push(group.name);
+ continue;
+ }
+
+ if (groupAllocatedToSome) {
+ groupsForSomeIdentities.push(group.name);
+ continue;
+ }
+
+ groupsForNoIdentities.push(group.name);
+ }
+
+ return {
+ groupsForAllIdentities,
+ groupsForSomeIdentities,
+ groupsForNoIdentities,
+ };
+};
+
+// Given a set of groups that's added to all identities and,
+// Given a set of groups that's removed from all identities
+// Generate groups to be assigned to each identity
+export const generateGroupAllocationsForIdentities = (
+ addedGroups: Set,
+ removedGroups: Set,
+ identities: LxdIdentity[],
+): Record => {
+ const newGroupsForIdentities: Record = {};
+ for (const identity of identities) {
+ const newGroupsForIdentity = new Set(identity.groups);
+
+ for (const group of addedGroups) {
+ newGroupsForIdentity.add(group);
+ }
+
+ for (const group of removedGroups) {
+ newGroupsForIdentity.delete(group);
+ }
+
+ newGroupsForIdentities[identity.id] = Array.from(newGroupsForIdentity);
+ }
+
+ return newGroupsForIdentities;
+};
+
+export const getChangesInGroupsForIdentities = (
+ identities: LxdIdentity[],
+ addedGroups: Set,
+ removedGroups: Set,
+): ChangeSummary => {
+ const newGroupsForIdentities = generateGroupAllocationsForIdentities(
+ addedGroups,
+ removedGroups,
+ identities,
+ );
+
+ const identityGroupsChangeSummary: ChangeSummary = {};
+
+ for (const identity of identities) {
+ const newIdentityGroups = newGroupsForIdentities[identity.id];
+ if (!newIdentityGroups) {
+ continue;
+ }
+
+ const groupsAddedForIdentity: Set = new Set();
+ const groupsRemovedForIdentity: Set = new Set();
+
+ // given a set of groups, for each identity check if each group is an addition
+ const existingIdentityGroupsLookup = new Set(identity.groups);
+ for (const newGroup of newIdentityGroups) {
+ if (!existingIdentityGroupsLookup.has(newGroup)) {
+ groupsAddedForIdentity.add(newGroup);
+ }
+ }
+
+ // Also check the reverse, if a group previously existed for the identity and is not part of the new groups, then that's a removal
+ const newIdentityGroupsLookup = new Set(newIdentityGroups);
+ for (const existingGroup of identity.groups || []) {
+ if (!newIdentityGroupsLookup.has(existingGroup)) {
+ groupsRemovedForIdentity.add(existingGroup);
+ }
+ }
+
+ // record the changes in groups for an identity, if there are changes
+ if (groupsAddedForIdentity.size || groupsRemovedForIdentity.size) {
+ identityGroupsChangeSummary[identity.id] = {
+ added: groupsAddedForIdentity,
+ removed: groupsRemovedForIdentity,
+ name: identity.name,
+ };
+ }
+ }
+
+ return identityGroupsChangeSummary;
+};
+
+export const pivotIdentityGroupsChangeSummary = (
+ identityGroupsChangeSummary: ChangeSummary,
+): ChangeSummary => {
+ const identityIds = Object.keys(identityGroupsChangeSummary);
+ const groupIdentitiesChangeSummary: ChangeSummary = {};
+
+ for (const id of identityIds) {
+ const identityGroupsChange = identityGroupsChangeSummary[id];
+ // group added to an identity also means the identity is added to the group
+ for (const group of identityGroupsChange.added) {
+ if (!groupIdentitiesChangeSummary[group]) {
+ groupIdentitiesChangeSummary[group] = {
+ added: new Set(),
+ removed: new Set(),
+ name: group,
+ };
+ }
+
+ groupIdentitiesChangeSummary[group].added.add(id);
+ }
+
+ // same logic as above but for removed groups
+ for (const group of identityGroupsChange.removed) {
+ if (!groupIdentitiesChangeSummary[group]) {
+ groupIdentitiesChangeSummary[group] = {
+ added: new Set(),
+ removed: new Set(),
+ name: group,
+ };
+ }
+
+ groupIdentitiesChangeSummary[group].removed.add(id);
+ }
+ }
+
+ return groupIdentitiesChangeSummary;
+};
diff --git a/src/util/permissionIdpGroups.tsx b/src/util/permissionIdpGroups.tsx
new file mode 100644
index 0000000000..f6ead0f851
--- /dev/null
+++ b/src/util/permissionIdpGroups.tsx
@@ -0,0 +1,23 @@
+import { AbortControllerState, checkDuplicateName } from "./helpers";
+import * as Yup from "yup";
+
+export const testDuplicateIdpGroupName = (
+ controllerState: AbortControllerState,
+ excludeName?: string,
+): [string, string, Yup.TestFunction] => {
+ return [
+ "deduplicate",
+ "A identity provider group with this name already exists",
+ (value?: string) => {
+ return (
+ (excludeName && value === excludeName) ||
+ checkDuplicateName(
+ value,
+ "",
+ controllerState,
+ "auth/identity-provider-groups",
+ )
+ );
+ },
+ ];
+};
diff --git a/src/util/permissions.tsx b/src/util/permissions.tsx
new file mode 100644
index 0000000000..4b925dae66
--- /dev/null
+++ b/src/util/permissions.tsx
@@ -0,0 +1,307 @@
+import { LxdIdentity, LxdPermission } from "types/permissions";
+import { OptionHTMLAttributes } from "react";
+import { LxdImage } from "types/image";
+import { useSupportedFeatures } from "context/useSupportedFeatures";
+import {
+ ResourceDetail,
+ extractResourceDetailsFromUrl,
+} from "./resourceDetails";
+
+export const defaultOption = {
+ disabled: true,
+ label: "Select an option",
+ value: "",
+};
+
+export const noneAvailableOption = {
+ disabled: true,
+ label: "None available",
+ value: "",
+};
+
+// the resource types comes from the openFGA authorisation model in lxd
+// ref: https://discourse.ubuntu.com/t/identity-and-access-management-for-lxd/41516
+export const resourceTypeOptions = [
+ {
+ ...defaultOption,
+ },
+ {
+ value: "server",
+ label: "Server",
+ },
+ {
+ value: "identity",
+ label: "Identity",
+ },
+ {
+ value: "group",
+ label: "Group",
+ },
+ {
+ value: "certificate",
+ label: "Certificate",
+ },
+ {
+ value: "project",
+ label: "Project",
+ },
+ {
+ value: "profile",
+ label: "Profile",
+ },
+ {
+ value: "instance",
+ label: "Instance",
+ },
+ {
+ value: "image",
+ label: "Image",
+ },
+ {
+ value: "image_alias",
+ label: "Image alias",
+ },
+ {
+ value: "storage_pool",
+ label: "Storage pool",
+ },
+ {
+ value: "storage_volume",
+ label: "Storage volume",
+ },
+ {
+ value: "storage_bucket",
+ label: "Storage bucket",
+ },
+ {
+ value: "network",
+ label: "Network",
+ },
+ {
+ value: "network_acl",
+ label: "Network ACL",
+ },
+ {
+ value: "network_zone",
+ label: "Network zone",
+ },
+];
+
+const sortOptions = (
+ a: OptionHTMLAttributes,
+ b: OptionHTMLAttributes,
+): number => {
+ if (b.label === "Select an option") {
+ return 1;
+ }
+
+ return (a.label ?? "").localeCompare(b.label as string);
+};
+
+export const generateResourceOptions = (
+ resourceType: string,
+ permissions: LxdPermission[],
+ imageNamesLookup: Record,
+ identityNamesLookup: Record,
+): OptionHTMLAttributes[] => {
+ if (!permissions.length || !resourceType) {
+ return [];
+ }
+
+ const resourceOptions: OptionHTMLAttributes[] = [
+ defaultOption,
+ ];
+
+ const processedResources = new Set();
+ for (const permission of permissions) {
+ const resource = extractResourceDetailsFromUrl(
+ resourceType,
+ permission.url,
+ imageNamesLookup,
+ identityNamesLookup,
+ );
+ const resourceLabel = constructResourceSelectorLabel(resource);
+
+ if (processedResources.has(resourceLabel)) {
+ continue;
+ }
+
+ processedResources.add(resourceLabel);
+ resourceOptions.push({
+ value: permission.url,
+ label: resourceLabel,
+ });
+ }
+
+ resourceOptions.sort(sortOptions);
+
+ return resourceOptions;
+};
+
+export const generateEntitlementOptions = (
+ resourceType: string,
+ permissions?: LxdPermission[],
+): (
+ | OptionHTMLAttributes
+ | {
+ disabled: boolean;
+ label: string;
+ value: string;
+ }
+)[] => {
+ if (!permissions || !resourceType) {
+ return [defaultOption];
+ }
+
+ // entitlements for all resources related to a particular resource type are the same
+ const resource = permissions[0].url;
+
+ // split entitlement options into two based on 'can_' prefix
+ const genericEntitlementOptions: OptionHTMLAttributes[] =
+ [];
+ const granularEntitlementOptions: OptionHTMLAttributes[] =
+ [];
+ for (const permission of permissions) {
+ if (permission.url !== resource) {
+ continue;
+ }
+
+ const option = {
+ value: permission.entitlement,
+ label: permission.entitlement,
+ };
+
+ if (permission.entitlement.includes("can_")) {
+ granularEntitlementOptions.push(option);
+ continue;
+ }
+
+ genericEntitlementOptions.push(option);
+ }
+
+ genericEntitlementOptions.sort(sortOptions);
+ granularEntitlementOptions.sort(sortOptions);
+
+ // add disabled option as a delimiter between the two sets of entitlements
+ if (
+ genericEntitlementOptions.length > 1 &&
+ granularEntitlementOptions.length
+ ) {
+ genericEntitlementOptions.unshift({
+ disabled: true,
+ label: "Built-in roles",
+ value: "",
+ });
+
+ granularEntitlementOptions.unshift({
+ disabled: true,
+ label: "Granular entitlements",
+ value: "",
+ });
+ }
+
+ return [
+ defaultOption,
+ ...genericEntitlementOptions,
+ ...granularEntitlementOptions,
+ ];
+};
+
+export const constructResourceSelectorLabel = (
+ resource: ResourceDetail,
+): string => {
+ const projectName = resource.project
+ ? ` (project: ${resource.project}) `
+ : "";
+ const targetName = resource.target ? ` (target: ${resource.target}) ` : "";
+ const poolName = resource.pool ? ` (pool: ${resource.pool}) ` : "";
+ return `${resource.name}${targetName}${poolName}${projectName}`;
+};
+
+export const getPermissionId = (permission: LxdPermission): string => {
+ return permission.entity_type + permission.url + permission.entitlement;
+};
+
+export const generateImageNamesLookup = (
+ images: LxdImage[],
+): Record => {
+ const nameLookup: Record = {};
+ for (const image of images) {
+ nameLookup[image.fingerprint] =
+ image.properties?.description ?? image.fingerprint;
+ }
+
+ return nameLookup;
+};
+
+export const generateIdentityNamesLookup = (
+ identities: LxdIdentity[],
+): Record => {
+ const nameLookup: Record = {};
+ for (const identity of identities) {
+ nameLookup[identity.id] = identity.name;
+ }
+
+ return nameLookup;
+};
+
+const getResourceTypeSortOrder = (): Record => {
+ const sortOrder: Record = {};
+ resourceTypeOptions.forEach((option, idx) => {
+ sortOrder[option.value] = idx;
+ });
+
+ return sortOrder;
+};
+
+const resourceTypeSortOrder = getResourceTypeSortOrder();
+export const generatePermissionSort = (
+ imageNamesLookup: Record,
+ identityNamesLookup: Record,
+): ((permissionA: LxdPermission, permissionB: LxdPermission) => number) => {
+ return (permissionA: LxdPermission, permissionB: LxdPermission) => {
+ const resourceTypeComparison =
+ resourceTypeSortOrder[permissionA.entity_type] -
+ resourceTypeSortOrder[permissionB.entity_type];
+
+ const resourceA = extractResourceDetailsFromUrl(
+ permissionA.entity_type,
+ permissionA.url,
+ imageNamesLookup,
+ identityNamesLookup,
+ );
+
+ const resourceB = extractResourceDetailsFromUrl(
+ permissionB.entity_type,
+ permissionB.url,
+ imageNamesLookup,
+ identityNamesLookup,
+ );
+
+ const resourceLabelA = constructResourceSelectorLabel(resourceA);
+ const resourceLabelB = constructResourceSelectorLabel(resourceB);
+ const resourceNameComparison = resourceLabelA.localeCompare(resourceLabelB);
+
+ const entitlementComparison = permissionA.entitlement.localeCompare(
+ permissionB.entitlement,
+ );
+
+ return (
+ resourceTypeComparison || resourceNameComparison || entitlementComparison
+ );
+ };
+};
+
+export const enablePermissionsFeature = (): boolean => {
+ const { hasAccessManagement, settings } = useSupportedFeatures();
+
+ const userShowPermissions =
+ (settings?.config?.["user.show_permissions"] ?? "false") === "true";
+
+ const hasOIDCSettings =
+ !!settings?.config?.["oidc.audience"] &&
+ !!settings?.config?.["oidc.client.id"] &&
+ !!settings?.config?.["oidc.issuer"];
+
+ return hasAccessManagement && (hasOIDCSettings || userShowPermissions);
+};
diff --git a/src/util/projects.tsx b/src/util/projects.tsx
index 95e60695a7..13c7925b8c 100644
--- a/src/util/projects.tsx
+++ b/src/util/projects.tsx
@@ -2,6 +2,11 @@ import { LxdProject } from "types/project";
import { slugify } from "./slugify";
export const storageTabs: string[] = ["Pools", "Volumes", "Custom ISOs"];
+export const storageTabToName: Record = {
+ pools: "Pools",
+ volumes: "Volumes",
+ "custom-isos": "Custom ISOs",
+};
export const storageTabPaths = storageTabs.map((tab) => slugify(tab));
export const projectSubpages = [
"instances",
diff --git a/src/util/queryKeys.tsx b/src/util/queryKeys.tsx
index 1bde6ea2ac..7d0dd56ebc 100644
--- a/src/util/queryKeys.tsx
+++ b/src/util/queryKeys.tsx
@@ -1,4 +1,4 @@
-export const queryKeys: Record = {
+export const queryKeys = {
certificates: "certificates",
cluster: "cluster",
configOptions: "configOptions",
@@ -22,4 +22,8 @@ export const queryKeys: Record = {
volumes: "volumes",
warnings: "warnings",
snapshots: "snapshots",
+ identities: "identities",
+ authGroups: "authGroups",
+ idpGroups: "idpGroups",
+ permissions: "permissions",
};
diff --git a/src/util/resourceDetails.tsx b/src/util/resourceDetails.tsx
new file mode 100644
index 0000000000..e20dbd9b1d
--- /dev/null
+++ b/src/util/resourceDetails.tsx
@@ -0,0 +1,58 @@
+export type ResourceDetail = {
+ project?: string;
+ target?: string;
+ pool?: string;
+ instance?: string;
+ volume?: string;
+ path: string;
+ name: string;
+ type: string;
+};
+
+// refer to api spec to see how the names can be extracted from resource url
+// https://documentation.ubuntu.com/lxd/en/latest/api/
+export const extractResourceDetailsFromUrl = (
+ resourceType: string,
+ path: string,
+ imageNamesLookup?: Record,
+ identityNamesLookup?: Record,
+): ResourceDetail => {
+ const url = new URL(`http://localhost/${path}`);
+ const project = url.searchParams.get("project");
+ const target = url.searchParams.get("target");
+ const urlSegments = url.pathname.split("/");
+ const name = decodeURIComponent(urlSegments[urlSegments.length - 1]);
+ const resourceName =
+ (identityNamesLookup ?? {})[name] || (imageNamesLookup ?? {})[name] || name;
+
+ const resourceDetail: ResourceDetail = {
+ project: project ? project : undefined,
+ target: target ? target : undefined,
+ // calling decode twice because the result is double encoded
+ // see https://github.com/canonical/lxd/issues/12398
+ name: decodeURIComponent(resourceName),
+ path,
+ type: resourceType,
+ };
+
+ if (resourceType === "server") {
+ resourceDetail.name = "server";
+ }
+
+ if (resourceType === "storage_volume") {
+ resourceDetail.pool = urlSegments[4];
+ }
+
+ if (resourceType === "snapshot") {
+ if (path.includes("1.0/instances")) {
+ resourceDetail.instance = urlSegments[4];
+ }
+
+ if (path.includes("1.0/storage-pools")) {
+ resourceDetail.pool = urlSegments[4];
+ resourceDetail.volume = urlSegments[7];
+ }
+ }
+
+ return resourceDetail;
+};
diff --git a/src/util/updateMaxHeight.tsx b/src/util/updateMaxHeight.tsx
index 619461df3b..39e530864f 100644
--- a/src/util/updateMaxHeight.tsx
+++ b/src/util/updateMaxHeight.tsx
@@ -1,4 +1,4 @@
-import { getAbsoluteHeightBelow } from "./helpers";
+import { getAbsoluteHeightBelowById } from "./helpers";
type HeightProperty = "height" | "max-height" | "min-height";
@@ -22,7 +22,7 @@ export const updateMaxHeight = (
: 0;
below += belowIds.reduce(
- (acc, belowId) => acc + getAbsoluteHeightBelow(belowId),
+ (acc, belowId) => acc + getAbsoluteHeightBelowById(belowId),
0,
);
const offset = Math.ceil(above + below + additionalOffset);
diff --git a/src/util/useEditHistory.tsx b/src/util/useEditHistory.tsx
new file mode 100644
index 0000000000..85200cc3b7
--- /dev/null
+++ b/src/util/useEditHistory.tsx
@@ -0,0 +1,118 @@
+import { useEffect, useReducer } from "react";
+import useEventListener from "@use-it/event-listener";
+
+type EditHistoryState = {
+ currentState: T;
+ undoStack: T[];
+ redoStack: T[];
+};
+
+type EditHistoryAction =
+ | {
+ type: "save";
+ payload: T;
+ }
+ | {
+ type: "undo" | "redo";
+ };
+
+function createHistoryReducer() {
+ return (
+ state: EditHistoryState,
+ action: EditHistoryAction,
+ ): EditHistoryState => {
+ if (action.type === "save") {
+ const desiredState = action.payload;
+ const newUndoStack = [...state.undoStack, desiredState];
+ return {
+ currentState: desiredState,
+ undoStack: newUndoStack,
+ // reset redo stack whenever an action is performed and saved
+ redoStack: [],
+ };
+ }
+
+ if (action.type === "undo") {
+ if (state.undoStack.length < 2) {
+ return state;
+ }
+ const latestState = state.undoStack[state.undoStack.length - 1];
+ const desiredState = state.undoStack[state.undoStack.length - 2];
+ const newUndoStack = state.undoStack.slice(0, state.undoStack.length - 1);
+ const newRedoStack = [...state.redoStack, latestState];
+ return {
+ currentState: desiredState,
+ undoStack: newUndoStack,
+ redoStack: newRedoStack,
+ };
+ }
+
+ if (action.type === "redo") {
+ if (!state.redoStack.length) {
+ return state;
+ }
+
+ const desiredState = state.redoStack[state.redoStack.length - 1];
+ const newUndoStack = [...state.undoStack, desiredState];
+ const newRedoStack = state.redoStack.slice(0, state.redoStack.length - 1);
+ return {
+ currentState: desiredState,
+ undoStack: newUndoStack,
+ redoStack: newRedoStack,
+ };
+ }
+
+ return state;
+ };
+}
+
+interface EditHistoryProps {
+ initialState: T;
+}
+
+function useEditHistory(props: EditHistoryProps) {
+ const { initialState } = props;
+ const [state, dispatch] = useReducer(createHistoryReducer(), {
+ currentState: initialState,
+ undoStack: [],
+ redoStack: [],
+ });
+
+ useEffect(() => {
+ save(initialState);
+ }, []);
+
+ useEventListener<"keydown">("keydown", (event) => {
+ const ctrlOrCmdKey = event.ctrlKey || event.metaKey;
+ if (ctrlOrCmdKey && !event.shiftKey && event.key.toLowerCase() === "z") {
+ event.preventDefault();
+ undo();
+ }
+
+ if (ctrlOrCmdKey && event.shiftKey && event.key.toLowerCase() === "z") {
+ event.preventDefault();
+ redo();
+ }
+ });
+
+ const save = (payload: T) => {
+ dispatch({ type: "save", payload });
+ };
+
+ const undo = () => {
+ dispatch({ type: "undo" });
+ };
+
+ const redo = () => {
+ dispatch({ type: "redo" });
+ };
+
+ return {
+ desiredState: state.currentState,
+ undo,
+ redo,
+ save,
+ };
+}
+
+export default useEditHistory;
diff --git a/src/util/usePanelParams.tsx b/src/util/usePanelParams.tsx
index ddff86103e..65aadc8d68 100644
--- a/src/util/usePanelParams.tsx
+++ b/src/util/usePanelParams.tsx
@@ -4,17 +4,34 @@ export interface PanelHelper {
panel: string | null;
instance: string | null;
profile: string | null;
+ group: string | null;
+ idpGroup: string | null;
+ identity: string | null;
project: string;
clear: () => void;
openInstanceSummary: (instance: string, project: string) => void;
openImageImport: () => void;
openProfileSummary: (profile: string, project: string) => void;
+ openIdentityGroups: (identity?: string) => void;
+ openCreateGroup: () => void;
+ openEditGroup: (group: string) => void;
+ openGroupIdentities: (group?: string) => void;
+ openGroupPermissions: (group?: string) => void;
+ openCreateIdpGroup: () => void;
+ openEditIdpGroup: (group: string) => void;
}
export const panels = {
instanceSummary: "instance-summary",
imageImport: "image-import",
profileSummary: "profile-summary",
+ identityGroups: "identity-groups",
+ createGroup: "create-groups",
+ editGroup: "edit-groups",
+ groupIdentities: "group-identities",
+ groupPermissions: "group-permissions",
+ createIdpGroup: "create-idp-groups",
+ editIdpGroup: "edit-idp-groups",
};
type ParamMap = Record;
@@ -30,14 +47,26 @@ const usePanelParams = (): PanelHelper => {
const newParams = new URLSearchParams();
newParams.set("panel", panel);
for (const [key, value] of Object.entries(args)) {
- newParams.set(key, value);
+ if (value) {
+ newParams.set(key, value);
+ }
}
setParams(newParams);
craftResizeEvent();
};
const clearParams = () => {
- setParams(new URLSearchParams());
+ const newParams = new URLSearchParams(params);
+ // we only want to remove search params set when opening the panel
+ // pre-existing search params should be kept e.g. params from the search bar
+ newParams.delete("group");
+ newParams.delete("identity");
+ newParams.delete("idp-group");
+ newParams.delete("instance");
+ newParams.delete("panel");
+ newParams.delete("profile");
+ newParams.delete("project");
+ setParams(newParams);
craftResizeEvent();
};
@@ -46,6 +75,9 @@ const usePanelParams = (): PanelHelper => {
instance: params.get("instance"),
profile: params.get("profile"),
project: params.get("project") ?? "default",
+ identity: params.get("identity"),
+ group: params.get("group"),
+ idpGroup: params.get("idp-group"),
clear: () => {
clearParams();
@@ -62,6 +94,38 @@ const usePanelParams = (): PanelHelper => {
openProfileSummary: (profile, project) => {
setPanelParams(panels.profileSummary, { profile, project });
},
+
+ openIdentityGroups: (identity) => {
+ const newParams = new URLSearchParams(params);
+ newParams.append("identity", identity || "");
+ setPanelParams(panels.identityGroups, Object.fromEntries(newParams));
+ },
+
+ openCreateGroup: () => {
+ setPanelParams(panels.createGroup);
+ },
+
+ openEditGroup: (group) => {
+ setPanelParams(panels.editGroup, { group: group || "" });
+ },
+
+ openGroupIdentities: (group) => {
+ setPanelParams(panels.groupIdentities, { group: group || "" });
+ },
+
+ openGroupPermissions: (group) => {
+ setPanelParams(panels.groupPermissions, { group: group || "" });
+ },
+
+ openCreateIdpGroup: () => {
+ setPanelParams(panels.createIdpGroup);
+ },
+
+ openEditIdpGroup: (idpGroup) => {
+ setPanelParams(panels.editIdpGroup, {
+ "idp-group": idpGroup || "",
+ });
+ },
};
};
diff --git a/src/util/useSortTableData.tsx b/src/util/useSortTableData.tsx
index d41071071b..9aa7a3d89c 100644
--- a/src/util/useSortTableData.tsx
+++ b/src/util/useSortTableData.tsx
@@ -33,7 +33,7 @@ const useSortTableData = (props: Props) => {
} else {
setSortDirection("ascending");
}
- setSort(newSort);
+ setSort(newSort || defaultSort);
};
return {
diff --git a/src/util/usedBy.spec.ts b/src/util/usedBy.spec.ts
index adfb4d07b3..294aa4596b 100644
--- a/src/util/usedBy.spec.ts
+++ b/src/util/usedBy.spec.ts
@@ -3,7 +3,7 @@ import { filterUsedByType } from "./usedBy";
describe("filterUsedByType", () => {
it("finds standard instance", () => {
const paths = ["/1.0/instances/pet-lark"];
- const results = filterUsedByType("instances", paths);
+ const results = filterUsedByType("instance", paths);
expect(results[0].name).toBe("pet-lark");
expect(results[0].project).toBe("default");
@@ -11,7 +11,7 @@ describe("filterUsedByType", () => {
it("finds snapshot with custom project", () => {
const paths = ["/1.0/instances/relaxed-basilisk/snapshots/ff?project=foo"];
- const results = filterUsedByType("snapshots", paths);
+ const results = filterUsedByType("snapshot", paths);
expect(results[0].instance).toBe("relaxed-basilisk");
expect(results[0].name).toBe("ff");
@@ -20,7 +20,7 @@ describe("filterUsedByType", () => {
it("finds profile with custom project", () => {
const paths = ["/1.0/profiles/my_profile?project=foo"];
- const results = filterUsedByType("profiles", paths);
+ const results = filterUsedByType("profile", paths);
expect(results[0].name).toBe("my_profile");
expect(results[0].project).toBe("foo");
@@ -31,7 +31,7 @@ describe("filterUsedByType", () => {
const paths = [
"/1.0/storage-pools/dir/volumes/custom/t%25C3%25BCdeld%25C3%25BC",
];
- const results = filterUsedByType("volumes", paths);
+ const results = filterUsedByType("volume", paths);
expect(results[0].name).toBe("tüdeldü");
expect(results[0].project).toBe("default");
@@ -42,7 +42,7 @@ describe("filterUsedByType", () => {
const paths = [
"/1.0/instances/absolute-jennet/snapshots/snap0?project=Animals",
];
- const results = filterUsedByType("snapshots", paths);
+ const results = filterUsedByType("snapshot", paths);
expect(results[0].name).toBe("snap0");
expect(results[0].project).toBe("Animals");
@@ -54,7 +54,7 @@ describe("filterUsedByType", () => {
const paths = [
"/1.0/storage-pools/poolName/volumes/custom/volumeName/snapshots/snap1?project=fooProject",
];
- const results = filterUsedByType("snapshots", paths);
+ const results = filterUsedByType("snapshot", paths);
expect(results[0].name).toBe("snap1");
expect(results[0].project).toBe("fooProject");
diff --git a/src/util/usedBy.tsx b/src/util/usedBy.tsx
index 674de842e6..311ac038f5 100644
--- a/src/util/usedBy.tsx
+++ b/src/util/usedBy.tsx
@@ -1,3 +1,4 @@
+import { extractResourceDetailsFromUrl } from "./resourceDetails";
export interface LxdUsedBy {
name: string;
project: string;
@@ -16,51 +17,39 @@ export interface LxdUsedBy {
* "/1.0/storage-pools/pool-dir/volumes/custom/test/snapshots/snap1?project=bar"
*/
export const filterUsedByType = (
- type: "instances" | "profiles" | "snapshots" | "images" | "volumes",
+ type: "instance" | "profile" | "snapshot" | "image" | "volume",
usedByPaths?: string[],
): LxdUsedBy[] => {
return (
usedByPaths
?.filter((path) => {
- if (type === "instances" && path.includes("/snapshots/")) {
+ if (type === "instance" && path.includes("/snapshots/")) {
return false;
}
- if (type === "volumes" && path.includes("/snapshots/")) {
+ if (type === "volume" && path.includes("/snapshots/")) {
return false;
}
- if (type === "snapshots") {
+ if (type === "snapshot") {
return path.includes("/snapshots/");
}
- if (type === "volumes") {
+ if (type === "volume") {
return path.includes("/volumes/");
}
return path.startsWith(`/1.0/${type}`);
})
.map((path) => {
- const url = new URL(`http://localhost/${path}`);
- const encodedName = url.pathname.split("/").slice(-1)[0] ?? "";
- // calling decode twice because the result is double encoded
- // see https://github.com/canonical/lxd/issues/12398
- const name = decodeURIComponent(decodeURIComponent(encodedName));
+ const resource = extractResourceDetailsFromUrl(type, path);
+
return {
- name,
- project: url.searchParams.get("project") ?? "default",
- instance:
- type === "snapshots" && url.pathname.includes("1.0/instances")
- ? url.pathname.split("/")[4]
- : undefined,
- volume:
- type === "snapshots" && url.pathname.includes("1.0/storage-pools")
- ? url.pathname.split("/")[7]
- : undefined,
- pool:
- type === "snapshots" && url.pathname.includes("1.0/storage-pools")
- ? url.pathname.split("/")[4]
- : undefined,
+ name: resource.name,
+ project: resource.project ?? "default",
+ instance: resource.instance,
+ volume: resource.volume,
+ pool: resource.pool,
};
})
.sort((a, b) => {
@@ -82,7 +71,7 @@ export const getProfileInstances = (
isDefaultProject: boolean,
usedByPaths?: string[],
): LxdUsedBy[] => {
- return filterUsedByType("instances", usedByPaths).filter((instance) => {
+ return filterUsedByType("instance", usedByPaths).filter((instance) => {
if (isDefaultProject) {
return true;
}
diff --git a/tests/helpers/storagePool.ts b/tests/helpers/storagePool.ts
index cdcde9af01..5a2ef20fcb 100644
--- a/tests/helpers/storagePool.ts
+++ b/tests/helpers/storagePool.ts
@@ -7,7 +7,8 @@ export const randomPoolName = (): string => {
export const createPool = async (page: Page, pool: string) => {
await page.goto("/ui/");
- await page.getByRole("link", { name: "Storage" }).click();
+ await page.getByRole("button", { name: "Storage" }).click();
+ await page.getByRole("link", { name: "Pools" }).click();
await page.getByRole("button", { name: "Create pool" }).click();
await page.getByPlaceholder("Enter name").fill(pool);
await page.getByLabel("Driver").selectOption("dir");
@@ -27,7 +28,8 @@ export const deletePool = async (page: Page, pool: string) => {
export const visitPool = async (page: Page, pool: string) => {
await page.goto("/ui/");
- await page.getByRole("link", { name: "Storage" }).click();
+ await page.getByRole("button", { name: "Storage" }).click();
+ await page.getByRole("link", { name: "Pools" }).click();
await page.getByRole("link", { name: pool }).first().click();
};
diff --git a/tests/helpers/storageVolume.ts b/tests/helpers/storageVolume.ts
index 7f9b4cc321..ee344ba64a 100644
--- a/tests/helpers/storageVolume.ts
+++ b/tests/helpers/storageVolume.ts
@@ -7,8 +7,8 @@ export const randomVolumeName = (): string => {
export const createVolume = async (page: Page, volume: string) => {
await page.goto("/ui/");
- await page.getByRole("link", { name: "Storage" }).click();
- await page.getByTestId("tab-link-Volumes").click();
+ await page.getByRole("button", { name: "Storage" }).click();
+ await page.getByRole("link", { name: "Volumes" }).click();
await page.getByRole("button", { name: "Create volume" }).click();
await page.getByPlaceholder("Enter name").fill(volume);
await page.getByPlaceholder("Enter value").fill("1");
@@ -29,8 +29,8 @@ export const deleteVolume = async (page: Page, volume: string) => {
export const visitVolume = async (page: Page, volume: string) => {
await page.goto("/ui/");
- await page.getByRole("link", { name: "Storage" }).click();
- await page.getByTestId("tab-link-Volumes").click();
+ await page.getByRole("button", { name: "Storage" }).click();
+ await page.getByRole("link", { name: "Volumes" }).click();
await page.getByPlaceholder("Search and filter").fill(volume);
await page.getByPlaceholder("Search and filter").press("Enter");
await page.getByPlaceholder("Add filter").press("Escape");
diff --git a/tests/iso-volumes.spec.ts b/tests/iso-volumes.spec.ts
index a82a02b24e..599a1d8d9b 100644
--- a/tests/iso-volumes.spec.ts
+++ b/tests/iso-volumes.spec.ts
@@ -16,8 +16,8 @@ test("upload and delete custom iso", async ({ page, lxdVersion }) => {
const isoName = randomIso();
await page.goto("/ui/");
- await page.getByRole("link", { name: "Storage", exact: true }).click();
- await page.getByTestId("tab-link-Custom ISOs").click();
+ await page.getByRole("button", { name: "Storage", exact: true }).click();
+ await page.getByRole("link", { name: "Custom ISOs" }).click();
await page.getByRole("button", { name: "Upload custom ISO" }).click();
await page.getByLabel("Local file").setInputFiles(ISO_FILE);
await page.getByLabel("Alias").fill(isoName);
@@ -73,8 +73,8 @@ test("use custom iso for instance launch", async ({ page, lxdVersion }) => {
await deleteInstance(page, instance);
await page.goto("/ui/");
- await page.getByRole("link", { name: "Storage", exact: true }).click();
- await page.getByTestId("tab-link-Custom ISOs").click();
+ await page.getByRole("button", { name: "Storage", exact: true }).click();
+ await page.getByRole("link", { name: "Custom ISOs" }).click();
await page.getByPlaceholder("Search for custom ISOs").fill(isoName);
await page.getByRole("button", { name: "Delete" }).click();
await page.getByText("Delete", { exact: true }).click();
@@ -90,8 +90,8 @@ test("not allowed to upload custom iso for lxd v5.0/edge", async ({
`this test is specific to lxd v5.0/edge, current lxd snap channel is ${lxdVersion}`,
);
await page.goto("/ui/");
- await page.getByRole("link", { name: "Storage", exact: true }).click();
- await expect(page.getByTestId("tab-link-Custom ISOs")).toBeHidden();
+ await page.getByRole("button", { name: "Storage", exact: true }).click();
+ await expect(page.getByRole("link", { name: "Custom ISOs" })).toBeHidden();
});
test("not allowed to launch instance with custom iso for lxd v5.0/edge", async ({
diff --git a/tests/scripts/create_oidc_identities b/tests/scripts/create_oidc_identities
new file mode 100755
index 0000000000..8e4df8098e
--- /dev/null
+++ b/tests/scripts/create_oidc_identities
@@ -0,0 +1,32 @@
+#! /usr/bin/env bash
+set -e
+
+# create oidc user foo
+lxd sql global "
+ INSERT OR REPLACE INTO identities
+ (id, auth_method, type, identifier, name, metadata)
+ VALUES
+ (
+ (SELECT id from identities WHERE name='foo'),
+ 2,
+ 5,
+ 'foo@foo.com',
+ 'foo',
+ '{}'
+ );
+"
+
+# create oidc user bar
+lxd sql global "
+ INSERT OR REPLACE INTO identities
+ (id, auth_method, type, identifier, name, metadata)
+ VALUES
+ (
+ (SELECT id from identities WHERE name='bar'),
+ 2,
+ 5,
+ 'bar@bar.com',
+ 'bar',
+ '{}'
+ );
+"
diff --git a/tests/scripts/delete_oidc_identities b/tests/scripts/delete_oidc_identities
new file mode 100755
index 0000000000..82335eab29
--- /dev/null
+++ b/tests/scripts/delete_oidc_identities
@@ -0,0 +1,8 @@
+#! /usr/bin/env bash
+set -e
+
+lxd sql global "
+ DELETE
+ FROM identities
+ WHERE name IN ('foo', 'bar');
+"
diff --git a/yarn.lock b/yarn.lock
index e264f7b708..e82338f31d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1682,6 +1682,18 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
+"@types/lodash.isequal@^4.5.8":
+ version "4.5.8"
+ resolved "https://registry.yarnpkg.com/@types/lodash.isequal/-/lodash.isequal-4.5.8.tgz#b30bb6ff6a5f6c19b3daf389d649ac7f7a250499"
+ integrity sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==
+ dependencies:
+ "@types/lodash" "*"
+
+"@types/lodash@*":
+ version "4.17.0"
+ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.0.tgz#d774355e41f372d5350a4d0714abb48194a489c3"
+ integrity sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==
+
"@types/node-forge@1.3.11":
version "1.3.11"
resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.11.tgz#0972ea538ddb0f4d9c2fa0ec5db5724773a604da"