From c8ef8580e74af7e7e6b9b4b76d5fb6736e40312a Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Mon, 17 Feb 2025 09:38:12 +0200 Subject: [PATCH 01/22] feat: disbale create tls identity button when user has no permission Signed-off-by: Mason Hu --- src/pages/permissions/CreateTlsIdentityBtn.tsx | 8 ++++++++ src/util/entitlements/server.tsx | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/pages/permissions/CreateTlsIdentityBtn.tsx b/src/pages/permissions/CreateTlsIdentityBtn.tsx index d0d9031c73..44a88de73a 100644 --- a/src/pages/permissions/CreateTlsIdentityBtn.tsx +++ b/src/pages/permissions/CreateTlsIdentityBtn.tsx @@ -3,10 +3,12 @@ import { useSmallScreen } from "context/useSmallScreen"; import { FC } from "react"; import { usePortal } from "@canonical/react-components"; import CreateIdentityModal from "./CreateIdentityModal"; +import { useServerEntitlements } from "util/entitlements/server"; const CreateTlsIdentityBtn: FC = () => { const isSmallScreen = useSmallScreen(); const { openPortal, closePortal, isOpen, Portal } = usePortal(); + const { canCreateIdentities } = useServerEntitlements(); return ( <> @@ -20,6 +22,12 @@ const CreateTlsIdentityBtn: FC = () => { className="u-float-right u-no-margin--bottom" onClick={openPortal} hasIcon={!isSmallScreen} + title={ + canCreateIdentities() + ? "" + : "You do not have permission to create identities" + } + disabled={!canCreateIdentities()} > {!isSmallScreen && } Create TLS Identity diff --git a/src/util/entitlements/server.tsx b/src/util/entitlements/server.tsx index 2c6af0dc57..cef83c28a1 100644 --- a/src/util/entitlements/server.tsx +++ b/src/util/entitlements/server.tsx @@ -4,6 +4,15 @@ import { hasEntitlement } from "./helpers"; export const useServerEntitlements = () => { const { isFineGrained, serverEntitlements } = useAuth(); + const canCreateIdentities = () => + hasEntitlement( + isFineGrained, + "can_create_identities", + serverEntitlements, + ) || + hasEntitlement(isFineGrained, "permission_manager", serverEntitlements) || + hasEntitlement(isFineGrained, "admin", serverEntitlements); + const canCreateProjects = () => hasEntitlement(isFineGrained, "can_create_projects", serverEntitlements) || hasEntitlement(isFineGrained, "project_manager", serverEntitlements) || @@ -33,6 +42,7 @@ export const useServerEntitlements = () => { hasEntitlement(isFineGrained, "viewer", serverEntitlements); return { + canCreateIdentities, canCreateProjects, canCreateStoragePools, canEditServerConfiguration, From 845f76eaf9d1f003ff6191ba9a31dd8e5983bd0e Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Mon, 17 Feb 2025 09:52:39 +0200 Subject: [PATCH 02/22] feat: enable entitlement queries for identities Signed-off-by: Mason Hu --- src/api/auth-identities.tsx | 14 ++++++++-- src/context/useIdentities.tsx | 28 +++++++++++++++++++ src/context/useLoggedInUser.tsx | 11 ++------ .../permissions/PermissionIdentities.tsx | 13 ++------- .../panels/EditGroupIdentitiesPanel.tsx | 13 ++------- .../permissions/panels/EditGroupPanel.tsx | 10 +++---- .../permissions/panels/EditIdentitiesForm.tsx | 9 ++---- .../permissions/panels/PermissionSelector.tsx | 7 ++--- src/types/permissions.d.ts | 1 + src/util/entitlements/identities.tsx | 14 ++++++++++ 10 files changed, 69 insertions(+), 51 deletions(-) create mode 100644 src/context/useIdentities.tsx create mode 100644 src/util/entitlements/identities.tsx diff --git a/src/api/auth-identities.tsx b/src/api/auth-identities.tsx index 3a0f4455a3..bfbe473a3d 100644 --- a/src/api/auth-identities.tsx +++ b/src/api/auth-identities.tsx @@ -1,10 +1,16 @@ import { handleResponse, handleSettledResult } from "util/helpers"; import type { LxdApiResponse } from "types/apiResponse"; import type { LxdIdentity, TlsIdentityTokenDetail } from "types/permissions"; +import { withEntitlementsQuery } from "util/entitlements/api"; -export const fetchIdentities = (): Promise => { +export const identitiesEntitlements = ["can_edit"]; + +export const fetchIdentities = ( + isFineGrained: boolean | null, +): Promise => { + const entitlements = `&${withEntitlementsQuery(isFineGrained, identitiesEntitlements)}`; return new Promise((resolve, reject) => { - fetch(`/1.0/auth/identities?recursion=1`) + fetch(`/1.0/auth/identities?recursion=1${entitlements}`) .then(handleResponse) .then((data: LxdApiResponse) => resolve(data.metadata)) .catch(reject); @@ -23,9 +29,11 @@ export const fetchCurrentIdentity = (): Promise => { export const fetchIdentity = ( id: string, authMethod: string, + isFineGrained: boolean | null, ): Promise => { + const entitlements = `&${withEntitlementsQuery(isFineGrained, identitiesEntitlements)}`; return new Promise((resolve, reject) => { - fetch(`/1.0/auth/identities/${authMethod}/${id}`) + fetch(`/1.0/auth/identities/${authMethod}/${id}?recursion=1${entitlements}`) .then(handleResponse) .then((data: LxdApiResponse) => resolve(data.metadata)) .catch(reject); diff --git a/src/context/useIdentities.tsx b/src/context/useIdentities.tsx new file mode 100644 index 0000000000..e523226a19 --- /dev/null +++ b/src/context/useIdentities.tsx @@ -0,0 +1,28 @@ +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "util/queryKeys"; +import { UseQueryResult } from "@tanstack/react-query"; +import { useAuth } from "./auth"; +import { LxdIdentity } from "types/permissions"; +import { fetchIdentities, fetchIdentity } from "api/auth-identities"; + +export const useIdentities = (): UseQueryResult => { + const { isFineGrained } = useAuth(); + return useQuery({ + queryKey: [queryKeys.identities], + queryFn: () => fetchIdentities(isFineGrained), + enabled: isFineGrained !== null, + }); +}; + +export const useIdentity = ( + id: string, + authMethod: string, + enabled?: boolean, +): UseQueryResult => { + const { isFineGrained } = useAuth(); + return useQuery({ + queryKey: [queryKeys.identities, id], + queryFn: () => fetchIdentity(id, authMethod, isFineGrained), + enabled: (enabled ?? true) && isFineGrained !== null, + }); +}; diff --git a/src/context/useLoggedInUser.tsx b/src/context/useLoggedInUser.tsx index 6ed50d174f..c556cdb822 100644 --- a/src/context/useLoggedInUser.tsx +++ b/src/context/useLoggedInUser.tsx @@ -1,7 +1,5 @@ -import { useQuery } from "@tanstack/react-query"; import { useSettings } from "./useSettings"; -import { fetchIdentity } from "api/auth-identities"; -import { queryKeys } from "util/queryKeys"; +import { useIdentity } from "./useIdentities"; export const useLoggedInUser = () => { const { data: settings } = useSettings(); @@ -9,11 +7,8 @@ export const useLoggedInUser = () => { const id = settings?.auth_user_name || ""; const authMethod = settings?.auth_user_method || ""; - const { data: identity } = useQuery({ - queryKey: [queryKeys.identities, id], - queryFn: () => fetchIdentity(id, authMethod), - enabled: !!id && !!authMethod, - }); + const identityQueryEnabled = !!id && !!authMethod; + const { data: identity } = useIdentity(id, authMethod, identityQueryEnabled); return { loggedInUserName: identity?.name, diff --git a/src/pages/permissions/PermissionIdentities.tsx b/src/pages/permissions/PermissionIdentities.tsx index 13d0baedd8..7e9ac7a57c 100644 --- a/src/pages/permissions/PermissionIdentities.tsx +++ b/src/pages/permissions/PermissionIdentities.tsx @@ -5,15 +5,12 @@ import { 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, useEffect, useState } from "react"; import { useSearchParams } from "react-router-dom"; -import { queryKeys } from "util/queryKeys"; import useSortTableData from "util/useSortTableData"; import PermissionIdentitiesFilter, { AUTH_METHOD, @@ -36,17 +33,11 @@ import { useSupportedFeatures } from "context/useSupportedFeatures"; import { isUnrestricted } from "util/helpers"; import IdentityResource from "components/IdentityResource"; import CreateTlsIdentityBtn from "./CreateTlsIdentityBtn"; +import { useIdentities } from "context/useIdentities"; const PermissionIdentities: FC = () => { const notify = useNotify(); - const { - data: identities = [], - error, - isLoading, - } = useQuery({ - queryKey: [queryKeys.identities], - queryFn: fetchIdentities, - }); + const { data: identities = [], error, isLoading } = useIdentities(); const { data: settings } = useSettings(); const docBaseLink = useDocs(); const panelParams = usePanelParams(); diff --git a/src/pages/permissions/panels/EditGroupIdentitiesPanel.tsx b/src/pages/permissions/panels/EditGroupIdentitiesPanel.tsx index 4605e86b2b..d85db66258 100644 --- a/src/pages/permissions/panels/EditGroupIdentitiesPanel.tsx +++ b/src/pages/permissions/panels/EditGroupIdentitiesPanel.tsx @@ -4,10 +4,8 @@ import { 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"; @@ -15,7 +13,6 @@ 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 type { LxdGroup } from "types/permissions"; import { getCurrentIdentitiesForGroups } from "util/permissionGroups"; import GroupIdentitiesPanelConfirmModal from "./GroupIdentitiesPanelConfirmModal"; @@ -28,6 +25,7 @@ import NotificationRow from "components/NotificationRow"; import ScrollableContainer from "components/ScrollableContainer"; import useSortTableData from "util/useSortTableData"; import { isUnrestricted } from "util/helpers"; +import { useIdentities } from "context/useIdentities"; type IdentityEditHistory = { identitiesAdded: Set; @@ -44,14 +42,7 @@ const EditGroupIdentitiesPanel: FC = ({ groups }) => { const notify = useNotify(); const [confirming, setConfirming] = useState(false); - const { - data: identities = [], - error, - isLoading, - } = useQuery({ - queryKey: [queryKeys.identities], - queryFn: fetchIdentities, - }); + const { data: identities = [], error, isLoading } = useIdentities(); const { desiredState, diff --git a/src/pages/permissions/panels/EditGroupPanel.tsx b/src/pages/permissions/panels/EditGroupPanel.tsx index 0509a493c9..ac771fb8a6 100644 --- a/src/pages/permissions/panels/EditGroupPanel.tsx +++ b/src/pages/permissions/panels/EditGroupPanel.tsx @@ -4,7 +4,7 @@ import { ConfirmationModal, useNotify, } from "@canonical/react-components"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import SidePanel from "components/SidePanel"; import { FC, useEffect, useState } from "react"; import usePanelParams from "util/usePanelParams"; @@ -32,7 +32,7 @@ import { getPermissionId, getResourceLabel, } from "util/permissions"; -import { fetchIdentities, updateIdentities } from "api/auth-identities"; +import { updateIdentities } from "api/auth-identities"; import LoggedInUserNotification from "pages/permissions/panels/LoggedInUserNotification"; import { useSettings } from "context/useSettings"; import { pluralize } from "util/instanceBulkActions"; @@ -40,6 +40,7 @@ import GroupHeaderTitle from "pages/permissions/panels/GroupHeaderTitle"; import { GroupSubForm } from "pages/permissions/panels/CreateGroupPanel"; import ResourceLink from "components/ResourceLink"; import { useImagesInAllProjects } from "context/useImages"; +import { useIdentities } from "context/useIdentities"; interface Props { group: LxdGroup; @@ -70,10 +71,7 @@ const EditGroupPanel: FC = ({ group, onClose }) => { data: lxdIdentities = [], isLoading: lxdIdentityLoading, isError: lxdIdentitiesError, - } = useQuery({ - queryKey: [queryKeys.identities], - queryFn: fetchIdentities, - }); + } = useIdentities(); useEffect(() => { if (!lxdIdentityLoading && !lxdIdentitiesError) { const groupIds = new Set(getIdentityIdsForGroup(group)); diff --git a/src/pages/permissions/panels/EditIdentitiesForm.tsx b/src/pages/permissions/panels/EditIdentitiesForm.tsx index 31819efb53..79567848ee 100644 --- a/src/pages/permissions/panels/EditIdentitiesForm.tsx +++ b/src/pages/permissions/panels/EditIdentitiesForm.tsx @@ -1,13 +1,11 @@ import { Icon, SearchBox, useNotify } from "@canonical/react-components"; -import { useQuery } from "@tanstack/react-query"; import { FC, useState } from "react"; -import { queryKeys } from "util/queryKeys"; import ScrollableTable from "components/ScrollableTable"; import SelectableMainTable from "components/SelectableMainTable"; -import { fetchIdentities } from "api/auth-identities"; import useSortTableData from "util/useSortTableData"; import type { LxdIdentity } from "types/permissions"; import { isUnrestricted } from "util/helpers"; +import { useIdentities } from "context/useIdentities"; export type FormIdentity = LxdIdentity & { isRemoved?: boolean; @@ -28,10 +26,7 @@ const EditIdentitiesForm: FC = ({ const notify = useNotify(); const [filter, setFilter] = useState(null); - const { data: identities = [], error } = useQuery({ - queryKey: [queryKeys.identities], - queryFn: fetchIdentities, - }); + const { data: identities = [], error } = useIdentities(); if (error) { notify.failure("Loading details failed", error); diff --git a/src/pages/permissions/panels/PermissionSelector.tsx b/src/pages/permissions/panels/PermissionSelector.tsx index 681d518950..04b8c793cf 100644 --- a/src/pages/permissions/panels/PermissionSelector.tsx +++ b/src/pages/permissions/panels/PermissionSelector.tsx @@ -16,11 +16,11 @@ import { } from "util/permissions"; import { queryKeys } from "util/queryKeys"; import { FormPermission } from "pages/permissions/panels/EditGroupPermissionsForm"; -import { fetchIdentities } from "api/auth-identities"; import ResourceOptionHeader from "./ResourceOptionHeader"; import type { LxdPermission } from "types/permissions"; import { SelectRef } from "@canonical/react-components/dist/components/CustomSelect/CustomSelect"; import { useImagesInAllProjects } from "context/useImages"; +import { useIdentities } from "context/useIdentities"; interface Props { onAddPermission: (permission: FormPermission) => void; @@ -52,10 +52,7 @@ const PermissionSelector: FC = ({ onAddPermission }) => { const { data: images = [] } = useImagesInAllProjects(); - const { data: identities = [] } = useQuery({ - queryKey: [queryKeys.identities], - queryFn: fetchIdentities, - }); + const { data: identities = [] } = useIdentities(); const imageLookup = getImageLookup(images); const identityNamesLookup = getIdentityNameLookup(identities); diff --git a/src/types/permissions.d.ts b/src/types/permissions.d.ts index e4800d6e01..6913edecc2 100644 --- a/src/types/permissions.d.ts +++ b/src/types/permissions.d.ts @@ -12,6 +12,7 @@ export interface LxdIdentity { effective_groups?: string[]; effective_permissions?: LxdPermission[]; fine_grained: boolean; + access_entitlements?: string[]; } export interface LxdGroup { diff --git a/src/util/entitlements/identities.tsx b/src/util/entitlements/identities.tsx new file mode 100644 index 0000000000..edac3cbb42 --- /dev/null +++ b/src/util/entitlements/identities.tsx @@ -0,0 +1,14 @@ +import { useAuth } from "context/auth"; +import { hasEntitlement } from "./helpers"; +import { LxdIdentity } from "types/permissions"; + +export const useIdentityEntitlements = () => { + const { isFineGrained } = useAuth(); + + const canEditIdentity = (identity?: LxdIdentity) => + hasEntitlement(isFineGrained, "can_edit", identity?.access_entitlements); + + return { + canEditIdentity, + }; +}; From ef0613dc3abfca379a15b55c811450c9a7815f56 Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Mon, 17 Feb 2025 09:59:48 +0200 Subject: [PATCH 03/22] feat: disable edit identity groups for restricted permissions Signed-off-by: Mason Hu --- src/pages/permissions/PermissionIdentities.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/pages/permissions/PermissionIdentities.tsx b/src/pages/permissions/PermissionIdentities.tsx index 7e9ac7a57c..a5b4d4a67e 100644 --- a/src/pages/permissions/PermissionIdentities.tsx +++ b/src/pages/permissions/PermissionIdentities.tsx @@ -34,6 +34,7 @@ import { isUnrestricted } from "util/helpers"; import IdentityResource from "components/IdentityResource"; import CreateTlsIdentityBtn from "./CreateTlsIdentityBtn"; import { useIdentities } from "context/useIdentities"; +import { useIdentityEntitlements } from "util/entitlements/identities"; const PermissionIdentities: FC = () => { const notify = useNotify(); @@ -44,6 +45,7 @@ const PermissionIdentities: FC = () => { const [searchParams] = useSearchParams(); const [selectedIdentityIds, setSelectedIdentityIds] = useState([]); const { hasAccessManagementTLS } = useSupportedFeatures(); + const { canEditIdentity } = useIdentityEntitlements(); useEffect(() => { const validIdentityIds = new Set(identities.map((identity) => identity.id)); @@ -147,10 +149,12 @@ const PermissionIdentities: FC = () => { className: "u-truncate", }, { - content: ( + content: canEditIdentity(identity) ? ( + ) : ( + identity.groups?.length || 0 ), role: "cell", className: "u-align--right group-count", @@ -167,7 +171,12 @@ const PermissionIdentities: FC = () => { onClick={openGroupPanelForIdentity} type="button" aria-label="Manage groups" - title="Manage groups" + title={ + canEditIdentity() + ? "Manage groups" + : "You do not have permission to modify this identity" + } + disabled={!canEditIdentity(identity)} > From 5a5fa5290d4344aeb166b8b4dda8c7733b54d3e4 Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Mon, 17 Feb 2025 10:08:55 +0200 Subject: [PATCH 04/22] feat: disable delete identity button when the identity is restricted Signed-off-by: Mason Hu --- src/pages/permissions/actions/DeleteIdentityBtn.tsx | 9 ++++++++- src/util/entitlements/identities.tsx | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/pages/permissions/actions/DeleteIdentityBtn.tsx b/src/pages/permissions/actions/DeleteIdentityBtn.tsx index fcc72d8d29..29701728b0 100644 --- a/src/pages/permissions/actions/DeleteIdentityBtn.tsx +++ b/src/pages/permissions/actions/DeleteIdentityBtn.tsx @@ -11,6 +11,7 @@ import type { LxdIdentity } from "types/permissions"; import ItemName from "components/ItemName"; import { deleteIdentity } from "api/auth-identities"; import IdentityResource from "components/IdentityResource"; +import { useIdentityEntitlements } from "util/entitlements/identities"; interface Props { identity: LxdIdentity; @@ -21,6 +22,7 @@ const DeleteIdentityBtn: FC = ({ identity }) => { const notify = useNotify(); const toastNotify = useToastNotification(); const [isDeleting, setDeleting] = useState(false); + const { canDeleteIdentity } = useIdentityEntitlements(); const handleDelete = () => { setDeleting(true); @@ -53,7 +55,11 @@ const DeleteIdentityBtn: FC = ({ identity }) => { return ( = ({ identity }) => { shiftClickEnabled showShiftClickHint loading={isDeleting} + disabled={!canDeleteIdentity(identity)} > diff --git a/src/util/entitlements/identities.tsx b/src/util/entitlements/identities.tsx index edac3cbb42..a7394ab4f3 100644 --- a/src/util/entitlements/identities.tsx +++ b/src/util/entitlements/identities.tsx @@ -8,7 +8,11 @@ export const useIdentityEntitlements = () => { const canEditIdentity = (identity?: LxdIdentity) => hasEntitlement(isFineGrained, "can_edit", identity?.access_entitlements); + const canDeleteIdentity = (identity?: LxdIdentity) => + hasEntitlement(isFineGrained, "can_delete", identity?.access_entitlements); + return { + canDeleteIdentity, canEditIdentity, }; }; From 773c8a95ba2f9cf92e3fb4aa951b550aadb49694 Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Mon, 17 Feb 2025 13:05:20 +0200 Subject: [PATCH 05/22] feat: disable bulk edit identity groups button when user has no permission Signed-off-by: Mason Hu --- .../actions/EditIdentityGroupsBtn.tsx | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/pages/permissions/actions/EditIdentityGroupsBtn.tsx b/src/pages/permissions/actions/EditIdentityGroupsBtn.tsx index bea67e14fc..10e3da0e62 100644 --- a/src/pages/permissions/actions/EditIdentityGroupsBtn.tsx +++ b/src/pages/permissions/actions/EditIdentityGroupsBtn.tsx @@ -2,6 +2,8 @@ import { FC } from "react"; import { Button, ButtonProps } from "@canonical/react-components"; import type { LxdIdentity } from "types/permissions"; import usePanelParams from "util/usePanelParams"; +import { useIdentityEntitlements } from "util/entitlements/identities"; +import { pluralize } from "util/instanceBulkActions"; interface Props { identities: LxdIdentity[]; @@ -13,20 +15,38 @@ const EditIdentityGroupsBtn: FC = ({ className, ...buttonProps }) => { + const { canEditIdentity } = useIdentityEntitlements(); const panelParams = usePanelParams(); const buttonText = identities.length > 1 ? `Modify groups for ${identities.length} identities` : "Modify groups"; + const restrictedIdentities = identities.filter( + (identity) => !canEditIdentity(identity), + ); + + const getRestrictedWarning = () => { + const test = restrictedIdentities + .map((identity) => `\n- ${identity.name}`) + .join(""); + return `You do not have permission to modify ${restrictedIdentities.length > 1 ? "some of the selected" : "the selected"} ${pluralize("identity", restrictedIdentities.length)}:${test}`; + }; + return ( <> @@ -288,6 +294,12 @@ const PermissionGroups: FC = () => { appearance="positive" className="u-no-margin--bottom u-float-right" onClick={panelParams.openCreateGroup} + disabled={!canCreateGroups()} + title={ + canCreateGroups() + ? "" + : "You do not have permission to create groups" + } > Create group diff --git a/src/util/entitlements/server.tsx b/src/util/entitlements/server.tsx index cef83c28a1..7bc96e7196 100644 --- a/src/util/entitlements/server.tsx +++ b/src/util/entitlements/server.tsx @@ -4,6 +4,11 @@ import { hasEntitlement } from "./helpers"; export const useServerEntitlements = () => { const { isFineGrained, serverEntitlements } = useAuth(); + const canCreateGroups = () => + hasEntitlement(isFineGrained, "can_create_groups", serverEntitlements) || + hasEntitlement(isFineGrained, "permission_manager", serverEntitlements) || + hasEntitlement(isFineGrained, "admin", serverEntitlements); + const canCreateIdentities = () => hasEntitlement( isFineGrained, @@ -42,6 +47,7 @@ export const useServerEntitlements = () => { hasEntitlement(isFineGrained, "viewer", serverEntitlements); return { + canCreateGroups, canCreateIdentities, canCreateProjects, canCreateStoragePools, From 8c6065f0b49abe0cff530e7ed241cd2b2b02502c Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Mon, 17 Feb 2025 14:32:32 +0200 Subject: [PATCH 09/22] feat: disable delete group button if user does not have permission Signed-off-by: Mason Hu --- src/pages/permissions/actions/GroupActions.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/pages/permissions/actions/GroupActions.tsx b/src/pages/permissions/actions/GroupActions.tsx index 47c158d5c7..d611cab418 100644 --- a/src/pages/permissions/actions/GroupActions.tsx +++ b/src/pages/permissions/actions/GroupActions.tsx @@ -4,6 +4,7 @@ import type { LxdGroup } from "types/permissions"; import usePanelParams from "util/usePanelParams"; import DeleteGroupModal from "./DeleteGroupModal"; import { usePortal } from "@canonical/react-components"; +import { useGroupEntitlements } from "util/entitlements/groups"; interface Props { group: LxdGroup; @@ -12,6 +13,7 @@ interface Props { const GroupActions: FC = ({ group }) => { const panelParams = usePanelParams(); const { openPortal, closePortal, isOpen, Portal } = usePortal(); + const { canDeleteGroup } = useGroupEntitlements(); return ( <> @@ -37,7 +39,12 @@ const GroupActions: FC = ({ group }) => { hasIcon onClick={openPortal} type="button" - title="Delete group" + title={ + canDeleteGroup() + ? "Delete group" + : "You do not have permission to delete this group" + } + disabled={!canDeleteGroup(group)} > , From eff8daa6f433f8be4e4b0c9bda696f6f4c5ecc94 Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Mon, 17 Feb 2025 14:52:48 +0200 Subject: [PATCH 10/22] feat: show warning about groups with restricted permissions when bulk deleting Signed-off-by: Mason Hu --- .../permissions/actions/DeleteGroupModal.tsx | 97 ++++++++++++++----- 1 file changed, 75 insertions(+), 22 deletions(-) diff --git a/src/pages/permissions/actions/DeleteGroupModal.tsx b/src/pages/permissions/actions/DeleteGroupModal.tsx index cbe908dfe8..8a75d6a3d4 100644 --- a/src/pages/permissions/actions/DeleteGroupModal.tsx +++ b/src/pages/permissions/actions/DeleteGroupModal.tsx @@ -2,6 +2,7 @@ import { ActionButton, Input, Modal, + Notification, useNotify, } from "@canonical/react-components"; import { useQueryClient } from "@tanstack/react-query"; @@ -10,6 +11,7 @@ import ResourceLabel from "components/ResourceLabel"; import { useToastNotification } from "context/toastNotificationProvider"; import { ChangeEvent, FC, useState } from "react"; import type { LxdGroup } from "types/permissions"; +import { useGroupEntitlements } from "util/entitlements/groups"; import { pluralize } from "util/instanceBulkActions"; import { queryKeys } from "util/queryKeys"; @@ -27,6 +29,17 @@ const DeleteGroupModal: FC = ({ groups, close }) => { const [submitting, setSubmitting] = useState(false); const hasOneGroup = groups.length === 1; const confirmText = "confirm-delete-group"; + const { canDeleteGroup } = useGroupEntitlements(); + + const restrictedGroups: LxdGroup[] = []; + const deletableGroups: LxdGroup[] = []; + groups.forEach((group) => { + if (canDeleteGroup(group)) { + deletableGroups.push(group); + } else { + restrictedGroups.push(group); + } + }); const handleConfirmInputChange = (e: ChangeEvent) => { if (e.target.value === confirmText) { @@ -40,18 +53,19 @@ const DeleteGroupModal: FC = ({ groups, close }) => { const handleDeleteGroups = () => { setSubmitting(true); - const hasSingleGroup = groups.length === 1; + const hasSingleGroup = deletableGroups.length === 1; const mutationPromise = hasSingleGroup - ? deleteGroup(groups[0].name) - : deleteGroups(groups.map((group) => group.name)); + ? deleteGroup(deletableGroups[0].name) + : deleteGroups(deletableGroups.map((group) => group.name)); const successMessage = hasSingleGroup ? ( <> - Group {" "} + Group{" "} + {" "} deleted. ) : ( - `${groups.length} groups deleted.` + `${deletableGroups.length} groups deleted.` ); mutationPromise @@ -68,7 +82,7 @@ const DeleteGroupModal: FC = ({ groups, close }) => { }) .catch((e) => { notify.failure( - `${pluralize("group", groups.length)} deletion failed`, + `${pluralize("group", deletableGroups.length)} deletion failed`, e, ); }) @@ -77,6 +91,58 @@ const DeleteGroupModal: FC = ({ groups, close }) => { }); }; + const getModalContent = () => { + if (!deletableGroups.length) { + return ( + + You do not have permission to delete the selected groups + + ); + } + + return ( + <> +

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

+ {restrictedGroups.length && ( + +

+ You do not have permission to delete the following groups: +

+
    + {restrictedGroups.map((group) => ( +
  • {group.name}
  • + ))} +
+
+ )} +

+ 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} +

+ + ); + }; + return ( = ({ groups, close }) => { value={confirmInput} placeholder={confirmText} className="u-no-margin--bottom" + disabled={!deletableGroups.length} /> , = ({ groups, close }) => { loading={submitting} disabled={disableConfirm} > - {`Permanently delete ${groups.length} ${pluralize("group", groups.length)}`} + {`Permanently delete ${deletableGroups.length} ${pluralize("group", deletableGroups.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} -

+ {getModalContent()}
); }; From 8e2ac2a0e51255b153ab5378fbb4373e71c37852 Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Mon, 17 Feb 2025 15:18:37 +0200 Subject: [PATCH 11/22] feat: don't allow group identities bulk edits if the user doesn't have the required permissions Signed-off-by: Mason Hu --- .../panels/EditGroupIdentitiesPanel.tsx | 95 ++++++++++++------- 1 file changed, 62 insertions(+), 33 deletions(-) diff --git a/src/pages/permissions/panels/EditGroupIdentitiesPanel.tsx b/src/pages/permissions/panels/EditGroupIdentitiesPanel.tsx index d85db66258..905792e46e 100644 --- a/src/pages/permissions/panels/EditGroupIdentitiesPanel.tsx +++ b/src/pages/permissions/panels/EditGroupIdentitiesPanel.tsx @@ -2,6 +2,7 @@ import { ActionButton, Button, Icon, + Notification, useNotify, } from "@canonical/react-components"; import SidePanel from "components/SidePanel"; @@ -26,6 +27,7 @@ import ScrollableContainer from "components/ScrollableContainer"; import useSortTableData from "util/useSortTableData"; import { isUnrestricted } from "util/helpers"; import { useIdentities } from "context/useIdentities"; +import { useIdentityEntitlements } from "util/entitlements/identities"; type IdentityEditHistory = { identitiesAdded: Set; @@ -43,6 +45,10 @@ const EditGroupIdentitiesPanel: FC = ({ groups }) => { const [confirming, setConfirming] = useState(false); const { data: identities = [], error, isLoading } = useIdentities(); + const { canEditIdentity } = useIdentityEntitlements(); + const editableIdentities = identities.filter((identity) => + canEditIdentity(identity), + ); const { desiredState, @@ -68,7 +74,7 @@ const EditGroupIdentitiesPanel: FC = ({ groups }) => { }, [groups]); const fineGrainedIdentities = identities.filter( - (identity) => !isUnrestricted(identity), + (identity) => !isUnrestricted(identity) && canEditIdentity(identity), ); const { @@ -245,54 +251,77 @@ const EditGroupIdentitiesPanel: FC = ({ groups }) => { defaultSort: "name", }); - const content = ( - - identity.id)} - indeterminateNames={Array.from(indeterminateIdentities)} - onToggleRow={toggleRow} - hideContextualMenu - /> - - ); + const confirmButtonText = modifiedIdentities.size + ? `Apply ${modifiedIdentities.size} identity ${pluralize("change", modifiedIdentities.size)}` + : "Modify identities"; + + const getPanelTitle = () => { + if (!editableIdentities.length) { + return; + } - const panelTitle = - groups.length > 1 + return 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"; + const getPanelContent = () => { + if (!editableIdentities.length) { + return ( + + You do not have permission to change identities in groups. + + ); + } + + return ( + + identity.id)} + indeterminateNames={Array.from(indeterminateIdentities)} + onToggleRow={toggleRow} + hideContextualMenu + /> + + ); + }; return ( <> - {panelTitle} + {getPanelTitle()} - + {editableIdentities.length ? : null} - {content} + {getPanelContent()} From 836130592720aadddc7a20cbf5f2a8aa284d1e71 Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Mon, 17 Feb 2025 16:50:31 +0200 Subject: [PATCH 12/22] feat: disable bulk delete identities button when user has no permission to delete any of the selected identities Signed-off-by: Mason Hu --- .../actions/BulkDeleteIdentitiesBtn.tsx | 66 ++++++++----------- 1 file changed, 26 insertions(+), 40 deletions(-) diff --git a/src/pages/permissions/actions/BulkDeleteIdentitiesBtn.tsx b/src/pages/permissions/actions/BulkDeleteIdentitiesBtn.tsx index 7b59aba125..eb57c22a18 100644 --- a/src/pages/permissions/actions/BulkDeleteIdentitiesBtn.tsx +++ b/src/pages/permissions/actions/BulkDeleteIdentitiesBtn.tsx @@ -2,7 +2,6 @@ import { FC, useState } from "react"; import { ButtonProps, ConfirmationButton, - Notification, useNotify, } from "@canonical/react-components"; import type { LxdIdentity } from "types/permissions"; @@ -61,57 +60,44 @@ const BulkDeleteIdentitiesBtn: FC = ({ }); }; - const getDeleteConfirmationMessage = () => { - if (!deletableIdentities.length) { - return ( - - You do not have permission to delete the selected identities - - ); - } - - return ( -

- This will permanently delete the following{" "} - {pluralize("identity", deletableIdentities.length)}: -

    - {deletableIdentities.map((identity) => ( -
  • {identity.name}
  • - ))} -
- You do not have permission to delete the following{" "} - {pluralize("identity", deletableIdentities.length)}: -
    - {restrictedIdentities.map((identity) => ( -
  • {identity.name}
  • - ))} -
- This action cannot be undone, and can result in data loss. -

- ); - }; - return ( + This will permanently delete the following{" "} + {pluralize("identity", deletableIdentities.length)}: +
    + {deletableIdentities.map((identity) => ( +
  • {identity.name}
  • + ))} +
+ You do not have permission to delete the following{" "} + {pluralize("identity", deletableIdentities.length)}: +
    + {restrictedIdentities.map((identity) => ( +
  • {identity.name}
  • + ))} +
+ This action cannot be undone, and can result in data loss. +

+ ), confirmButtonLabel: "Delete", - confirmButtonDisabled: !deletableIdentities.length, onConfirm: handleDelete, }} - disabled={!identities.length} + disabled={!deletableIdentities.length} shiftClickEnabled - showShiftClickHint={!!deletableIdentities.length} + showShiftClickHint > {buttonText}
From 79354c6ed80253a7e36e098d318acac584f65c85 Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Mon, 17 Feb 2025 17:01:12 +0200 Subject: [PATCH 13/22] feat: disable bulk delete group button if user does not have permission to delete all selected groups Signed-off-by: Mason Hu --- .../permissions/actions/BulkDeleteGroupsBtn.tsx | 10 +++++++++- src/pages/permissions/actions/DeleteGroupModal.tsx | 12 ------------ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/pages/permissions/actions/BulkDeleteGroupsBtn.tsx b/src/pages/permissions/actions/BulkDeleteGroupsBtn.tsx index c20013e641..fa7b16f1ce 100644 --- a/src/pages/permissions/actions/BulkDeleteGroupsBtn.tsx +++ b/src/pages/permissions/actions/BulkDeleteGroupsBtn.tsx @@ -3,6 +3,7 @@ import { Button, Icon } from "@canonical/react-components"; import type { LxdGroup } from "types/permissions"; import DeleteGroupModal from "./DeleteGroupModal"; import { pluralize } from "util/instanceBulkActions"; +import { useGroupEntitlements } from "util/entitlements/groups"; interface Props { groups: LxdGroup[]; @@ -12,6 +13,8 @@ interface Props { const BulkDeleteGroupsBtn: FC = ({ groups, className, onDelete }) => { const [confirming, setConfirming] = useState(false); + const { canDeleteGroup } = useGroupEntitlements(); + const deletableGroups = groups.filter(canDeleteGroup); const handleConfirmDelete = () => { setConfirming(true); @@ -27,10 +30,15 @@ const BulkDeleteGroupsBtn: FC = ({ groups, className, onDelete }) => { @@ -212,7 +221,10 @@ const EditGroupPermissionsForm: FC = ({ Select the appropriate resource and entitlement below and add it to the list of permissions for this group. - + {!permissions.length ? ( diff --git a/src/pages/permissions/panels/PermissionSelector.tsx b/src/pages/permissions/panels/PermissionSelector.tsx index 04b8c793cf..f89e651b31 100644 --- a/src/pages/permissions/panels/PermissionSelector.tsx +++ b/src/pages/permissions/panels/PermissionSelector.tsx @@ -24,9 +24,10 @@ import { useIdentities } from "context/useIdentities"; interface Props { onAddPermission: (permission: FormPermission) => void; + disableReason?: string; } -const PermissionSelector: FC = ({ onAddPermission }) => { +const PermissionSelector: FC = ({ onAddPermission, disableReason }) => { const notify = useNotify(); const [resourceType, setResourceType] = useState(""); const [resource, setResource] = useState(""); @@ -47,7 +48,7 @@ const PermissionSelector: FC = ({ onAddPermission }) => { } = useQuery({ queryKey: [queryKeys.permissions, resourceType], queryFn: () => fetchPermissions({ resourceType }), - enabled: !!resourceType, + enabled: !!resourceType && !!disableReason, }); const { data: images = [] } = useImagesInAllProjects(); @@ -180,6 +181,7 @@ const PermissionSelector: FC = ({ onAddPermission }) => { className="permission-selector" tabIndex={0} ref={permissionSelectorRef} + title={disableReason} > = ({ onAddPermission }) => { value={resourceType} selectRef={resourceTypeRef as SelectRef} searchable="always" + disabled={!!disableReason} /> { hasEntitlement(isFineGrained, "admin", serverEntitlements) || hasEntitlement(isFineGrained, "viewer", serverEntitlements); + const canViewPermissions = () => + hasEntitlement(isFineGrained, "can_view_permissions", serverEntitlements) || + hasEntitlement(isFineGrained, "permission_manager", serverEntitlements) || + hasEntitlement(isFineGrained, "admin", serverEntitlements); + const canViewResources = () => hasEntitlement(isFineGrained, "can_view_resources", serverEntitlements) || hasEntitlement(isFineGrained, "admin", serverEntitlements) || @@ -53,6 +58,7 @@ export const useServerEntitlements = () => { canCreateStoragePools, canEditServerConfiguration, canViewMetrics, + canViewPermissions, canViewResources, }; }; From d80b4ddb3af61c4e2f9d8a25a768aab26178fe8d Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Mon, 17 Feb 2025 20:58:04 +0200 Subject: [PATCH 17/22] feat: enable idp group entitlement query Signed-off-by: Mason Hu --- src/api/auth-idp-groups.tsx | 10 ++++++++-- src/context/useIdpGroups.tsx | 15 +++++++++++++++ src/pages/permissions/PermissionIdpGroups.tsx | 13 ++----------- src/types/permissions.d.ts | 1 + src/util/entitlements/idp-groups.tsx | 18 ++++++++++++++++++ 5 files changed, 44 insertions(+), 13 deletions(-) create mode 100644 src/context/useIdpGroups.tsx create mode 100644 src/util/entitlements/idp-groups.tsx diff --git a/src/api/auth-idp-groups.tsx b/src/api/auth-idp-groups.tsx index 01c964bf64..a4b5934bca 100644 --- a/src/api/auth-idp-groups.tsx +++ b/src/api/auth-idp-groups.tsx @@ -1,10 +1,16 @@ import { handleResponse, handleSettledResult } from "util/helpers"; import type { LxdApiResponse } from "types/apiResponse"; import type { IdpGroup } from "types/permissions"; +import { withEntitlementsQuery } from "util/entitlements/api"; -export const fetchIdpGroups = (): Promise => { +const idpGroupEntitlements = ["can_edit", "can_delete"]; + +export const fetchIdpGroups = ( + isFineGrained: boolean | null, +): Promise => { + const entitlements = `&${withEntitlementsQuery(isFineGrained, idpGroupEntitlements)}`; return new Promise((resolve, reject) => { - fetch(`/1.0/auth/identity-provider-groups?recursion=1`) + fetch(`/1.0/auth/identity-provider-groups?recursion=1${entitlements}`) .then(handleResponse) .then((data: LxdApiResponse) => resolve(data.metadata)) .catch(reject); diff --git a/src/context/useIdpGroups.tsx b/src/context/useIdpGroups.tsx new file mode 100644 index 0000000000..3b7955f2d9 --- /dev/null +++ b/src/context/useIdpGroups.tsx @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "util/queryKeys"; +import { UseQueryResult } from "@tanstack/react-query"; +import { useAuth } from "./auth"; +import { IdpGroup } from "types/permissions"; +import { fetchIdpGroups } from "api/auth-idp-groups"; + +export const useIdpGroups = (): UseQueryResult => { + const { isFineGrained } = useAuth(); + return useQuery({ + queryKey: [queryKeys.idpGroups], + queryFn: () => fetchIdpGroups(isFineGrained), + enabled: isFineGrained !== null, + }); +}; diff --git a/src/pages/permissions/PermissionIdpGroups.tsx b/src/pages/permissions/PermissionIdpGroups.tsx index da7805482a..901b5ffc53 100644 --- a/src/pages/permissions/PermissionIdpGroups.tsx +++ b/src/pages/permissions/PermissionIdpGroups.tsx @@ -8,13 +8,11 @@ import { 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"; @@ -23,24 +21,17 @@ 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 DeleteIdpGroupBtn from "./actions/DeleteIdpGroupBtn"; import { useSettings } from "context/useSettings"; import { Link } from "react-router-dom"; +import { useIdpGroups } from "context/useIdpGroups"; const PermissionIdpGroups: FC = () => { const notify = useNotify(); - const { - data: groups = [], - error, - isLoading, - } = useQuery({ - queryKey: [queryKeys.idpGroups], - queryFn: fetchIdpGroups, - }); + const { data: groups = [], error, isLoading } = useIdpGroups(); const docBaseLink = useDocs(); const panelParams = usePanelParams(); const [search, setSearch] = useState(""); diff --git a/src/types/permissions.d.ts b/src/types/permissions.d.ts index e4223dca6c..525ff24b78 100644 --- a/src/types/permissions.d.ts +++ b/src/types/permissions.d.ts @@ -37,6 +37,7 @@ export interface LxdPermission { export interface IdpGroup { name: string; groups: string[]; // these should be names of lxd groups + access_entitlements?: string[]; } export interface TlsIdentityTokenDetail { diff --git a/src/util/entitlements/idp-groups.tsx b/src/util/entitlements/idp-groups.tsx new file mode 100644 index 0000000000..0a637ffb2d --- /dev/null +++ b/src/util/entitlements/idp-groups.tsx @@ -0,0 +1,18 @@ +import { useAuth } from "context/auth"; +import { hasEntitlement } from "./helpers"; +import { IdpGroup } from "types/permissions"; + +export const useIdpGroupEntitlements = () => { + const { isFineGrained } = useAuth(); + + const canEditGroup = (group?: IdpGroup) => + hasEntitlement(isFineGrained, "can_edit", group?.access_entitlements); + + const canDeleteGroup = (group?: IdpGroup) => + hasEntitlement(isFineGrained, "can_delete", group?.access_entitlements); + + return { + canDeleteGroup, + canEditGroup, + }; +}; From 0f5afe6e7aaa4bb2130a706877c0dd76f4b3f948 Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Mon, 17 Feb 2025 21:04:21 +0200 Subject: [PATCH 18/22] feat: disable create idp groups for restricted permissions Signed-off-by: Mason Hu --- src/pages/permissions/PermissionIdpGroups.tsx | 14 ++++++++++++++ src/pages/permissions/forms/GroupForm.tsx | 2 +- src/util/entitlements/server.tsx | 10 ++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/pages/permissions/PermissionIdpGroups.tsx b/src/pages/permissions/PermissionIdpGroups.tsx index 901b5ffc53..55f1919fe5 100644 --- a/src/pages/permissions/PermissionIdpGroups.tsx +++ b/src/pages/permissions/PermissionIdpGroups.tsx @@ -28,6 +28,7 @@ import DeleteIdpGroupBtn from "./actions/DeleteIdpGroupBtn"; import { useSettings } from "context/useSettings"; import { Link } from "react-router-dom"; import { useIdpGroups } from "context/useIdpGroups"; +import { useServerEntitlements } from "util/entitlements/server"; const PermissionIdpGroups: FC = () => { const notify = useNotify(); @@ -38,6 +39,7 @@ const PermissionIdpGroups: FC = () => { const [selectedGroupNames, setSelectedGroupNames] = useState([]); const { data: settings } = useSettings(); const hasCustomClaim = settings?.config?.["oidc.groups.claim"]; + const { canCreateIdpGroups } = useServerEntitlements(); if (error) { notify.failure("Loading provider groups failed", error); @@ -239,6 +241,12 @@ const PermissionIdpGroups: FC = () => { className="empty-state-button" appearance="positive" onClick={panelParams.openCreateIdpGroup} + disabled={!canCreateIdpGroups()} + title={ + canCreateIdpGroups() + ? "" + : "You do not have permission to create IDP groups" + } > Create IDP group @@ -285,6 +293,12 @@ const PermissionIdpGroups: FC = () => { appearance="positive" className="u-no-margin--bottom u-float-right" onClick={panelParams.openCreateIdpGroup} + disabled={!canCreateIdpGroups()} + title={ + canCreateIdpGroups() + ? "" + : "You do not have permission to create IDP groups" + } > Create IDP group diff --git a/src/pages/permissions/forms/GroupForm.tsx b/src/pages/permissions/forms/GroupForm.tsx index 64ce8d4c1f..7548f6cbcb 100644 --- a/src/pages/permissions/forms/GroupForm.tsx +++ b/src/pages/permissions/forms/GroupForm.tsx @@ -21,7 +21,7 @@ interface Props { permissionCount: number; permissionModifyCount: number; isEditing?: boolean; - group: LxdGroup; + group?: LxdGroup; } const GroupForm: FC = ({ diff --git a/src/util/entitlements/server.tsx b/src/util/entitlements/server.tsx index 0faa0892e7..bc13a52bbc 100644 --- a/src/util/entitlements/server.tsx +++ b/src/util/entitlements/server.tsx @@ -18,6 +18,15 @@ export const useServerEntitlements = () => { hasEntitlement(isFineGrained, "permission_manager", serverEntitlements) || hasEntitlement(isFineGrained, "admin", serverEntitlements); + const canCreateIdpGroups = () => + hasEntitlement( + isFineGrained, + "can_create_identity_provider_groups", + serverEntitlements, + ) || + hasEntitlement(isFineGrained, "permission_manager", serverEntitlements) || + hasEntitlement(isFineGrained, "admin", serverEntitlements); + const canCreateProjects = () => hasEntitlement(isFineGrained, "can_create_projects", serverEntitlements) || hasEntitlement(isFineGrained, "project_manager", serverEntitlements) || @@ -54,6 +63,7 @@ export const useServerEntitlements = () => { return { canCreateGroups, canCreateIdentities, + canCreateIdpGroups, canCreateProjects, canCreateStoragePools, canEditServerConfiguration, From cd4e3c12ca26d1e8592a2dceb1cfd2e033bd0cbb Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Mon, 17 Feb 2025 21:12:04 +0200 Subject: [PATCH 19/22] feat: disable bulk delete idp group button with restricted permissions Signed-off-by: Mason Hu --- .../actions/BulkDeleteIdpGroupsBtn.tsx | 10 +++- .../permissions/actions/DeleteGroupModal.tsx | 3 +- .../actions/DeleteIdpGroupsModal.tsx | 51 +++++++++++++++---- 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/pages/permissions/actions/BulkDeleteIdpGroupsBtn.tsx b/src/pages/permissions/actions/BulkDeleteIdpGroupsBtn.tsx index a16f8cf6ee..053c511bb1 100644 --- a/src/pages/permissions/actions/BulkDeleteIdpGroupsBtn.tsx +++ b/src/pages/permissions/actions/BulkDeleteIdpGroupsBtn.tsx @@ -3,6 +3,7 @@ import { Button, Icon } from "@canonical/react-components"; import type { IdpGroup } from "types/permissions"; import { pluralize } from "util/instanceBulkActions"; import DeleteIdpGroupsModal from "./DeleteIdpGroupsModal"; +import { useIdpGroupEntitlements } from "util/entitlements/idp-groups"; interface Props { idpGroups: IdpGroup[]; @@ -11,6 +12,8 @@ interface Props { const BulkDeleteIdpGroupsBtn: FC = ({ idpGroups, className }) => { const [confirming, setConfirming] = useState(false); + const { canDeleteGroup } = useIdpGroupEntitlements(); + const deletableGroups = idpGroups.filter(canDeleteGroup); const handleConfirmDelete = () => { setConfirming(true); @@ -25,10 +28,15 @@ const BulkDeleteIdpGroupsBtn: FC = ({ idpGroups, className }) => { , From 445950c8bb35bea8d9d2b3554d0d039ee50ee67f Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Mon, 17 Feb 2025 21:17:15 +0200 Subject: [PATCH 21/22] feat: disable delete idp group button for restricted permissions Signed-off-by: Mason Hu --- src/pages/permissions/PermissionIdpGroups.tsx | 4 +++- src/pages/permissions/actions/DeleteIdpGroupBtn.tsx | 9 ++++++++- src/pages/permissions/panels/PermissionSelector.tsx | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/pages/permissions/PermissionIdpGroups.tsx b/src/pages/permissions/PermissionIdpGroups.tsx index 73cf2cfbbe..6036c36b1e 100644 --- a/src/pages/permissions/PermissionIdpGroups.tsx +++ b/src/pages/permissions/PermissionIdpGroups.tsx @@ -94,7 +94,7 @@ const PermissionIdpGroups: FC = () => { title: idpGroup.name, }, { - content: ( + content: canEditGroup(idpGroup) ? ( + ) : ( + idpGroup.groups.length ), role: "cell", className: "u-align--right", diff --git a/src/pages/permissions/actions/DeleteIdpGroupBtn.tsx b/src/pages/permissions/actions/DeleteIdpGroupBtn.tsx index 99fbe45ac5..a7066923bd 100644 --- a/src/pages/permissions/actions/DeleteIdpGroupBtn.tsx +++ b/src/pages/permissions/actions/DeleteIdpGroupBtn.tsx @@ -3,6 +3,7 @@ import { Button, Icon } from "@canonical/react-components"; import type { IdpGroup } from "types/permissions"; import DeleteIdpGroupsModal from "./DeleteIdpGroupsModal"; import { usePortal } from "@canonical/react-components"; +import { useIdpGroupEntitlements } from "util/entitlements/idp-groups"; interface Props { idpGroup: IdpGroup; @@ -10,6 +11,7 @@ interface Props { const DeleteIdpGroupBtn: FC = ({ idpGroup }) => { const { openPortal, closePortal, isOpen, Portal } = usePortal(); + const { canDeleteGroup } = useIdpGroupEntitlements(); return ( <> @@ -20,7 +22,12 @@ const DeleteIdpGroupBtn: FC = ({ idpGroup }) => { onClick={openPortal} type="button" aria-label="Delete IDP group" - title="Delete IDP group" + title={ + canDeleteGroup() + ? "Delete IDP group" + : "You do not have permission to delete this IDP group" + } + disabled={!canDeleteGroup(idpGroup)} > diff --git a/src/pages/permissions/panels/PermissionSelector.tsx b/src/pages/permissions/panels/PermissionSelector.tsx index f89e651b31..a96848b972 100644 --- a/src/pages/permissions/panels/PermissionSelector.tsx +++ b/src/pages/permissions/panels/PermissionSelector.tsx @@ -48,7 +48,7 @@ const PermissionSelector: FC = ({ onAddPermission, disableReason }) => { } = useQuery({ queryKey: [queryKeys.permissions, resourceType], queryFn: () => fetchPermissions({ resourceType }), - enabled: !!resourceType && !!disableReason, + enabled: !!resourceType && !disableReason, }); const { data: images = [] } = useImagesInAllProjects(); From 01995ebc1f755003b61cf796d1552aa5bbcf61b7 Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Tue, 18 Feb 2025 10:20:31 +0200 Subject: [PATCH 22/22] fix: misc fixes Signed-off-by: Mason Hu --- src/api/auth-groups.tsx | 4 +- src/api/auth-identities.tsx | 12 +- src/api/auth-idp-groups.tsx | 15 +-- src/components/SelectableMainTable.tsx | 18 +-- src/context/useIdentities.tsx | 2 +- src/pages/cluster/ClusterGroupForm.tsx | 2 +- src/pages/images/ImageList.tsx | 2 +- src/pages/instances/InstanceList.tsx | 4 +- src/pages/instances/InstanceSnapshots.tsx | 2 +- src/pages/permissions/PermissionGroups.tsx | 8 +- .../permissions/PermissionIdentities.tsx | 32 +++-- src/pages/permissions/PermissionIdpGroups.tsx | 55 +++++---- .../actions/BulkDeleteGroupsBtn.tsx | 3 +- .../actions/BulkDeleteIdentitiesBtn.tsx | 23 ++-- .../actions/BulkDeleteIdpGroupsBtn.tsx | 106 +++++++++------- .../permissions/actions/DeleteGroupModal.tsx | 60 ++++++---- .../permissions/actions/DeleteIdpGroupBtn.tsx | 63 +++++----- .../actions/DeleteIdpGroupsModal.tsx | 113 ------------------ .../actions/EditIdentityGroupsBtn.tsx | 10 +- .../panels/EditGroupIdentitiesPanel.tsx | 24 ++-- .../permissions/panels/EditGroupPanel.tsx | 4 +- .../panels/EditGroupPermissionsForm.tsx | 21 ++-- .../permissions/panels/EditIdentitiesForm.tsx | 16 +-- .../permissions/panels/GroupSelection.tsx | 2 +- src/pages/storage/StorageVolumeSnapshots.tsx | 2 +- src/sass/_selectable_main_table.scss | 2 +- src/util/entitlements/groups.tsx | 6 +- src/util/entitlements/identities.tsx | 6 +- src/util/entitlements/idp-groups.tsx | 12 +- src/util/permissionIdpGroups.tsx | 68 +++++++++++ 30 files changed, 373 insertions(+), 324 deletions(-) delete mode 100644 src/pages/permissions/actions/DeleteIdpGroupsModal.tsx diff --git a/src/api/auth-groups.tsx b/src/api/auth-groups.tsx index d664f4c5cc..de0dacbbb8 100644 --- a/src/api/auth-groups.tsx +++ b/src/api/auth-groups.tsx @@ -3,12 +3,12 @@ import type { LxdApiResponse } from "types/apiResponse"; import type { LxdGroup } from "types/permissions"; import { withEntitlementsQuery } from "util/entitlements/api"; -export const groupEntitlements = ["can_edit", "can_delete"]; +export const groupEntitlements = ["can_delete", "can_edit"]; export const fetchGroups = ( isFineGrained: boolean | null, ): Promise => { - const entitlements = `&${withEntitlementsQuery(isFineGrained, groupEntitlements)}`; + const entitlements = withEntitlementsQuery(isFineGrained, groupEntitlements); return new Promise((resolve, reject) => { fetch(`/1.0/auth/groups?recursion=1${entitlements}`) .then(handleResponse) diff --git a/src/api/auth-identities.tsx b/src/api/auth-identities.tsx index 4244f0e922..59dfbd3634 100644 --- a/src/api/auth-identities.tsx +++ b/src/api/auth-identities.tsx @@ -3,12 +3,15 @@ import type { LxdApiResponse } from "types/apiResponse"; import type { LxdIdentity, TlsIdentityTokenDetail } from "types/permissions"; import { withEntitlementsQuery } from "util/entitlements/api"; -export const identitiesEntitlements = ["can_edit", "can_delete"]; +export const identitiesEntitlements = ["can_delete", "can_edit"]; export const fetchIdentities = ( isFineGrained: boolean | null, ): Promise => { - const entitlements = `&${withEntitlementsQuery(isFineGrained, identitiesEntitlements)}`; + const entitlements = withEntitlementsQuery( + isFineGrained, + identitiesEntitlements, + ); return new Promise((resolve, reject) => { fetch(`/1.0/auth/identities?recursion=1${entitlements}`) .then(handleResponse) @@ -31,7 +34,10 @@ export const fetchIdentity = ( authMethod: string, isFineGrained: boolean | null, ): Promise => { - const entitlements = `&${withEntitlementsQuery(isFineGrained, identitiesEntitlements)}`; + const entitlements = withEntitlementsQuery( + isFineGrained, + identitiesEntitlements, + ); return new Promise((resolve, reject) => { fetch(`/1.0/auth/identities/${authMethod}/${id}?recursion=1${entitlements}`) .then(handleResponse) diff --git a/src/api/auth-idp-groups.tsx b/src/api/auth-idp-groups.tsx index a4b5934bca..7a76cd848e 100644 --- a/src/api/auth-idp-groups.tsx +++ b/src/api/auth-idp-groups.tsx @@ -3,12 +3,15 @@ import type { LxdApiResponse } from "types/apiResponse"; import type { IdpGroup } from "types/permissions"; import { withEntitlementsQuery } from "util/entitlements/api"; -const idpGroupEntitlements = ["can_edit", "can_delete"]; +const idpGroupEntitlements = ["can_delete", "can_edit"]; export const fetchIdpGroups = ( isFineGrained: boolean | null, ): Promise => { - const entitlements = `&${withEntitlementsQuery(isFineGrained, idpGroupEntitlements)}`; + const entitlements = withEntitlementsQuery( + isFineGrained, + idpGroupEntitlements, + ); return new Promise((resolve, reject) => { fetch(`/1.0/auth/identity-provider-groups?recursion=1${entitlements}`) .then(handleResponse) @@ -21,14 +24,8 @@ export const createIdpGroup = (group: Partial): Promise => { return new Promise((resolve, reject) => { fetch(`/1.0/auth/identity-provider-groups`, { method: "POST", - body: JSON.stringify({ name: group.name }), + body: JSON.stringify({ name: group.name, groups: group.groups }), }) - .then(() => - fetch(`/1.0/auth/identity-provider-groups/${group.name}`, { - method: "PUT", - body: JSON.stringify(group), - }), - ) .then(handleResponse) .then(resolve) .catch(reject); diff --git a/src/components/SelectableMainTable.tsx b/src/components/SelectableMainTable.tsx index b8fdd2f42e..215574d428 100644 --- a/src/components/SelectableMainTable.tsx +++ b/src/components/SelectableMainTable.tsx @@ -19,14 +19,14 @@ interface SelectableMainTableProps { parentName: string; selectedNames: string[]; setSelectedNames: (val: string[], isUnselectAll?: boolean) => void; - processingNames: string[]; + disabledNames: string[]; rows: MainTableRow[]; indeterminateNames?: string[]; disableSelect?: boolean; onToggleRow?: (rowName: string) => void; hideContextualMenu?: boolean; defaultSortKey?: string; - disableHeaderCheckbox?: boolean; + disableSelectAll?: boolean; } type Props = SelectableMainTableProps & MainTableProps; @@ -37,7 +37,7 @@ const SelectableMainTable: FC = ({ parentName, selectedNames, setSelectedNames, - processingNames, + disabledNames, rows, headers, indeterminateNames = [], @@ -45,7 +45,7 @@ const SelectableMainTable: FC = ({ onToggleRow, hideContextualMenu, defaultSortKey, - disableHeaderCheckbox, + disableSelectAll, ...props }: Props) => { const [currentSelectedIndex, setCurrentSelectedIndex] = useState(); @@ -93,7 +93,7 @@ const SelectableMainTable: FC = ({ checked={isAllSelected} indeterminate={isSomeSelected && !isAllSelected} onChange={isSomeSelected ? selectNone : selectPage} - disabled={disableSelect || disableHeaderCheckbox} + disabled={disableSelect || disableSelectAll} /> {!hideContextualMenu && ( = ({ ]; const selectedNamesLookup = new Set(selectedNames); - const processingNamesLookup = new Set(processingNames); + const disabledNamesLookup = new Set(disabledNames); const indeterminateNamesLookup = new Set(indeterminateNames); const rowsWithCheckbox = rows.map((row, rowIndex) => { const isRowSelected = selectedNamesLookup.has(row.name ?? ""); - const isRowProcessing = processingNamesLookup.has(row.name ?? ""); + const isRowDisabled = disabledNamesLookup.has(row.name ?? ""); const isRowIndeterminate = indeterminateNamesLookup.has(row.name ?? ""); const toggleRow = (event: PointerEvent) => { @@ -185,7 +185,7 @@ const SelectableMainTable: FC = ({ labelClassName="u-no-margin--bottom" checked={isRowSelected} onChange={toggleRow} - disabled={isRowProcessing || !row.name || disableSelect} + disabled={isRowDisabled || !row.name || disableSelect} indeterminate={isRowIndeterminate && !isRowSelected} /> ), @@ -197,7 +197,7 @@ const SelectableMainTable: FC = ({ const className = classnames(row.className, { "selected-row": isRowSelected, - "processing-row": isRowProcessing, + "disabled-row": isRowDisabled, }); const key = row.key ?? row.name; diff --git a/src/context/useIdentities.tsx b/src/context/useIdentities.tsx index e523226a19..615756013d 100644 --- a/src/context/useIdentities.tsx +++ b/src/context/useIdentities.tsx @@ -21,7 +21,7 @@ export const useIdentity = ( ): UseQueryResult => { const { isFineGrained } = useAuth(); return useQuery({ - queryKey: [queryKeys.identities, id], + queryKey: [queryKeys.identities, authMethod, id], queryFn: () => fetchIdentity(id, authMethod, isFineGrained), enabled: (enabled ?? true) && isFineGrained !== null, }); diff --git a/src/pages/cluster/ClusterGroupForm.tsx b/src/pages/cluster/ClusterGroupForm.tsx index 595cda1760..cf52d243ae 100644 --- a/src/pages/cluster/ClusterGroupForm.tsx +++ b/src/pages/cluster/ClusterGroupForm.tsx @@ -170,7 +170,7 @@ const ClusterGroupForm: FC = ({ group }) => { setSelectedNames={(newMembers: string[]) => void formik.setFieldValue("members", newMembers) } - processingNames={[]} + disabledNames={[]} /> diff --git a/src/pages/images/ImageList.tsx b/src/pages/images/ImageList.tsx index dcfc204dd1..73b6e0c4dd 100644 --- a/src/pages/images/ImageList.tsx +++ b/src/pages/images/ImageList.tsx @@ -288,7 +288,7 @@ const ImageList: FC = () => { itemName="image" parentName="project" filteredNames={filteredImages.map((item) => item.fingerprint)} - processingNames={processingNames} + disabledNames={processingNames} rows={[]} disableSelect={!deletableImages.length} /> diff --git a/src/pages/instances/InstanceList.tsx b/src/pages/instances/InstanceList.tsx index 7fcf01d86c..8f73764bb6 100644 --- a/src/pages/instances/InstanceList.tsx +++ b/src/pages/instances/InstanceList.tsx @@ -699,7 +699,7 @@ const InstanceList: FC = () => { parentName="project" selectedNames={selectedNames} setSelectedNames={setSelectedNames} - processingNames={processingNames} + disabledNames={processingNames} filteredNames={filteredInstances.map( (instance) => instance.name, )} @@ -716,7 +716,7 @@ const InstanceList: FC = () => { parentName="project" selectedNames={selectedNames} setSelectedNames={setSelectedNames} - processingNames={processingNames} + disabledNames={processingNames} filteredNames={filteredInstances.map( (instance) => instance.name, )} diff --git a/src/pages/instances/InstanceSnapshots.tsx b/src/pages/instances/InstanceSnapshots.tsx index 13744285f7..6eba368e5f 100644 --- a/src/pages/instances/InstanceSnapshots.tsx +++ b/src/pages/instances/InstanceSnapshots.tsx @@ -278,7 +278,7 @@ const InstanceSnapshots = (props: Props) => { parentName="instance" selectedNames={selectedNames} setSelectedNames={setSelectedNames} - processingNames={processingNames} + disabledNames={processingNames} filteredNames={filteredSnapshots.map( (snapshot) => snapshot.name, )} diff --git a/src/pages/permissions/PermissionGroups.tsx b/src/pages/permissions/PermissionGroups.tsx index be37c60e1e..fa910b9ae3 100644 --- a/src/pages/permissions/PermissionGroups.tsx +++ b/src/pages/permissions/PermissionGroups.tsx @@ -209,7 +209,7 @@ const PermissionGroups: FC = () => { parentName="" selectedNames={selectedGroupNames} setSelectedNames={setSelectedGroupNames} - processingNames={[]} + disabledNames={[]} filteredNames={filteredGroups.map((item) => item.name)} disableSelect={!!panelParams.panel} /> @@ -275,14 +275,14 @@ const PermissionGroups: FC = () => { )} {selectedGroupNames.length > 0 && !panelParams.panel && ( <> - setSelectedGroupNames([])} /> - setSelectedGroupNames([])} /> )} diff --git a/src/pages/permissions/PermissionIdentities.tsx b/src/pages/permissions/PermissionIdentities.tsx index a5b4d4a67e..09613e51c3 100644 --- a/src/pages/permissions/PermissionIdentities.tsx +++ b/src/pages/permissions/PermissionIdentities.tsx @@ -35,6 +35,7 @@ import IdentityResource from "components/IdentityResource"; import CreateTlsIdentityBtn from "./CreateTlsIdentityBtn"; import { useIdentities } from "context/useIdentities"; import { useIdentityEntitlements } from "util/entitlements/identities"; +import { pluralize } from "util/instanceBulkActions"; const PermissionIdentities: FC = () => { const notify = useNotify(); @@ -113,6 +114,25 @@ const PermissionIdentities: FC = () => { setSelectedIdentityIds([identity.id]); }; + const getGroupLink = () => { + if (canEditIdentity(identity)) { + return ( + + ); + } + + const groupsText = pluralize("group", identity.groups?.length ?? 0); + const groupsList = identity.groups?.join("\n- "); + const groupsTitle = `Assigned ${groupsText}:\n- ${groupsList}`; + return ( +
+ {identity.groups?.length || 0} +
+ ); + }; + return { key: identity.id, name: isUnrestricted(identity) ? "" : identity.id, @@ -149,13 +169,7 @@ const PermissionIdentities: FC = () => { className: "u-truncate", }, { - content: canEditIdentity(identity) ? ( - - ) : ( - identity.groups?.length || 0 - ), + content: getGroupLink(), role: "cell", className: "u-align--right group-count", "aria-label": "Groups for this identity", @@ -265,7 +279,7 @@ const PermissionIdentities: FC = () => { {!!selectedIdentityIds.length && hasAccessManagementTLS && ( )} @@ -302,7 +316,7 @@ const PermissionIdentities: FC = () => { parentName="" selectedNames={selectedIdentityIds} setSelectedNames={setSelectedIdentityIds} - processingNames={[]} + disabledNames={[]} filteredNames={fineGrainedIdentities.map( (identity) => identity.id, )} diff --git a/src/pages/permissions/PermissionIdpGroups.tsx b/src/pages/permissions/PermissionIdpGroups.tsx index 6036c36b1e..9caf4a156e 100644 --- a/src/pages/permissions/PermissionIdpGroups.tsx +++ b/src/pages/permissions/PermissionIdpGroups.tsx @@ -30,6 +30,7 @@ import { Link } from "react-router-dom"; import { useIdpGroups } from "context/useIdpGroups"; import { useServerEntitlements } from "util/entitlements/server"; import { useIdpGroupEntitlements } from "util/entitlements/idp-groups"; +import { pluralize } from "util/instanceBulkActions"; const PermissionIdpGroups: FC = () => { const notify = useNotify(); @@ -41,7 +42,7 @@ const PermissionIdpGroups: FC = () => { const { data: settings } = useSettings(); const hasCustomClaim = settings?.config?.["oidc.groups.claim"]; const { canCreateIdpGroups } = useServerEntitlements(); - const { canEditGroup } = useIdpGroupEntitlements(); + const { canEditIdpGroup } = useIdpGroupEntitlements(); if (error) { notify.failure("Loading provider groups failed", error); @@ -81,6 +82,29 @@ const PermissionIdpGroups: FC = () => { ); const rows = filteredGroups.map((idpGroup) => { + const getGroupLink = () => { + if (canEditIdpGroup(idpGroup)) { + return ( + + ); + } + + const groupsText = pluralize("group", idpGroup.groups?.length ?? 0); + const groupsList = idpGroup.groups?.join("\n- "); + const groupsTitle = `Assigned ${groupsText}:\n- ${groupsList}`; + return ( +
+ {idpGroup.groups?.length || 0} +
+ ); + }; + return { key: idpGroup.name, name: idpGroup.name, @@ -94,17 +118,7 @@ const PermissionIdpGroups: FC = () => { title: idpGroup.name, }, { - content: canEditGroup(idpGroup) ? ( - - ) : ( - idpGroup.groups.length - ), + content: getGroupLink(), role: "cell", className: "u-align--right", "aria-label": "Number of mapped groups", @@ -125,11 +139,11 @@ const PermissionIdpGroups: FC = () => { type="button" aria-label="Edit IDP group details" title={ - canEditGroup(idpGroup) + canEditIdpGroup(idpGroup) ? "Edit details" - : "You do not have permission to edit this IDP group" + : "You do not have permission to modify this IDP group" } - disabled={!canEditGroup(idpGroup)} + disabled={!canEditIdpGroup(idpGroup)} > , @@ -232,7 +246,7 @@ const PermissionIdpGroups: FC = () => { parentName="" selectedNames={selectedGroupNames} setSelectedNames={setSelectedGroupNames} - processingNames={[]} + disabledNames={[]} filteredNames={filteredGroups.map((item) => item.name)} disableSelect={!!panelParams.panel} /> @@ -277,7 +291,7 @@ const PermissionIdpGroups: FC = () => { Identity provider groups - {!selectedGroupNames.length && hasGroups && ( + {!selectedGroupNames.length && hasGroups ? ( { disabled={!!panelParams.idpGroup} /> - )} + ) : null} {selectedGroupNames.length > 0 && !panelParams.panel && ( <> - + )} diff --git a/src/pages/permissions/actions/BulkDeleteGroupsBtn.tsx b/src/pages/permissions/actions/BulkDeleteGroupsBtn.tsx index fa7b16f1ce..7bee3a6863 100644 --- a/src/pages/permissions/actions/BulkDeleteGroupsBtn.tsx +++ b/src/pages/permissions/actions/BulkDeleteGroupsBtn.tsx @@ -36,11 +36,10 @@ const BulkDeleteGroupsBtn: FC = ({ groups, className, onDelete }) => { : `You do not have permission to delete the selected ${pluralize("group", groups.length)}` } className={className} - appearance="negative" hasIcon disabled={!deletableGroups.length} > - + {`Delete ${groups.length} ${pluralize("group", groups.length)}`} {confirming && ( diff --git a/src/pages/permissions/actions/BulkDeleteIdentitiesBtn.tsx b/src/pages/permissions/actions/BulkDeleteIdentitiesBtn.tsx index eb57c22a18..1b41eae16f 100644 --- a/src/pages/permissions/actions/BulkDeleteIdentitiesBtn.tsx +++ b/src/pages/permissions/actions/BulkDeleteIdentitiesBtn.tsx @@ -2,6 +2,7 @@ import { FC, useState } from "react"; import { ButtonProps, ConfirmationButton, + Icon, useNotify, } from "@canonical/react-components"; import type { LxdIdentity } from "types/permissions"; @@ -67,7 +68,6 @@ const BulkDeleteIdentitiesBtn: FC = ({ ? buttonText : `You do not have permission to delete the selected ${pluralize("identity", identities.length)}` } - appearance="" aria-label="Delete identities" className={className} loading={isLoading} @@ -82,13 +82,17 @@ const BulkDeleteIdentitiesBtn: FC = ({
  • {identity.name}
  • ))} - You do not have permission to delete the following{" "} - {pluralize("identity", deletableIdentities.length)}: -
      - {restrictedIdentities.map((identity) => ( -
    • {identity.name}
    • - ))} -
    + {restrictedIdentities.length ? ( + <> + You do not have permission to delete the following{" "} + {pluralize("identity", restrictedIdentities.length)}: +
      + {restrictedIdentities.map((identity) => ( +
    • {identity.name}
    • + ))} +
    + + ) : null} This action cannot be undone, and can result in data loss.

    ), @@ -99,7 +103,8 @@ const BulkDeleteIdentitiesBtn: FC = ({ shiftClickEnabled showShiftClickHint > - {buttonText} + + {buttonText} ); }; diff --git a/src/pages/permissions/actions/BulkDeleteIdpGroupsBtn.tsx b/src/pages/permissions/actions/BulkDeleteIdpGroupsBtn.tsx index 053c511bb1..a6cfb7dc76 100644 --- a/src/pages/permissions/actions/BulkDeleteIdpGroupsBtn.tsx +++ b/src/pages/permissions/actions/BulkDeleteIdpGroupsBtn.tsx @@ -1,53 +1,79 @@ -import { FC, useState } from "react"; -import { Button, Icon } from "@canonical/react-components"; +import { FC } from "react"; +import { + ConfirmationButton, + Icon, + Notification, +} from "@canonical/react-components"; import type { IdpGroup } from "types/permissions"; import { pluralize } from "util/instanceBulkActions"; -import DeleteIdpGroupsModal from "./DeleteIdpGroupsModal"; -import { useIdpGroupEntitlements } from "util/entitlements/idp-groups"; +import { useDeleteIdpGroups } from "util/permissionIdpGroups"; interface Props { idpGroups: IdpGroup[]; - className?: string; } -const BulkDeleteIdpGroupsBtn: FC = ({ idpGroups, className }) => { - const [confirming, setConfirming] = useState(false); - const { canDeleteGroup } = useIdpGroupEntitlements(); - const deletableGroups = idpGroups.filter(canDeleteGroup); +const BulkDeleteIdpGroupsBtn: FC = ({ idpGroups }) => { + const { + isDeleting, + deletableIdpGroups, + restrictedIdpGroups, + deleteIdpGroups, + } = useDeleteIdpGroups(idpGroups); - const handleConfirmDelete = () => { - setConfirming(true); - }; - - const handleCloseConfirm = () => { - setConfirming(false); - }; + const hasOneGroup = deletableIdpGroups.length === 1; return ( - <> - - {confirming && ( - - )} - + +

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

    + {restrictedIdpGroups.length ? ( + +

    + You do not have permission to delete the following IDP groups: +

    +
      + {restrictedIdpGroups.map((group) => ( +
    • {group.name}
    • + ))} +
    +
    + ) : null} + + ), + }} + > + + {`Delete ${idpGroups.length} ${pluralize("IDP group", idpGroups.length)}`} +
    ); }; diff --git a/src/pages/permissions/actions/DeleteGroupModal.tsx b/src/pages/permissions/actions/DeleteGroupModal.tsx index 470c90c7bd..e03ee31975 100644 --- a/src/pages/permissions/actions/DeleteGroupModal.tsx +++ b/src/pages/permissions/actions/DeleteGroupModal.tsx @@ -6,7 +6,7 @@ import { useNotify, } from "@canonical/react-components"; import { useQueryClient } from "@tanstack/react-query"; -import { deleteGroup, deleteGroups } from "api/auth-groups"; +import { deleteGroups } from "api/auth-groups"; import ResourceLabel from "components/ResourceLabel"; import { useToastNotification } from "context/toastNotificationProvider"; import { ChangeEvent, FC, useState } from "react"; @@ -14,6 +14,8 @@ import type { LxdGroup } from "types/permissions"; import { useGroupEntitlements } from "util/entitlements/groups"; import { pluralize } from "util/instanceBulkActions"; import { queryKeys } from "util/queryKeys"; +import LoggedInUserNotification from "../panels/LoggedInUserNotification"; +import { useSettings } from "context/useSettings"; interface Props { groups: LxdGroup[]; @@ -29,15 +31,26 @@ const DeleteGroupModal: FC = ({ groups, close }) => { const [submitting, setSubmitting] = useState(false); const confirmText = "confirm-delete-group"; const { canDeleteGroup } = useGroupEntitlements(); + const { data: settings } = useSettings(); + const loggedInIdentityID = settings?.auth_user_name ?? ""; const restrictedGroups: LxdGroup[] = []; const deletableGroups: LxdGroup[] = []; + let hasGroupsForLoggedInUser = false; groups.forEach((group) => { if (canDeleteGroup(group)) { deletableGroups.push(group); } else { restrictedGroups.push(group); } + + if (group.identities?.oidc?.includes(loggedInIdentityID)) { + hasGroupsForLoggedInUser = true; + } + + if (group.identities?.tls?.includes(loggedInIdentityID)) { + hasGroupsForLoggedInUser = true; + } }); const hasOneGroup = deletableGroups.length === 1; @@ -55,9 +68,6 @@ const DeleteGroupModal: FC = ({ groups, close }) => { const handleDeleteGroups = () => { setSubmitting(true); const hasSingleGroup = deletableGroups.length === 1; - const mutationPromise = hasSingleGroup - ? deleteGroup(deletableGroups[0].name) - : deleteGroups(deletableGroups.map((group) => group.name)); const successMessage = hasSingleGroup ? ( <> @@ -69,7 +79,7 @@ const DeleteGroupModal: FC = ({ groups, close }) => { `${deletableGroups.length} groups deleted.` ); - mutationPromise + deleteGroups(deletableGroups.map((group) => group.name)) .then(() => { void queryClient.invalidateQueries({ predicate: (query) => { @@ -83,7 +93,7 @@ const DeleteGroupModal: FC = ({ groups, close }) => { }) .catch((e) => { notify.failure( - `${pluralize("group", deletableGroups.length)} deletion failed`, + `Failed deleting ${deletableGroups.length} ${pluralize("group", deletableGroups.length)}.`, e, ); }) @@ -104,22 +114,30 @@ const DeleteGroupModal: FC = ({ groups, close }) => { ?

    - {restrictedGroups.length && ( - -

    - You do not have permission to delete the following groups: -

    -
      - {restrictedGroups.map((group) => ( -
    • {group.name}
    • - ))} -
    -
    + {hasGroupsForLoggedInUser && ( +
    + +
    )} + {restrictedGroups.length ? ( +
    + +

    + You do not have permission to delete the following groups: +

    +
      + {restrictedGroups.map((group) => ( +
    • {group.name}
    • + ))} +
    +
    +
    + ) : null}

    This action cannot be undone and may result in users losing access to LXD, including the possibility that all users lose admin access. diff --git a/src/pages/permissions/actions/DeleteIdpGroupBtn.tsx b/src/pages/permissions/actions/DeleteIdpGroupBtn.tsx index a7066923bd..c3af385560 100644 --- a/src/pages/permissions/actions/DeleteIdpGroupBtn.tsx +++ b/src/pages/permissions/actions/DeleteIdpGroupBtn.tsx @@ -1,42 +1,49 @@ import { FC } from "react"; -import { Button, Icon } from "@canonical/react-components"; +import { ConfirmationButton, Icon } from "@canonical/react-components"; import type { IdpGroup } from "types/permissions"; -import DeleteIdpGroupsModal from "./DeleteIdpGroupsModal"; -import { usePortal } from "@canonical/react-components"; import { useIdpGroupEntitlements } from "util/entitlements/idp-groups"; +import { useDeleteIdpGroups } from "util/permissionIdpGroups"; interface Props { idpGroup: IdpGroup; } const DeleteIdpGroupBtn: FC = ({ idpGroup }) => { - const { openPortal, closePortal, isOpen, Portal } = usePortal(); - const { canDeleteGroup } = useIdpGroupEntitlements(); + const { canDeleteIdpGroup } = useIdpGroupEntitlements(); + const { isDeleting, deletableIdpGroups, deleteIdpGroups } = + useDeleteIdpGroups([idpGroup]); return ( - <> - - {isOpen && ( - - - - )} - + + Are you sure you want to delete{" "} + {deletableIdpGroups[0]?.name}? This action is + permanent and can not be undone. +

    + ), + }} + > + + ); }; diff --git a/src/pages/permissions/actions/DeleteIdpGroupsModal.tsx b/src/pages/permissions/actions/DeleteIdpGroupsModal.tsx deleted file mode 100644 index d5dba19756..0000000000 --- a/src/pages/permissions/actions/DeleteIdpGroupsModal.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { - ConfirmationModal, - Notification, - useNotify, -} from "@canonical/react-components"; -import { useQueryClient } from "@tanstack/react-query"; -import { deleteIdpGroup, deleteIdpGroups } from "api/auth-idp-groups"; -import ResourceLabel from "components/ResourceLabel"; -import { useToastNotification } from "context/toastNotificationProvider"; -import { FC, useState } from "react"; -import type { IdpGroup } from "types/permissions"; -import { useIdpGroupEntitlements } from "util/entitlements/idp-groups"; -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 { canDeleteGroup } = useIdpGroupEntitlements(); - - const restrictedGroups: IdpGroup[] = []; - const deletableGroups: IdpGroup[] = []; - idpGroups.forEach((group) => { - if (canDeleteGroup(group)) { - deletableGroups.push(group); - } else { - restrictedGroups.push(group); - } - }); - - const hasOneGroup = deletableGroups.length === 1; - - const handleDeleteIdpGroups = () => { - setSubmitting(true); - const mutationPromise = hasOneGroup - ? deleteIdpGroup(deletableGroups[0].name) - : deleteIdpGroups(deletableGroups.map((group) => group.name)); - - const successMessage = hasOneGroup ? ( - <> - IDP group{" "} - {" "} - deleted. - - ) : ( - `${deletableGroups.length} IDP groups deleted.` - ); - - mutationPromise - .then(() => { - void queryClient.invalidateQueries({ - queryKey: [queryKeys.idpGroups], - }); - toastNotify.success(successMessage); - close(); - }) - .catch((e) => { - notify.failure( - `${pluralize("IDP group", deletableGroups.length)} deletion failed`, - e, - ); - }) - .finally(() => { - setSubmitting(false); - }); - }; - - return ( - -

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

    - {restrictedGroups.length && ( - -

    - You do not have permission to delete the following IDP groups: -

    -
      - {restrictedGroups.map((group) => ( -
    • {group.name}
    • - ))} -
    -
    - )} -
    - ); -}; - -export default DeleteIdpGroupsModal; diff --git a/src/pages/permissions/actions/EditIdentityGroupsBtn.tsx b/src/pages/permissions/actions/EditIdentityGroupsBtn.tsx index 10e3da0e62..122861a2ca 100644 --- a/src/pages/permissions/actions/EditIdentityGroupsBtn.tsx +++ b/src/pages/permissions/actions/EditIdentityGroupsBtn.tsx @@ -1,5 +1,5 @@ import { FC } from "react"; -import { Button, ButtonProps } from "@canonical/react-components"; +import { Button, ButtonProps, Icon } from "@canonical/react-components"; import type { LxdIdentity } from "types/permissions"; import usePanelParams from "util/usePanelParams"; import { useIdentityEntitlements } from "util/entitlements/identities"; @@ -27,10 +27,10 @@ const EditIdentityGroupsBtn: FC = ({ ); const getRestrictedWarning = () => { - const test = restrictedIdentities + const restrictedList = restrictedIdentities .map((identity) => `\n- ${identity.name}`) .join(""); - return `You do not have permission to modify ${restrictedIdentities.length > 1 ? "some of the selected" : "the selected"} ${pluralize("identity", restrictedIdentities.length)}:${test}`; + return `You do not have permission to modify ${restrictedIdentities.length > 1 ? "some of the selected" : "the selected"} ${pluralize("identity", restrictedIdentities.length)}:${restrictedList}`; }; return ( @@ -47,9 +47,11 @@ const EditIdentityGroupsBtn: FC = ({ !identities.length || !!panelParams.panel } + hasIcon {...buttonProps} > - {buttonText} + + {buttonText} ); diff --git a/src/pages/permissions/panels/EditGroupIdentitiesPanel.tsx b/src/pages/permissions/panels/EditGroupIdentitiesPanel.tsx index d5caee4f11..808d73d2fe 100644 --- a/src/pages/permissions/panels/EditGroupIdentitiesPanel.tsx +++ b/src/pages/permissions/panels/EditGroupIdentitiesPanel.tsx @@ -229,7 +229,7 @@ const EditGroupIdentitiesPanel: FC = ({ groups }) => { "aria-label": "Identity", title: canEditIdentity(identity) ? identity.name - : "You do not have permission to allocate this identity to groups", + : "You do not have permission to manage this identity", }, { content: modifiedIdentities.has(identity.id) && ( @@ -252,15 +252,6 @@ const EditGroupIdentitiesPanel: FC = ({ groups }) => { defaultSort: "name", }); - const confirmButtonText = modifiedIdentities.size - ? `Apply ${modifiedIdentities.size} identity ${pluralize("change", modifiedIdentities.size)}` - : "Modify identities"; - - const panelTitle = - groups.length > 1 - ? `Change identities for ${groups.length} groups` - : `Change identities for ${groups[0]?.name}`; - const content = ( = ({ groups }) => { parentName="server" selectedNames={Array.from(selectedIdentities)} setSelectedNames={modifyIdentities} - processingNames={restrictedIdentities.map((identity) => identity.id)} + disabledNames={restrictedIdentities.map((identity) => identity.id)} filteredNames={fineGrainedIdentities.map((identity) => identity.id)} indeterminateNames={Array.from(indeterminateIdentities)} onToggleRow={toggleRow} hideContextualMenu - disableHeaderCheckbox={!!restrictedIdentities.length} + disableSelectAll={!!restrictedIdentities.length} /> ); + const confirmButtonText = modifiedIdentities.size + ? `Apply ${modifiedIdentities.size} identity ${pluralize("change", modifiedIdentities.size)}` + : "Modify identities"; + + const panelTitle = + groups.length > 1 + ? `Change identities for ${groups.length} groups` + : `Change identities for ${groups[0]?.name}`; + return ( <> diff --git a/src/pages/permissions/panels/EditGroupPanel.tsx b/src/pages/permissions/panels/EditGroupPanel.tsx index a05f0ea7ef..f4a92b7faa 100644 --- a/src/pages/permissions/panels/EditGroupPanel.tsx +++ b/src/pages/permissions/panels/EditGroupPanel.tsx @@ -167,7 +167,7 @@ const EditGroupPanel: FC = ({ group, onClose }) => { permissions: permissions.filter((p) => !p.isRemoved), }; - const getMutationPromise = () => { + const mutation = () => { if (!canEditGroup(group)) { return saveIdentities(); } @@ -179,7 +179,7 @@ const EditGroupPanel: FC = ({ group, onClose }) => { : updateGroup(groupPayload).then(saveIdentities); }; - getMutationPromise() + mutation() .then(() => { closePanel(); toastNotify.success( diff --git a/src/pages/permissions/panels/EditGroupPermissionsForm.tsx b/src/pages/permissions/panels/EditGroupPermissionsForm.tsx index 1f18f20f2a..1c9201bd80 100644 --- a/src/pages/permissions/panels/EditGroupPermissionsForm.tsx +++ b/src/pages/permissions/panels/EditGroupPermissionsForm.tsx @@ -42,10 +42,17 @@ const EditGroupPermissionsForm: FC = ({ const [search, setSearch] = useState(""); const { canViewPermissions } = useServerEntitlements(); const { canEditGroup } = useGroupEntitlements(); - const permissionEditRestriction = - !canViewPermissions() || !canEditGroup(group) - ? "You do not have permission to edit entitlements for this group" - : ""; + const getEditRestriction = () => { + if (!canEditGroup(group)) { + return "You do not have permission to edit this group"; + } + + if (!canViewPermissions()) { + return "You are not allowed to view permissions"; + } + + return ""; + }; const addPermission = (newPermission: FormPermission) => { const permissionExists = permissions.find( @@ -178,9 +185,9 @@ const EditGroupPermissionsForm: FC = ({ onClick={() => deletePermission(permission.id ?? "")} type="button" aria-label="Delete permission" - title={permissionEditRestriction ?? "Delete permission"} + title={getEditRestriction() ?? "Delete permission"} className="u-no-margin--right" - disabled={!!permissionEditRestriction} + disabled={!!getEditRestriction()} > @@ -223,7 +230,7 @@ const EditGroupPermissionsForm: FC = ({ diff --git a/src/pages/permissions/panels/EditIdentitiesForm.tsx b/src/pages/permissions/panels/EditIdentitiesForm.tsx index b59d0ac628..3e1799a488 100644 --- a/src/pages/permissions/panels/EditIdentitiesForm.tsx +++ b/src/pages/permissions/panels/EditIdentitiesForm.tsx @@ -29,9 +29,9 @@ const EditIdentitiesForm: FC = ({ const { data: identities = [], error } = useIdentities(); const { canEditIdentity } = useIdentityEntitlements(); - const restrictedIdentities = identities.filter( - (identity) => !canEditIdentity(identity), - ); + const restrictedIdentityNames = identities + .filter((identity) => !canEditIdentity(identity)) + .map((identity) => identity.id); if (error) { notify.failure("Loading details failed", error); @@ -42,6 +42,10 @@ const EditIdentitiesForm: FC = ({ ); const toggleRow = (id: string) => { + if (restrictedIdentityNames.includes(id)) { + return; + } + const existing = selected.find((identity) => identity.id === id); if (existing) { const filtered = selected.filter((identity) => identity.id !== id); @@ -171,14 +175,12 @@ const EditIdentitiesForm: FC = ({ .filter((id) => !id.isRemoved) .map((identity) => identity.id)} setSelectedNames={bulkSelect} - processingNames={restrictedIdentities.map( - (identity) => identity.name, - )} + disabledNames={restrictedIdentityNames} filteredNames={fineGrainedIdentities.map((identity) => identity.id)} indeterminateNames={[]} onToggleRow={toggleRow} hideContextualMenu - disableHeaderCheckbox={!!restrictedIdentities.length} + disableSelectAll={!!restrictedIdentityNames.length} /> diff --git a/src/pages/permissions/panels/GroupSelection.tsx b/src/pages/permissions/panels/GroupSelection.tsx index 822f21c54c..dabb2c48b7 100644 --- a/src/pages/permissions/panels/GroupSelection.tsx +++ b/src/pages/permissions/panels/GroupSelection.tsx @@ -145,7 +145,7 @@ const GroupSelection: FC = ({ parentName="" selectedNames={Array.from(selectedGroups)} setSelectedNames={setSelectedGroups} - processingNames={[]} + disabledNames={[]} filteredNames={groups.map((group) => group.name)} indeterminateNames={Array.from(indeterminateGroups ?? new Set())} onToggleRow={toggleGroup} diff --git a/src/pages/storage/StorageVolumeSnapshots.tsx b/src/pages/storage/StorageVolumeSnapshots.tsx index 5fbd64a09e..a1e769e6f7 100644 --- a/src/pages/storage/StorageVolumeSnapshots.tsx +++ b/src/pages/storage/StorageVolumeSnapshots.tsx @@ -276,7 +276,7 @@ const StorageVolumeSnapshots: FC = ({ volume }) => { parentName="instance" selectedNames={selectedNames} setSelectedNames={setSelectedNames} - processingNames={processingNames} + disabledNames={processingNames} filteredNames={filteredSnapshots.map( (snapshot) => snapshot.name, )} diff --git a/src/sass/_selectable_main_table.scss b/src/sass/_selectable_main_table.scss index 8472068236..f80a8eb8ae 100644 --- a/src/sass/_selectable_main_table.scss +++ b/src/sass/_selectable_main_table.scss @@ -38,7 +38,7 @@ } } -.processing-row { +.disabled-row { opacity: 0.5; .actions > * { diff --git a/src/util/entitlements/groups.tsx b/src/util/entitlements/groups.tsx index 1556ddf873..46f7d5e5be 100644 --- a/src/util/entitlements/groups.tsx +++ b/src/util/entitlements/groups.tsx @@ -5,12 +5,12 @@ import { LxdGroup } from "types/permissions"; export const useGroupEntitlements = () => { const { isFineGrained } = useAuth(); - const canEditGroup = (group?: LxdGroup) => - hasEntitlement(isFineGrained, "can_edit", group?.access_entitlements); - const canDeleteGroup = (group?: LxdGroup) => hasEntitlement(isFineGrained, "can_delete", group?.access_entitlements); + const canEditGroup = (group?: LxdGroup) => + hasEntitlement(isFineGrained, "can_edit", group?.access_entitlements); + return { canDeleteGroup, canEditGroup, diff --git a/src/util/entitlements/identities.tsx b/src/util/entitlements/identities.tsx index a7394ab4f3..56eaa4b1d6 100644 --- a/src/util/entitlements/identities.tsx +++ b/src/util/entitlements/identities.tsx @@ -5,12 +5,12 @@ import { LxdIdentity } from "types/permissions"; export const useIdentityEntitlements = () => { const { isFineGrained } = useAuth(); - const canEditIdentity = (identity?: LxdIdentity) => - hasEntitlement(isFineGrained, "can_edit", identity?.access_entitlements); - const canDeleteIdentity = (identity?: LxdIdentity) => hasEntitlement(isFineGrained, "can_delete", identity?.access_entitlements); + const canEditIdentity = (identity?: LxdIdentity) => + hasEntitlement(isFineGrained, "can_edit", identity?.access_entitlements); + return { canDeleteIdentity, canEditIdentity, diff --git a/src/util/entitlements/idp-groups.tsx b/src/util/entitlements/idp-groups.tsx index 0a637ffb2d..a55d5e614a 100644 --- a/src/util/entitlements/idp-groups.tsx +++ b/src/util/entitlements/idp-groups.tsx @@ -5,14 +5,14 @@ import { IdpGroup } from "types/permissions"; export const useIdpGroupEntitlements = () => { const { isFineGrained } = useAuth(); - const canEditGroup = (group?: IdpGroup) => - hasEntitlement(isFineGrained, "can_edit", group?.access_entitlements); - - const canDeleteGroup = (group?: IdpGroup) => + const canDeleteIdpGroup = (group?: IdpGroup) => hasEntitlement(isFineGrained, "can_delete", group?.access_entitlements); + const canEditIdpGroup = (group?: IdpGroup) => + hasEntitlement(isFineGrained, "can_edit", group?.access_entitlements); + return { - canDeleteGroup, - canEditGroup, + canDeleteIdpGroup, + canEditIdpGroup, }; }; diff --git a/src/util/permissionIdpGroups.tsx b/src/util/permissionIdpGroups.tsx index f6ead0f851..2afc893dfd 100644 --- a/src/util/permissionIdpGroups.tsx +++ b/src/util/permissionIdpGroups.tsx @@ -1,5 +1,15 @@ import { AbortControllerState, checkDuplicateName } from "./helpers"; import * as Yup from "yup"; +import { deleteIdpGroups } from "api/auth-idp-groups"; +import { useNotify } from "@canonical/react-components"; +import { useState } from "react"; +import { useIdpGroupEntitlements } from "util/entitlements/idp-groups"; +import ResourceLabel from "components/ResourceLabel"; +import { pluralize } from "util/instanceBulkActions"; +import { useQueryClient } from "@tanstack/react-query"; +import { useToastNotification } from "context/toastNotificationProvider"; +import { IdpGroup } from "types/permissions"; +import { queryKeys } from "./queryKeys"; export const testDuplicateIdpGroupName = ( controllerState: AbortControllerState, @@ -21,3 +31,61 @@ export const testDuplicateIdpGroupName = ( }, ]; }; + +export const useDeleteIdpGroups = (idpGroups: IdpGroup[]) => { + const queryClient = useQueryClient(); + const notify = useNotify(); + const toastNotify = useToastNotification(); + const [isDeleting, setIsDeleting] = useState(false); + const { canDeleteIdpGroup } = useIdpGroupEntitlements(); + + const restrictedGroups: IdpGroup[] = []; + const deletableGroups: IdpGroup[] = []; + idpGroups.forEach((group) => { + if (canDeleteIdpGroup(group)) { + deletableGroups.push(group); + } else { + restrictedGroups.push(group); + } + }); + + const hasOneGroup = deletableGroups.length === 1; + + const handleDeleteIdpGroups = () => { + setIsDeleting(true); + + const successMessage = hasOneGroup ? ( + <> + IDP group{" "} + {" "} + deleted. + + ) : ( + `${deletableGroups.length} IDP groups deleted.` + ); + + deleteIdpGroups(deletableGroups.map((group) => group.name)) + .then(() => { + void queryClient.invalidateQueries({ + queryKey: [queryKeys.idpGroups], + }); + toastNotify.success(successMessage); + }) + .catch((e) => { + notify.failure( + `${pluralize("IDP group", deletableGroups.length)} deletion failed`, + e, + ); + }) + .finally(() => { + setIsDeleting(false); + }); + }; + + return { + deleteIdpGroups: handleDeleteIdpGroups, + isDeleting, + restrictedIdpGroups: restrictedGroups, + deletableIdpGroups: deletableGroups, + }; +};