diff --git a/web/src/pages/PlaygroundPage/PlaygroundPage.tsx b/web/src/pages/PlaygroundPage/PlaygroundPage.tsx
index fcc148e4ef..aeb4560c8c 100644
--- a/web/src/pages/PlaygroundPage/PlaygroundPage.tsx
+++ b/web/src/pages/PlaygroundPage/PlaygroundPage.tsx
@@ -47,12 +47,37 @@ import { formChangeLogic } from '../../shared/formLogic';
import { openModal } from '../../shared/hooks/modalControls/modalsSubjects';
import { ModalName } from '../../shared/hooks/modalControls/modalTypes';
import { getLicenseInfoQueryOptions } from '../../shared/query';
+import { delay } from '../../shared/utils/delay';
import { FoldableRadioSection } from '../FoldableRadioSection/FoldableRadioSection';
import testIconSrc from './assets/actionable-test1.png';
export const PlaygroundPage = () => {
return (
+
+
+
diff --git a/web/src/routes/_authorized.tsx b/web/src/routes/_authorized.tsx
index 3cb8ae7321..514868ac81 100644
--- a/web/src/routes/_authorized.tsx
+++ b/web/src/routes/_authorized.tsx
@@ -1,5 +1,6 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
import { DisplayListModal } from '../shared/components/DisplayListModal/DisplayListModal';
+import { ConfirmActionModal } from '../shared/components/modals/ConfirmActionModal/ConfirmActionModal';
import { LicenseExpiredModal } from '../shared/components/modals/license/LicenseExpiredModal/LicenseExpiredModal';
import { LicenseLimitConflictModal } from '../shared/components/modals/license/LicenseLimitConflictModal/LicenseLimitConflictModal';
import { LimitReachedModal } from '../shared/components/modals/license/LimitReachedModal/LimitReachedModal';
@@ -58,6 +59,7 @@ function RouteComponent() {
+
);
diff --git a/web/src/shared/components/modals/ConfirmActionModal/ConfirmActionModal.tsx b/web/src/shared/components/modals/ConfirmActionModal/ConfirmActionModal.tsx
new file mode 100644
index 0000000000..eb3aaeaf76
--- /dev/null
+++ b/web/src/shared/components/modals/ConfirmActionModal/ConfirmActionModal.tsx
@@ -0,0 +1,101 @@
+import './style.scss';
+import { useMutation } from '@tanstack/react-query';
+import { useEffect, useState } from 'react';
+import { m } from '../../../../paraglide/messages';
+import { Button } from '../../../defguard-ui/components/Button/Button';
+import { Modal } from '../../../defguard-ui/components/Modal/Modal';
+import { RenderMarkdown } from '../../../defguard-ui/components/RenderMarkdown/RenderMarkdown';
+import { isPresent } from '../../../defguard-ui/utils/isPresent';
+import {
+ closeModal,
+ subscribeCloseModal,
+ subscribeOpenModal,
+} from '../../../hooks/modalControls/modalsSubjects';
+import type { ModalNameValue } from '../../../hooks/modalControls/modalTypes';
+import type { OpenConfirmActionModal } from '../../../hooks/modalControls/types';
+import { Controls } from '../../Controls/Controls';
+
+const modalNameValue: ModalNameValue = 'confirmAction';
+
+type ModalData = OpenConfirmActionModal;
+
+export const ConfirmActionModal = () => {
+ const [isOpen, setOpen] = useState(false);
+ const [modalData, setModalData] = useState(null);
+
+ useEffect(() => {
+ const openSub = subscribeOpenModal(modalNameValue, (data) => {
+ setModalData(data);
+ setOpen(true);
+ });
+ const closeSub = subscribeCloseModal(modalNameValue, () => setOpen(false));
+ return () => {
+ openSub.unsubscribe();
+ closeSub.unsubscribe();
+ };
+ }, []);
+ return (
+ {
+ setOpen(false);
+ }}
+ afterClose={() => {
+ setModalData(null);
+ }}
+ >
+ {isPresent(modalData) && }
+
+ );
+};
+
+const ModalContent = ({ data }: { data: ModalData }) => {
+ const { mutate, isPending } = useMutation({
+ mutationFn: data.actionPromise,
+ meta: {
+ invalidate: data.invalidateKeys,
+ },
+ onSuccess: () => {
+ closeModal(modalNameValue);
+ data.onSuccess?.();
+ },
+ onError: (e) => {
+ data.onError?.();
+ console.error(e);
+ },
+ });
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+};
diff --git a/web/src/shared/components/modals/ConfirmActionModal/style.scss b/web/src/shared/components/modals/ConfirmActionModal/style.scss
new file mode 100644
index 0000000000..d8aa7ee77f
--- /dev/null
+++ b/web/src/shared/components/modals/ConfirmActionModal/style.scss
@@ -0,0 +1,25 @@
+#action-modal .markdown-render {
+ display: flex;
+ flex-flow: column;
+ row-gap: var(--spacing-sm);
+
+ p,
+ span,
+ ul,
+ li {
+ font: var(--t-body-sm-400);
+ color: var(--fg-default);
+ }
+
+ ul {
+ margin-left: 0;
+
+ li {
+ margin-left: var(--spacing-md);
+
+ &:not(:last-child) {
+ margin-bottom: var(--spacing-sm);
+ }
+ }
+ }
+}
diff --git a/web/src/shared/hooks/modalControls/modalTypes.ts b/web/src/shared/hooks/modalControls/modalTypes.ts
index 9d9122314d..b52673c342 100644
--- a/web/src/shared/hooks/modalControls/modalTypes.ts
+++ b/web/src/shared/hooks/modalControls/modalTypes.ts
@@ -11,6 +11,7 @@ import type {
OpenCEGroupModal,
OpenCEOpenIdClientModal,
OpenCEWebhookModal,
+ OpenConfirmActionModal,
OpenDeleteAliasDestinationBlockedModal,
OpenDeleteAliasDestinationConfirmModal,
OpenDeleteGatewayModal,
@@ -74,6 +75,7 @@ export const ModalName = {
AddNewDevice: 'addNewDevice',
AssignUserIP: 'assignUserIP',
AssignUserDeviceIP: 'assignUserDeviceIP',
+ ConfirmAction: 'confirmAction',
} as const;
export type ModalNameValue = (typeof ModalName)[keyof typeof ModalName];
@@ -239,6 +241,10 @@ const modalOpenArgsSchema = z.discriminatedUnion('name', [
name: z.literal(ModalName.AssignUserDeviceIP),
data: z.custom(),
}),
+ z.object({
+ name: z.literal(ModalName.ConfirmAction),
+ data: z.custom(),
+ }),
]);
export type ModalOpenEvent = z.infer;
diff --git a/web/src/shared/hooks/modalControls/types.ts b/web/src/shared/hooks/modalControls/types.ts
index a08d495163..beadff12f5 100644
--- a/web/src/shared/hooks/modalControls/types.ts
+++ b/web/src/shared/hooks/modalControls/types.ts
@@ -1,3 +1,5 @@
+import type { QueryKey } from '@tanstack/react-query';
+import type { HTMLProps } from 'react';
import type {
AvailableLocationIpResponse,
Device,
@@ -13,6 +15,19 @@ import type {
User,
Webhook,
} from '../../api/types';
+import type { ButtonProps } from '../../defguard-ui/components/Button/types';
+
+export interface OpenConfirmActionModal {
+ title: string;
+ contentMd: string;
+ actionPromise: () => Promise;
+ invalidateKeys?: QueryKey[];
+ cancelProps?: ButtonProps;
+ submitProps?: ButtonProps;
+ contentContainerProps?: HTMLProps;
+ onSuccess?: () => void;
+ onError?: () => void;
+}
export interface OpenEditDeviceModal {
device: Device;