diff --git a/src/Common/ShimmerTree/index.tsx b/src/Common/ShimmerTree/index.tsx new file mode 100644 index 000000000..ef3579233 --- /dev/null +++ b/src/Common/ShimmerTree/index.tsx @@ -0,0 +1,42 @@ +import { Shimmer, ShimmerElementType, Stack } from "@fluentui/react"; +import * as React from "react"; + +export interface IndentLevel { + level: number, + width?: string +} +interface ShimmerTreeProps { + indentLevels: IndentLevel[]; + style?: React.CSSProperties; +} + +const ShimmerTree = ({ indentLevels, style = {} }: ShimmerTreeProps) => { + /** + * indentLevels - Array of indent levels for shimmer tree + * 0 - Root + * 1 - Level 1 + * 2 - Level 2 + * 3 - Level 3 + * n - Level n + * */ + const renderShimmers = (indent: IndentLevel) => ( + + ); + + return ( + + { + indentLevels.map((indentLevel: IndentLevel) => renderShimmers(indentLevel)) + } + + ); +}; + +export default ShimmerTree; \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx index 59bb024fb..6af75fd38 100644 --- a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx +++ b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx @@ -2,6 +2,7 @@ import React from "react"; import { userContext } from "UserContext"; import { useAADAuth } from "../../../hooks/useAADAuth"; import { useConfig } from "../../../hooks/useConfig"; +import { CopyJobMigrationType } from "../Enums"; import { CopyJobContextProviderType, CopyJobContextState, CopyJobFlowType } from "../Types"; export const CopyJobContext = React.createContext(null); @@ -20,7 +21,7 @@ interface CopyJobContextProviderProps { const getInitialCopyJobState = (): CopyJobContextState => { return { jobName: "", - migrationType: "offline", + migrationType: CopyJobMigrationType.Offline, source: { subscription: null, account: null, @@ -33,6 +34,7 @@ const getInitialCopyJobState = (): CopyJobContextState => { databaseId: "", containerId: "", }, + sourceReadAccessFromTarget: false } } diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.tsx index e87d960a1..24a54cecb 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.tsx @@ -1,10 +1,13 @@ import { Link, Stack, Text, Toggle } from "@fluentui/react"; import React, { useMemo } from "react"; +import { updateSystemIdentity } from "../../../../../Utils/arm/identityUtils"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; import { useCopyJobContext } from "../../../Context/CopyJobContext"; import { buildResourceLink } from "../../../CopyJobUtils"; import InfoTooltip from "../Components/InfoTooltip"; import PopoverMessage from "../Components/PopoverContainer"; +import useManagedIdentity from "./hooks/useManagedIdentity"; +import { PermissionSectionConfig } from "./hooks/usePermissionsSection"; import useToggle from "./hooks/useToggle"; const managedIdentityTooltip = ContainerCopyMessages.addManagedIdentity.managedIdentityTooltip; @@ -12,9 +15,12 @@ const userAssignedTooltip = ContainerCopyMessages.addManagedIdentity.userAssigne const textStyle = { display: "flex", alignItems: "center" }; -const AddManagedIdentity: React.FC = () => { +type AddManagedIdentityProps = Partial; + +const AddManagedIdentity: React.FC = () => { const { copyJobState } = useCopyJobContext(); const [systemAssigned, onToggle] = useToggle(false); + const { loading, handleAddSystemIdentity } = useManagedIdentity(updateSystemIdentity); const manageIdentityLink = useMemo(() => { const { target } = copyJobState; @@ -44,10 +50,12 @@ const AddManagedIdentity: React.FC = () => { onToggle(null, false)} - onPrimary={() => console.log('Primary action taken')} + onPrimary={handleAddSystemIdentity} + > {ContainerCopyMessages.addManagedIdentity.enablementDescription(copyJobState.target?.account?.name)} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx index cec472a53..f35d62701 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx @@ -1,16 +1,45 @@ import { ITooltipHostStyles, Stack, Toggle } from "@fluentui/react"; -import React from "react"; +import React, { useCallback } from "react"; +import { assignRole } from "../../../../../Utils/arm/RbacUtils"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; +import { useCopyJobContext } from "../../../Context/CopyJobContext"; import InfoTooltip from "../Components/InfoTooltip"; import PopoverMessage from "../Components/PopoverContainer"; +import { PermissionSectionConfig } from "./hooks/usePermissionsSection"; import useToggle from "./hooks/useToggle"; const TooltipContent = ContainerCopyMessages.readPermissionAssigned.tooltip; const hostStyles: Partial = { root: { display: 'inline-block' } }; +type AddManagedIdentityProps = Partial; -const AddReadPermissionToDefaultIdentity: React.FC = () => { +const AddReadPermissionToDefaultIdentity: React.FC = () => { + const [loading, setLoading] = React.useState(false); + const { copyJobState, setCopyJobState } = useCopyJobContext(); const [readPermissionAssigned, onToggle] = useToggle(false); + const handleAddReadPermission = useCallback(async () => { + const { source, target } = copyJobState; + try { + setLoading(true); + const assignedRole = await assignRole( + source?.subscription?.subscriptionId, + source?.account?.resourceGroup, + source?.account?.name, + target?.account?.identity?.principalId!, + ); + if (assignedRole) { + setCopyJobState((prevState) => ({ + ...prevState, + sourceReadAccessFromTarget: true, + })); + } + } catch (error) { + console.error("Error assigning read permission to default identity:", error); + } finally { + setLoading(false); + } + }, [copyJobState]); + return (
@@ -28,10 +57,11 @@ const AddReadPermissionToDefaultIdentity: React.FC = () => { }} /> onToggle(null, false)} - onPrimary={() => console.log('Primary action taken')} + onPrimary={handleAddReadPermission} > {ContainerCopyMessages.readPermissionAssigned.popoverDescription} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx index 50de01ae2..8ae19b42a 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx @@ -1,14 +1,19 @@ import { Stack, Toggle } from "@fluentui/react"; import React from "react"; +import { updateDefaultIdentity } from "../../../../../Utils/arm/identityUtils"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; import InfoTooltip from "../Components/InfoTooltip"; import PopoverMessage from "../Components/PopoverContainer"; +import useManagedIdentity from "./hooks/useManagedIdentity"; +import { PermissionSectionConfig } from "./hooks/usePermissionsSection"; import useToggle from "./hooks/useToggle"; const managedIdentityTooltip = ContainerCopyMessages.defaultManagedIdentity.tooltip; +type AddManagedIdentityProps = Partial; -const DefaultManagedIdentity: React.FC = () => { +const DefaultManagedIdentity: React.FC = () => { const [defaultSystemAssigned, onToggle] = useToggle(false); + const { loading, handleAddSystemIdentity } = useManagedIdentity(updateDefaultIdentity); return ( @@ -27,10 +32,11 @@ const DefaultManagedIdentity: React.FC = () => { }} /> onToggle(null, false)} - onPrimary={() => console.log('Primary action taken')} + onPrimary={handleAddSystemIdentity} > {ContainerCopyMessages.defaultManagedIdentity.popoverDescription} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx index b8d041db1..142d1ab4c 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx @@ -3,9 +3,11 @@ import React from "react"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; import { useCopyJobContext } from "../../../Context/CopyJobContext"; import { buildResourceLink } from "../../../CopyJobUtils"; +import { PermissionSectionConfig } from "./hooks/usePermissionsSection"; import useWindowOpenMonitor from "./hooks/useWindowOpenMonitor"; -const OnlineCopyEnabled: React.FC = () => { +type AddManagedIdentityProps = Partial; +const OnlineCopyEnabled: React.FC = () => { const { copyJobState: { source } = {} } = useCopyJobContext(); const sourceAccountLink = buildResourceLink(source?.account); const onlineCopyUrl = `${sourceAccountLink}/Features`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx index 3ef23d0c3..7bbba6cfc 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx @@ -1,18 +1,44 @@ import { PrimaryButton, Stack } from "@fluentui/react"; -import React from "react"; +import React, { useCallback, useState } from "react"; +import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; import { useCopyJobContext } from "../../../Context/CopyJobContext"; import { buildResourceLink } from "../../../CopyJobUtils"; +import { PermissionSectionConfig } from "./hooks/usePermissionsSection"; import useWindowOpenMonitor from "./hooks/useWindowOpenMonitor"; -const PointInTimeRestore: React.FC = () => { - const { copyJobState: { source } = {} } = useCopyJobContext(); +type AddManagedIdentityProps = Partial; +const PointInTimeRestore: React.FC = () => { + const [loading, setLoading] = useState(false); + const { copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext(); const sourceAccountLink = buildResourceLink(source?.account); const pitrUrl = `${sourceAccountLink}/backupRestore`; - const onWindowClosed = () => { - console.log('Point-in-time restore window closed'); - }; + const onWindowClosed = useCallback(async () => { + try { + setLoading(true); + const account = await fetchDatabaseAccount( + source?.subscription?.subscriptionId, + source?.account?.resourceGroup, + source?.account?.name + ); + /* account.properties = { + backupPolicy: { + type: "Continuous" + } + } */ + if (account) { + setCopyJobState((prevState) => ({ + ...prevState, + source: { ...prevState.source, account: account } + })); + } + } catch (error) { + console.error("Error fetching database account after PITR window closed:", error); + } finally { + setLoading(false); + } + }, []) const openWindowAndMonitor = useWindowOpenMonitor(pitrUrl, onWindowClosed); return ( @@ -21,7 +47,9 @@ const PointInTimeRestore: React.FC = () => { {ContainerCopyMessages.pointInTimeRestore.description}
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx new file mode 100644 index 000000000..30d9ca193 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx @@ -0,0 +1,49 @@ +import { DatabaseAccount } from "Contracts/DataModels"; +import { useCallback, useState } from "react"; +import { useCopyJobContext } from "../../../../Context/CopyJobContext"; + +interface UseManagedIdentityUpdaterParams { + updateIdentityFn: ( + subscriptionId: string, + resourceGroup?: string, + accountName?: string + ) => Promise; +} + +interface UseManagedIdentityUpdaterReturn { + loading: boolean; + handleAddSystemIdentity: () => Promise; +} + +const useManagedIdentity = ( + updateIdentityFn: UseManagedIdentityUpdaterParams["updateIdentityFn"] +): UseManagedIdentityUpdaterReturn => { + const { copyJobState, setCopyJobState } = useCopyJobContext(); + const [loading, setLoading] = useState(false); + + const handleAddSystemIdentity = useCallback(async (): Promise => { + try { + setLoading(true); + const { target } = copyJobState; + const updatedAccount = await updateIdentityFn( + target.subscriptionId, + target.account?.resourceGroup, + target.account?.name + ); + if (updatedAccount) { + setCopyJobState((prevState) => ({ + ...prevState, + target: { ...prevState.target, account: updatedAccount } + })); + } + } catch (error) { + console.error("Error enabling system-assigned managed identity:", error); + } finally { + setLoading(false); + } + }, [copyJobState, updateIdentityFn, setCopyJobState]); + + return { loading, handleAddSystemIdentity }; +}; + +export default useManagedIdentity; \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx index fc9b4b4d9..8e2917a86 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx @@ -1,6 +1,9 @@ -import { useRoleAssignments } from "hooks/useRoleAssignments"; -import { RoleDefinitionType, useRoleDefinitions } from "hooks/useRoleDefinition"; -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + fetchRoleAssignments, + fetchRoleDefinitions, + RoleDefinitionType +} from "../../../../../../Utils/arm/RbacUtils"; import ContainerCopyMessages from "../../../../ContainerCopyMessages"; import { BackupPolicyType, @@ -9,6 +12,7 @@ import { IdentityType } from "../../../../Enums"; import { CopyJobContextState } from "../../../../Types"; +import { useCopyJobPrerequisitesCache } from "../../../Utils/useCopyJobPrerequisitesCache"; import AddManagedIdentity from "../AddManagedIdentity"; import AddReadPermissionToDefaultIdentity from "../AddReadPermissionToDefaultIdentity"; import DefaultManagedIdentity from "../DefaultManagedIdentity"; @@ -18,13 +22,14 @@ import PointInTimeRestore from "../PointInTimeRestore"; export interface PermissionSectionConfig { id: string; title: string; - Component: React.FC; - disabled?: boolean; + Component: React.ComponentType + disabled: boolean; completed?: boolean; + validate?: (state: CopyJobContextState, armToken?: string) => boolean | Promise; } // Section IDs for maintainability -const SECTION_IDS = { +export const SECTION_IDS = { addManagedIdentity: "addManagedIdentity", defaultManagedIdentity: "defaultManagedIdentity", readPermissionAssigned: "readPermissionAssigned", @@ -37,16 +42,46 @@ const PERMISSION_SECTIONS_CONFIG: PermissionSectionConfig[] = [ id: SECTION_IDS.addManagedIdentity, title: ContainerCopyMessages.addManagedIdentity.title, Component: AddManagedIdentity, + disabled: true, + validate: (state: CopyJobContextState) => { + const targetAccountIdentityType = (state?.target?.account?.identity?.type ?? "").toLowerCase(); + return ( + targetAccountIdentityType === IdentityType.SystemAssigned || + targetAccountIdentityType === IdentityType.UserAssigned + ); + } }, { id: SECTION_IDS.defaultManagedIdentity, title: ContainerCopyMessages.defaultManagedIdentity.title, Component: DefaultManagedIdentity, + disabled: true, + validate: (state: CopyJobContextState) => { + const targetAccountDefaultIdentity = (state?.target?.account?.properties?.defaultIdentity ?? "").toLowerCase(); + return targetAccountDefaultIdentity === DefaultIdentityType.SystemAssignedIdentity; + } }, { id: SECTION_IDS.readPermissionAssigned, title: ContainerCopyMessages.readPermissionAssigned.title, Component: AddReadPermissionToDefaultIdentity, + disabled: true, + validate: async (state: CopyJobContextState, armToken?: string) => { + const principalId = state?.target?.account?.identity?.principalId; + const rolesAssigned = await fetchRoleAssignments( + armToken, + state.source?.subscription?.subscriptionId, + state.source?.account?.resourceGroup, + state.source?.account?.name, + principalId + ); + + const roleDefinitions = await fetchRoleDefinitions( + armToken, + rolesAssigned ?? [] + ); + return checkTargetHasReaderRoleOnSource(roleDefinitions ?? []); + } } ]; @@ -55,11 +90,20 @@ const PERMISSION_SECTIONS_FOR_ONLINE_JOBS: PermissionSectionConfig[] = [ id: SECTION_IDS.pointInTimeRestore, title: ContainerCopyMessages.pointInTimeRestore.title, Component: PointInTimeRestore, + disabled: true, + validate: (state: CopyJobContextState) => { + const sourceAccountBackupPolicy = state?.source?.account?.properties?.backupPolicy?.type ?? ""; + return sourceAccountBackupPolicy === BackupPolicyType.Continuous; + } }, { id: SECTION_IDS.onlineCopyEnabled, title: ContainerCopyMessages.onlineCopyEnabled.title, Component: OnlineCopyEnabled, + disabled: true, + validate: (_state: CopyJobContextState) => { + return false; + } } ]; @@ -67,7 +111,7 @@ const PERMISSION_SECTIONS_FOR_ONLINE_JOBS: PermissionSectionConfig[] = [ /** * Checks if the user has the Reader role based on role definitions. */ -export function checkUserHasReaderRole(roleDefinitions: RoleDefinitionType[]): boolean { +export function checkTargetHasReaderRoleOnSource(roleDefinitions: RoleDefinitionType[]): boolean { return roleDefinitions?.some( role => role.name === "00000000-0000-0000-0000-000000000001" || @@ -86,105 +130,76 @@ export function checkUserHasReaderRole(roleDefinitions: RoleDefinitionType[]): b const usePermissionSections = ( state: CopyJobContextState, armToken: string, - principalId: string ): PermissionSectionConfig[] => { - const { source, target } = state; + const { validationCache, setValidationCache } = useCopyJobPrerequisitesCache(); + const [permissionSections, setPermissionSections] = useState(null); + const isValidatingRef = useRef(false); - // Memoize identity types and backup policy - const targetAccountIdentityType = useMemo( - () => (target?.account?.identity?.type ?? "").toLowerCase(), - [target?.account?.identity?.type] - ); - const targetAccountDefaultIdentityType = useMemo( - () => (target?.account?.properties?.defaultIdentity ?? "").toLowerCase(), - [target?.account?.properties?.defaultIdentity] - ); - const sourceAccountBackupPolicy = useMemo( - () => source?.account?.properties?.backupPolicy?.type ?? "", - [source?.account?.properties?.backupPolicy?.type] - ); + const sectionToValidate = useMemo(() => { + const baseSections = [...PERMISSION_SECTIONS_CONFIG]; + if (state.migrationType === CopyJobMigrationType.Online) { + return [...baseSections, ...PERMISSION_SECTIONS_FOR_ONLINE_JOBS]; + } + return baseSections; + }, [state.migrationType]); - // Fetch role assignments and definitions - const roleAssigned = useRoleAssignments( - armToken, - source?.subscription?.subscriptionId, - source?.account?.resourceGroup, - source?.account?.name, - principalId - ); + const memoizedValidationCache = useMemo(() => { + if (state.migrationType === CopyJobMigrationType.Offline) { + validationCache.delete(SECTION_IDS.pointInTimeRestore); + validationCache.delete(SECTION_IDS.onlineCopyEnabled); + } + return validationCache; + }, [state.migrationType]); - const roleDefinitions = useRoleDefinitions( - armToken, - roleAssigned ?? [] - ); + useEffect(() => { + const validateSections = async () => { + if (isValidatingRef.current) return; - const hasReaderRole = useMemo( - () => checkUserHasReaderRole(roleDefinitions ?? []), - [roleDefinitions] - ); + isValidatingRef.current = true; + const result: PermissionSectionConfig[] = []; + const newValidationCache = new Map(memoizedValidationCache); - // Decouple section state logic for testability - const getBaseSections = useMemo(() => { - return PERMISSION_SECTIONS_CONFIG.map(section => { - if ( - section.id === SECTION_IDS.addManagedIdentity && - (targetAccountIdentityType === IdentityType.SystemAssigned || - targetAccountIdentityType === IdentityType.UserAssigned) - ) { - return { - ...section, - disabled: true, - completed: true - }; + for (let i = 0; i < sectionToValidate.length; i++) { + const section = sectionToValidate[i]; + + // Check if this section was already validated and passed + if (newValidationCache.has(section.id) && newValidationCache.get(section.id) === true) { + result.push({ ...section, completed: true }); + continue; + } + // We've reached the first non-cached section - validate it + if (section.validate) { + const isValid = await section.validate(state, armToken); + newValidationCache.set(section.id, isValid); + result.push({ ...section, completed: isValid }); + // Stop validation if current section failed + if (!isValid) { + for (let j = i + 1; j < sectionToValidate.length; j++) { + result.push({ ...sectionToValidate[j], completed: false }); + } + break; + } + } + else { + // Section has no validate method + newValidationCache.set(section.id, false); + result.push({ ...section, completed: false }); + } } - if ( - section.id === SECTION_IDS.defaultManagedIdentity && - targetAccountDefaultIdentityType === DefaultIdentityType.SystemAssignedIdentity - ) { - return { - ...section, - disabled: true, - completed: true - }; - } - if ( - section.id === SECTION_IDS.readPermissionAssigned && - hasReaderRole - ) { - return { - ...section, - disabled: true, - completed: true - }; - } - return section; - }); - }, [targetAccountIdentityType, targetAccountDefaultIdentityType, hasReaderRole]); - const getOnlineSections = useMemo(() => { - if (state.migrationType !== CopyJobMigrationType.Online) return []; - return PERMISSION_SECTIONS_FOR_ONLINE_JOBS.map(section => { - if ( - section.id === SECTION_IDS.pointInTimeRestore && - sourceAccountBackupPolicy === BackupPolicyType.Continuous - ) { - return { - ...section, - disabled: true, - completed: true - }; - } - return section; - }); - }, [state.migrationType, sourceAccountBackupPolicy]); + setValidationCache(newValidationCache); + setPermissionSections(result); + isValidatingRef.current = false; + } - // Combine and memoize final sections - const permissionSections = useMemo( - () => [...getBaseSections, ...getOnlineSections], - [getBaseSections, getOnlineSections] - ); + validateSections(); - return permissionSections; + return () => { + isValidatingRef.current = false; + } + }, [state, armToken, sectionToValidate]); + + return permissionSections ?? []; }; export default usePermissionSections; \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/index.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/index.tsx index efcd34567..b3fee3fb4 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/index.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/index.tsx @@ -1,10 +1,17 @@ import { Image, Stack, Text } from "@fluentui/react"; -import { Accordion, AccordionHeader, AccordionItem, AccordionPanel } from "@fluentui/react-components"; -import React from "react"; +import { + Accordion, + AccordionHeader, + AccordionItem, + AccordionPanel +} from "@fluentui/react-components"; +import React, { useEffect } from "react"; import CheckmarkIcon from "../../../../../../images/successfulPopup.svg"; import WarningIcon from "../../../../../../images/warning.svg"; +import ShimmerTree, { IndentLevel } from "../../../../../Common/ShimmerTree"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import { CopyJobMigrationType } from "../../../Enums"; import usePermissionSections, { PermissionSectionConfig } from "./hooks/usePermissionsSection"; const PermissionSection: React.FC = ({ @@ -32,20 +39,49 @@ const PermissionSection: React.FC = ({ ); const AssignPermissions = () => { - const { armToken, principalId, copyJobState } = useCopyJobContext(); - const permissionSections = usePermissionSections(copyJobState, armToken, principalId); + const { armToken, copyJobState } = useCopyJobContext(); + const permissionSections = usePermissionSections(copyJobState, armToken); + const [openItems, setOpenItems] = React.useState([]); + + const indentLevels = React.useMemo( + () => Array(copyJobState.migrationType === CopyJobMigrationType.Online ? 5 : 3).fill({ level: 0, width: "100%" }), + [] + ); + + /* const onMoveToNextSection: AccordionToggleEventHandler = useCallback((_event, data) => { + setOpenItems(data.openItems); + }, []); */ + + useEffect(() => { + const firstIncompleteSection = permissionSections.find(section => !section.completed); + const nextOpenItems = firstIncompleteSection ? [firstIncompleteSection.id] : []; + if (JSON.stringify(openItems) !== JSON.stringify(nextOpenItems)) { + setOpenItems(nextOpenItems); + } + }, [permissionSections]); + return ( {ContainerCopyMessages.assignPermissions.description} - - { - permissionSections.map(section => ( - - )) - } - + { + permissionSections?.length === 0 ? ( + + ) : ( + + { + permissionSections.map(section => ( + + )) + } + + ) + } ); }; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx index dc8424233..097178634 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx @@ -2,26 +2,33 @@ import { DefaultButton, PrimaryButton, Stack, Text } from "@fluentui/react"; import React from "react"; interface PopoverContainerProps { + isLoading?: boolean; title?: string; children?: React.ReactNode; onPrimary: () => void; onCancel: () => void; } -const PopoverContainer: React.FC = React.memo(({ title, children, onPrimary, onCancel }) => { +const PopoverContainer: React.FC = React.memo(({ isLoading = false, title, children, onPrimary, onCancel }) => { return ( - + {title} {children} - - + + ); }); interface PopoverMessageProps { + isLoading?: boolean; visible: boolean; title: string; onCancel: () => void; @@ -29,10 +36,10 @@ interface PopoverMessageProps { children: React.ReactNode; } -const PopoverMessage: React.FC = ({ visible, title, onCancel, onPrimary, children }) => { +const PopoverMessage: React.FC = ({ isLoading = false, visible, title, onCancel, onPrimary, children }) => { if (!visible) return null; return ( - + {children} ); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts index ffe9ef86e..538422304 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts @@ -1,7 +1,8 @@ -import { submitCreateCopyJob } from "Explorer/ContainerCopy/Actions/CopyJobActions"; import { useCallback, useMemo, useReducer } from "react"; import { useSidePanel } from "../../../../hooks/useSidePanel"; +import { submitCreateCopyJob } from "../../Actions/CopyJobActions"; import { CopyJobContextState } from "../../Types"; +import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache"; import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList"; type NavigationState = { @@ -34,14 +35,18 @@ function navigationReducer(state: NavigationState, action: Action): NavigationSt export function useCopyJobNavigation(copyJobState: CopyJobContextState) { const screens = useCreateCopyJobScreensList(); + const { validationCache: cache } = useCopyJobPrerequisitesCache(); const [state, dispatch] = useReducer(navigationReducer, { screenHistory: [SCREEN_KEYS.SelectAccount] }); const currentScreenKey = state.screenHistory[state.screenHistory.length - 1]; const currentScreen = screens.find((screen) => screen.key === currentScreenKey); const isPrimaryDisabled = useMemo( - () => !currentScreen?.validations.every((v) => v.validate(copyJobState)), - [currentScreen.key, copyJobState] + () => { + const context = currentScreenKey === SCREEN_KEYS.AssignPermissions ? cache : copyJobState; + return !currentScreen?.validations.every((v) => v.validate(context)); + }, + [currentScreen.key, copyJobState, cache] ); const primaryBtnText = useMemo(() => { if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) { @@ -53,13 +58,16 @@ export function useCopyJobNavigation(copyJobState: CopyJobContextState) { const isPreviousDisabled = state.screenHistory.length <= 1; const handlePrimary = useCallback(() => { - if (currentScreenKey === SCREEN_KEYS.SelectAccount) { - dispatch({ type: "NEXT", nextScreen: SCREEN_KEYS.SelectSourceAndTargetContainers }); - } - if (currentScreenKey === SCREEN_KEYS.SelectSourceAndTargetContainers) { - dispatch({ type: "NEXT", nextScreen: SCREEN_KEYS.PreviewCopyJob }); - } - if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) { + const transitions = { + [SCREEN_KEYS.SelectAccount]: SCREEN_KEYS.AssignPermissions, + [SCREEN_KEYS.AssignPermissions]: SCREEN_KEYS.SelectSourceAndTargetContainers, + [SCREEN_KEYS.SelectSourceAndTargetContainers]: SCREEN_KEYS.PreviewCopyJob, + }; + + const nextScreen = transitions[currentScreenKey]; + if (nextScreen) { + dispatch({ type: "NEXT", nextScreen }); + } else if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) { submitCreateCopyJob(copyJobState); } }, [currentScreenKey, copyJobState]); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobPrerequisitesCache.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobPrerequisitesCache.tsx new file mode 100644 index 000000000..471365cb2 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobPrerequisitesCache.tsx @@ -0,0 +1,11 @@ +import create from "zustand"; + +interface CopyJobPrerequisitesCacheState { + validationCache: Map; + setValidationCache: (cache: Map) => void; +} + +export const useCopyJobPrerequisitesCache = create((set) => ({ + validationCache: new Map(), + setValidationCache: (cache) => set({ validationCache: cache }) +})); \ No newline at end of file diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx index 9ea950d22..a9a6027b2 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx @@ -13,7 +13,7 @@ const SCREEN_KEYS = { }; type Validation = { - validate: (state: CopyJobContextState) => boolean; + validate: (state: CopyJobContextState | Map) => boolean; message: string; }; @@ -61,7 +61,18 @@ function useCreateCopyJobScreensList() { { key: SCREEN_KEYS.AssignPermissions, component: , - validations: [], + validations: [ + { + validate: (cache: Map) => { + const cacheValuesIterator = Array.from(cache.values()); + if (cacheValuesIterator.length === 0) return false; + + const allValid = cacheValuesIterator.every((isValid: boolean) => isValid); + return allValid; + }, + message: "Please ensure all previous steps are valid to proceed", + } + ], }, ], [] diff --git a/src/Explorer/ContainerCopy/Types/index.ts b/src/Explorer/ContainerCopy/Types/index.ts index 88071c021..0cedfb875 100644 --- a/src/Explorer/ContainerCopy/Types/index.ts +++ b/src/Explorer/ContainerCopy/Types/index.ts @@ -58,6 +58,7 @@ export interface DatabaseContainerSectionProps { export interface CopyJobContextState { jobName: string; migrationType: CopyJobMigrationType; + sourceReadAccessFromTarget: boolean; // source details source: { subscription: Subscription; diff --git a/src/Explorer/ContainerCopy/containerCopyStyles.less b/src/Explorer/ContainerCopy/containerCopyStyles.less index 84ba37b53..3241f00da 100644 --- a/src/Explorer/ContainerCopy/containerCopyStyles.less +++ b/src/Explorer/ContainerCopy/containerCopyStyles.less @@ -60,13 +60,18 @@ } } } - + .popover-container { + button[disabled] { + cursor: not-allowed; + opacity: 0.8; + } + } .foreground { z-index: 10; background-color: white; padding: 20px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); - // transform: translate(0%, -5%); + transform: translate(0%, -9%); position: absolute; } } diff --git a/src/Utils/arm/RbacUtils.ts b/src/Utils/arm/RbacUtils.ts new file mode 100644 index 000000000..6275f2019 --- /dev/null +++ b/src/Utils/arm/RbacUtils.ts @@ -0,0 +1,128 @@ +import { configContext } from "ConfigContext"; +import { armRequest } from "Utils/arm/request"; + +export type FetchAccountDetailsParams = { + armToken: string; + subscriptionId: string; + resourceGroupName: string; + accountName: string; +}; + +export type RoleAssignmentPropertiesType = { + roleDefinitionId: string; + principalId: string; + scope: string; +}; + +export type RoleAssignmentType = { + id: string; + name: string; + properties: RoleAssignmentPropertiesType; + type: string; +}; + +type RoleDefinitionDataActions = { + dataActions: string[]; +}; + +export type RoleDefinitionType = { + assignableScopes: string[]; + id: string; + name: string; + permissions: RoleDefinitionDataActions[]; + resourceGroup: string; + roleName: string; + type: string; + typePropertiesType: string; +}; + +const apiVersion = "2025-04-15"; + +const getArmBaseUrl = (): string => { + const base = configContext.ARM_ENDPOINT; + return base.endsWith("/") ? base.slice(0, -1) : base; +}; + +const createAuthHeaders = (armToken: string): Headers => { + const headers = new Headers(); + headers.append("Authorization", `Bearer ${armToken}`); + headers.append("Content-Type", "application/json"); + return headers; +}; + +const buildArmUrl = (path: string): string => + `${getArmBaseUrl()}${path}?api-version=${apiVersion}`; + +const handleResponse = async (response: Response, context: string) => { + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `Failed to fetch ${context}. Status: ${response.status}. ${body || ""}` + ); + } + return response.json(); +}; + +export const fetchRoleAssignments = async ( + armToken: string, + subscriptionId: string, + resourceGroupName: string, + accountName: string, + principalId: string +): Promise => { + const uri = buildArmUrl( + `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/sqlRoleAssignments` + ); + + const response = await fetch(uri, { method: "GET", headers: createAuthHeaders(armToken) }); + const data = await handleResponse(response, "role assignments"); + + return (data.value || []).filter( + (assignment: RoleAssignmentType) => + assignment?.properties?.principalId === principalId + ); +}; + +export const fetchRoleDefinitions = async ( + armToken: string, + roleAssignments: RoleAssignmentType[] +): Promise => { + const roleDefinitionIds = roleAssignments.map(assignment => assignment.properties.roleDefinitionId); + const uniqueRoleDefinitionIds = Array.from(new Set(roleDefinitionIds)); + + const headers = createAuthHeaders(armToken); + const roleDefinitionUris = uniqueRoleDefinitionIds.map((id) => buildArmUrl(id)); + + const promises = roleDefinitionUris.map((url) => fetch(url, { method: "GET", headers })); + const responses = await Promise.all(promises); + + const roleDefinitions = await Promise.all( + responses.map((res, i) => handleResponse(res, `role definition ${uniqueRoleDefinitionIds[i]}`)) + ); + + return roleDefinitions; +}; + +export const assignRole = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string, + principalId: string +): Promise => { + const accountScope = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}`; + const roleDefinitionId = `${accountScope}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000001`; + const roleAssignmentName = crypto.randomUUID(); + const path = `${accountScope}/sqlRoleAssignments/${roleAssignmentName}`; + + const body = { + properties: { + roleDefinitionId, + scope: `${accountScope}/`, + principalId + } + }; + const response: RoleAssignmentType = await armRequest({ + host: configContext.ARM_ENDPOINT, path, method: "PUT", apiVersion, body + }); + return response; +}; diff --git a/src/Utils/arm/databaseAccountUtils.ts b/src/Utils/arm/databaseAccountUtils.ts new file mode 100644 index 000000000..be160f5ba --- /dev/null +++ b/src/Utils/arm/databaseAccountUtils.ts @@ -0,0 +1,38 @@ +import { DatabaseAccount } from "Contracts/DataModels"; +import { userContext } from "UserContext"; +import { configContext } from "../../ConfigContext"; + +const apiVersion = "2025-04-15"; +export type FetchAccountDetailsParams = { + subscriptionId: string; + resourceGroupName: string; + accountName: string; +}; + +const buildUrl = (params: FetchAccountDetailsParams): string => { + const { subscriptionId, resourceGroupName, accountName } = params; + + let armEndpoint = configContext.ARM_ENDPOINT; + if (armEndpoint.endsWith("/")) { + armEndpoint = armEndpoint.slice(0, -1); + } + return `${armEndpoint}/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}?api-version=${apiVersion}`; +} + +export async function fetchDatabaseAccount( + subscriptionId: string, + resourceGroupName: string, + accountName: string +) { + const headers = new Headers(); + headers.append("Authorization", userContext.authorizationToken); + headers.append("Content-Type", "application/json"); + const uri = buildUrl({ subscriptionId, resourceGroupName, accountName }); + const response = await fetch(uri, { method: "GET", headers: headers }); + + if (!response.ok) { + throw new Error(`Error fetching database account: ${response.statusText}`); + } + const account: DatabaseAccount = await response.json(); + return account; +} diff --git a/src/Utils/arm/identityUtils.ts b/src/Utils/arm/identityUtils.ts new file mode 100644 index 000000000..be6969f0b --- /dev/null +++ b/src/Utils/arm/identityUtils.ts @@ -0,0 +1,56 @@ +import { DatabaseAccount } from "Contracts/DataModels"; +import { configContext } from "../../ConfigContext"; +import { fetchDatabaseAccount } from "./databaseAccountUtils"; +import { armRequest } from "./request"; + +const apiVersion = "2025-04-15"; + +const updateIdentity = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string, + body: object +): Promise => { + const path = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}`; + const response: { status: string } = await armRequest({ + host: configContext.ARM_ENDPOINT, path, method: "PATCH", apiVersion, body + }); + if (response.status === "Succeeded") { + const account = await fetchDatabaseAccount(subscriptionId, resourceGroupName, accountName); + return account; + } + return null; +}; + +const updateSystemIdentity = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string +): Promise => { + const body = { + identity: { + type: "SystemAssigned" + } + }; + const updatedAccount = await updateIdentity(subscriptionId, resourceGroupName, accountName, body); + return updatedAccount; +}; + +const updateDefaultIdentity = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string +): Promise => { + const body = { + properties: { + defaultIdentity: "SystemAssignedIdentity" + } + }; + const updatedAccount = await updateIdentity(subscriptionId, resourceGroupName, accountName, body); + return updatedAccount; +}; + + + +export { updateDefaultIdentity, updateSystemIdentity }; + diff --git a/src/hooks/useRoleAssignments.tsx b/src/hooks/useRoleAssignments.tsx deleted file mode 100644 index 5860260f2..000000000 --- a/src/hooks/useRoleAssignments.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import useSWR from "swr"; -import { configContext } from "../ConfigContext"; - -const apiVersion = "2025-04-15"; - -export type FetchAccountDetailsParams = { - armToken: string; - subscriptionId: string; - resourceGroupName: string; - accountName: string; -}; - -export type RoleAssignmentPropertiesType = { - roleDefinitionId: string; - principalId: string; - scope: string; -}; - -export type RoleAssignmentType = { - id: string; - name: string; - properties: RoleAssignmentPropertiesType; - type: string; -}; - -const buildRoleAssignmentsListUrl = (params: FetchAccountDetailsParams): string => { - const { subscriptionId, resourceGroupName, accountName } = params; - - let armEndpoint = configContext.ARM_ENDPOINT; - if (armEndpoint.endsWith("/")) { - armEndpoint = armEndpoint.slice(0, -1); - } - return `${armEndpoint}/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/sqlRoleAssignments?api-version=${apiVersion}`; -} - -const fetchRoleAssignments = async ( - armToken: string, - subscriptionId: string, - resourceGroupName: string, - accountName: string, - principalId: string -): Promise => { - const uri = buildRoleAssignmentsListUrl({ - armToken, - subscriptionId, - resourceGroupName, - accountName - }); - const headers = new Headers(); - const bearer = `Bearer ${armToken}`; - headers.append("Authorization", bearer); - headers.append("Content-Type", "application/json"); - - const response = await fetch(uri, { - method: "GET", - headers: headers - }); - - if (!response.ok) { - throw new Error("Failed to fetch containers"); - } - - const data = await response.json(); - const assignments = data.value; - const rolesAssignedToLoggedinUser = assignments.filter((assignment: RoleAssignmentType) => assignment?.properties?.principalId === principalId); - return rolesAssignedToLoggedinUser; -}; - -export function useRoleAssignments( - armToken: string, - subscriptionId: string, - resourceGroupName: string, - accountName: string, - principalId: string -): RoleAssignmentType[] | undefined { - const { data } = useSWR( - () => ( - armToken && subscriptionId && resourceGroupName && accountName && principalId ? [ - "fetchRoleAssignmentsLinkedToAccount", - armToken, subscriptionId, resourceGroupName, accountName, principalId - ] : undefined - ), - (_, armToken, subscriptionId, resourceGroupName, accountName, principalId) => fetchRoleAssignments( - armToken, - subscriptionId, - resourceGroupName, - accountName, - principalId - ), - ); - - return data; -} \ No newline at end of file diff --git a/src/hooks/useRoleDefinition.tsx b/src/hooks/useRoleDefinition.tsx deleted file mode 100644 index 9774ed8f2..000000000 --- a/src/hooks/useRoleDefinition.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import useSWR from "swr"; -import { configContext } from "../ConfigContext"; -import { RoleAssignmentType } from "./useRoleAssignments"; - -type RoleDefinitionDataActions = { - dataActions: string[]; -}; - -export type RoleDefinitionType = { - assignableScopes: string[]; - id: string; - name: string; - permissions: RoleDefinitionDataActions[]; - resourceGroup: string; - roleName: string; - type: string; - typePropertiesType: string; -}; - -const apiVersion = "2025-04-15"; -const buildRoleDefinitionUrl = (roleDefinitionId: string): string => { - let armEndpoint = configContext.ARM_ENDPOINT; - if (armEndpoint.endsWith("/")) { - armEndpoint = armEndpoint.slice(0, -1); - } - return `${armEndpoint}${roleDefinitionId}?api-version=${apiVersion}`; -} - -const fetchRoleDefinitions = async ( - armToken: string, - roleAssignments: RoleAssignmentType[], -): Promise => { - const roleDefinitionIds = roleAssignments.map(assignment => assignment.properties.roleDefinitionId); - const uniqueRoleDefinitionIds = Array.from(new Set(roleDefinitionIds)); - - const roleDefinitionUris = uniqueRoleDefinitionIds.map(roleDefinitionId => buildRoleDefinitionUrl(roleDefinitionId)); - const headers = { - Authorization: `Bearer ${armToken}`, - "Content-Type": "application/json" - }; - const promises = roleDefinitionUris.map(uri => fetch(uri, { method: "GET", headers })); - const responses = await Promise.all(promises); - for (const response of responses) { - if (!response.ok) throw new Error("Failed to fetch role definitions"); - } - const roleDefinitions = await Promise.all(responses.map(r => r.json())); - return roleDefinitions; -}; - -export function useRoleDefinitions( - armToken: string, - roleAssignments: RoleAssignmentType[], -): RoleDefinitionType[] | undefined { - const { data } = useSWR( - () => ( - armToken && roleAssignments?.length ? [ - "fetchRoleDefinitionsForTheAssignments", - armToken, roleAssignments - ] : undefined - ), - (_, armToken, roleAssignments) => fetchRoleDefinitions( - armToken, - roleAssignments - ), - ); - - return data; -} \ No newline at end of file