diff --git a/packages/manager/.changeset/pr-13339-upcoming-features-1769622645991.md b/packages/manager/.changeset/pr-13339-upcoming-features-1769622645991.md
new file mode 100644
index 00000000000..d65b25520f2
--- /dev/null
+++ b/packages/manager/.changeset/pr-13339-upcoming-features-1769622645991.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Upcoming Features
+---
+
+Implemented Add Lock Dialog accessible from Linode action menu ([#13339](https://github.com/linode/manager/pull/13339))
diff --git a/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx b/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx
index d9cb9f55a56..1fd835d3f3e 100644
--- a/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx
+++ b/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx
@@ -29,6 +29,7 @@ export const LinodeExample: Story = {
Linode Details:
{
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should render the dialog with correct title and content', () => {
+ const { getByText } = renderWithTheme();
+
+ expect(getByText('Add lock?')).toBeVisible();
+ expect(getByText('Choose the type of lock to apply.')).toBeVisible();
+ expect(getByText('Apply Lock')).toBeVisible();
+ expect(getByText('Cancel')).toBeVisible();
+ });
+
+ it('should display both lock type options', () => {
+ const { getByText } = renderWithTheme();
+
+ expect(getByText('Prevent deletion')).toBeVisible();
+ expect(
+ getByText('Prevents this Linode from being deleted or rebuilt.')
+ ).toBeVisible();
+
+ expect(
+ getByText('Prevent deletion (including attached resources)')
+ ).toBeVisible();
+ expect(
+ getByText(
+ 'Prevents this Linode and its attached resources (Disks, Configurations, IP Addresses, and Subinterfaces) from being deleted or rebuilt.'
+ )
+ ).toBeVisible();
+ });
+
+ it('should have "Prevent deletion" selected by default', () => {
+ const { getByRole } = renderWithTheme();
+
+ const preventDeletionRadio = getByRole('radio', {
+ name: /Prevent deletion Prevents this Linode from being deleted or rebuilt/i,
+ });
+
+ expect(preventDeletionRadio).toBeChecked();
+ });
+
+ it('should allow changing lock type selection', async () => {
+ const { getByRole } = renderWithTheme();
+
+ const preventDeletionWithSubresourcesRadio = getByRole('radio', {
+ name: /Prevent deletion \(including attached resources\)/i,
+ });
+
+ await userEvent.click(preventDeletionWithSubresourcesRadio);
+
+ expect(preventDeletionWithSubresourcesRadio).toBeChecked();
+ });
+
+ it('should call onClose when Cancel is clicked', async () => {
+ const { getByText } = renderWithTheme();
+
+ const cancelButton = getByText('Cancel');
+ await userEvent.click(cancelButton);
+
+ expect(defaultProps.onClose).toHaveBeenCalled();
+ });
+
+ it('should submit with correct payload and call onClose on success', async () => {
+ let capturedPayload: unknown;
+
+ server.use(
+ http.post('*/v4beta/locks', async ({ request }) => {
+ capturedPayload = await request.json();
+ return HttpResponse.json({
+ created: '2026-01-28T00:00:00',
+ entity: {
+ id: 123,
+ label: 'my-linode',
+ type: 'linode',
+ url: '/v4beta/linodes/instances/123',
+ },
+ id: 1,
+ lock_type: 'cannot_delete',
+ });
+ })
+ );
+
+ const { getByText } = renderWithTheme();
+
+ const applyLockButton = getByText('Apply Lock');
+ await userEvent.click(applyLockButton);
+
+ await waitFor(() => {
+ expect(defaultProps.onClose).toHaveBeenCalled();
+ });
+
+ expect(capturedPayload).toEqual({
+ entity_id: 123,
+ entity_type: 'linode',
+ lock_type: 'cannot_delete',
+ });
+ });
+
+ it('should submit with cannot_delete_with_subresources when selected', async () => {
+ let capturedPayload: unknown;
+
+ server.use(
+ http.post('*/v4beta/locks', async ({ request }) => {
+ capturedPayload = await request.json();
+ return HttpResponse.json({
+ created: '2026-01-28T00:00:00',
+ entity: {
+ id: 123,
+ label: 'my-linode',
+ type: 'linode',
+ url: '/v4beta/linodes/instances/123',
+ },
+ id: 1,
+ lock_type: 'cannot_delete_with_subresources',
+ });
+ })
+ );
+
+ const { getByRole, getByText } = renderWithTheme(
+
+ );
+
+ const preventDeletionWithSubresourcesRadio = getByRole('radio', {
+ name: /Prevent deletion \(including attached resources\)/i,
+ });
+ await userEvent.click(preventDeletionWithSubresourcesRadio);
+
+ const applyLockButton = getByText('Apply Lock');
+ await userEvent.click(applyLockButton);
+
+ await waitFor(() => {
+ expect(capturedPayload).toEqual({
+ entity_id: 123,
+ entity_type: 'linode',
+ lock_type: 'cannot_delete_with_subresources',
+ });
+ });
+ });
+
+ it('should reset state when dialog is reopened', async () => {
+ const { getByRole, rerender } = renderWithTheme(
+
+ );
+
+ // Select the second option
+ const preventDeletionWithSubresourcesRadio = getByRole('radio', {
+ name: /Prevent deletion \(including attached resources\)/i,
+ });
+ await userEvent.click(preventDeletionWithSubresourcesRadio);
+ expect(preventDeletionWithSubresourcesRadio).toBeChecked();
+
+ // Close and reopen the dialog
+ rerender();
+ rerender();
+
+ // The default option should be selected again
+ const preventDeletionRadio = getByRole('radio', {
+ name: /Prevent deletion Prevents this Linode from being deleted or rebuilt/i,
+ });
+ expect(preventDeletionRadio).toBeChecked();
+ });
+
+ it('should not render dialog content when open is false', () => {
+ const { queryByText } = renderWithTheme(
+
+ );
+
+ expect(queryByText('Add lock?')).not.toBeInTheDocument();
+ });
+
+ it('should not submit if linodeId is undefined', async () => {
+ const mockOnClose = vi.fn();
+
+ server.use(
+ http.post('*/v4beta/locks', () => {
+ return HttpResponse.json({});
+ })
+ );
+
+ const { getByText } = renderWithTheme(
+
+ );
+
+ const applyLockButton = getByText('Apply Lock');
+ await userEvent.click(applyLockButton);
+
+ // Wait a bit to ensure no async operation completes
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ // onClose should not have been called since the submission should be blocked
+ expect(mockOnClose).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeLock/AddLockDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeLock/AddLockDialog.tsx
new file mode 100644
index 00000000000..4fa76251777
--- /dev/null
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeLock/AddLockDialog.tsx
@@ -0,0 +1,167 @@
+import { useCreateLockMutation } from '@linode/queries';
+import {
+ ActionsPanel,
+ FormControlLabel,
+ Notice,
+ Radio,
+ RadioGroup,
+ Stack,
+ Typography,
+} from '@linode/ui';
+import { styled, useTheme } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import * as React from 'react';
+
+import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog';
+import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
+
+import type { LockType } from '@linode/api-v4';
+
+interface Props {
+ linodeId: number | undefined;
+ linodeLabel: string | undefined;
+ onClose: () => void;
+ open: boolean;
+}
+
+export const AddLockDialog = (props: Props) => {
+ const { linodeId, linodeLabel, onClose, open } = props;
+ const { enqueueSnackbar } = useSnackbar();
+
+ const [lockType, setLockType] = React.useState('cannot_delete');
+
+ const { error, isPending, mutateAsync, reset } = useCreateLockMutation();
+
+ const handleLockTypeChange = (
+ _e: React.ChangeEvent,
+ value: string
+ ) => {
+ setLockType(value as LockType);
+ };
+
+ React.useEffect(() => {
+ if (open) {
+ reset();
+ setLockType('cannot_delete');
+ }
+ }, [open, reset]);
+
+ const handleSubmit = async () => {
+ if (!linodeId) {
+ return;
+ }
+
+ await mutateAsync({
+ entity_id: linodeId,
+ entity_type: 'linode',
+ lock_type: lockType,
+ });
+
+ enqueueSnackbar(`Lock applied to ${linodeLabel}.`, {
+ variant: 'success',
+ });
+ onClose();
+ };
+
+ const errorMessage = error
+ ? getAPIErrorOrDefault(error, 'Failed to apply lock.')[0].reason
+ : undefined;
+
+ return (
+
+ }
+ onClose={onClose}
+ open={open}
+ title="Add lock?"
+ >
+ {errorMessage && }
+ Choose the type of lock to apply.
+
+
+ }
+ label={
+
+ }
+ value="cannot_delete"
+ />
+ }
+ label={
+
+ }
+ value="cannot_delete_with_subresources"
+ />
+
+
+
+ );
+};
+
+interface LockOptionLabelProps {
+ description: string;
+ title: string;
+}
+
+const LockOptionLabel = ({ description, title }: LockOptionLabelProps) => {
+ const theme = useTheme();
+
+ return (
+
+
+ {title}
+
+
+ {description}
+
+
+ );
+};
+
+/**
+ * Custom styled FormControlLabel to align the radio button with multi-line labels.
+ * Since our labels have a title and description (two lines), the MUI default alignment doesn't work for us.
+ * We use `alignItems: flex-start` to position the radio button at the top. The `marginTop: 10` on the label
+ * compensates for the radio button's internal padding, ensuring the label text aligns visually
+ * with the radio button.
+ */
+const RadioButtonAlignedFormControlLabel = styled(FormControlLabel)(
+ ({ theme }) => ({
+ alignItems: 'flex-start',
+ '& .MuiFormControlLabel-label': {
+ marginTop: 10,
+ },
+ })
+);
+
+const StyledHeading = styled(Typography)(({ theme }) => ({
+ font: theme.tokens.alias.Typography.Heading.S,
+ marginBottom: theme.tokens.spacing.S20,
+}));
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx
index 6b603bea58a..d8eddcc24ea 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx
@@ -22,6 +22,7 @@ import { addMaintenanceToLinodes } from 'src/utilities/linodes';
import { DeleteLinodeDialog } from '../../LinodesLanding/DeleteLinodeDialog';
import { EnableBackupsDialog } from '../LinodeBackup/EnableBackupsDialog';
+import { AddLockDialog } from '../LinodeLock/AddLockDialog';
import { LinodeRebuildDialog } from '../LinodeRebuild/LinodeRebuildDialog';
import { RescueDialog } from '../LinodeRescue/RescueDialog';
import { LinodeResize } from '../LinodeResize/LinodeResize';
@@ -63,6 +64,7 @@ export const LinodeDetailHeader = () => {
);
const [powerAction, setPowerAction] = React.useState('Reboot');
const [powerDialogOpen, setPowerDialogOpen] = React.useState(false);
+ const [addLockDialogOpen, setAddLockDialogOpen] = React.useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(search.delete);
const [rebuildDialogOpen, setRebuildDialogOpen] = React.useState(
search.rebuild
@@ -89,6 +91,7 @@ export const LinodeDetailHeader = () => {
navigate({ search: undefined });
}
+ setAddLockDialogOpen(false);
setPowerDialogOpen(false);
setDeleteDialogOpen(false);
setResizeDialogOpen(false);
@@ -134,6 +137,10 @@ export const LinodeDetailHeader = () => {
setPowerAction(action);
};
+ const onOpenAddLockDialog = () => {
+ setAddLockDialogOpen(true);
+ };
+
const onOpenDeleteDialog = () => {
setDeleteDialogOpen(true);
};
@@ -155,6 +162,7 @@ export const LinodeDetailHeader = () => {
};
const handlers = {
+ onOpenAddLockDialog,
onOpenDeleteDialog,
onOpenMigrateDialog,
onOpenPowerDialog,
@@ -219,6 +227,12 @@ export const LinodeDetailHeader = () => {
linodeLabel={linode.label}
onClose={closeDialogs}
/>
+
{
+ openDialog('add_lock', linode.id, linode.label),
onOpenDeleteDialog: () =>
openDialog('delete', linode.id, linode.label),
onOpenMigrateDialog: () =>
diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx
index 3c94974d57d..e1761c63d7b 100644
--- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx
@@ -18,6 +18,7 @@ const props: LinodeActionMenuProps = {
linodeRegion: 'us-east',
linodeStatus: 'running',
linodeType: extendedTypes[0],
+ onOpenAddLockDialog: vi.fn(),
onOpenDeleteDialog: vi.fn(),
onOpenMigrateDialog: vi.fn(),
onOpenPowerDialog: vi.fn(),
diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx
index 9790af2da33..4843faf6256 100644
--- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx
+++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx
@@ -256,7 +256,7 @@ export const LinodeActionMenu = (props: LinodeActionMenuProps) => {
// props.onOpenUnlockDialog();
} else {
sendLinodeActionMenuItemEvent('Lock Linode');
- // props.onOpenAddLockDialog();
+ props.onOpenAddLockDialog();
}
},
title: isLocked ? 'Unlock' : 'Lock',
diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx
index d1c9937f99b..c11a5bd9c0c 100644
--- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.test.tsx
@@ -33,6 +33,7 @@ describe('LinodeRow', () => {
const renderedLinode = (
{},
onOpenDeleteDialog: () => {},
onOpenMigrateDialog: () => {},
onOpenPowerDialog: () => {},
diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx
index 82c8e6027d2..386d91735e4 100644
--- a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx
+++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx
@@ -22,6 +22,7 @@ import {
} from 'src/utilities/analytics/customEventAnalytics';
import { EnableBackupsDialog } from '../LinodesDetail/LinodeBackup/EnableBackupsDialog';
+import { AddLockDialog } from '../LinodesDetail/LinodeLock/AddLockDialog';
import { LinodeRebuildDialog } from '../LinodesDetail/LinodeRebuild/LinodeRebuildDialog';
import { RescueDialog } from '../LinodesDetail/LinodeRescue/RescueDialog';
import { LinodeResize } from '../LinodesDetail/LinodeResize/LinodeResize';
@@ -54,6 +55,7 @@ import type { LinodeWithMaintenance } from 'src/utilities/linodes';
import type { RegionFilter } from 'src/utilities/storage';
interface State {
+ addLockDialogOpen: boolean;
deleteDialogOpen: boolean;
enableBackupsDialogOpen: boolean;
groupByTag: boolean;
@@ -69,6 +71,7 @@ interface State {
}
export interface LinodeHandlers {
+ onOpenAddLockDialog: () => void;
onOpenDeleteDialog: () => void;
onOpenMigrateDialog: () => void;
onOpenPowerDialog: (action: Action) => void;
@@ -109,6 +112,7 @@ type CombinedProps = LinodesLandingProps &
class ListLinodes extends React.Component {
state: State = {
+ addLockDialogOpen: false,
deleteDialogOpen: false,
enableBackupsDialogOpen: false,
groupByTag: false,
@@ -133,6 +137,7 @@ class ListLinodes extends React.Component {
closeDialogs = () => {
this.setState({
+ addLockDialogOpen: false,
deleteDialogOpen: false,
enableBackupsDialogOpen: false,
linodeMigrateOpen: false,
@@ -143,8 +148,21 @@ class ListLinodes extends React.Component {
});
};
+ openAddLockDialog = (linodeID: number, linodeLabel: string) => {
+ this.setState({
+ addLockDialogOpen: true,
+ selectedLinodeID: linodeID,
+ selectedLinodeLabel: linodeLabel,
+ });
+ };
+
openDialog = (type: DialogType, linodeID: number, linodeLabel?: string) => {
switch (type) {
+ case 'add_lock':
+ this.setState({
+ addLockDialogOpen: true,
+ });
+ break;
case 'delete':
this.setState({
deleteDialogOpen: true,
@@ -217,6 +235,7 @@ class ListLinodes extends React.Component {
: undefined;
const componentProps = {
+ openAddLockDialog: this.openAddLockDialog,
openDialog: this.openDialog,
openPowerActionDialog: this.openPowerDialog,
someLinodesHaveMaintenance:
@@ -317,6 +336,12 @@ class ListLinodes extends React.Component {
onClose={this.closeDialogs}
open={this.state.deleteDialogOpen}
/>
+
)}
diff --git a/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx b/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx
index 7a6b45406d1..a2160ad8a43 100644
--- a/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx
+++ b/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx
@@ -21,6 +21,8 @@ export const ListView = (props: RenderLinodesProps) => {
{data.map((linode, idx: number) => (
+ openDialog('add_lock', linode.id, linode.label),
onOpenDeleteDialog: () =>
openDialog('delete', linode.id, linode.label),
onOpenMigrateDialog: () =>
diff --git a/packages/manager/src/features/Linodes/types.ts b/packages/manager/src/features/Linodes/types.ts
index ce7295268c1..85b06cd0be2 100644
--- a/packages/manager/src/features/Linodes/types.ts
+++ b/packages/manager/src/features/Linodes/types.ts
@@ -1,6 +1,7 @@
import type { BaseQueryParams, LinodeCreateType } from '@linode/utilities';
export type DialogType =
+ | 'add_lock'
| 'delete'
| 'detach_vlan'
| 'enable_backups'
diff --git a/packages/manager/src/mocks/presets/crud/handlers/locks.ts b/packages/manager/src/mocks/presets/crud/handlers/locks.ts
index 5b1b6ca0dca..1f7126625c0 100644
--- a/packages/manager/src/mocks/presets/crud/handlers/locks.ts
+++ b/packages/manager/src/mocks/presets/crud/handlers/locks.ts
@@ -10,12 +10,11 @@
* Update ENTITY_TYPE_CONFIG below with the new resource type mapping
*
*/
-import { http } from 'msw';
+import { http, HttpResponse } from 'msw';
import { lockFactory } from 'src/factories';
import { queueEvents } from 'src/mocks/utilities/events';
import {
- makeErrorResponse,
makeNotFoundResponse,
makePaginatedResponse,
makeResponse,
@@ -24,6 +23,7 @@ import {
import { mswDB } from '../../../indexedDB';
import type {
+ APIError,
CreateLockPayload,
Entity,
LockType,
@@ -53,20 +53,20 @@ const ENTITY_TYPE_CONFIG: Record<
const validateLockCreation = async (
payload: CreateLockPayload,
mockState: MockState
-): Promise => {
+): Promise => {
const { entity_id, entity_type, lock_type } = payload;
// Check if entity type is supported
const entityConfig = ENTITY_TYPE_CONFIG[entity_type];
if (!entityConfig) {
- return `Unsupported entity type: ${entity_type}`;
+ return { reason: `Unsupported entity type: ${entity_type}` };
}
// Check if entity exists and is accessible
const entities = await mswDB.getAll(entityConfig.store);
const entity = entities?.find((e: Entity) => e.id === entity_id);
if (!entity) {
- return 'The specified entity could not be found.';
+ return { reason: 'The specified entity could not be found.' };
}
// Check if entity already has a lock of conflicting type
@@ -87,7 +87,11 @@ const validateLockCreation = async (
lock.lock_type === 'cannot_delete_with_subresources'
);
if (hasDeleteLock) {
- return 'This resource already has a lock. Only one delete protection lock is allowed at a time.';
+ return {
+ field: 'lock_type',
+ reason:
+ 'This resource already has a lock. Only one delete protection lock is allowed at a time.',
+ };
}
}
}
@@ -225,7 +229,17 @@ export const createLock = (mockState: MockState) => [
// Validate the lock creation
const validationError = await validateLockCreation(payload, mockState);
if (validationError) {
- return makeErrorResponse(validationError, 400);
+ return HttpResponse.json(
+ {
+ errors: [
+ {
+ ...(validationError.field && { field: validationError.field }),
+ reason: validationError.reason,
+ },
+ ],
+ },
+ { status: 400 }
+ );
}
// Get entity configuration for URL building