feat(web): add accessible ConfirmDialog and migrate confirmations; and Markdown-safe descriptions (#5111)

Signed-off-by: Nic Luckie <nicolasluckie@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
pull/5159/head
Nic Luckie 6 days ago committed by GitHub
parent 4056a1bada
commit 20233c7051
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,131 @@
# ConfirmDialog - Accessible Confirmation Dialog
## Overview
`ConfirmDialog` standardizes confirmation flows across the app. It replaces adhoc `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();
<ConfirmDialog
open={open}
onOpenChange={setOpen}
title={t("memo.delete-confirm")}
description={t("memo.delete-confirm-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={handleDelete}
confirmVariant="destructive"
/>;
```
## 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 (12 words) |
| `cancelLabel` | `string` | Yes | Localized cancel label |
| `onConfirm` | `() => void | Promise<void>` | 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 / adhoc 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
<Dialog open={open} onOpenChange={(next) => !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.

@ -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<void>;
/** 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 (
<Dialog open={open} onOpenChange={(o: boolean) => !loading && onOpenChange(o)}>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
<DialogFooter>
<Button variant="ghost" disabled={loading} onClick={() => onOpenChange(false)}>
{cancelLabel}
</Button>
<Button variant={confirmVariant} disabled={loading} onClick={handleConfirm} data-loading={loading}>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

@ -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);

@ -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<Shortcut | undefined>();
const [editingShortcut, setEditingShortcut] = useState<Shortcut | undefined>();
useAsyncEffect(async () => {
@ -32,11 +35,15 @@ const ShortcutsSection = observer(() => {
}, []);
const handleDeleteShortcut = async (shortcut: Shortcut) => {
const confirmed = window.confirm("Are you sure you want to delete this shortcut?");
if (confirmed) {
await shortcutServiceClient.deleteShortcut({ name: shortcut.name });
setDeleteTarget(shortcut);
};
const confirmDeleteShortcut = async () => {
if (!deleteTarget) return;
await shortcutServiceClient.deleteShortcut({ name: deleteTarget.name });
await userStore.fetchUserSettings();
}
toast.success(t("setting.shortcut.delete-success", { title: deleteTarget.title }));
setDeleteTarget(undefined);
};
const handleCreateShortcut = () => {
@ -113,6 +120,15 @@ const ShortcutsSection = observer(() => {
shortcut={editingShortcut}
onSuccess={handleShortcutDialogSuccess}
/>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={t("setting.shortcut.delete-confirm", { title: deleteTarget?.title ?? "" })}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteShortcut}
confirmVariant="destructive"
/>
</div>
);
});

@ -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<boolean>("tag-tree-auto-expand", false);
const renameTagDialog = useDialog();
const [selectedTag, setSelectedTag] = useState<string>("");
const [deleteTagName, setDeleteTagName] = useState<string | undefined>(undefined);
const tags = Object.entries(userStore.state.tagCount)
.sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1]);
@ -52,14 +54,17 @@ const TagsSection = observer((props: Props) => {
};
const handleDeleteTag = async (tag: string) => {
const confirmed = window.confirm(t("tag.delete-confirm"));
if (confirmed) {
setDeleteTagName(tag);
};
const confirmDeleteTag = async () => {
if (!deleteTagName) return;
await memoServiceClient.deleteMemoTag({
parent: "memos/-",
tag: tag,
tag: deleteTagName,
});
toast.success(t("message.deleted-successfully"));
}
toast.success(t("tag.delete-success"));
setDeleteTagName(undefined);
};
return (
@ -139,6 +144,15 @@ const TagsSection = observer((props: Props) => {
tag={selectedTag}
onSuccess={handleRenameSuccess}
/>
<ConfirmDialog
open={!!deleteTagName}
onOpenChange={(open) => !open && setDeleteTagName(undefined)}
title={t("tag.delete-confirm")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteTag}
confirmVariant="destructive"
/>
</div>
);
});

@ -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,21 +127,24 @@ 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) {
const handleDeleteMemoClick = () => {
setDeleteDialogOpen(true);
};
const confirmDeleteMemo = async () => {
await memoStore.deleteMemo(memo.name);
toast.success(t("message.deleted-successfully"));
if (isInMemoDetailPage) {
navigateTo("/");
}
memoUpdatedCallback();
}
};
const handleRemoveCompletedTaskListItemsClick = async () => {
const confirmed = window.confirm(t("memo.remove-completed-task-list-items-confirm"));
if (confirmed) {
const handleRemoveCompletedTaskListItemsClick = () => {
setRemoveTasksDialogOpen(true);
};
const confirmRemoveCompletedTaskListItems = async () => {
const newNodes = JSON.parse(JSON.stringify(memo.nodes));
for (const node of newNodes) {
if (node.type === NodeType.LIST && node.listNode?.children?.length > 0) {
@ -164,7 +171,6 @@ const MemoActionMenu = observer((props: Props) => {
);
toast.success(t("message.remove-completed-task-list-items-successfully"));
memoUpdatedCallback();
}
};
return (
@ -216,6 +222,27 @@ const MemoActionMenu = observer((props: Props) => {
</>
)}
</DropdownMenuContent>
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title={t("memo.delete-confirm")}
confirmLabel={t("common.delete")}
description={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>
);
});

@ -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<UserAccessToken[]>([]);
const createTokenDialog = useDialog();
const [deleteTarget, setDeleteTarget] = useState<UserAccessToken | undefined>(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}
/>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !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"
/>
</div>
);
};

@ -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<User | undefined>();
const sortedUsers = sortBy(users, "id");
const [archiveTarget, setArchiveTarget] = useState<User | undefined>(undefined);
const [deleteTarget, setDeleteTarget] = useState<User | undefined>(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) {
setArchiveTarget(user);
};
const confirmArchiveUser = async () => {
if (!archiveTarget) return;
const username = archiveTarget.username;
await userServiceClient.updateUser({
user: {
name: user.name,
name: archiveTarget.name,
state: State.ARCHIVED,
},
updateMask: ["state"],
});
fetchUsers();
}
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 */}
<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 }) : ""}
description={deleteTarget ? t("setting.member-section.delete-warning-description") : ""}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteUser}
confirmVariant="destructive"
/>
</div>
);
});

@ -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<IdentityProvider[]>([]);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingIdentityProvider, setEditingIdentityProvider] = useState<IdentityProvider | undefined>();
const [deleteTarget, setDeleteTarget] = useState<IdentityProvider | undefined>(undefined);
useEffect(() => {
fetchIdentityProviderList();
@ -26,16 +28,19 @@ const SSOSection = () => {
};
const handleDeleteIdentityProvider = async (identityProvider: IdentityProvider) => {
const confirmed = window.confirm(t("setting.sso-section.confirm-delete", { name: identityProvider.title }));
if (confirmed) {
setDeleteTarget(identityProvider);
};
const confirmDeleteIdentityProvider = async () => {
if (!deleteTarget) return;
try {
await identityProviderServiceClient.deleteIdentityProvider({ name: identityProvider.name });
await identityProviderServiceClient.deleteIdentityProvider({ name: deleteTarget.name });
} catch (error: any) {
console.error(error);
toast.error(error.details);
}
await fetchIdentityProviderList();
}
setDeleteTarget(undefined);
};
const handleCreateIdentityProvider = () => {
@ -112,6 +117,16 @@ const SSOSection = () => {
identityProvider={editingIdentityProvider}
onSuccess={handleDialogSuccess}
/>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={deleteTarget ? t("setting.sso-section.confirm-delete", { name: deleteTarget.title }) : ""}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteIdentityProvider}
confirmVariant="destructive"
/>
</div>
);
};

@ -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<UserSession[]>([]);
const [revokeTarget, setRevokeTarget] = useState<UserSession | undefined>(undefined);
useEffect(() => {
listUserSessions(currentUser.name).then((sessions) => {
@ -24,13 +26,15 @@ const UserSessionsSection = () => {
}, []);
const handleRevokeSession = async (userSession: UserSession) => {
const formattedSessionId = getFormattedSessionId(userSession.sessionId);
const confirmed = window.confirm(t("setting.user-sessions-section.session-revocation", { sessionId: formattedSessionId }));
if (confirmed) {
await userServiceClient.revokeUserSession({ name: userSession.name });
setUserSessions(userSessions.filter((session) => session.sessionId !== userSession.sessionId));
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 = () => {
</div>
</div>
</div>
<ConfirmDialog
open={!!revokeTarget}
onOpenChange={(open) => !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"
/>
</div>
</div>
);

@ -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<UserWebhook[]>([]);
const [isCreateWebhookDialogOpen, setIsCreateWebhookDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<UserWebhook | undefined>(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}
</td>
<td className="relative whitespace-nowrap px-3 py-2 text-right text-sm">
<Button
variant="ghost"
onClick={() => {
handleDeleteWebhook(webhook);
}}
>
<Button variant="ghost" onClick={() => handleDeleteWebhook(webhook)}>
<TrashIcon className="text-destructive w-4 h-auto" />
</Button>
</td>
@ -118,6 +122,16 @@ const WebhookSection = () => {
onOpenChange={setIsCreateWebhookDialogOpen}
onSuccess={handleCreateWebhookDialogConfirm}
/>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={t("setting.webhook-section.delete-dialog.delete-webhook-title", { name: deleteTarget?.displayName || "" })}
description={t("setting.webhook-section.delete-dialog.delete-webhook-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteWebhook}
confirmVariant="destructive"
/>
</div>
);
};

@ -143,7 +143,8 @@
},
"copy-link": "Copy Link",
"count-memos-in-date": "{{count}} {{memos}} in {{date}}",
"delete-confirm": "Are you sure you want to delete this memo? THIS ACTION IS IRREVERSIBLE",
"delete-confirm": "Are you sure you want to delete this memo?",
"delete-confirm-description": "This action is irreversible. 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. Youll 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",

@ -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 (
<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 />}
@ -138,18 +126,6 @@ const Attachments = observer(() => {
<div className="w-full flex flex-row justify-start items-center gap-2">
<span className="text-muted-foreground">{t("resource.unused-resources")}</span>
<span className="text-muted-foreground opacity-80">({unusedAttachments.length})</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={handleDeleteUnusedAttachments}>
<TrashIcon className="w-4 h-auto opacity-60" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete all</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{unusedAttachments.map((attachment) => {
return (

Loading…
Cancel
Save