added copyjob pre-requsite screen along with it's validations

This commit is contained in:
Bikram Choudhury
2025-10-21 18:53:02 +05:30
parent a23a7791d4
commit c504d97f7c
21 changed files with 626 additions and 304 deletions

View File

@@ -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) => (
<Shimmer
key={Math.random()}
shimmerElements={[
{ type: ShimmerElementType.gap, width: `${indent.level * 20}px` }, // Indent for hierarchy
{ type: ShimmerElementType.line, height: 16, width: indent.width || "100%" },
]}
style={{ marginBottom: 8 }}
/>
);
return (
<Stack tokens={{ childrenGap: 8 }} style={{ width: "50%", ...style }} data-testid="shimmer-stack">
{
indentLevels.map((indentLevel: IndentLevel) => renderShimmers(indentLevel))
}
</Stack>
);
};
export default ShimmerTree;

View File

@@ -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<CopyJobContextProviderType>(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
}
}

View File

@@ -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<PermissionSectionConfig>;
const AddManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
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 = () => {
</Link>
</div>
<PopoverMessage
isLoading={loading}
visible={systemAssigned}
title={ContainerCopyMessages.addManagedIdentity.enablementTitle}
onCancel={() => onToggle(null, false)}
onPrimary={() => console.log('Primary action taken')}
onPrimary={handleAddSystemIdentity}
>
{ContainerCopyMessages.addManagedIdentity.enablementDescription(copyJobState.target?.account?.name)}
</PopoverMessage>

View File

@@ -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<ITooltipHostStyles> = { root: { display: 'inline-block' } };
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
const AddReadPermissionToDefaultIdentity: React.FC = () => {
const AddReadPermissionToDefaultIdentity: React.FC<AddManagedIdentityProps> = () => {
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 (
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<div className="toggle-label">
@@ -28,10 +57,11 @@ const AddReadPermissionToDefaultIdentity: React.FC = () => {
}}
/>
<PopoverMessage
isLoading={loading}
visible={readPermissionAssigned}
title={ContainerCopyMessages.readPermissionAssigned.popoverTitle}
onCancel={() => onToggle(null, false)}
onPrimary={() => console.log('Primary action taken')}
onPrimary={handleAddReadPermission}
>
{ContainerCopyMessages.readPermissionAssigned.popoverDescription}
</PopoverMessage>

View File

@@ -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<PermissionSectionConfig>;
const DefaultManagedIdentity: React.FC = () => {
const DefaultManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
const [defaultSystemAssigned, onToggle] = useToggle(false);
const { loading, handleAddSystemIdentity } = useManagedIdentity(updateDefaultIdentity);
return (
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
@@ -27,10 +32,11 @@ const DefaultManagedIdentity: React.FC = () => {
}}
/>
<PopoverMessage
isLoading={loading}
visible={defaultSystemAssigned}
title={ContainerCopyMessages.defaultManagedIdentity.popoverTitle}
onCancel={() => onToggle(null, false)}
onPrimary={() => console.log('Primary action taken')}
onPrimary={handleAddSystemIdentity}
>
{ContainerCopyMessages.defaultManagedIdentity.popoverDescription}
</PopoverMessage>

View File

@@ -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<PermissionSectionConfig>;
const OnlineCopyEnabled: React.FC<AddManagedIdentityProps> = () => {
const { copyJobState: { source } = {} } = useCopyJobContext();
const sourceAccountLink = buildResourceLink(source?.account);
const onlineCopyUrl = `${sourceAccountLink}/Features`;

View File

@@ -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<PermissionSectionConfig>;
const PointInTimeRestore: React.FC<AddManagedIdentityProps> = () => {
const [loading, setLoading] = useState<boolean>(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}
</div>
<PrimaryButton
text={ContainerCopyMessages.pointInTimeRestore.buttonText}
text={loading ? "" : ContainerCopyMessages.pointInTimeRestore.buttonText}
{...(loading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
disabled={loading}
onClick={openWindowAndMonitor}
/>
</Stack>

View File

@@ -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<DatabaseAccount | undefined>;
}
interface UseManagedIdentityUpdaterReturn {
loading: boolean;
handleAddSystemIdentity: () => Promise<void>;
}
const useManagedIdentity = (
updateIdentityFn: UseManagedIdentityUpdaterParams["updateIdentityFn"]
): UseManagedIdentityUpdaterReturn => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const [loading, setLoading] = useState<boolean>(false);
const handleAddSystemIdentity = useCallback(async (): Promise<void> => {
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;

View File

@@ -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<boolean>;
}
// 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<PermissionSectionConfig[] | null>(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;

View File

@@ -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<PermissionSectionConfig> = ({
@@ -32,20 +39,49 @@ const PermissionSection: React.FC<PermissionSectionConfig> = ({
);
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<string[]>([]);
const indentLevels = React.useMemo<IndentLevel[]>(
() => Array(copyJobState.migrationType === CopyJobMigrationType.Online ? 5 : 3).fill({ level: 0, width: "100%" }),
[]
);
/* const onMoveToNextSection: AccordionToggleEventHandler<string> = 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 (
<Stack className="assignPermissionsContainer" tokens={{ childrenGap: 15 }}>
<span>
{ContainerCopyMessages.assignPermissions.description}
</span>
<Accordion className="permissionsAccordion" collapsible>
{
permissionSections.map(section => (
<PermissionSection key={section.id} {...section} />
))
}
</Accordion>
{
permissionSections?.length === 0 ? (
<ShimmerTree indentLevels={indentLevels} style={{ width: '100%' }} />
) : (
<Accordion
className="permissionsAccordion"
collapsible
openItems={openItems}
>
{
permissionSections.map(section => (
<PermissionSection key={section.id} {...section} />
))
}
</Accordion>
)
}
</Stack>
);
};

View File

@@ -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<PopoverContainerProps> = React.memo(({ title, children, onPrimary, onCancel }) => {
const PopoverContainer: React.FC<PopoverContainerProps> = React.memo(({ isLoading = false, title, children, onPrimary, onCancel }) => {
return (
<Stack className="foreground" tokens={{ childrenGap: 20 }} style={{ maxWidth: 450 }}>
<Stack className={`popover-container foreground ${isLoading ? "loading" : ""}`} tokens={{ childrenGap: 20 }} style={{ maxWidth: 450 }}>
<Text variant="mediumPlus" style={{ fontWeight: 600 }}>{title}</Text>
<Text>{children}</Text>
<Stack horizontal tokens={{ childrenGap: 20 }}>
<PrimaryButton text="Yes" onClick={onPrimary} />
<DefaultButton text="No" onClick={onCancel} />
<PrimaryButton
text={isLoading ? "" : "Yes"}
{...(isLoading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
onClick={onPrimary}
disabled={isLoading}
/>
<DefaultButton text="No" onClick={onCancel} disabled={isLoading} />
</Stack>
</Stack>
);
});
interface PopoverMessageProps {
isLoading?: boolean;
visible: boolean;
title: string;
onCancel: () => void;
@@ -29,10 +36,10 @@ interface PopoverMessageProps {
children: React.ReactNode;
}
const PopoverMessage: React.FC<PopoverMessageProps> = ({ visible, title, onCancel, onPrimary, children }) => {
const PopoverMessage: React.FC<PopoverMessageProps> = ({ isLoading = false, visible, title, onCancel, onPrimary, children }) => {
if (!visible) return null;
return (
<PopoverContainer title={title} onCancel={onCancel} onPrimary={onPrimary}>
<PopoverContainer title={title} onCancel={onCancel} onPrimary={onPrimary} isLoading={isLoading}>
{children}
</PopoverContainer>
);

View File

@@ -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]);

View File

@@ -0,0 +1,11 @@
import create from "zustand";
interface CopyJobPrerequisitesCacheState {
validationCache: Map<string, boolean>;
setValidationCache: (cache: Map<string, boolean>) => void;
}
export const useCopyJobPrerequisitesCache = create<CopyJobPrerequisitesCacheState>((set) => ({
validationCache: new Map<string, boolean>(),
setValidationCache: (cache) => set({ validationCache: cache })
}));

View File

@@ -13,7 +13,7 @@ const SCREEN_KEYS = {
};
type Validation = {
validate: (state: CopyJobContextState) => boolean;
validate: (state: CopyJobContextState | Map<string, boolean>) => boolean;
message: string;
};
@@ -61,7 +61,18 @@ function useCreateCopyJobScreensList() {
{
key: SCREEN_KEYS.AssignPermissions,
component: <AssignPermissions />,
validations: [],
validations: [
{
validate: (cache: Map<string, boolean>) => {
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",
}
],
},
],
[]

View File

@@ -58,6 +58,7 @@ export interface DatabaseContainerSectionProps {
export interface CopyJobContextState {
jobName: string;
migrationType: CopyJobMigrationType;
sourceReadAccessFromTarget: boolean;
// source details
source: {
subscription: Subscription;

View File

@@ -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;
}
}

128
src/Utils/arm/RbacUtils.ts Normal file
View File

@@ -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<RoleAssignmentType[]> => {
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<RoleDefinitionType[]> => {
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<RoleAssignmentType> => {
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;
};

View File

@@ -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;
}

View File

@@ -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<DatabaseAccount> => {
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<DatabaseAccount> => {
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<DatabaseAccount> => {
const body = {
properties: {
defaultIdentity: "SystemAssignedIdentity"
}
};
const updatedAccount = await updateIdentity(subscriptionId, resourceGroupName, accountName, body);
return updatedAccount;
};
export { updateDefaultIdentity, updateSystemIdentity };

View File

@@ -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<RoleAssignmentType[]> => {
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;
}

View File

@@ -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<RoleDefinitionType[]> => {
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;
}