mirror of https://github.com/usememos/memos.git
feat(web): introduce accessible ConfirmDialog and migrate confirmations; add Markdown-safe descriptions
Why - window.confirm is not supported on Brave Mobile for iOS, which blocked destructive actions like deleting memos. Replacing it with an accessible, app-native dialog restores mobile functionality and improves UX. What changed - New ConfirmDialog component - Replaces window.confirm usage across the app. - Props: open/onOpenChange, title, description or descriptionMarkdown, confirm/cancel labels, onConfirm, confirmVariant. - Prevents double-submit and accidental dismiss while confirming (loading state). - Markdown support for dialog descriptions - descriptionMarkdown renders via marked and is sanitized with DOMPurify before injection. - Keeps translations readable (Markdown) and safe (sanitized HTML). - Member management flows - Updated archive/delete dialogs to use ConfirmDialog. - Added toast notifications for archive, restore, and delete actions. - i18n: added/updated relevant translation keys (en). Accessibility and mobile - Dialog buttons are touch-friendly. - Escape and outside-click behavior matches expectations. Manual Tests - Verified in Brave desktop (v1.82.166) and Brave for iOS (v1.81 (134)) - Memos: - Archive → confirm archival and shows success toast. - Restore (only when archived) → confirm restoration and shows success toast. - Delete → destructive dialog → confirm deletion and shows success toast. - Shortcuts: create → menu → Delete → dialog appears; cancel keeps; confirm deletes and list refreshes. - Access tokens: Settings → Access Tokens → Delete → dialog title shows masked token; confirm deletes. - Members: Settings → Members → non-current user: - Archive → warning dialog → confirm archives. - Delete (only when archived) → destructive dialog → confirm deletes. - Sessions: Settings → Sessions → Revoke non-current session → dialog appears; confirm revokes; current session remains disabled. - Webhooks: Settings → Webhooks → Delete → dialog appears; confirm deletes and list refreshes. - Mobile/accessibility: focus trap, inert background, tappable buttons, Escape/outside-click behavior verified. Notes / follow-ups - Deleting a member currently removes the account but does not cascade-delete the member’s content. Not sure if this is intended or not, so I left the warning description more general for now.
This commit is contained in:
parent
7ab57f8ed2
commit
30795d1d9c
|
|
@ -40,6 +40,7 @@
|
|||
"leaflet": "^1.9.4",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.486.0",
|
||||
"marked": "^16.3.0",
|
||||
"mermaid": "^11.11.0",
|
||||
"mime": "^4.1.0",
|
||||
"mobx": "^6.13.7",
|
||||
|
|
@ -92,4 +93,4 @@
|
|||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
descriptionMarkdown?: string;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
confirmVariant?: "default" | "destructive";
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
descriptionMarkdown,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
onConfirm,
|
||||
confirmVariant = "default",
|
||||
}: ConfirmDialogProps) {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await onConfirm();
|
||||
onOpenChange(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// prepare sanitized HTML if Markdown was provided
|
||||
const descriptionHtml =
|
||||
typeof descriptionMarkdown === "string"
|
||||
? DOMPurify.sanitize(String(marked.parse(descriptionMarkdown)))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o: boolean) => !loading && onOpenChange(o)}>
|
||||
<DialogContent size="sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{descriptionHtml ? (
|
||||
<DialogDescription dangerouslySetInnerHTML={{ __html: descriptionHtml }} />
|
||||
) : description ? (
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" disabled={loading} onClick={() => onOpenChange(false)}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant={confirmVariant} disabled={loading} onClick={handleConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { Edit3Icon, MoreVerticalIcon, TrashIcon, PlusIcon } from "lucide-react";
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { useState } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { shortcutServiceClient } from "@/grpcweb";
|
||||
import useAsyncEffect from "@/hooks/useAsyncEffect";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -11,6 +12,7 @@ import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
|||
import { useTranslate } from "@/utils/i18n";
|
||||
import CreateShortcutDialog from "../CreateShortcutDialog";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)$/u;
|
||||
|
||||
|
|
@ -25,6 +27,7 @@ const ShortcutsSection = observer(() => {
|
|||
const t = useTranslate();
|
||||
const shortcuts = userStore.state.shortcuts;
|
||||
const [isCreateShortcutDialogOpen, setIsCreateShortcutDialogOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Shortcut | undefined>();
|
||||
const [editingShortcut, setEditingShortcut] = useState<Shortcut | undefined>();
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
|
|
@ -32,11 +35,15 @@ const ShortcutsSection = observer(() => {
|
|||
}, []);
|
||||
|
||||
const handleDeleteShortcut = async (shortcut: Shortcut) => {
|
||||
const confirmed = window.confirm("Are you sure you want to delete this shortcut?");
|
||||
if (confirmed) {
|
||||
await shortcutServiceClient.deleteShortcut({ name: shortcut.name });
|
||||
await userStore.fetchUserSettings();
|
||||
}
|
||||
setDeleteTarget(shortcut);
|
||||
};
|
||||
|
||||
const confirmDeleteShortcut = async () => {
|
||||
if (!deleteTarget) return;
|
||||
await shortcutServiceClient.deleteShortcut({ name: deleteTarget.name });
|
||||
await userStore.fetchUserSettings();
|
||||
toast.success(t("setting.shortcut.delete-success", { title: deleteTarget.title }));
|
||||
setDeleteTarget(undefined);
|
||||
};
|
||||
|
||||
const handleCreateShortcut = () => {
|
||||
|
|
@ -113,6 +120,15 @@ const ShortcutsSection = observer(() => {
|
|||
shortcut={editingShortcut}
|
||||
onSuccess={handleShortcutDialogSuccess}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
|
||||
title={t("setting.shortcut.delete-confirm")}
|
||||
confirmLabel={t("common.delete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={confirmDeleteShortcut}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Edit3Icon, HashIcon, MoreVerticalIcon, TagsIcon, TrashIcon } from "luci
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import useLocalStorage from "react-use/lib/useLocalStorage";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { memoServiceClient } from "@/grpcweb";
|
||||
|
|
@ -25,6 +26,7 @@ const TagsSection = observer((props: Props) => {
|
|||
const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage<boolean>("tag-tree-auto-expand", false);
|
||||
const renameTagDialog = useDialog();
|
||||
const [selectedTag, setSelectedTag] = useState<string>("");
|
||||
const [deleteTagName, setDeleteTagName] = useState<string | undefined>(undefined);
|
||||
const tags = Object.entries(userStore.state.tagCount)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
|
@ -52,14 +54,17 @@ const TagsSection = observer((props: Props) => {
|
|||
};
|
||||
|
||||
const handleDeleteTag = async (tag: string) => {
|
||||
const confirmed = window.confirm(t("tag.delete-confirm"));
|
||||
if (confirmed) {
|
||||
await memoServiceClient.deleteMemoTag({
|
||||
parent: "memos/-",
|
||||
tag: tag,
|
||||
});
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
}
|
||||
setDeleteTagName(tag);
|
||||
};
|
||||
|
||||
const confirmDeleteTag = async () => {
|
||||
if (!deleteTagName) return;
|
||||
await memoServiceClient.deleteMemoTag({
|
||||
parent: "memos/-",
|
||||
tag: deleteTagName,
|
||||
});
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
setDeleteTagName(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -139,6 +144,15 @@ const TagsSection = observer((props: Props) => {
|
|||
tag={selectedTag}
|
||||
onSuccess={handleRenameSuccess}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={!!deleteTagName}
|
||||
onOpenChange={(open) => !open && setDeleteTagName(undefined)}
|
||||
title={t("tag.delete-confirm")}
|
||||
confirmLabel={t("common.delete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={confirmDeleteTag}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import copy from "copy-to-clipboard";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ArchiveRestoreIcon,
|
||||
|
|
@ -21,6 +22,7 @@ import { State } from "@/types/proto/api/v1/common";
|
|||
import { NodeType } from "@/types/proto/api/v1/markdown_service";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { Button } from "./ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
|
||||
|
||||
|
|
@ -49,6 +51,8 @@ const MemoActionMenu = observer((props: Props) => {
|
|||
const t = useTranslate();
|
||||
const location = useLocation();
|
||||
const navigateTo = useNavigateTo();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [removeTasksDialogOpen, setRemoveTasksDialogOpen] = useState(false);
|
||||
const hasCompletedTaskList = checkHasCompletedTaskList(memo);
|
||||
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
|
||||
const isComment = Boolean(memo.parent);
|
||||
|
|
@ -101,7 +105,7 @@ const MemoActionMenu = observer((props: Props) => {
|
|||
},
|
||||
["state"],
|
||||
);
|
||||
toast(message);
|
||||
toast.success(message);
|
||||
} catch (error: any) {
|
||||
toast.error(error.details);
|
||||
console.error(error);
|
||||
|
|
@ -123,48 +127,50 @@ const MemoActionMenu = observer((props: Props) => {
|
|||
toast.success(t("message.succeed-copy-link"));
|
||||
};
|
||||
|
||||
const handleDeleteMemoClick = async () => {
|
||||
const confirmed = window.confirm(t("memo.delete-confirm"));
|
||||
if (confirmed) {
|
||||
await memoStore.deleteMemo(memo.name);
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
if (isInMemoDetailPage) {
|
||||
navigateTo("/");
|
||||
}
|
||||
memoUpdatedCallback();
|
||||
}
|
||||
const handleDeleteMemoClick = () => {
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleRemoveCompletedTaskListItemsClick = async () => {
|
||||
const confirmed = window.confirm(t("memo.remove-completed-task-list-items-confirm"));
|
||||
if (confirmed) {
|
||||
const newNodes = JSON.parse(JSON.stringify(memo.nodes));
|
||||
for (const node of newNodes) {
|
||||
if (node.type === NodeType.LIST && node.listNode?.children?.length > 0) {
|
||||
const children = node.listNode.children;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (children[i].type === NodeType.TASK_LIST_ITEM && children[i].taskListItemNode?.complete) {
|
||||
// Remove completed taskList item and next line breaks
|
||||
const confirmDeleteMemo = async () => {
|
||||
await memoStore.deleteMemo(memo.name);
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
if (isInMemoDetailPage) {
|
||||
navigateTo("/");
|
||||
}
|
||||
memoUpdatedCallback();
|
||||
};
|
||||
|
||||
const handleRemoveCompletedTaskListItemsClick = () => {
|
||||
setRemoveTasksDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmRemoveCompletedTaskListItems = async () => {
|
||||
const newNodes = JSON.parse(JSON.stringify(memo.nodes));
|
||||
for (const node of newNodes) {
|
||||
if (node.type === NodeType.LIST && node.listNode?.children?.length > 0) {
|
||||
const children = node.listNode.children;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (children[i].type === NodeType.TASK_LIST_ITEM && children[i].taskListItemNode?.complete) {
|
||||
// Remove completed taskList item and next line breaks
|
||||
children.splice(i, 1);
|
||||
if (children[i]?.type === NodeType.LINE_BREAK) {
|
||||
children.splice(i, 1);
|
||||
if (children[i]?.type === NodeType.LINE_BREAK) {
|
||||
children.splice(i, 1);
|
||||
}
|
||||
i--;
|
||||
}
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}
|
||||
const { markdown } = await markdownServiceClient.restoreMarkdownNodes({ nodes: newNodes });
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
name: memo.name,
|
||||
content: markdown,
|
||||
},
|
||||
["content"],
|
||||
);
|
||||
toast.success(t("message.remove-completed-task-list-items-successfully"));
|
||||
memoUpdatedCallback();
|
||||
}
|
||||
const { markdown } = await markdownServiceClient.restoreMarkdownNodes({ nodes: newNodes });
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
name: memo.name,
|
||||
content: markdown,
|
||||
},
|
||||
["content"],
|
||||
);
|
||||
toast.success(t("message.remove-completed-task-list-items-successfully"));
|
||||
memoUpdatedCallback();
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -216,6 +222,27 @@ const MemoActionMenu = observer((props: Props) => {
|
|||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title={t("memo.delete-confirm")}
|
||||
confirmLabel={t("common.delete")}
|
||||
descriptionMarkdown={t("memo.delete-confirm-description")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={confirmDeleteMemo}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
{/* Remove completed tasks confirmation */}
|
||||
<ConfirmDialog
|
||||
open={removeTasksDialogOpen}
|
||||
onOpenChange={setRemoveTasksDialogOpen}
|
||||
title={t("memo.remove-completed-task-list-items-confirm")}
|
||||
confirmLabel={t("common.confirm")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={confirmRemoveCompletedTaskListItems}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ClipboardIcon, TrashIcon } from "lucide-react";
|
|||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { userServiceClient } from "@/grpcweb";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useDialog } from "@/hooks/useDialog";
|
||||
|
|
@ -20,6 +21,7 @@ const AccessTokenSection = () => {
|
|||
const currentUser = useCurrentUser();
|
||||
const [userAccessTokens, setUserAccessTokens] = useState<UserAccessToken[]>([]);
|
||||
const createTokenDialog = useDialog();
|
||||
const [deleteTarget, setDeleteTarget] = useState<UserAccessToken | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
listAccessTokens(currentUser.name).then((accessTokens) => {
|
||||
|
|
@ -42,12 +44,15 @@ const AccessTokenSection = () => {
|
|||
};
|
||||
|
||||
const handleDeleteAccessToken = async (userAccessToken: UserAccessToken) => {
|
||||
const formatedAccessToken = getFormatedAccessToken(userAccessToken.accessToken);
|
||||
const confirmed = window.confirm(t("setting.access-token-section.access-token-deletion", { accessToken: formatedAccessToken }));
|
||||
if (confirmed) {
|
||||
await userServiceClient.deleteUserAccessToken({ name: userAccessToken.name });
|
||||
setUserAccessTokens(userAccessTokens.filter((token) => token.accessToken !== userAccessToken.accessToken));
|
||||
}
|
||||
setDeleteTarget(userAccessToken);
|
||||
};
|
||||
|
||||
const confirmDeleteAccessToken = async () => {
|
||||
if (!deleteTarget) return;
|
||||
await userServiceClient.deleteUserAccessToken({ name: deleteTarget.name });
|
||||
setUserAccessTokens(userAccessTokens.filter((token) => token.accessToken !== deleteTarget.accessToken));
|
||||
setDeleteTarget(undefined);
|
||||
toast.success(t("setting.access-token-section.access-token-deleted", { description: deleteTarget.description }));
|
||||
};
|
||||
|
||||
const getFormatedAccessToken = (accessToken: string) => {
|
||||
|
|
@ -134,6 +139,20 @@ const AccessTokenSection = () => {
|
|||
onOpenChange={createTokenDialog.setOpen}
|
||||
onSuccess={handleCreateAccessTokenDialogConfirm}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
|
||||
title={
|
||||
deleteTarget
|
||||
? t("setting.access-token-section.access-token-deletion", { description: deleteTarget.description })
|
||||
: ""
|
||||
}
|
||||
descriptionMarkdown={t("setting.access-token-section.access-token-deletion-description")}
|
||||
confirmLabel={t("common.delete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={confirmDeleteAccessToken}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { User, User_Role } from "@/types/proto/api/v1/user_service";
|
|||
import { useTranslate } from "@/utils/i18n";
|
||||
import CreateUserDialog from "../CreateUserDialog";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
const MemberSection = observer(() => {
|
||||
const t = useTranslate();
|
||||
|
|
@ -21,6 +23,8 @@ const MemberSection = observer(() => {
|
|||
const editDialog = useDialog();
|
||||
const [editingUser, setEditingUser] = useState<User | undefined>();
|
||||
const sortedUsers = sortBy(users, "id");
|
||||
const [archiveTarget, setArchiveTarget] = useState<User | undefined>(undefined);
|
||||
const [deleteTarget, setDeleteTarget] = useState<User | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
|
|
@ -52,17 +56,21 @@ const MemberSection = observer(() => {
|
|||
};
|
||||
|
||||
const handleArchiveUserClick = async (user: User) => {
|
||||
const confirmed = window.confirm(t("setting.member-section.archive-warning", { username: user.displayName }));
|
||||
if (confirmed) {
|
||||
await userServiceClient.updateUser({
|
||||
user: {
|
||||
name: user.name,
|
||||
state: State.ARCHIVED,
|
||||
},
|
||||
updateMask: ["state"],
|
||||
});
|
||||
fetchUsers();
|
||||
}
|
||||
setArchiveTarget(user);
|
||||
};
|
||||
|
||||
const confirmArchiveUser = async () => {
|
||||
if (!archiveTarget) return;
|
||||
await userServiceClient.updateUser({
|
||||
user: {
|
||||
name: archiveTarget.name,
|
||||
state: State.ARCHIVED,
|
||||
},
|
||||
updateMask: ["state"],
|
||||
});
|
||||
setArchiveTarget(undefined);
|
||||
toast.success(t("setting.member-section.archive-success", { username: archiveTarget.username }));
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
const handleRestoreUserClick = async (user: User) => {
|
||||
|
|
@ -73,15 +81,20 @@ const MemberSection = observer(() => {
|
|||
},
|
||||
updateMask: ["state"],
|
||||
});
|
||||
toast.success(t("setting.member-section.restore-success", { username: user.username }));
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
const handleDeleteUserClick = async (user: User) => {
|
||||
const confirmed = window.confirm(t("setting.member-section.delete-warning", { username: user.displayName }));
|
||||
if (confirmed) {
|
||||
await userStore.deleteUser(user.name);
|
||||
fetchUsers();
|
||||
}
|
||||
setDeleteTarget(user);
|
||||
};
|
||||
|
||||
const confirmDeleteUser = async () => {
|
||||
if (!deleteTarget) return;
|
||||
await userStore.deleteUser(deleteTarget.name);
|
||||
setDeleteTarget(undefined);
|
||||
toast.success(t("setting.member-section.delete-success", { username: deleteTarget.username }));
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -169,6 +182,28 @@ const MemberSection = observer(() => {
|
|||
|
||||
{/* Edit User Dialog */}
|
||||
<CreateUserDialog open={editDialog.isOpen} onOpenChange={editDialog.setOpen} user={editingUser} onSuccess={fetchUsers} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!archiveTarget}
|
||||
onOpenChange={(open) => !open && setArchiveTarget(undefined)}
|
||||
title={archiveTarget ? t("setting.member-section.archive-warning", { username: archiveTarget.username }) : ""}
|
||||
description={archiveTarget ? t("setting.member-section.archive-warning-description") : ""}
|
||||
confirmLabel={t("common.confirm")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={confirmArchiveUser}
|
||||
confirmVariant="default"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
|
||||
title={deleteTarget ? t("setting.member-section.delete-warning", { username: deleteTarget.username }) : ""}
|
||||
descriptionMarkdown={deleteTarget ? t("setting.member-section.delete-warning-description") : ""}
|
||||
confirmLabel={t("common.delete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={confirmDeleteUser}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { MoreVerticalIcon } from "lucide-react";
|
|||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { identityProviderServiceClient } from "@/grpcweb";
|
||||
|
|
@ -15,6 +16,7 @@ const SSOSection = () => {
|
|||
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [editingIdentityProvider, setEditingIdentityProvider] = useState<IdentityProvider | undefined>();
|
||||
const [deleteTarget, setDeleteTarget] = useState<IdentityProvider | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
fetchIdentityProviderList();
|
||||
|
|
@ -26,16 +28,19 @@ const SSOSection = () => {
|
|||
};
|
||||
|
||||
const handleDeleteIdentityProvider = async (identityProvider: IdentityProvider) => {
|
||||
const confirmed = window.confirm(t("setting.sso-section.confirm-delete", { name: identityProvider.title }));
|
||||
if (confirmed) {
|
||||
try {
|
||||
await identityProviderServiceClient.deleteIdentityProvider({ name: identityProvider.name });
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
}
|
||||
await fetchIdentityProviderList();
|
||||
setDeleteTarget(identityProvider);
|
||||
};
|
||||
|
||||
const confirmDeleteIdentityProvider = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await identityProviderServiceClient.deleteIdentityProvider({ name: deleteTarget.name });
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
}
|
||||
await fetchIdentityProviderList();
|
||||
setDeleteTarget(undefined);
|
||||
};
|
||||
|
||||
const handleCreateIdentityProvider = () => {
|
||||
|
|
@ -112,6 +117,16 @@ const SSOSection = () => {
|
|||
identityProvider={editingIdentityProvider}
|
||||
onSuccess={handleDialogSuccess}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
|
||||
title={deleteTarget ? t("setting.sso-section.confirm-delete", { name: deleteTarget.title }) : ""}
|
||||
confirmLabel={t("common.delete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={confirmDeleteIdentityProvider}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { ClockIcon, MonitorIcon, SmartphoneIcon, TabletIcon, TrashIcon, WifiIcon
|
|||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { userServiceClient } from "@/grpcweb";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { UserSession } from "@/types/proto/api/v1/user_service";
|
||||
|
|
@ -16,6 +17,7 @@ const UserSessionsSection = () => {
|
|||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [userSessions, setUserSessions] = useState<UserSession[]>([]);
|
||||
const [revokeTarget, setRevokeTarget] = useState<UserSession | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
listUserSessions(currentUser.name).then((sessions) => {
|
||||
|
|
@ -24,13 +26,15 @@ const UserSessionsSection = () => {
|
|||
}, []);
|
||||
|
||||
const handleRevokeSession = async (userSession: UserSession) => {
|
||||
const formattedSessionId = getFormattedSessionId(userSession.sessionId);
|
||||
const confirmed = window.confirm(t("setting.user-sessions-section.session-revocation", { sessionId: formattedSessionId }));
|
||||
if (confirmed) {
|
||||
await userServiceClient.revokeUserSession({ name: userSession.name });
|
||||
setUserSessions(userSessions.filter((session) => session.sessionId !== userSession.sessionId));
|
||||
toast.success(t("setting.user-sessions-section.session-revoked"));
|
||||
}
|
||||
setRevokeTarget(userSession);
|
||||
};
|
||||
|
||||
const confirmRevokeSession = async () => {
|
||||
if (!revokeTarget) return;
|
||||
await userServiceClient.revokeUserSession({ name: revokeTarget.name });
|
||||
setUserSessions(userSessions.filter((session) => session.sessionId !== revokeTarget.sessionId));
|
||||
toast.success(t("setting.user-sessions-section.session-revoked"));
|
||||
setRevokeTarget(undefined);
|
||||
};
|
||||
|
||||
const getFormattedSessionId = (sessionId: string) => {
|
||||
|
|
@ -148,6 +152,26 @@ const UserSessionsSection = () => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={!!revokeTarget}
|
||||
onOpenChange={(open) => !open && setRevokeTarget(undefined)}
|
||||
title={
|
||||
revokeTarget
|
||||
? t("setting.user-sessions-section.session-revocation", {
|
||||
sessionId: getFormattedSessionId(revokeTarget.sessionId),
|
||||
})
|
||||
: ""
|
||||
}
|
||||
descriptionMarkdown={
|
||||
revokeTarget
|
||||
? t("setting.user-sessions-section.session-revocation-description")
|
||||
: ""
|
||||
}
|
||||
confirmLabel={t("common.delete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={confirmRevokeSession}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,17 +2,20 @@ import { ExternalLinkIcon, TrashIcon } from "lucide-react";
|
|||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { userServiceClient } from "@/grpcweb";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { UserWebhook } from "@/types/proto/api/v1/user_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import CreateWebhookDialog from "../CreateWebhookDialog";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
const WebhookSection = () => {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [webhooks, setWebhooks] = useState<UserWebhook[]>([]);
|
||||
const [isCreateWebhookDialogOpen, setIsCreateWebhookDialogOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<UserWebhook | undefined>(undefined);
|
||||
|
||||
const listWebhooks = async () => {
|
||||
if (!currentUser) return [];
|
||||
|
|
@ -35,11 +38,15 @@ const WebhookSection = () => {
|
|||
};
|
||||
|
||||
const handleDeleteWebhook = async (webhook: UserWebhook) => {
|
||||
const confirmed = window.confirm(`Are you sure to delete webhook \`${webhook.displayName}\`? You cannot undo this action.`);
|
||||
if (confirmed) {
|
||||
await userServiceClient.deleteUserWebhook({ name: webhook.name });
|
||||
setWebhooks(webhooks.filter((item) => item.name !== webhook.name));
|
||||
}
|
||||
setDeleteTarget(webhook);
|
||||
};
|
||||
|
||||
const confirmDeleteWebhook = async () => {
|
||||
if (!deleteTarget) return;
|
||||
await userServiceClient.deleteUserWebhook({ name: deleteTarget.name });
|
||||
setWebhooks(webhooks.filter((item) => item.name !== deleteTarget.name));
|
||||
toast.success(t("setting.webhook-section.delete-dialog.delete-webhook-success", { name: deleteTarget?.displayName || "" }));
|
||||
setDeleteTarget(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -79,12 +86,7 @@ const WebhookSection = () => {
|
|||
{webhook.url}
|
||||
</td>
|
||||
<td className="relative whitespace-nowrap px-3 py-2 text-right text-sm">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
handleDeleteWebhook(webhook);
|
||||
}}
|
||||
>
|
||||
<Button variant="ghost" onClick={() => handleDeleteWebhook(webhook)}>
|
||||
<TrashIcon className="text-destructive w-4 h-auto" />
|
||||
</Button>
|
||||
</td>
|
||||
|
|
@ -118,6 +120,16 @@ const WebhookSection = () => {
|
|||
onOpenChange={setIsCreateWebhookDialogOpen}
|
||||
onSuccess={handleCreateWebhookDialogConfirm}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
|
||||
title={t("setting.webhook-section.delete-dialog.delete-webhook-title", { name: deleteTarget?.displayName || "" })}
|
||||
descriptionMarkdown={t("setting.webhook-section.delete-dialog.delete-webhook-description")}
|
||||
confirmLabel={t("common.delete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={confirmDeleteWebhook}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -143,7 +143,8 @@
|
|||
},
|
||||
"copy-link": "Copy Link",
|
||||
"count-memos-in-date": "{{count}} {{memos}} in {{date}}",
|
||||
"delete-confirm": "Are you sure you want to delete this memo? THIS ACTION IS IRREVERSIBLE",
|
||||
"delete-confirm": "Are you sure you want to delete this memo?",
|
||||
"delete-confirm-description": "**THIS ACTION IS IRREVERSIBLE**\n\nAttachments, links, and references will also be removed.",
|
||||
"direction": "Direction",
|
||||
"direction-asc": "Ascending",
|
||||
"direction-desc": "Descending",
|
||||
|
|
@ -174,7 +175,7 @@
|
|||
"archived-successfully": "Archived successfully",
|
||||
"change-memo-created-time": "Change memo created time",
|
||||
"copied": "Copied",
|
||||
"deleted-successfully": "Deleted successfully",
|
||||
"deleted-successfully": "Memo deleted successfully",
|
||||
"description-is-required": "Description is required",
|
||||
"failed-to-embed-memo": "Failed to embed memo",
|
||||
"fill-all": "Please fill in all fields.",
|
||||
|
|
@ -219,6 +220,8 @@
|
|||
},
|
||||
"delete-resource": "Delete Resource",
|
||||
"delete-selected-resources": "Delete Selected Resources",
|
||||
"delete-all-unused": "Delete all unused",
|
||||
"delete-all-unused-confirm": "Are you sure you want to delete all unused resources? THIS ACTION IS IRREVERSIBLE",
|
||||
"fetching-data": "Fetching data…",
|
||||
"file-drag-drop-prompt": "Drag and drop your file here to upload file",
|
||||
"linked-amount": "Linked amount",
|
||||
|
|
@ -226,7 +229,7 @@
|
|||
"no-resources": "No resources.",
|
||||
"no-unused-resources": "No unused resources",
|
||||
"reset-link": "Reset Link",
|
||||
"reset-link-prompt": "Are you sure to reset the link? This will break all current link usages. THIS ACTION IS IRREVERSIBLE",
|
||||
"reset-link-prompt": "Are you sure you want to reset the link? This will break all current link usages. THIS ACTION IS IRREVERSIBLE",
|
||||
"reset-resource-link": "Reset Resource Link",
|
||||
"unused-resources": "Unused resources"
|
||||
},
|
||||
|
|
@ -237,7 +240,9 @@
|
|||
"setting": {
|
||||
"access-token-section": {
|
||||
"access-token-copied-to-clipboard": "Access token copied to clipboard",
|
||||
"access-token-deletion": "Are you sure to delete access token {{accessToken}}? THIS ACTION IS IRREVERSIBLE.",
|
||||
"access-token-deletion": "Are you sure you want to delete access token \"{{description}}\"?",
|
||||
"access-token-deletion-description": "**THIS ACTION IS IRREVERSIBLE**\n\nYou will need to update any services using this token to use a new token.",
|
||||
"access-token-deleted": "Access token \"{{description}}\" deleted successfully",
|
||||
"create-dialog": {
|
||||
"create-access-token": "Create Access Token",
|
||||
"created-at": "Created At",
|
||||
|
|
@ -262,7 +267,8 @@
|
|||
"expires": "Expires",
|
||||
"current": "Current",
|
||||
"never": "Never",
|
||||
"session-revocation": "Are you sure to revoke session {{sessionId}}? You will need to sign in again on that device.",
|
||||
"session-revocation": "Are you sure you want to revoke session `{{sessionId}}`?",
|
||||
"session-revocation-description": "You will need to sign in again on that device.",
|
||||
"session-revoked": "Session revoked successfully",
|
||||
"revoke-session": "Revoke session",
|
||||
"cannot-revoke-current": "Cannot revoke current session",
|
||||
|
|
@ -286,10 +292,15 @@
|
|||
"member-section": {
|
||||
"admin": "Admin",
|
||||
"archive-member": "Archive member",
|
||||
"archive-warning": "Are you sure to archive {{username}}?",
|
||||
"archive-warning": "Are you sure you want to archive {{username}}?",
|
||||
"archive-warning-description": "Archiving disables the account. You can restore or delete it later.",
|
||||
"archive-success": "{{username}} archived successfully",
|
||||
"restore-success": "{{username}} restored successfully",
|
||||
"create-a-member": "Create a member",
|
||||
"delete-member": "Delete Member",
|
||||
"delete-warning": "Are you sure to delete {{username}}? THIS ACTION IS IRREVERSIBLE",
|
||||
"delete-warning": "Are you sure you want to delete {{username}}?",
|
||||
"delete-warning-description": "**THIS ACTION IS IRREVERSIBLE**",
|
||||
"delete-success": "{{username}} deleted successfully",
|
||||
"user": "User"
|
||||
},
|
||||
"memo-related": "Memo",
|
||||
|
|
@ -309,12 +320,16 @@
|
|||
"default-memo-visibility": "Default memo visibility",
|
||||
"theme": "Theme"
|
||||
},
|
||||
"shortcut": {
|
||||
"delete-confirm": "Are you sure you want to delete shortcut \"{{title}}\"?",
|
||||
"delete-success": "Shortcut \"{{title}}\" deleted successfully"
|
||||
},
|
||||
"sso": "SSO",
|
||||
"sso-section": {
|
||||
"authorization-endpoint": "Authorization endpoint",
|
||||
"client-id": "Client ID",
|
||||
"client-secret": "Client secret",
|
||||
"confirm-delete": "Are you sure to delete \"{{name}}\" SSO configuration? THIS ACTION IS IRREVERSIBLE",
|
||||
"confirm-delete": "Are you sure you want to delete \"{{name}}\" SSO configuration? THIS ACTION IS IRREVERSIBLE",
|
||||
"create-sso": "Create SSO",
|
||||
"custom": "Custom",
|
||||
"delete-sso": "Confirm delete",
|
||||
|
|
@ -367,7 +382,7 @@
|
|||
"url-prefix-placeholder": "Custom URL prefix, optional",
|
||||
"url-suffix": "URL suffix",
|
||||
"url-suffix-placeholder": "Custom URL suffix, optional",
|
||||
"warning-text": "Are you sure to delete storage service \"{{name}}\"? THIS ACTION IS IRREVERSIBLE"
|
||||
"warning-text": "Are you sure you want to delete storage service \"{{name}}\"? THIS ACTION IS IRREVERSIBLE"
|
||||
},
|
||||
"system": "System",
|
||||
"system-section": {
|
||||
|
|
@ -408,6 +423,11 @@
|
|||
"title": "Title",
|
||||
"url-example-post-receive": "https://example.com/postreceive"
|
||||
},
|
||||
"delete-dialog": {
|
||||
"delete-webhook-description": "**THIS ACTION IS IRREVERSIBLE**",
|
||||
"delete-webhook-title": "Are you sure you want to delete webhook `{{name}}`?",
|
||||
"delete-webhook-success": "Webhook `{{name}}` deleted successfully"
|
||||
},
|
||||
"no-webhooks-found": "No webhooks found.",
|
||||
"title": "Webhooks",
|
||||
"url": "URL"
|
||||
|
|
@ -427,7 +447,7 @@
|
|||
"all-tags": "All Tags",
|
||||
"create-tag": "Create Tag",
|
||||
"create-tags-guide": "You can create tags by inputting `#tag`.",
|
||||
"delete-confirm": "Are you sure to delete this tag? All related memos will be archived.",
|
||||
"delete-confirm": "Are you sure you want to delete this tag? All related memos will be archived.",
|
||||
"delete-tag": "Delete Tag",
|
||||
"new-name": "New Name",
|
||||
"no-tag-found": "No tag found",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import AttachmentIcon from "@/components/AttachmentIcon";
|
|||
import Empty from "@/components/Empty";
|
||||
import MobileHeader from "@/components/MobileHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -47,6 +48,7 @@ const Attachments = observer(() => {
|
|||
const filteredAttachments = attachments.filter((attachment) => includes(attachment.filename, state.searchQuery));
|
||||
const groupedAttachments = groupAttachmentsByDate(filteredAttachments.filter((attachment) => attachment.memo));
|
||||
const unusedAttachments = filteredAttachments.filter((attachment) => !attachment.memo);
|
||||
const [deleteUnusedOpen, setDeleteUnusedOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
attachmentServiceClient.listAttachments({}).then(({ attachments }) => {
|
||||
|
|
@ -57,17 +59,19 @@ const Attachments = observer(() => {
|
|||
}, []);
|
||||
|
||||
const handleDeleteUnusedAttachments = async () => {
|
||||
const confirmed = window.confirm("Are you sure to delete all unused attachments? This action cannot be undone.");
|
||||
if (confirmed) {
|
||||
for (const attachment of unusedAttachments) {
|
||||
await attachmentServiceClient.deleteAttachment({ name: attachment.name });
|
||||
}
|
||||
setAttachments(attachments.filter((attachment) => attachment.memo));
|
||||
setDeleteUnusedOpen(true);
|
||||
};
|
||||
|
||||
const confirmDeleteUnusedAttachments = async () => {
|
||||
for (const attachment of unusedAttachments) {
|
||||
await attachmentServiceClient.deleteAttachment({ name: attachment.name });
|
||||
}
|
||||
setAttachments(attachments.filter((attachment) => attachment.memo));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
|
||||
<>
|
||||
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
|
||||
{!md && <MobileHeader />}
|
||||
<div className="w-full px-4 sm:px-6">
|
||||
<div className="w-full border border-border flex flex-col justify-start items-start px-4 py-3 rounded-xl bg-background text-foreground">
|
||||
|
|
@ -141,12 +145,17 @@ const Attachments = observer(() => {
|
|||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={handleDeleteUnusedAttachments}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDeleteUnusedAttachments}
|
||||
aria-label={t("resource.delete-all-unused")}
|
||||
>
|
||||
<TrashIcon className="w-4 h-auto opacity-60" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete all</p>
|
||||
<p>{t("resource.delete-all-unused")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
|
@ -174,8 +183,19 @@ const Attachments = observer(() => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
<ConfirmDialog
|
||||
open={deleteUnusedOpen}
|
||||
onOpenChange={setDeleteUnusedOpen}
|
||||
title={t("resource.delete-all-unused-confirm")}
|
||||
confirmLabel={t("common.delete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={confirmDeleteUnusedAttachments}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default Attachments;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue