From 30795d1d9cacb00c47681a7bdfd11c252bb05911 Mon Sep 17 00:00:00 2001 From: Nic Luckie Date: Sat, 20 Sep 2025 12:08:07 -0400 Subject: [PATCH] feat(web): introduce accessible ConfirmDialog and migrate confirmations; add Markdown-safe descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/package.json | 3 +- web/src/components/ConfirmDialog.tsx | 72 ++++++++++++++ .../HomeSidebar/ShortcutsSection.tsx | 26 ++++- .../components/HomeSidebar/TagsSection.tsx | 30 ++++-- web/src/components/MemoActionMenu.tsx | 95 ++++++++++++------- .../Settings/AccessTokenSection.tsx | 31 ++++-- web/src/components/Settings/MemberSection.tsx | 67 +++++++++---- web/src/components/Settings/SSOSection.tsx | 33 +++++-- .../Settings/UserSessionsSection.tsx | 38 ++++++-- .../components/Settings/WebhookSection.tsx | 34 ++++--- web/src/locales/en.json | 40 ++++++-- web/src/pages/Attachments.tsx | 40 ++++++-- 12 files changed, 392 insertions(+), 117 deletions(-) create mode 100644 web/src/components/ConfirmDialog.tsx diff --git a/web/package.json b/web/package.json index f0803b16c..3d193c70f 100644 --- a/web/package.json +++ b/web/package.json @@ -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" ] } -} \ No newline at end of file +} diff --git a/web/src/components/ConfirmDialog.tsx b/web/src/components/ConfirmDialog.tsx new file mode 100644 index 000000000..af8b17db8 --- /dev/null +++ b/web/src/components/ConfirmDialog.tsx @@ -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; + 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 ( + !loading && onOpenChange(o)}> + + + {title} + {descriptionHtml ? ( + + ) : description ? ( + {description} + ) : null} + + + + + + + + ); +} diff --git a/web/src/components/HomeSidebar/ShortcutsSection.tsx b/web/src/components/HomeSidebar/ShortcutsSection.tsx index f36c3a7f4..f50613080 100644 --- a/web/src/components/HomeSidebar/ShortcutsSection.tsx +++ b/web/src/components/HomeSidebar/ShortcutsSection.tsx @@ -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(); const [editingShortcut, setEditingShortcut] = useState(); 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} /> + !open && setDeleteTarget(undefined)} + title={t("setting.shortcut.delete-confirm")} + confirmLabel={t("common.delete")} + cancelLabel={t("common.cancel")} + onConfirm={confirmDeleteShortcut} + confirmVariant="destructive" + /> ); }); diff --git a/web/src/components/HomeSidebar/TagsSection.tsx b/web/src/components/HomeSidebar/TagsSection.tsx index 2eaeaf3d4..eff77ae4a 100644 --- a/web/src/components/HomeSidebar/TagsSection.tsx +++ b/web/src/components/HomeSidebar/TagsSection.tsx @@ -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("tag-tree-auto-expand", false); const renameTagDialog = useDialog(); const [selectedTag, setSelectedTag] = useState(""); + const [deleteTagName, setDeleteTagName] = useState(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} /> + !open && setDeleteTagName(undefined)} + title={t("tag.delete-confirm")} + confirmLabel={t("common.delete")} + cancelLabel={t("common.cancel")} + onConfirm={confirmDeleteTag} + confirmVariant="destructive" + /> ); }); diff --git a/web/src/components/MemoActionMenu.tsx b/web/src/components/MemoActionMenu.tsx index 051a9a8bc..cb473ff96 100644 --- a/web/src/components/MemoActionMenu.tsx +++ b/web/src/components/MemoActionMenu.tsx @@ -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 confirmDeleteMemo = async () => { + await memoStore.deleteMemo(memo.name); + toast.success(t("message.deleted-successfully")); + if (isInMemoDetailPage) { + navigateTo("/"); } + memoUpdatedCallback(); + }; + + const handleRemoveCompletedTaskListItemsClick = () => { + setRemoveTasksDialogOpen(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 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) => { )} + {/* Delete confirmation dialog */} + + {/* Remove completed tasks confirmation */} + ); }); diff --git a/web/src/components/Settings/AccessTokenSection.tsx b/web/src/components/Settings/AccessTokenSection.tsx index 60c9f0e16..55505fa1a 100644 --- a/web/src/components/Settings/AccessTokenSection.tsx +++ b/web/src/components/Settings/AccessTokenSection.tsx @@ -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([]); const createTokenDialog = useDialog(); + const [deleteTarget, setDeleteTarget] = useState(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} /> + !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" + /> ); }; diff --git a/web/src/components/Settings/MemberSection.tsx b/web/src/components/Settings/MemberSection.tsx index 7734063bf..f7c03ed5c 100644 --- a/web/src/components/Settings/MemberSection.tsx +++ b/web/src/components/Settings/MemberSection.tsx @@ -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(); const sortedUsers = sortBy(users, "id"); + const [archiveTarget, setArchiveTarget] = useState(undefined); + const [deleteTarget, setDeleteTarget] = useState(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 */} + + !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" + /> + + !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" + /> ); }); diff --git a/web/src/components/Settings/SSOSection.tsx b/web/src/components/Settings/SSOSection.tsx index 0dfd7b94e..3760b1bb3 100644 --- a/web/src/components/Settings/SSOSection.tsx +++ b/web/src/components/Settings/SSOSection.tsx @@ -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([]); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [editingIdentityProvider, setEditingIdentityProvider] = useState(); + const [deleteTarget, setDeleteTarget] = useState(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} /> + + !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" + /> ); }; diff --git a/web/src/components/Settings/UserSessionsSection.tsx b/web/src/components/Settings/UserSessionsSection.tsx index 8e5d00e59..eceabd605 100644 --- a/web/src/components/Settings/UserSessionsSection.tsx +++ b/web/src/components/Settings/UserSessionsSection.tsx @@ -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([]); + const [revokeTarget, setRevokeTarget] = useState(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 = () => { + !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" + /> ); diff --git a/web/src/components/Settings/WebhookSection.tsx b/web/src/components/Settings/WebhookSection.tsx index 78e22a148..7e9b2b6df 100644 --- a/web/src/components/Settings/WebhookSection.tsx +++ b/web/src/components/Settings/WebhookSection.tsx @@ -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([]); const [isCreateWebhookDialogOpen, setIsCreateWebhookDialogOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(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} - @@ -118,6 +120,16 @@ const WebhookSection = () => { onOpenChange={setIsCreateWebhookDialogOpen} onSuccess={handleCreateWebhookDialogConfirm} /> + !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" + /> ); }; diff --git a/web/src/locales/en.json b/web/src/locales/en.json index d3833b9a6..72a0904da 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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", diff --git a/web/src/pages/Attachments.tsx b/web/src/pages/Attachments.tsx index f8bafcdd5..1bc2b1320 100644 --- a/web/src/pages/Attachments.tsx +++ b/web/src/pages/Attachments.tsx @@ -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 ( -
+ <> +
{!md && }
@@ -141,12 +145,17 @@ const Attachments = observer(() => { - -

Delete all

+

{t("resource.delete-all-unused")}

@@ -174,8 +183,19 @@ const Attachments = observer(() => {
-
+
+ + ); }); export default Attachments; +