Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Implements disabling of delete and rebuild actions when a Linode has active locks ([#13377](https://github.com/linode/manager/pull/13377))
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as React from 'react';

import { NO_PERMISSION_TOOLTIP_TEXT } from 'src/constants';
import { linodeConfigFactory } from 'src/factories';
import { LINODE_LOCKED_DELETE_CONFIG_TOOLTIP } from 'src/features/Linodes/constants';
import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers';

import { ConfigActionMenu } from './LinodeConfigActionMenu';
Expand All @@ -28,12 +29,23 @@ const queryMocks = vi.hoisted(() => ({
},
})),
useNavigate: vi.fn(() => navigate),
useLinodeQuery: vi.fn().mockReturnValue({
data: { locks: [] as string[] },
}),
}));

vi.mock('src/features/IAM/hooks/usePermissions', () => ({
usePermissions: queryMocks.userPermissions,
}));

vi.mock('@linode/queries', async () => {
const actual = await vi.importActual('@linode/queries');
return {
...actual,
useLinodeQuery: queryMocks.useLinodeQuery,
};
});

const defaultProps = {
config: linodeConfigFactory.build(),
linodeId: 0,
Expand Down Expand Up @@ -114,4 +126,107 @@ describe('ConfigActionMenu', () => {
const deleteBtn = screen.getByTestId('Delete');
expect(deleteBtn).not.toHaveAttribute('aria-disabled', 'true');
});

describe('Lock functionality', () => {
it('should disable Delete action when Linode is locked', async () => {
queryMocks.userPermissions.mockReturnValue({
data: {
reboot_linode: true,
update_linode: true,
clone_linode: true,
delete_linode: true,
},
});
queryMocks.useLinodeQuery.mockReturnValue({
data: { locks: ['cannot_delete_with_subresources'] },
});

renderWithTheme(<ConfigActionMenu {...defaultProps} />);

const actionBtn = screen.getByRole('button');
await userEvent.click(actionBtn);

const deleteBtn = screen.getByTestId('Delete');
expect(deleteBtn).toHaveAttribute('aria-disabled', 'true');
});

it('should show lock tooltip for Delete when Linode is locked', async () => {
queryMocks.userPermissions.mockReturnValue({
data: {
reboot_linode: true,
update_linode: true,
clone_linode: true,
delete_linode: true,
},
});
queryMocks.useLinodeQuery.mockReturnValue({
data: { locks: ['cannot_delete_with_subresources'] },
});

renderWithTheme(<ConfigActionMenu {...defaultProps} />);

const actionBtn = screen.getByRole('button');
await userEvent.click(actionBtn);

const tooltip = screen.getByLabelText(
LINODE_LOCKED_DELETE_CONFIG_TOOLTIP
);
expect(tooltip).toBeInTheDocument();
});

it('should enable Delete action when Linode is not locked', async () => {
queryMocks.userPermissions.mockReturnValue({
data: {
reboot_linode: true,
update_linode: true,
clone_linode: true,
delete_linode: true,
},
});
queryMocks.useLinodeQuery.mockReturnValue({
data: { locks: [] },
});

renderWithTheme(<ConfigActionMenu {...defaultProps} />);

const actionBtn = screen.getByRole('button');
await userEvent.click(actionBtn);

const deleteBtn = screen.getByTestId('Delete');
expect(deleteBtn).not.toHaveAttribute('aria-disabled', 'true');
});

it('should not affect other actions when Linode is locked', async () => {
queryMocks.userPermissions.mockReturnValue({
data: {
reboot_linode: true,
update_linode: true,
clone_linode: true,
delete_linode: true,
},
});
queryMocks.useLinodeQuery.mockReturnValue({
data: { locks: ['cannot_delete'] },
});

renderWithTheme(<ConfigActionMenu {...defaultProps} />);

const actionBtn = screen.getByRole('button');
await userEvent.click(actionBtn);

// Boot, Edit, Clone should still be enabled
expect(screen.getByTestId('Boot')).not.toHaveAttribute(
'aria-disabled',
'true'
);
expect(screen.getByTestId('Edit')).not.toHaveAttribute(
'aria-disabled',
'true'
);
expect(screen.getByTestId('Clone')).not.toHaveAttribute(
'aria-disabled',
'true'
);
});
});
});
Copy link
Copy Markdown
Contributor

@grevanak-akamai grevanak-akamai Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to this, we need to block deletion of linode interfaces too here. This is mentioned in API doc too

Image

Copy link
Copy Markdown
Contributor Author

@tanushree-akamai tanushree-akamai Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Addressed in a320ced

Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useLinodeQuery } from '@linode/queries';
import { useNavigate } from '@tanstack/react-router';
import * as React from 'react';

import { ActionMenu } from 'src/components/ActionMenu/ActionMenu';
import { NO_PERMISSION_TOOLTIP_TEXT } from 'src/constants';
import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
import { LINODE_LOCKED_DELETE_CONFIG_TOOLTIP } from 'src/features/Linodes/constants';

import type { Config } from '@linode/api-v4/lib/linodes';
import type { Action } from 'src/components/ActionMenu/ActionMenu';
Expand All @@ -22,6 +24,10 @@ export const ConfigActionMenu = (props: Props) => {
const [isOpen, setIsOpen] = React.useState<boolean>(false);
const navigate = useNavigate();

const { data: linode } = useLinodeQuery(linodeId);
const isLinodeSubResourcesLocked =
linode?.locks?.includes('cannot_delete_with_subresources') ?? false;

const { data: permissions, isLoading } = usePermissions(
'linode',
['reboot_linode', 'update_linode', 'clone_linode', 'delete_linode'],
Expand Down Expand Up @@ -63,12 +69,14 @@ export const ConfigActionMenu = (props: Props) => {
: undefined,
},
{
disabled: !permissions.delete_linode,
disabled: !permissions.delete_linode || isLinodeSubResourcesLocked,
onClick: onDelete,
title: 'Delete',
tooltip: !permissions.delete_linode
? NO_PERMISSION_TOOLTIP_TEXT
: undefined,
tooltip: isLinodeSubResourcesLocked
? LINODE_LOCKED_DELETE_CONFIG_TOOLTIP
: !permissions.delete_linode
? NO_PERMISSION_TOOLTIP_TEXT
: undefined,
},
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('AddLockDialog', () => {
it('should render the dialog with correct title and content', () => {
const { getByText } = renderWithTheme(<AddLockDialog {...defaultProps} />);

expect(getByText('Add lock?')).toBeVisible();
expect(getByText('Add lock to my-linode?')).toBeVisible();
expect(getByText('Choose the type of lock to apply.')).toBeVisible();
expect(getByText('Apply Lock')).toBeVisible();
expect(getByText('Cancel')).toBeVisible();
Expand Down Expand Up @@ -182,7 +182,7 @@ describe('AddLockDialog', () => {
<AddLockDialog {...defaultProps} open={false} />
);

expect(queryByText('Add lock?')).not.toBeInTheDocument();
expect(queryByText('Add lock to my-linode?')).not.toBeInTheDocument();
});

it('should not submit if linodeId is undefined', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const AddLockDialog = (props: Props) => {
}
onClose={onClose}
open={open}
title="Add lock?"
title={`Add lock to ${linodeLabel ?? ''}?`}
>
{errorMessage && <Notice text={errorMessage} variant="error" />}
<StyledHeading>Choose the type of lock to apply.</StyledHeading>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe('RemoveLockDialog', () => {
<RemoveLockDialog {...defaultProps} />
);

expect(getByText('Remove Lock?')).toBeVisible();
expect(getByText('Remove lock from test-linode?')).toBeVisible();
});

it('should display correct description for cannot_delete lock type', () => {
Expand Down Expand Up @@ -103,7 +103,7 @@ describe('RemoveLockDialog', () => {
<RemoveLockDialog {...defaultProps} open={false} />
);

expect(queryByText('Remove Lock?')).toBeNull();
expect(queryByText('Remove lock from test-linode?')).toBeNull();
});

it('should fetch locks and delete lock on submit', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export const RemoveLockDialog = (props: Props) => {
}
onClose={onClose}
open={open}
title="Remove Lock?"
title={`Remove lock from ${linodeLabel ?? ''}?`}
>
{localError && <Notice text={localError} variant="error" />}
<Typography>{getLockTypeDescription(linodeLocks)}</Typography>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ export const LinodeIPAddressRow = (props: LinodeIPAddressRowProps) => {
} = props;

const { data: ips } = useLinodeIPsQuery(linodeId);
const { data: linode } = useLinodeQuery(linodeId);
const isLinodeSubResourcesLocked =
linode?.locks?.includes('cannot_delete_with_subresources') ?? false;
const { data: maskSensitiveDataPreference } = usePreferences(
(preferences) => preferences?.maskSensitiveData
);
Expand Down Expand Up @@ -111,6 +114,7 @@ export const LinodeIPAddressRow = (props: LinodeIPAddressRowProps) => {
ipAddress={_ip}
ipType={type}
isLinodeInterface={isLinodeInterface}
isLinodeSubResourcesLocked={isLinodeSubResourcesLocked}
isOnlyPublicIP={isOnlyPublicIP}
onEdit={handleOpenEditRDNS}
onRemove={openRemoveIPDialog}
Expand All @@ -123,6 +127,7 @@ export const LinodeIPAddressRow = (props: LinodeIPAddressRowProps) => {
ipAddress={_range}
ipType={type}
isLinodeInterface={isLinodeInterface}
isLinodeSubResourcesLocked={isLinodeSubResourcesLocked}
isOnlyPublicIP={isOnlyPublicIP}
onEdit={() => handleOpenEditRDNSForRange(_range)}
onRemove={openRemoveIPRangeDialog}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { useLinodeQuery } from '@linode/queries';
import React from 'react';

import { ActionMenu } from 'src/components/ActionMenu/ActionMenu';
import { LINODE_LOCKED_DELETE_INTERFACE_TOOLTIP } from 'src/features/Linodes/constants';

import type { LinodeInterfaceType } from './utilities';

interface Props {
handlers: InterfaceActionHandlers;
id: number;
linodeId: number;
type: LinodeInterfaceType;
}

Expand All @@ -17,7 +20,11 @@ export interface InterfaceActionHandlers {
}

export const LinodeInterfaceActionMenu = (props: Props) => {
const { handlers, id, type } = props;
const { handlers, id, linodeId, type } = props;

const { data: linode } = useLinodeQuery(linodeId);
const isLinodeSubResourcesLocked =
linode?.locks?.includes('cannot_delete_with_subresources') ?? false;

const editOptions =
type === 'VLAN'
Expand All @@ -34,7 +41,14 @@ export const LinodeInterfaceActionMenu = (props: Props) => {
title: 'Edit',
...editOptions,
},
{ onClick: () => handlers.onDelete(id), title: 'Delete' },
{
disabled: isLinodeSubResourcesLocked,
onClick: () => handlers.onDelete(id),
title: 'Delete',
tooltip: isLinodeSubResourcesLocked
? LINODE_LOCKED_DELETE_INTERFACE_TOOLTIP
: undefined,
},
];

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,12 @@ export const LinodeInterfaceTableRow = (props: Props) => {
</TableCell>
</Hidden>
<TableCell actionCell>
<LinodeInterfaceActionMenu handlers={handlers} id={id} type={type} />
<LinodeInterfaceActionMenu
handlers={handlers}
id={id}
linodeId={linodeId}
type={type}
/>
</TableCell>
</TableRow>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as React from 'react';
import { ActionMenu } from 'src/components/ActionMenu/ActionMenu';
import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction';
import {
LINODE_LOCKED_DELETE_IP_TOOLTIP,
PUBLIC_IP_ADDRESSES_CONFIG_INTERFACE_TOOLTIP_TEXT,
PUBLIC_IP_ADDRESSES_LINODE_INTERFACE_DEFAULT_ROUTE_TOOLTIP_TEXT,
PUBLIC_IP_ADDRESSES_LINODE_INTERFACE_NOT_ASSIGNED_TOOLTIP_TEXT,
Expand All @@ -22,6 +23,7 @@ interface Props {
ipAddress: IPAddress | IPRange;
ipType: IPTypes;
isLinodeInterface: boolean;
isLinodeSubResourcesLocked?: boolean;
isOnlyPublicIP: boolean;
onEdit?: (ip: IPAddress | IPRange) => void;
onRemove?: (ip: IPAddress | IPRange) => void;
Expand All @@ -35,6 +37,7 @@ export const LinodeNetworkingActionMenu = (props: Props) => {
hasPublicInterface,
ipAddress,
ipType,
isLinodeSubResourcesLocked,
isOnlyPublicIP,
isLinodeInterface,
disabledFromInterfaces,
Expand Down Expand Up @@ -91,19 +94,25 @@ export const LinodeNetworkingActionMenu = (props: Props) => {
deletableIPTypes.includes(ipType) &&
!isLinodeInterface
? {
disabled: readOnly || isOnlyPublicIP || disabledFromInterfaces,
disabled:
readOnly ||
isOnlyPublicIP ||
disabledFromInterfaces ||
isLinodeSubResourcesLocked,
id: 'delete',
onClick: () => {
onRemove(ipAddress);
},
title: 'Delete',
tooltip: readOnly
? readOnlyTooltip
: disabledFromInterfaces
? isPublicIPNotAssignedCopy
: isOnlyPublicIP
? isOnlyPublicIPTooltip
: undefined,
tooltip: isLinodeSubResourcesLocked
? LINODE_LOCKED_DELETE_IP_TOOLTIP
: readOnly
? readOnlyTooltip
: disabledFromInterfaces
? isPublicIPNotAssignedCopy
: isOnlyPublicIP
? isOnlyPublicIPTooltip
: undefined,
}
: null,
onEdit && ipAddress && showEdit
Expand Down
Loading