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.
pull/5111/head
Nic Luckie 3 weeks ago
parent 7ab57f8ed2
commit 30795d1d9c

@ -40,6 +40,7 @@
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-react": "^0.486.0", "lucide-react": "^0.486.0",
"marked": "^16.3.0",
"mermaid": "^11.11.0", "mermaid": "^11.11.0",
"mime": "^4.1.0", "mime": "^4.1.0",
"mobx": "^6.13.7", "mobx": "^6.13.7",

@ -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 { observer } from "mobx-react-lite";
import { useState } from "react"; import { useState } from "react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import ConfirmDialog from "@/components/ConfirmDialog";
import { shortcutServiceClient } from "@/grpcweb"; import { shortcutServiceClient } from "@/grpcweb";
import useAsyncEffect from "@/hooks/useAsyncEffect"; import useAsyncEffect from "@/hooks/useAsyncEffect";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -11,6 +12,7 @@ import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import CreateShortcutDialog from "../CreateShortcutDialog"; import CreateShortcutDialog from "../CreateShortcutDialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
import toast from "react-hot-toast";
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)$/u; const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)$/u;
@ -25,6 +27,7 @@ const ShortcutsSection = observer(() => {
const t = useTranslate(); const t = useTranslate();
const shortcuts = userStore.state.shortcuts; const shortcuts = userStore.state.shortcuts;
const [isCreateShortcutDialogOpen, setIsCreateShortcutDialogOpen] = useState(false); const [isCreateShortcutDialogOpen, setIsCreateShortcutDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Shortcut | undefined>();
const [editingShortcut, setEditingShortcut] = useState<Shortcut | undefined>(); const [editingShortcut, setEditingShortcut] = useState<Shortcut | undefined>();
useAsyncEffect(async () => { useAsyncEffect(async () => {
@ -32,11 +35,15 @@ const ShortcutsSection = observer(() => {
}, []); }, []);
const handleDeleteShortcut = async (shortcut: Shortcut) => { const handleDeleteShortcut = async (shortcut: Shortcut) => {
const confirmed = window.confirm("Are you sure you want to delete this shortcut?"); setDeleteTarget(shortcut);
if (confirmed) { };
await shortcutServiceClient.deleteShortcut({ name: shortcut.name });
const confirmDeleteShortcut = async () => {
if (!deleteTarget) return;
await shortcutServiceClient.deleteShortcut({ name: deleteTarget.name });
await userStore.fetchUserSettings(); await userStore.fetchUserSettings();
} toast.success(t("setting.shortcut.delete-success", { title: deleteTarget.title }));
setDeleteTarget(undefined);
}; };
const handleCreateShortcut = () => { const handleCreateShortcut = () => {
@ -113,6 +120,15 @@ const ShortcutsSection = observer(() => {
shortcut={editingShortcut} shortcut={editingShortcut}
onSuccess={handleShortcutDialogSuccess} 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> </div>
); );
}); });

@ -2,6 +2,7 @@ import { Edit3Icon, HashIcon, MoreVerticalIcon, TagsIcon, TrashIcon } from "luci
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import useLocalStorage from "react-use/lib/useLocalStorage"; import useLocalStorage from "react-use/lib/useLocalStorage";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { memoServiceClient } from "@/grpcweb"; import { memoServiceClient } from "@/grpcweb";
@ -25,6 +26,7 @@ const TagsSection = observer((props: Props) => {
const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage<boolean>("tag-tree-auto-expand", false); const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage<boolean>("tag-tree-auto-expand", false);
const renameTagDialog = useDialog(); const renameTagDialog = useDialog();
const [selectedTag, setSelectedTag] = useState<string>(""); const [selectedTag, setSelectedTag] = useState<string>("");
const [deleteTagName, setDeleteTagName] = useState<string | undefined>(undefined);
const tags = Object.entries(userStore.state.tagCount) const tags = Object.entries(userStore.state.tagCount)
.sort((a, b) => a[0].localeCompare(b[0])) .sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1]); .sort((a, b) => b[1] - a[1]);
@ -52,14 +54,17 @@ const TagsSection = observer((props: Props) => {
}; };
const handleDeleteTag = async (tag: string) => { const handleDeleteTag = async (tag: string) => {
const confirmed = window.confirm(t("tag.delete-confirm")); setDeleteTagName(tag);
if (confirmed) { };
const confirmDeleteTag = async () => {
if (!deleteTagName) return;
await memoServiceClient.deleteMemoTag({ await memoServiceClient.deleteMemoTag({
parent: "memos/-", parent: "memos/-",
tag: tag, tag: deleteTagName,
}); });
toast.success(t("message.deleted-successfully")); toast.success(t("message.deleted-successfully"));
} setDeleteTagName(undefined);
}; };
return ( return (
@ -139,6 +144,15 @@ const TagsSection = observer((props: Props) => {
tag={selectedTag} tag={selectedTag}
onSuccess={handleRenameSuccess} 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> </div>
); );
}); });

@ -1,4 +1,5 @@
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { useState } from "react";
import { import {
ArchiveIcon, ArchiveIcon,
ArchiveRestoreIcon, ArchiveRestoreIcon,
@ -21,6 +22,7 @@ import { State } from "@/types/proto/api/v1/common";
import { NodeType } from "@/types/proto/api/v1/markdown_service"; import { NodeType } from "@/types/proto/api/v1/markdown_service";
import { Memo } from "@/types/proto/api/v1/memo_service"; import { Memo } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
@ -49,6 +51,8 @@ const MemoActionMenu = observer((props: Props) => {
const t = useTranslate(); const t = useTranslate();
const location = useLocation(); const location = useLocation();
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [removeTasksDialogOpen, setRemoveTasksDialogOpen] = useState(false);
const hasCompletedTaskList = checkHasCompletedTaskList(memo); const hasCompletedTaskList = checkHasCompletedTaskList(memo);
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`); const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
const isComment = Boolean(memo.parent); const isComment = Boolean(memo.parent);
@ -101,7 +105,7 @@ const MemoActionMenu = observer((props: Props) => {
}, },
["state"], ["state"],
); );
toast(message); toast.success(message);
} catch (error: any) { } catch (error: any) {
toast.error(error.details); toast.error(error.details);
console.error(error); console.error(error);
@ -123,21 +127,24 @@ const MemoActionMenu = observer((props: Props) => {
toast.success(t("message.succeed-copy-link")); toast.success(t("message.succeed-copy-link"));
}; };
const handleDeleteMemoClick = async () => { const handleDeleteMemoClick = () => {
const confirmed = window.confirm(t("memo.delete-confirm")); setDeleteDialogOpen(true);
if (confirmed) { };
const confirmDeleteMemo = async () => {
await memoStore.deleteMemo(memo.name); await memoStore.deleteMemo(memo.name);
toast.success(t("message.deleted-successfully")); toast.success(t("message.deleted-successfully"));
if (isInMemoDetailPage) { if (isInMemoDetailPage) {
navigateTo("/"); navigateTo("/");
} }
memoUpdatedCallback(); memoUpdatedCallback();
}
}; };
const handleRemoveCompletedTaskListItemsClick = async () => { const handleRemoveCompletedTaskListItemsClick = () => {
const confirmed = window.confirm(t("memo.remove-completed-task-list-items-confirm")); setRemoveTasksDialogOpen(true);
if (confirmed) { };
const confirmRemoveCompletedTaskListItems = async () => {
const newNodes = JSON.parse(JSON.stringify(memo.nodes)); const newNodes = JSON.parse(JSON.stringify(memo.nodes));
for (const node of newNodes) { for (const node of newNodes) {
if (node.type === NodeType.LIST && node.listNode?.children?.length > 0) { if (node.type === NodeType.LIST && node.listNode?.children?.length > 0) {
@ -164,7 +171,6 @@ const MemoActionMenu = observer((props: Props) => {
); );
toast.success(t("message.remove-completed-task-list-items-successfully")); toast.success(t("message.remove-completed-task-list-items-successfully"));
memoUpdatedCallback(); memoUpdatedCallback();
}
}; };
return ( return (
@ -216,6 +222,27 @@ const MemoActionMenu = observer((props: Props) => {
</> </>
)} )}
</DropdownMenuContent> </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> </DropdownMenu>
); );
}); });

@ -3,6 +3,7 @@ import { ClipboardIcon, TrashIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import ConfirmDialog from "@/components/ConfirmDialog";
import { userServiceClient } from "@/grpcweb"; import { userServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { useDialog } from "@/hooks/useDialog"; import { useDialog } from "@/hooks/useDialog";
@ -20,6 +21,7 @@ const AccessTokenSection = () => {
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const [userAccessTokens, setUserAccessTokens] = useState<UserAccessToken[]>([]); const [userAccessTokens, setUserAccessTokens] = useState<UserAccessToken[]>([]);
const createTokenDialog = useDialog(); const createTokenDialog = useDialog();
const [deleteTarget, setDeleteTarget] = useState<UserAccessToken | undefined>(undefined);
useEffect(() => { useEffect(() => {
listAccessTokens(currentUser.name).then((accessTokens) => { listAccessTokens(currentUser.name).then((accessTokens) => {
@ -42,12 +44,15 @@ const AccessTokenSection = () => {
}; };
const handleDeleteAccessToken = async (userAccessToken: UserAccessToken) => { const handleDeleteAccessToken = async (userAccessToken: UserAccessToken) => {
const formatedAccessToken = getFormatedAccessToken(userAccessToken.accessToken); setDeleteTarget(userAccessToken);
const confirmed = window.confirm(t("setting.access-token-section.access-token-deletion", { accessToken: formatedAccessToken })); };
if (confirmed) {
await userServiceClient.deleteUserAccessToken({ name: userAccessToken.name }); const confirmDeleteAccessToken = async () => {
setUserAccessTokens(userAccessTokens.filter((token) => token.accessToken !== userAccessToken.accessToken)); 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) => { const getFormatedAccessToken = (accessToken: string) => {
@ -134,6 +139,20 @@ const AccessTokenSection = () => {
onOpenChange={createTokenDialog.setOpen} onOpenChange={createTokenDialog.setOpen}
onSuccess={handleCreateAccessTokenDialogConfirm} 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> </div>
); );
}; };

@ -12,6 +12,8 @@ import { User, User_Role } from "@/types/proto/api/v1/user_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import CreateUserDialog from "../CreateUserDialog"; import CreateUserDialog from "../CreateUserDialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
import ConfirmDialog from "@/components/ConfirmDialog";
import toast from "react-hot-toast";
const MemberSection = observer(() => { const MemberSection = observer(() => {
const t = useTranslate(); const t = useTranslate();
@ -21,6 +23,8 @@ const MemberSection = observer(() => {
const editDialog = useDialog(); const editDialog = useDialog();
const [editingUser, setEditingUser] = useState<User | undefined>(); const [editingUser, setEditingUser] = useState<User | undefined>();
const sortedUsers = sortBy(users, "id"); const sortedUsers = sortBy(users, "id");
const [archiveTarget, setArchiveTarget] = useState<User | undefined>(undefined);
const [deleteTarget, setDeleteTarget] = useState<User | undefined>(undefined);
useEffect(() => { useEffect(() => {
fetchUsers(); fetchUsers();
@ -52,17 +56,21 @@ const MemberSection = observer(() => {
}; };
const handleArchiveUserClick = async (user: User) => { const handleArchiveUserClick = async (user: User) => {
const confirmed = window.confirm(t("setting.member-section.archive-warning", { username: user.displayName })); setArchiveTarget(user);
if (confirmed) { };
const confirmArchiveUser = async () => {
if (!archiveTarget) return;
await userServiceClient.updateUser({ await userServiceClient.updateUser({
user: { user: {
name: user.name, name: archiveTarget.name,
state: State.ARCHIVED, state: State.ARCHIVED,
}, },
updateMask: ["state"], updateMask: ["state"],
}); });
setArchiveTarget(undefined);
toast.success(t("setting.member-section.archive-success", { username: archiveTarget.username }));
fetchUsers(); fetchUsers();
}
}; };
const handleRestoreUserClick = async (user: User) => { const handleRestoreUserClick = async (user: User) => {
@ -73,15 +81,20 @@ const MemberSection = observer(() => {
}, },
updateMask: ["state"], updateMask: ["state"],
}); });
toast.success(t("setting.member-section.restore-success", { username: user.username }));
fetchUsers(); fetchUsers();
}; };
const handleDeleteUserClick = async (user: User) => { const handleDeleteUserClick = async (user: User) => {
const confirmed = window.confirm(t("setting.member-section.delete-warning", { username: user.displayName })); setDeleteTarget(user);
if (confirmed) { };
await userStore.deleteUser(user.name);
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(); fetchUsers();
}
}; };
return ( return (
@ -169,6 +182,28 @@ const MemberSection = observer(() => {
{/* Edit User Dialog */} {/* Edit User Dialog */}
<CreateUserDialog open={editDialog.isOpen} onOpenChange={editDialog.setOpen} user={editingUser} onSuccess={fetchUsers} /> <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> </div>
); );
}); });

@ -2,6 +2,7 @@ import { MoreVerticalIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import ConfirmDialog from "@/components/ConfirmDialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { identityProviderServiceClient } from "@/grpcweb"; import { identityProviderServiceClient } from "@/grpcweb";
@ -15,6 +16,7 @@ const SSOSection = () => {
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]); const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingIdentityProvider, setEditingIdentityProvider] = useState<IdentityProvider | undefined>(); const [editingIdentityProvider, setEditingIdentityProvider] = useState<IdentityProvider | undefined>();
const [deleteTarget, setDeleteTarget] = useState<IdentityProvider | undefined>(undefined);
useEffect(() => { useEffect(() => {
fetchIdentityProviderList(); fetchIdentityProviderList();
@ -26,16 +28,19 @@ const SSOSection = () => {
}; };
const handleDeleteIdentityProvider = async (identityProvider: IdentityProvider) => { const handleDeleteIdentityProvider = async (identityProvider: IdentityProvider) => {
const confirmed = window.confirm(t("setting.sso-section.confirm-delete", { name: identityProvider.title })); setDeleteTarget(identityProvider);
if (confirmed) { };
const confirmDeleteIdentityProvider = async () => {
if (!deleteTarget) return;
try { try {
await identityProviderServiceClient.deleteIdentityProvider({ name: identityProvider.name }); await identityProviderServiceClient.deleteIdentityProvider({ name: deleteTarget.name });
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.details); toast.error(error.details);
} }
await fetchIdentityProviderList(); await fetchIdentityProviderList();
} setDeleteTarget(undefined);
}; };
const handleCreateIdentityProvider = () => { const handleCreateIdentityProvider = () => {
@ -112,6 +117,16 @@ const SSOSection = () => {
identityProvider={editingIdentityProvider} identityProvider={editingIdentityProvider}
onSuccess={handleDialogSuccess} 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> </div>
); );
}; };

@ -2,6 +2,7 @@ import { ClockIcon, MonitorIcon, SmartphoneIcon, TabletIcon, TrashIcon, WifiIcon
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import ConfirmDialog from "@/components/ConfirmDialog";
import { userServiceClient } from "@/grpcweb"; import { userServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { UserSession } from "@/types/proto/api/v1/user_service"; import { UserSession } from "@/types/proto/api/v1/user_service";
@ -16,6 +17,7 @@ const UserSessionsSection = () => {
const t = useTranslate(); const t = useTranslate();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const [userSessions, setUserSessions] = useState<UserSession[]>([]); const [userSessions, setUserSessions] = useState<UserSession[]>([]);
const [revokeTarget, setRevokeTarget] = useState<UserSession | undefined>(undefined);
useEffect(() => { useEffect(() => {
listUserSessions(currentUser.name).then((sessions) => { listUserSessions(currentUser.name).then((sessions) => {
@ -24,13 +26,15 @@ const UserSessionsSection = () => {
}, []); }, []);
const handleRevokeSession = async (userSession: UserSession) => { const handleRevokeSession = async (userSession: UserSession) => {
const formattedSessionId = getFormattedSessionId(userSession.sessionId); setRevokeTarget(userSession);
const confirmed = window.confirm(t("setting.user-sessions-section.session-revocation", { sessionId: formattedSessionId })); };
if (confirmed) {
await userServiceClient.revokeUserSession({ name: userSession.name }); const confirmRevokeSession = async () => {
setUserSessions(userSessions.filter((session) => session.sessionId !== userSession.sessionId)); 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")); toast.success(t("setting.user-sessions-section.session-revoked"));
} setRevokeTarget(undefined);
}; };
const getFormattedSessionId = (sessionId: string) => { const getFormattedSessionId = (sessionId: string) => {
@ -148,6 +152,26 @@ const UserSessionsSection = () => {
</div> </div>
</div> </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>
</div> </div>
); );

@ -2,17 +2,20 @@ import { ExternalLinkIcon, TrashIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import ConfirmDialog from "@/components/ConfirmDialog";
import { userServiceClient } from "@/grpcweb"; import { userServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { UserWebhook } from "@/types/proto/api/v1/user_service"; import { UserWebhook } from "@/types/proto/api/v1/user_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import CreateWebhookDialog from "../CreateWebhookDialog"; import CreateWebhookDialog from "../CreateWebhookDialog";
import toast from "react-hot-toast";
const WebhookSection = () => { const WebhookSection = () => {
const t = useTranslate(); const t = useTranslate();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const [webhooks, setWebhooks] = useState<UserWebhook[]>([]); const [webhooks, setWebhooks] = useState<UserWebhook[]>([]);
const [isCreateWebhookDialogOpen, setIsCreateWebhookDialogOpen] = useState(false); const [isCreateWebhookDialogOpen, setIsCreateWebhookDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<UserWebhook | undefined>(undefined);
const listWebhooks = async () => { const listWebhooks = async () => {
if (!currentUser) return []; if (!currentUser) return [];
@ -35,11 +38,15 @@ const WebhookSection = () => {
}; };
const handleDeleteWebhook = async (webhook: UserWebhook) => { const handleDeleteWebhook = async (webhook: UserWebhook) => {
const confirmed = window.confirm(`Are you sure to delete webhook \`${webhook.displayName}\`? You cannot undo this action.`); setDeleteTarget(webhook);
if (confirmed) { };
await userServiceClient.deleteUserWebhook({ name: webhook.name });
setWebhooks(webhooks.filter((item) => item.name !== webhook.name)); 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 ( return (
@ -79,12 +86,7 @@ const WebhookSection = () => {
{webhook.url} {webhook.url}
</td> </td>
<td className="relative whitespace-nowrap px-3 py-2 text-right text-sm"> <td className="relative whitespace-nowrap px-3 py-2 text-right text-sm">
<Button <Button variant="ghost" onClick={() => handleDeleteWebhook(webhook)}>
variant="ghost"
onClick={() => {
handleDeleteWebhook(webhook);
}}
>
<TrashIcon className="text-destructive w-4 h-auto" /> <TrashIcon className="text-destructive w-4 h-auto" />
</Button> </Button>
</td> </td>
@ -118,6 +120,16 @@ const WebhookSection = () => {
onOpenChange={setIsCreateWebhookDialogOpen} onOpenChange={setIsCreateWebhookDialogOpen}
onSuccess={handleCreateWebhookDialogConfirm} 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> </div>
); );
}; };

@ -143,7 +143,8 @@
}, },
"copy-link": "Copy Link", "copy-link": "Copy Link",
"count-memos-in-date": "{{count}} {{memos}} in {{date}}", "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": "Direction",
"direction-asc": "Ascending", "direction-asc": "Ascending",
"direction-desc": "Descending", "direction-desc": "Descending",
@ -174,7 +175,7 @@
"archived-successfully": "Archived successfully", "archived-successfully": "Archived successfully",
"change-memo-created-time": "Change memo created time", "change-memo-created-time": "Change memo created time",
"copied": "Copied", "copied": "Copied",
"deleted-successfully": "Deleted successfully", "deleted-successfully": "Memo deleted successfully",
"description-is-required": "Description is required", "description-is-required": "Description is required",
"failed-to-embed-memo": "Failed to embed memo", "failed-to-embed-memo": "Failed to embed memo",
"fill-all": "Please fill in all fields.", "fill-all": "Please fill in all fields.",
@ -219,6 +220,8 @@
}, },
"delete-resource": "Delete Resource", "delete-resource": "Delete Resource",
"delete-selected-resources": "Delete Selected Resources", "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…", "fetching-data": "Fetching data…",
"file-drag-drop-prompt": "Drag and drop your file here to upload file", "file-drag-drop-prompt": "Drag and drop your file here to upload file",
"linked-amount": "Linked amount", "linked-amount": "Linked amount",
@ -226,7 +229,7 @@
"no-resources": "No resources.", "no-resources": "No resources.",
"no-unused-resources": "No unused resources", "no-unused-resources": "No unused resources",
"reset-link": "Reset Link", "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", "reset-resource-link": "Reset Resource Link",
"unused-resources": "Unused resources" "unused-resources": "Unused resources"
}, },
@ -237,7 +240,9 @@
"setting": { "setting": {
"access-token-section": { "access-token-section": {
"access-token-copied-to-clipboard": "Access token copied to clipboard", "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-dialog": {
"create-access-token": "Create Access Token", "create-access-token": "Create Access Token",
"created-at": "Created At", "created-at": "Created At",
@ -262,7 +267,8 @@
"expires": "Expires", "expires": "Expires",
"current": "Current", "current": "Current",
"never": "Never", "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", "session-revoked": "Session revoked successfully",
"revoke-session": "Revoke session", "revoke-session": "Revoke session",
"cannot-revoke-current": "Cannot revoke current session", "cannot-revoke-current": "Cannot revoke current session",
@ -286,10 +292,15 @@
"member-section": { "member-section": {
"admin": "Admin", "admin": "Admin",
"archive-member": "Archive member", "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", "create-a-member": "Create a member",
"delete-member": "Delete 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" "user": "User"
}, },
"memo-related": "Memo", "memo-related": "Memo",
@ -309,12 +320,16 @@
"default-memo-visibility": "Default memo visibility", "default-memo-visibility": "Default memo visibility",
"theme": "Theme" "theme": "Theme"
}, },
"shortcut": {
"delete-confirm": "Are you sure you want to delete shortcut \"{{title}}\"?",
"delete-success": "Shortcut \"{{title}}\" deleted successfully"
},
"sso": "SSO", "sso": "SSO",
"sso-section": { "sso-section": {
"authorization-endpoint": "Authorization endpoint", "authorization-endpoint": "Authorization endpoint",
"client-id": "Client ID", "client-id": "Client ID",
"client-secret": "Client secret", "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", "create-sso": "Create SSO",
"custom": "Custom", "custom": "Custom",
"delete-sso": "Confirm delete", "delete-sso": "Confirm delete",
@ -367,7 +382,7 @@
"url-prefix-placeholder": "Custom URL prefix, optional", "url-prefix-placeholder": "Custom URL prefix, optional",
"url-suffix": "URL suffix", "url-suffix": "URL suffix",
"url-suffix-placeholder": "Custom URL suffix, optional", "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": "System",
"system-section": { "system-section": {
@ -408,6 +423,11 @@
"title": "Title", "title": "Title",
"url-example-post-receive": "https://example.com/postreceive" "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.", "no-webhooks-found": "No webhooks found.",
"title": "Webhooks", "title": "Webhooks",
"url": "URL" "url": "URL"
@ -427,7 +447,7 @@
"all-tags": "All Tags", "all-tags": "All Tags",
"create-tag": "Create Tag", "create-tag": "Create Tag",
"create-tags-guide": "You can create tags by inputting `#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-tag": "Delete Tag",
"new-name": "New Name", "new-name": "New Name",
"no-tag-found": "No tag found", "no-tag-found": "No tag found",

@ -7,6 +7,7 @@ import AttachmentIcon from "@/components/AttachmentIcon";
import Empty from "@/components/Empty"; import Empty from "@/components/Empty";
import MobileHeader from "@/components/MobileHeader"; import MobileHeader from "@/components/MobileHeader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; 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 filteredAttachments = attachments.filter((attachment) => includes(attachment.filename, state.searchQuery));
const groupedAttachments = groupAttachmentsByDate(filteredAttachments.filter((attachment) => attachment.memo)); const groupedAttachments = groupAttachmentsByDate(filteredAttachments.filter((attachment) => attachment.memo));
const unusedAttachments = filteredAttachments.filter((attachment) => !attachment.memo); const unusedAttachments = filteredAttachments.filter((attachment) => !attachment.memo);
const [deleteUnusedOpen, setDeleteUnusedOpen] = useState(false);
useEffect(() => { useEffect(() => {
attachmentServiceClient.listAttachments({}).then(({ attachments }) => { attachmentServiceClient.listAttachments({}).then(({ attachments }) => {
@ -57,16 +59,18 @@ const Attachments = observer(() => {
}, []); }, []);
const handleDeleteUnusedAttachments = async () => { const handleDeleteUnusedAttachments = async () => {
const confirmed = window.confirm("Are you sure to delete all unused attachments? This action cannot be undone."); setDeleteUnusedOpen(true);
if (confirmed) { };
const confirmDeleteUnusedAttachments = async () => {
for (const attachment of unusedAttachments) { for (const attachment of unusedAttachments) {
await attachmentServiceClient.deleteAttachment({ name: attachment.name }); await attachmentServiceClient.deleteAttachment({ name: attachment.name });
} }
setAttachments(attachments.filter((attachment) => attachment.memo)); setAttachments(attachments.filter((attachment) => attachment.memo));
}
}; };
return ( 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 />} {!md && <MobileHeader />}
<div className="w-full px-4 sm:px-6"> <div className="w-full px-4 sm:px-6">
@ -141,12 +145,17 @@ const Attachments = observer(() => {
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <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" /> <TrashIcon className="w-4 h-auto opacity-60" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Delete all</p> <p>{t("resource.delete-all-unused")}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@ -175,7 +184,18 @@ const Attachments = observer(() => {
</div> </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; export default Attachments;

Loading…
Cancel
Save