From 20233c7051cd6504171f31c6fa1e4ddc48337489 Mon Sep 17 00:00:00 2001 From: Nic Luckie Date: Wed, 8 Oct 2025 12:40:08 -0400 Subject: [PATCH] feat(web): add accessible ConfirmDialog and migrate confirmations; and Markdown-safe descriptions (#5111) Signed-off-by: Nic Luckie Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- web/package.json | 2 +- web/src/components/ConfirmDialog/README.md | 131 ++++++++++++++++++ web/src/components/ConfirmDialog/index.tsx | 73 ++++++++++ .../components/CreateAccessTokenDialog.tsx | 7 +- .../HomeSidebar/ShortcutsSection.tsx | 26 +++- .../components/HomeSidebar/TagsSection.tsx | 30 ++-- web/src/components/MemoActionMenu.tsx | 95 ++++++++----- .../Settings/AccessTokenSection.tsx | 32 ++++- web/src/components/Settings/MemberSection.tsx | 72 +++++++--- web/src/components/Settings/SSOSection.tsx | 33 +++-- .../Settings/UserSessionsSection.tsx | 34 ++++- .../components/Settings/WebhookSection.tsx | 36 +++-- web/src/locales/en.json | 46 ++++-- web/src/pages/Attachments.tsx | 26 +--- 14 files changed, 505 insertions(+), 138 deletions(-) create mode 100644 web/src/components/ConfirmDialog/README.md create mode 100644 web/src/components/ConfirmDialog/index.tsx diff --git a/web/package.json b/web/package.json index b790485e5..4f0f18e66 100644 --- a/web/package.json +++ b/web/package.json @@ -92,4 +92,4 @@ "esbuild" ] } -} \ No newline at end of file +} diff --git a/web/src/components/ConfirmDialog/README.md b/web/src/components/ConfirmDialog/README.md new file mode 100644 index 000000000..98d41f8df --- /dev/null +++ b/web/src/components/ConfirmDialog/README.md @@ -0,0 +1,131 @@ +# ConfirmDialog - Accessible Confirmation Dialog + +## Overview + +`ConfirmDialog` standardizes confirmation flows across the app. It replaces ad‑hoc `window.confirm` usage with an accessible, themeable dialog that supports asynchronous operations. + +## Key Features + +### 1. Accessibility & UX +- Uses shared `Dialog` primitives (focus trap, ARIA roles) +- Blocks dismissal while async confirm is pending +- Clear separation of title (action) vs description (context) + +### 2. Async-Aware +- Accepts sync or async `onConfirm` +- Auto-closes on resolve; remains open on error for retry / toast + +### 3. Internationalization Ready +- All labels / text provided by caller through i18n hook +- Supports interpolation for dynamic context + +### 4. Minimal Surface, Easy Extension +- Lightweight API (few required props) +- Style hook via `.container` class (SCSS module) + +## Architecture + +``` +ConfirmDialog +├── State: loading (tracks pending confirm action) +├── Dialog primitives: Header (title + description), Footer (buttons) +└── External control: parent owns open state via onOpenChange +``` + +## Usage + +```tsx +import { useTranslate } from "@/utils/i18n"; +import ConfirmDialog from "@/components/ConfirmDialog"; + +const t = useTranslate(); + +; +``` + +## Props + +| Prop | Type | Required | Acceptable Values | +|------|------|----------|------------------| +| `open` | `boolean` | Yes | `true` (visible) / `false` (hidden) | +| `onOpenChange` | `(open: boolean) => void` | Yes | Callback receiving next state; should update parent state | +| `title` | `React.ReactNode` | Yes | Short localized action summary (text / node) | +| `description` | `React.ReactNode` | No | Optional contextual message | +| `confirmLabel` | `string` | Yes | Non-empty localized action text (1–2 words) | +| `cancelLabel` | `string` | Yes | Localized cancel label | +| `onConfirm` | `() => void | Promise` | Yes | Sync or async handler; resolve = close, reject = stay open | +| `confirmVariant` | `"default" | "destructive"` | No | Defaults to `"default"`; use `"destructive"` for irreversible actions | + +## Benefits vs Previous Implementation + +### Before (window.confirm / ad‑hoc dialogs) +- Blocking native prompt, inconsistent styling +- No async progress handling +- No rich formatting +- Hard to localize consistently + +### After (ConfirmDialog) +- Unified styling + accessibility semantics +- Async-safe with loading state shielding +- Plain description flexibility +- i18n-first via externalized labels + +## Technical Implementation Details + +### Async Handling +```tsx +const handleConfirm = async () => { + setLoading(true); + try { + await onConfirm(); // resolve -> close + onOpenChange(false); + } catch (e) { + console.error(e); // remain open for retry + } finally { + setLoading(false); + } +}; +``` + +### Close Guard +```tsx + !loading && onOpenChange(next)} /> +``` + +## Browser / Environment Support +- Works anywhere the existing `Dialog` primitives work (modern browsers) +- No ResizeObserver / layout dependencies + +## Performance Considerations +1. Minimal renders: loading state toggles once per confirm attempt +2. No portal churn—relies on underlying dialog infra + +## Future Enhancements +1. Severity icon / header accent +2. Auto-focus destructive button toggle +3. Secondary action (e.g. "Archive" vs "Delete") +4. Built-in retry / error slot +5. Optional checkbox confirmation ("I understand the consequences") +6. Motion/animation tokens integration + +## Styling +The `ConfirmDialog.module.scss` file provides a `.container` hook. It currently only hosts a harmless custom property so the stylesheet is non-empty. Add real layout or variant tokens there instead of inline styles. + +## Internationalization +All visible strings must come from the translation system. Use `useTranslate()` and pass localized values into props. Separate keys for title/description. + +## Error Handling +Errors thrown in `onConfirm` are caught and logged. The dialog stays open so the caller can surface a toast or inline message and allow retry. (Consider routing serious errors to a higher-level handler.) + +--- + +If you extend this component, update this README to keep usage discoverable. diff --git a/web/src/components/ConfirmDialog/index.tsx b/web/src/components/ConfirmDialog/index.tsx new file mode 100644 index 000000000..495d68ceb --- /dev/null +++ b/web/src/components/ConfirmDialog/index.tsx @@ -0,0 +1,73 @@ +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; + +export interface ConfirmDialogProps { + /** Whether the dialog is open */ + open: boolean; + /** Open state change callback (closing disabled while loading) */ + onOpenChange: (open: boolean) => void; + /** Title content (plain text or React nodes) */ + title: React.ReactNode; + /** Optional description (plain text or React nodes) */ + description?: React.ReactNode; + /** Confirm / primary action button label */ + confirmLabel: string; + /** Cancel button label */ + cancelLabel: string; + /** Async or sync confirm handler. Dialog auto-closes on resolve, stays open on reject */ + onConfirm: () => void | Promise; + /** Variant style of confirm button */ + confirmVariant?: "default" | "destructive"; +} + +/** + * Accessible confirmation dialog. + * - Renders optional description content + * - Prevents closing while async confirm action is in-flight + * - Minimal opinionated styling; leverages existing UI primitives + */ +export default function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel, + cancelLabel, + onConfirm, + confirmVariant = "default", +}: ConfirmDialogProps) { + const [loading, setLoading] = React.useState(false); + + const handleConfirm = async () => { + try { + setLoading(true); + await onConfirm(); + onOpenChange(false); + } catch (e) { + // Intentionally swallow errors so user can retry; surface via caller's toast/logging + console.error("ConfirmDialog error for action:", title, e); + } finally { + setLoading(false); + } + }; + + return ( + !loading && onOpenChange(o)}> + + + {title} + {description ? {description} : null} + + + + + + + + ); +} diff --git a/web/src/components/CreateAccessTokenDialog.tsx b/web/src/components/CreateAccessTokenDialog.tsx index 2e8d22dcb..8af13e509 100644 --- a/web/src/components/CreateAccessTokenDialog.tsx +++ b/web/src/components/CreateAccessTokenDialog.tsx @@ -8,12 +8,13 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { userServiceClient } from "@/grpcweb"; import useCurrentUser from "@/hooks/useCurrentUser"; import useLoading from "@/hooks/useLoading"; +import { UserAccessToken } from "@/types/proto/api/v1/user_service"; import { useTranslate } from "@/utils/i18n"; interface Props { open: boolean; onOpenChange: (open: boolean) => void; - onSuccess: () => void; + onSuccess: (created: UserAccessToken) => void; } interface State { @@ -72,7 +73,7 @@ function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: Props) { try { requestState.setLoading(); - await userServiceClient.createUserAccessToken({ + const created = await userServiceClient.createUserAccessToken({ parent: currentUser.name, accessToken: { description: state.description, @@ -81,7 +82,7 @@ function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: Props) { }); requestState.setFinish(); - onSuccess(); + onSuccess(created); onOpenChange(false); } catch (error: any) { toast.error(error.details); diff --git a/web/src/components/HomeSidebar/ShortcutsSection.tsx b/web/src/components/HomeSidebar/ShortcutsSection.tsx index f36c3a7f4..1b3d18bd3 100644 --- a/web/src/components/HomeSidebar/ShortcutsSection.tsx +++ b/web/src/components/HomeSidebar/ShortcutsSection.tsx @@ -1,6 +1,8 @@ import { Edit3Icon, MoreVerticalIcon, TrashIcon, PlusIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; import { useState } from "react"; +import toast from "react-hot-toast"; +import ConfirmDialog from "@/components/ConfirmDialog"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { shortcutServiceClient } from "@/grpcweb"; import useAsyncEffect from "@/hooks/useAsyncEffect"; @@ -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", { title: deleteTarget?.title ?? "" })} + 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..bed64fe2a 100644 --- a/web/src/components/HomeSidebar/TagsSection.tsx +++ b/web/src/components/HomeSidebar/TagsSection.tsx @@ -3,6 +3,7 @@ import { observer } from "mobx-react-lite"; import { useState } from "react"; import toast from "react-hot-toast"; import useLocalStorage from "react-use/lib/useLocalStorage"; +import ConfirmDialog from "@/components/ConfirmDialog"; import { Switch } from "@/components/ui/switch"; import { memoServiceClient } from "@/grpcweb"; import { useDialog } from "@/hooks/useDialog"; @@ -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("tag.delete-success")); + 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..a07401699 100644 --- a/web/src/components/MemoActionMenu.tsx +++ b/web/src/components/MemoActionMenu.tsx @@ -11,8 +11,10 @@ import { SquareCheckIcon, } from "lucide-react"; import { observer } from "mobx-react-lite"; +import { useState } from "react"; import toast from "react-hot-toast"; import { useLocation } from "react-router-dom"; +import ConfirmDialog from "@/components/ConfirmDialog"; import { markdownServiceClient } from "@/grpcweb"; import useNavigateTo from "@/hooks/useNavigateTo"; import { memoStore, userStore } from "@/store"; @@ -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..e4cf3f6da 100644 --- a/web/src/components/Settings/AccessTokenSection.tsx +++ b/web/src/components/Settings/AccessTokenSection.tsx @@ -2,6 +2,7 @@ import copy from "copy-to-clipboard"; import { ClipboardIcon, TrashIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "react-hot-toast"; +import ConfirmDialog from "@/components/ConfirmDialog"; import { Button } from "@/components/ui/button"; import { userServiceClient } from "@/grpcweb"; import useCurrentUser from "@/hooks/useCurrentUser"; @@ -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) => { @@ -27,9 +29,10 @@ const AccessTokenSection = () => { }); }, []); - const handleCreateAccessTokenDialogConfirm = async () => { + const handleCreateAccessTokenDialogConfirm = async (created: UserAccessToken) => { const accessTokens = await listAccessTokens(currentUser.name); setUserAccessTokens(accessTokens); + toast.success(t("setting.access-token-section.create-dialog.access-token-created", { description: created.description })); }; const handleCreateToken = () => { @@ -42,12 +45,17 @@ 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; + const { name: tokenName, description } = deleteTarget; + await userServiceClient.deleteUserAccessToken({ name: tokenName }); + // Filter by stable resource name to avoid ambiguity with duplicate token strings + setUserAccessTokens((prev) => prev.filter((token) => token.name !== tokenName)); + setDeleteTarget(undefined); + toast.success(t("setting.access-token-section.access-token-deleted", { description })); }; const getFormatedAccessToken = (accessToken: string) => { @@ -134,6 +142,16 @@ const AccessTokenSection = () => { onOpenChange={createTokenDialog.setOpen} onSuccess={handleCreateAccessTokenDialogConfirm} /> + !open && setDeleteTarget(undefined)} + title={deleteTarget ? t("setting.access-token-section.access-token-deletion", { description: deleteTarget.description }) : ""} + description={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..f6ee2137e 100644 --- a/web/src/components/Settings/MemberSection.tsx +++ b/web/src/components/Settings/MemberSection.tsx @@ -2,6 +2,8 @@ import { sortBy } from "lodash-es"; import { MoreVerticalIcon, PlusIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; import React, { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import ConfirmDialog from "@/components/ConfirmDialog"; import { Button } from "@/components/ui/button"; import { userServiceClient } from "@/grpcweb"; import useCurrentUser from "@/hooks/useCurrentUser"; @@ -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,20 +56,26 @@ 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; + const username = archiveTarget.username; + await userServiceClient.updateUser({ + user: { + name: archiveTarget.name, + state: State.ARCHIVED, + }, + updateMask: ["state"], + }); + setArchiveTarget(undefined); + toast.success(t("setting.member-section.archive-success", { username })); + await fetchUsers(); }; const handleRestoreUserClick = async (user: User) => { + const { username } = user; await userServiceClient.updateUser({ user: { name: user.name, @@ -73,15 +83,21 @@ const MemberSection = observer(() => { }, updateMask: ["state"], }); - fetchUsers(); + toast.success(t("setting.member-section.restore-success", { username })); + await 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; + const { username, name } = deleteTarget; + await userStore.deleteUser(name); + setDeleteTarget(undefined); + toast.success(t("setting.member-section.delete-success", { username })); + await fetchUsers(); }; return ( @@ -169,6 +185,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 }) : ""} + description={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..8cc31690c 100644 --- a/web/src/components/Settings/SSOSection.tsx +++ b/web/src/components/Settings/SSOSection.tsx @@ -1,6 +1,7 @@ import { MoreVerticalIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "react-hot-toast"; +import ConfirmDialog from "@/components/ConfirmDialog"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; @@ -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..292e09140 100644 --- a/web/src/components/Settings/UserSessionsSection.tsx +++ b/web/src/components/Settings/UserSessionsSection.tsx @@ -1,6 +1,7 @@ import { ClockIcon, MonitorIcon, SmartphoneIcon, TabletIcon, TrashIcon, WifiIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "react-hot-toast"; +import ConfirmDialog from "@/components/ConfirmDialog"; import { Button } from "@/components/ui/button"; import { userServiceClient } from "@/grpcweb"; import useCurrentUser from "@/hooks/useCurrentUser"; @@ -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,22 @@ const UserSessionsSection = () => { + !open && setRevokeTarget(undefined)} + title={ + revokeTarget + ? t("setting.user-sessions-section.session-revocation", { + sessionId: getFormattedSessionId(revokeTarget.sessionId), + }) + : "" + } + description={revokeTarget ? t("setting.user-sessions-section.session-revocation-description") : ""} + confirmLabel={t("setting.user-sessions-section.revoke-session-button")} + 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..817db5c99 100644 --- a/web/src/components/Settings/WebhookSection.tsx +++ b/web/src/components/Settings/WebhookSection.tsx @@ -1,6 +1,8 @@ import { ExternalLinkIcon, TrashIcon } from "lucide-react"; import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; import { Link } from "react-router-dom"; +import ConfirmDialog from "@/components/ConfirmDialog"; import { Button } from "@/components/ui/button"; import { userServiceClient } from "@/grpcweb"; import useCurrentUser from "@/hooks/useCurrentUser"; @@ -13,6 +15,7 @@ const WebhookSection = () => { const currentUser = useCurrentUser(); const [webhooks, setWebhooks] = useState([]); const [isCreateWebhookDialogOpen, setIsCreateWebhookDialogOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(undefined); const listWebhooks = async () => { if (!currentUser) return []; @@ -30,16 +33,22 @@ const WebhookSection = () => { const handleCreateWebhookDialogConfirm = async () => { const webhooks = await listWebhooks(); + const name = webhooks[webhooks.length - 1]?.displayName || ""; setWebhooks(webhooks); setIsCreateWebhookDialogOpen(false); + toast.success(t("setting.webhook-section.create-dialog.create-webhook-success", { name })); }; 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)); + setDeleteTarget(undefined); + toast.success(t("setting.webhook-section.delete-dialog.delete-webhook-success", { name: deleteTarget.displayName })); }; return ( @@ -79,12 +88,7 @@ const WebhookSection = () => { {webhook.url} - @@ -118,6 +122,16 @@ const WebhookSection = () => { onOpenChange={setIsCreateWebhookDialogOpen} onSuccess={handleCreateWebhookDialogConfirm} /> + !open && setDeleteTarget(undefined)} + title={t("setting.webhook-section.delete-dialog.delete-webhook-title", { name: deleteTarget?.displayName || "" })} + description={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..7f001d02e 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. Attachments, 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,8 +240,11 @@ "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. You will need to update any services using this token to use a new token.", + "access-token-deleted": "Access token `{{description}}` deleted", "create-dialog": { + "access-token-created": "Access token `{{description}}` created", "create-access-token": "Create Access Token", "created-at": "Created At", "description": "Description", @@ -262,9 +268,11 @@ "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", + "revoke-session-button": "Revoke", "cannot-revoke-current": "Cannot revoke current session", "no-sessions": "No active sessions found" }, @@ -286,10 +294,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 +322,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 +384,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": { @@ -384,7 +401,7 @@ }, "disable-markdown-shortcuts-in-editor": "Disable Markdown shortcuts in editor", "disable-password-login": "Disable password login", - "disable-password-login-final-warning": "Please type \"CONFIRM\" if you know what you are doing.", + "disable-password-login-final-warning": "Please type `CONFIRM` if you know what you are doing.", "disable-password-login-warning": "This will disable password login for all users. It is not possible to log in without reverting this setting in the database if your configured identity providers fail. You’ll also have to be extra careful when removing an identity provider", "disable-public-memos": "Disable public memos", "display-with-updated-time": "Display with updated time", @@ -403,11 +420,17 @@ "create-dialog": { "an-easy-to-remember-name": "An easy-to-remember name", "create-webhook": "Create webhook", + "create-webhook-success": "Webhook `{{name}}` created", "edit-webhook": "Edit webhook", "payload-url": "Payload URL", "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,8 +450,9 @@ "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", + "delete-success": "Tag deleted successfully", "new-name": "New Name", "no-tag-found": "No tag found", "old-name": "Old Name", diff --git a/web/src/pages/Attachments.tsx b/web/src/pages/Attachments.tsx index f8bafcdd5..7e6d33e40 100644 --- a/web/src/pages/Attachments.tsx +++ b/web/src/pages/Attachments.tsx @@ -1,15 +1,13 @@ import dayjs from "dayjs"; import { includes } from "lodash-es"; -import { PaperclipIcon, SearchIcon, TrashIcon } from "lucide-react"; +import { PaperclipIcon, SearchIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; import { useEffect, useState } from "react"; import AttachmentIcon from "@/components/AttachmentIcon"; import Empty from "@/components/Empty"; import MobileHeader from "@/components/MobileHeader"; -import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { attachmentServiceClient } from "@/grpcweb"; import useLoading from "@/hooks/useLoading"; import useResponsiveWidth from "@/hooks/useResponsiveWidth"; @@ -56,16 +54,6 @@ 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)); - } - }; - return (
{!md && } @@ -138,18 +126,6 @@ const Attachments = observer(() => {
{t("resource.unused-resources")} ({unusedAttachments.length}) - - - - - - -

Delete all

-
-
-
{unusedAttachments.map((attachment) => { return (