From 3cc8172526ef51f42b21c677dc1156f66e9360bc Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 12 Jun 2026 03:47:31 -0700 Subject: [PATCH 1/3] feat: success/error toasts on every admin panel save action Wires click-ui's Toast component into every save mutation in the admin panel: config editor (per-field, bulk, YAML import), groups (create / edit / delete), roles (create / edit / delete), users (invite, delete, role / group assignment, user profile create / delete), system grants (EditCapabilitiesDialog), and per-field profile mutations (useProfileMutations). Uses click-ui's official createToast directly; ClickUIProvider already mounts the matching ToastProvider, so the global createToast routes through it. Toasts inherit click-ui brand colours, WCAG contrast, keyboard accessibility, swipe-to-dismiss, close button, ARIA live region, and 5s auto-dismiss from the library. src/utils/toast.ts is a five-line wrapper exposing notifySuccess and notifyError that delegate to click-ui's createToast. Call sites pass the affected resource through mutation variables (not closed-over component state) so the toast renders the correct name even when the confirm dialog has already cleared its target by the time the response lands. Refs AI-1206. --- src/components/access/CreateGroupDialog.tsx | 27 +++--- src/components/access/CreateRoleDialog.tsx | 34 +++++--- src/components/access/EditGroupDialog.tsx | 38 ++++++--- src/components/access/EditRoleDialog.tsx | 45 ++++++---- src/components/access/GroupsTab.tsx | 10 ++- src/components/access/RolesTab.tsx | 25 ++---- src/components/configuration/ConfigPage.tsx | 70 +++------------ .../grants/EditCapabilitiesDialog.tsx | 12 +-- src/components/users/CreateUserDialog.tsx | 4 +- src/components/users/UserDetailDialog.tsx | 85 +++++++++++++------ src/components/users/UsersPage.tsx | 10 ++- src/hooks/useProfileMutations.ts | 15 +++- src/locales/en/translation.json | 17 ++++ src/styles.css | 60 ------------- src/types/config-ui.ts | 6 -- src/utils/index.ts | 1 + src/utils/toast.ts | 5 ++ 17 files changed, 233 insertions(+), 231 deletions(-) create mode 100644 src/utils/toast.ts diff --git a/src/components/access/CreateGroupDialog.tsx b/src/components/access/CreateGroupDialog.tsx index 4c064fd..9bbb965 100644 --- a/src/components/access/CreateGroupDialog.tsx +++ b/src/components/access/CreateGroupDialog.tsx @@ -5,8 +5,8 @@ import type { AdminUserSearchResult } from '@librechat/data-schemas'; import type * as t from '@/types'; import { SelectedMemberList, UserSearchInline } from '@/components/shared'; import { addGroupMemberFn, createGroupFn } from '@/server'; +import { cn, notifySuccess, notifyError } from '@/utils'; import { useLocalize } from '@/hooks'; -import { cn } from '@/utils'; export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) { const localize = useLocalize(); @@ -38,9 +38,10 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) { queryClient.invalidateQueries({ queryKey: ['groupMembers'] }); queryClient.invalidateQueries({ queryKey: ['availableScopes'] }); queryClient.invalidateQueries({ queryKey: ['groupAssignments'] }); + notifySuccess(localize('com_toast_group_created', { name })); resetAndClose(); }, - onError: (err: Error) => setError(err.message), + onError: (err: Error) => notifyError(err.message), }); const doSubmit = () => { @@ -89,14 +90,15 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) { ariaLabel={localize('com_access_create_group')} > - - {localize('com_access_tab_details')} - - - {localize('com_access_tab_members')} - + {localize('com_access_tab_details')} + {localize('com_access_tab_members')} - +
- +
u.id)} diff --git a/src/components/access/CreateRoleDialog.tsx b/src/components/access/CreateRoleDialog.tsx index 9234fdc..64d58be 100644 --- a/src/components/access/CreateRoleDialog.tsx +++ b/src/components/access/CreateRoleDialog.tsx @@ -6,9 +6,9 @@ import type * as t from '@/types'; import { addRoleMemberFn, createRoleFn, updateRolePermissionsFn } from '@/server'; import { SelectedMemberList, UserSearchInline } from '@/components/shared'; import { RolePermissionsPanel } from './RolePermissionsPanel'; +import { cn, notifySuccess, notifyError } from '@/utils'; import { defaultPermissions } from '@/constants'; import { useLocalize } from '@/hooks'; -import { cn } from '@/utils'; export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) { const localize = useLocalize(); @@ -43,9 +43,10 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) { queryClient.invalidateQueries({ queryKey: ['roleMembers'] }); queryClient.invalidateQueries({ queryKey: ['availableScopes'] }); queryClient.invalidateQueries({ queryKey: ['roleAssignments'] }); + notifySuccess(localize('com_toast_role_created', { name })); resetAndClose(); }, - onError: (err: Error) => setError(err.message), + onError: (err: Error) => notifyError(err.message), }); const doSubmit = () => { @@ -94,17 +95,18 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) { ariaLabel={localize('com_access_create_role')} > - - {localize('com_access_tab_details')} - + {localize('com_access_tab_details')} {localize('com_access_tab_permissions')} - - {localize('com_access_tab_members')} - + {localize('com_access_tab_members')} - +
- +
- +
u.id)} diff --git a/src/components/access/EditGroupDialog.tsx b/src/components/access/EditGroupDialog.tsx index e7fb923..e4bd622 100644 --- a/src/components/access/EditGroupDialog.tsx +++ b/src/components/access/EditGroupDialog.tsx @@ -18,8 +18,8 @@ import { TrashButton, UserSearchInline, } from '@/components/shared'; +import { cn, notifySuccess, notifyError } from '@/utils'; import { useLocalize } from '@/hooks'; -import { cn } from '@/utils'; type EditGroupTab = 'details' | 'members'; @@ -100,9 +100,10 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog queryClient.invalidateQueries({ queryKey: ['groups'] }); queryClient.invalidateQueries({ queryKey: ['groupAssignments'] }); queryClient.invalidateQueries({ queryKey: ['groupMembers', group?.id] }); + notifySuccess(localize('com_toast_group_updated', { name })); onClose(); }, - onError: (err: Error) => setError(err.message), + onError: (err: Error) => notifyError(err.message), }); const doSubmit = () => { @@ -140,14 +141,15 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog ariaLabel={localize('com_access_edit_group')} > - - {localize('com_access_tab_details')} - - - {localize('com_access_tab_members')} - + {localize('com_access_tab_details')} + {localize('com_access_tab_members')} - +
- +
{canManage && ( ({ id: m.userId, name: m.name, email: m.email, avatarUrl: m.avatarUrl }))} + users={pendingRemovals.map((m) => ({ + id: m.userId, + name: m.name, + email: m.email, + avatarUrl: m.avatarUrl, + }))} onRemove={unstageRemoval} disabled={mutation.isPending} /> @@ -254,7 +266,9 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog
diff --git a/src/components/access/EditRoleDialog.tsx b/src/components/access/EditRoleDialog.tsx index 841b238..9c8c1f4 100644 --- a/src/components/access/EditRoleDialog.tsx +++ b/src/components/access/EditRoleDialog.tsx @@ -21,8 +21,8 @@ import { UserSearchInline, } from '@/components/shared'; import { RolePermissionsPanel } from './RolePermissionsPanel'; +import { cn, notifySuccess, notifyError } from '@/utils'; import { useLocalize } from '@/hooks'; -import { cn } from '@/utils'; type EditRoleTab = 'details' | 'permissions' | 'members'; @@ -113,9 +113,7 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro } } const memberResults = await Promise.allSettled([ - ...pendingAdditions.map((user) => - addRoleMemberFn({ data: { roleId, userId: user.id } }), - ), + ...pendingAdditions.map((user) => addRoleMemberFn({ data: { roleId, userId: user.id } })), ...pendingRemovals.map((member) => removeRoleMemberFn({ data: { roleId, userId: member.userId } }), ), @@ -143,9 +141,10 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro if (newRoleId !== role?.id) { queryClient.invalidateQueries({ queryKey: ['roleMembers', newRoleId] }); } + notifySuccess(localize('com_toast_role_updated', { name })); onClose(); }, - onError: (err: Error) => setError(err.message), + onError: (err: Error) => notifyError(err.message), }); const doSubmit = () => { @@ -183,17 +182,18 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro ariaLabel={localize('com_access_edit_role')} > - - {localize('com_access_tab_details')} - + {localize('com_access_tab_details')} {localize('com_access_tab_permissions')} - - {localize('com_access_tab_members')} - + {localize('com_access_tab_members')} - +
{role?.isSystemRole && ( @@ -239,7 +239,12 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro
- +
{(() => { if (roleDetail.isLoading) { @@ -269,7 +274,12 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro })()}
- +
{canManage && ( ({ id: m.userId, name: m.name, email: m.email, avatarUrl: m.avatarUrl }))} + users={pendingRemovals.map((m) => ({ + id: m.userId, + name: m.name, + email: m.email, + avatarUrl: m.avatarUrl, + }))} onRemove={unstageRemoval} disabled={updateMutation.isPending} /> diff --git a/src/components/access/GroupsTab.tsx b/src/components/access/GroupsTab.tsx index 4829ade..2819f3c 100644 --- a/src/components/access/GroupsTab.tsx +++ b/src/components/access/GroupsTab.tsx @@ -11,11 +11,11 @@ import { TrashButton, } from '@/components/shared'; import { deleteGroupFn, groupsQueryOptions, GROUPS_PAGE_SIZE } from '@/server'; +import { cn, notifySuccess, notifyError } from '@/utils'; import { useCapabilities, useLocalize } from '@/hooks'; import { EditGroupDialog } from './EditGroupDialog'; import { SystemCapabilities } from '@/constants'; import { ConfirmDialog } from './ConfirmDialog'; -import { cn } from '@/utils'; export function GroupsTab({ onCreateGroup }: t.GroupsTabProps) { const localize = useLocalize(); @@ -52,17 +52,19 @@ export function GroupsTab({ onCreateGroup }: t.GroupsTabProps) { const totalPages = Math.ceil(total / GROUPS_PAGE_SIZE); const deleteMutation = useMutation({ - mutationFn: (id: string) => deleteGroupFn({ data: { id } }), - onSuccess: () => { + mutationFn: (group: AdminGroup) => deleteGroupFn({ data: { id: group.id } }), + onSuccess: (_data, group) => { queryClient.invalidateQueries({ queryKey: ['groups'] }); queryClient.invalidateQueries({ queryKey: ['availableScopes'] }); queryClient.invalidateQueries({ queryKey: ['groupAssignments'] }); queryClient.invalidateQueries({ queryKey: ['groupMembers'] }); + notifySuccess(localize('com_toast_group_deleted', { name: group.name })); setDeleteTarget(null); if (groups.length === 1) { setPage((prev) => (prev > 1 ? prev - 1 : prev)); } }, + onError: (err: Error) => notifyError(err.message), }); if (isLoading && !data) { @@ -151,7 +153,7 @@ export function GroupsTab({ onCreateGroup }: t.GroupsTabProps) { confirmLabel={localize('com_ui_delete')} saving={deleteMutation.isPending} onConfirm={() => { - if (deleteTarget) deleteMutation.mutate(deleteTarget.id); + if (deleteTarget) deleteMutation.mutate(deleteTarget); }} onCancel={() => setDeleteTarget(null)} /> diff --git a/src/components/access/RolesTab.tsx b/src/components/access/RolesTab.tsx index bf945dd..50d53a1 100644 --- a/src/components/access/RolesTab.tsx +++ b/src/components/access/RolesTab.tsx @@ -11,6 +11,7 @@ import { } from '@/components/shared'; import { deleteRoleFn, allRolesQueryOptions, ROLES_PAGE_SIZE } from '@/server'; import { useCapabilities, useLocalize } from '@/hooks'; +import { notifySuccess, notifyError } from '@/utils'; import { EditRoleDialog } from './EditRoleDialog'; import { SystemCapabilities } from '@/constants'; import { ConfirmDialog } from './ConfirmDialog'; @@ -22,7 +23,6 @@ export function RolesTab({ onCreateRole }: t.RolesTabProps) { const canManage = hasCapability(SystemCapabilities.MANAGE_ROLES); const [editTarget, setEditTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); - const [deleteError, setDeleteError] = useState(''); const [search, setSearch] = useState(''); const [page, setPage] = useState(1); @@ -48,19 +48,19 @@ export function RolesTab({ onCreateRole }: t.RolesTabProps) { }; const deleteMutation = useMutation({ - mutationFn: (id: string) => deleteRoleFn({ data: { id } }), - onSuccess: () => { + mutationFn: (role: t.Role) => deleteRoleFn({ data: { id: role.id } }), + onSuccess: (_data, role) => { queryClient.invalidateQueries({ queryKey: ['roles'] }); queryClient.invalidateQueries({ queryKey: ['availableScopes'] }); queryClient.invalidateQueries({ queryKey: ['roleAssignments'] }); queryClient.invalidateQueries({ queryKey: ['roleMembers'] }); + notifySuccess(localize('com_toast_role_deleted', { name: role.name })); setDeleteTarget(null); - setDeleteError(''); if (paged.length === 1) { setPage((prev) => (prev > 1 ? prev - 1 : prev)); } }, - onError: (err: Error) => setDeleteError(err.message), + onError: (err: Error) => notifyError(err.message), }); if (isLoading) { @@ -96,11 +96,7 @@ export function RolesTab({ onCreateRole }: t.RolesTabProps) { {paged.length === 0 ? ( ) : (
@@ -157,15 +153,10 @@ export function RolesTab({ onCreateRole }: t.RolesTabProps) { description={localize('com_access_delete_role_desc', { name: deleteTarget?.name ?? '' })} confirmLabel={localize('com_ui_delete')} saving={deleteMutation.isPending} - error={deleteError} onConfirm={() => { - setDeleteError(''); - if (deleteTarget) deleteMutation.mutate(deleteTarget.id); - }} - onCancel={() => { - setDeleteTarget(null); - setDeleteError(''); + if (deleteTarget) deleteMutation.mutate(deleteTarget); }} + onCancel={() => setDeleteTarget(null)} />
); diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index ccd9ed1..ea0a966 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -22,13 +22,16 @@ import { unflattenObject, serializeKVPairs, deepSerializeKVPairs, - cn, normalizeImportConfig, hasConfigCapability, getTabsWithPermission, + notifySuccess, + notifyError, } from '@/utils'; import { useLocalize, useHighlightRef, useActiveSection, useCapabilities } from '@/hooks'; import { CONFIG_TABS, OTHER_TAB, SECTION_META, HIDDEN_SECTIONS } from './configMeta'; +import { mergeIndexedArrayEdits, partitionScopeResetPaths } from './utils'; +import { validateMcpCrossField } from './sections/McpServersRenderer'; import { ScopeSelector, ScopeTriggerButton } from './ScopeSelector'; import { ConfigTableOfContents } from './ConfigTableOfContents'; import { ConfirmSaveDialog } from './ConfirmSaveDialog'; @@ -36,8 +39,6 @@ import { StickyActionBar } from '@/components/shared'; import { ConfigTabContent } from './ConfigTabContent'; import { ImportYamlDialog } from './ImportYamlDialog'; import { ContentToolbar } from './ContentToolbar'; -import { validateMcpCrossField } from './sections/McpServersRenderer'; -import { mergeIndexedArrayEdits, partitionScopeResetPaths } from './utils'; import { SystemCapabilities } from '@/constants'; import { ConfigTabBar } from './ConfigTabBar'; import { InfoBanner } from './InfoBanner'; @@ -193,18 +194,6 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi const dismissTimer = useRef | undefined>(undefined); useEffect(() => () => clearTimeout(dismissTimer.current), []); - const [toast, setToast] = useState(null); - const toastTimer = useRef | undefined>(undefined); - useEffect(() => () => clearTimeout(toastTimer.current), []); - - const showToast = useCallback((state: t.ToastState, autoHideMs?: number) => { - setToast(state); - clearTimeout(toastTimer.current); - if (autoHideMs) { - toastTimer.current = setTimeout(() => setToast(null), autoHideMs); - } - }, []); - const [showConfiguredOnly, setShowConfiguredOnly] = useState(false); const [scopeSelectorOpen, setScopeSelectorOpen] = useState(false); @@ -477,8 +466,8 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi setConfirmSaveOpen(false); setSaving(false); setSaveError(null); - showToast({ type: 'saved' }, 3000); - }, [showToast]); + notifySuccess(localize('com_config_saved')); + }, [localize]); const invalidateAndResetBase = useCallback(() => { queryClient.invalidateQueries({ queryKey: ['baseConfig'] }); @@ -494,8 +483,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi const importMutation = useMutation({ mutationFn: (config: Record) => importBaseConfigFn({ data: { config } }), - onMutate: () => showToast({ type: 'saving' }), - onError: (err: Error) => showToast({ type: 'error', message: err.message }, 5000), + onError: (err: Error) => notifyError(err.message), onSuccess: invalidateAndResetBase, }); @@ -544,7 +532,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi field: missingField, }); setSaveError(message); - showToast({ type: 'error', message }, 5000); + notifyError(message); return; } } @@ -568,7 +556,6 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi setSaving(true); setSaveError(null); - showToast({ type: 'saving' }); try { /** Resets must land before saves so a delete-then-recreate at the same path (e.g. MCP entry replaced with different fields) wipes stale fields first and the new leaf PATCHes don't race against the DELETE. */ @@ -629,7 +616,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi const message = err instanceof Error ? err.message : String(err); setSaving(false); setSaveError(message); - showToast({ type: 'error', message }, 5000); + notifyError(message); } }, [ touchedPaths, @@ -640,7 +627,6 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi configValues, baseConfigData, localize, - showToast, editingScope, invalidateAndResetScope, invalidateAndResetBase, @@ -718,7 +704,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi const normalized = normalizeImportConfig(appConfig); if (isEditingScope && editingScope) { handleImportAsProfile(normalized, editingScope).catch((err: Error) => { - showToast({ type: 'error', message: err.message }, 5000); + notifyError(err.message); }); } else { importMutation.mutate(normalized, { onSuccess: () => showImportSuccess() }); @@ -954,7 +940,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi showConfiguredOnly={showConfiguredOnly} isEditingScope={isEditingScope} baseRecordKeys={baseRecordKeys} - onValidationError={(message) => showToast({ type: 'error', message }, 5000)} + onValidationError={(message) => notifyError(message)} />
@@ -978,38 +964,6 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi /> )} - {toast && - createPortal( -
- {toast.type === 'saving' && ( - <> - - {localize('com_config_saving')} - - )} - {toast.type === 'saved' && ( - <> - - {localize('com_config_saved')} - - )} - {toast.type === 'error' && ( - <> - - {toast.message} - - )} -
, - document.body, - )} - showToast({ type: 'error', message: msg }, 5000)} + onError={(msg) => notifyError(msg)} /> { const record: Record = {}; @@ -51,7 +51,7 @@ export function EditCapabilitiesDialog({ }, [open, isLoading, grants]); const saveMutation = useMutation({ - mutationFn: async () => { + mutationFn: async (vars: { name: string }) => { if (!principalType || !principalId) return; const toGrant: string[] = []; const toRevoke: string[] = []; @@ -66,21 +66,23 @@ export function EditCapabilitiesDialog({ for (const cap of toRevoke) { await revokeCapabilityFn({ data: { ...shared, capability: cap } }); } + return vars; }, - onSuccess: () => { + onSuccess: (_data, vars) => { queryClient.invalidateQueries({ queryKey: ['systemGrants'] }); queryClient.invalidateQueries({ queryKey: ['effectiveCapabilities'] }); queryClient.invalidateQueries({ queryKey: ['auditLog'] }); + notifySuccess(localize('com_toast_capabilities_saved', { name: vars.name })); onClose(); }, - onError: (err: Error) => setError(err.message), + onError: (err: Error) => notifyError(err.message), }); const hasChanges = Object.keys(capabilities).some((cap) => capabilities[cap] !== baseline[cap]); const handleSave = useCallback(() => { setError(''); - saveMutation.mutate(); + saveMutation.mutate({ name: principalName }); }, [saveMutation]); const dialogTitle = principalType diff --git a/src/components/users/CreateUserDialog.tsx b/src/components/users/CreateUserDialog.tsx index 87ec8cc..b830bff 100644 --- a/src/components/users/CreateUserDialog.tsx +++ b/src/components/users/CreateUserDialog.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { SystemRoles } from 'librechat-data-provider'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import type * as t from '@/types'; +import { notifySuccess, notifyError } from '@/utils'; import { FormDialog } from '@/components/shared'; import { createUserFn } from '@/server'; import { useLocalize } from '@/hooks'; @@ -18,9 +19,10 @@ export function CreateUserDialog({ open, onClose }: t.CreateUserDialogProps) { mutationFn: () => createUserFn({ data: { name, email, role } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); + notifySuccess(localize('com_toast_user_invited', { name })); resetAndClose(); }, - onError: (err: Error) => setError(err.message), + onError: (err: Error) => notifyError(err.message), }); const resetAndClose = () => { diff --git a/src/components/users/UserDetailDialog.tsx b/src/components/users/UserDetailDialog.tsx index 8c0ac21..c218491 100644 --- a/src/components/users/UserDetailDialog.tsx +++ b/src/components/users/UserDetailDialog.tsx @@ -18,9 +18,9 @@ import { allRolesQueryOptions, } from '@/server'; import { Avatar, TrashButton } from '@/components/shared'; +import { cn, notifySuccess, notifyError } from '@/utils'; import { ConfirmDialog } from '@/components/access'; import { useLocalize } from '@/hooks'; -import { cn } from '@/utils'; const CONFIRM_TITLE_KEYS: Record = { role: 'com_users_remove_role_title', @@ -89,57 +89,73 @@ export function UserDetailDialog({ queryClient.invalidateQueries({ queryKey: ['availableScopes'] }); }; + const handleError = (err: Error) => notifyError(err.message); + const addRoleMutation = useMutation({ - mutationFn: (roleId: string) => { + mutationFn: (role: { id: string; name: string }) => { if (!userId) throw new Error('No user selected'); - return addRoleMemberFn({ data: { roleId, userId } }); + return addRoleMemberFn({ data: { roleId: role.id, userId } }); + }, + onSuccess: (_data, role) => { + invalidateAll(); + notifySuccess(localize('com_toast_role_assigned', { name: role.name })); }, - onSuccess: invalidateAll, + onError: handleError, }); const removeRoleMutation = useMutation({ - mutationFn: (roleId: string) => { + mutationFn: (role: { id: string; name: string }) => { if (!userId) throw new Error('No user selected'); - return removeRoleMemberFn({ data: { roleId, userId } }); + return removeRoleMemberFn({ data: { roleId: role.id, userId } }); }, - onSuccess: () => { + onSuccess: (_data, role) => { invalidateAll(); + notifySuccess(localize('com_toast_role_unassigned', { name: role.name })); setRemoveTarget(null); }, + onError: handleError, }); const addGroupMutation = useMutation({ - mutationFn: (groupId: string) => { + mutationFn: (group: { id: string; name: string }) => { if (!userId) throw new Error('No user selected'); - return addGroupMemberFn({ data: { groupId, userId } }); + return addGroupMemberFn({ data: { groupId: group.id, userId } }); + }, + onSuccess: (_data, group) => { + invalidateAll(); + notifySuccess(localize('com_toast_group_assigned', { name: group.name })); }, - onSuccess: invalidateAll, + onError: handleError, }); const removeGroupMutation = useMutation({ - mutationFn: (groupId: string) => { + mutationFn: (group: { id: string; name: string }) => { if (!userId) throw new Error('No user selected'); - return removeGroupMemberFn({ data: { groupId, userId } }); + return removeGroupMemberFn({ data: { groupId: group.id, userId } }); }, - onSuccess: () => { + onSuccess: (_data, group) => { invalidateAll(); + notifySuccess(localize('com_toast_group_unassigned', { name: group.name })); setRemoveTarget(null); }, + onError: handleError, }); const createProfileMutation = useMutation({ - mutationFn: () => { - if (!userId) throw new Error('No user selected'); - return createScopeFn({ + mutationFn: (vars: { userId: string; name: string }) => + createScopeFn({ data: { principalType: PrincipalType.USER, - name: userName, + name: vars.name, priority: 100, - principalId: userId, + principalId: vars.userId, }, - }); + }), + onSuccess: (_data, vars) => { + invalidateAll(); + notifySuccess(localize('com_toast_user_profile_created', { name: vars.name })); }, - onSuccess: invalidateAll, + onError: handleError, }); const deleteProfileMutation = useMutation({ @@ -149,8 +165,10 @@ export function UserDetailDialog({ }), onSuccess: () => { invalidateAll(); + notifySuccess(localize('com_toast_user_profile_deleted')); setRemoveTarget(null); }, + onError: handleError, }); const busy = @@ -166,9 +184,13 @@ export function UserDetailDialog({ const handleConfirmRemove = () => { if (!removeTarget) return; - if (removeTarget.kind === 'role') removeRoleMutation.mutate(removeTarget.ref.id); - else if (removeTarget.kind === 'group') removeGroupMutation.mutate(removeTarget.ref.id); - else if (removeTarget.kind === 'profile') deleteProfileMutation.mutate(removeTarget.scope); + if (removeTarget.kind === 'role') { + removeRoleMutation.mutate({ id: removeTarget.ref.id, name: removeTarget.ref.name }); + } else if (removeTarget.kind === 'group') { + removeGroupMutation.mutate({ id: removeTarget.ref.id, name: removeTarget.ref.name }); + } else if (removeTarget.kind === 'profile') { + deleteProfileMutation.mutate(removeTarget.scope); + } }; const confirmTitle = removeTarget ? localize(CONFIRM_TITLE_KEYS[removeTarget.kind]) : ''; @@ -240,9 +262,20 @@ export function UserDetailDialog({ canManageRoles={canManageRoles} canManageGroups={canManageGroups} canAssignConfigs={canAssignConfigs} - onAddRole={(id) => addRoleMutation.mutate(id)} - onAddGroup={(id) => addGroupMutation.mutate(id)} - onCreateUserProfile={() => createProfileMutation.mutate()} + onAddRole={(id) => { + if (addRoleMutation.isPending) return; + const role = availableRoles.find((r) => r.id === id); + if (role) addRoleMutation.mutate({ id: role.id, name: role.name }); + }} + onAddGroup={(id) => { + if (addGroupMutation.isPending) return; + const group = availableGroups.find((g) => g.id === id); + if (group) addGroupMutation.mutate({ id: group.id, name: group.name }); + }} + onCreateUserProfile={() => { + if (createProfileMutation.isPending || !userId) return; + createProfileMutation.mutate({ userId, name: userName }); + }} onDone={() => setView('main')} /> )} diff --git a/src/components/users/UsersPage.tsx b/src/components/users/UsersPage.tsx index e3ea904..fa1158f 100644 --- a/src/components/users/UsersPage.tsx +++ b/src/components/users/UsersPage.tsx @@ -20,11 +20,11 @@ import { SearchInput, } from '@/components/shared'; import { useAnnouncement, useCapabilities, useLocalize } from '@/hooks'; +import { cn, notifySuccess, notifyError } from '@/utils'; import { CreateUserDialog } from './CreateUserDialog'; import { UserDetailDialog } from './UserDetailDialog'; import { ConfirmDialog } from '@/components/access'; import { SystemCapabilities } from '@/constants'; -import { cn } from '@/utils'; const ROLE_FILTER_LABELS: Record = { all: 'com_ui_all', @@ -61,16 +61,18 @@ export function UsersPage() { }, [allScopes]); const deleteMutation = useMutation({ - mutationFn: (id: string) => deleteUserFn({ data: { id } }), - onSuccess: () => { + mutationFn: (user: TUser) => deleteUserFn({ data: { id: user.id } }), + onSuccess: (_data, user) => { queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['roleAssignments'] }); queryClient.invalidateQueries({ queryKey: ['groupAssignments'] }); queryClient.invalidateQueries({ queryKey: ['roleMembers'] }); queryClient.invalidateQueries({ queryKey: ['groupMembers'] }); queryClient.invalidateQueries({ queryKey: ['availableScopes'] }); + notifySuccess(localize('com_toast_user_deleted', { name: user.name })); setDeleteTarget(null); }, + onError: (err: Error) => notifyError(err.message), }); const applyFilters = (list: TUser[], q: string, role: t.RoleFilter) => @@ -221,7 +223,7 @@ export function UsersPage() { confirmLabel={localize('com_ui_delete')} saving={deleteMutation.isPending} onConfirm={() => { - if (deleteTarget) deleteMutation.mutate(deleteTarget.id); + if (deleteTarget) deleteMutation.mutate(deleteTarget); }} onCancel={() => setDeleteTarget(null)} /> diff --git a/src/hooks/useProfileMutations.ts b/src/hooks/useProfileMutations.ts index 38e8b54..a81508d 100644 --- a/src/hooks/useProfileMutations.ts +++ b/src/hooks/useProfileMutations.ts @@ -3,12 +3,15 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { PrincipalType } from 'librechat-data-provider'; import type * as t from '@/types'; import { removeFieldProfileValueFn, saveFieldProfileValueFn } from '@/server'; +import { notifySuccess, notifyError } from '@/utils'; +import { useLocalize } from './useLocalize'; export function useProfileMutations({ fieldPath, onProfileChange, }: t.UseProfileMutationsOptions): t.UseProfileMutationsReturn { const queryClient = useQueryClient(); + const localize = useLocalize(); const invalidate = useCallback(() => { queryClient.invalidateQueries({ queryKey: ['profileMap'] }); @@ -20,13 +23,21 @@ export function useProfileMutations({ const saveMutation = useMutation({ mutationFn: (params: { principalType: PrincipalType; principalId: string; value: unknown }) => saveFieldProfileValueFn({ data: { fieldPath, ...params } }), - onSuccess: () => invalidate(), + onSuccess: () => { + invalidate(); + notifySuccess(localize('com_toast_profile_value_saved')); + }, + onError: (err: Error) => notifyError(err.message), }); const removeMutation = useMutation({ mutationFn: (params: { principalType: PrincipalType; principalId: string }) => removeFieldProfileValueFn({ data: { fieldPath, ...params } }), - onSuccess: () => invalidate(), + onSuccess: () => { + invalidate(); + notifySuccess(localize('com_toast_profile_value_removed')); + }, + onError: (err: Error) => notifyError(err.message), }); return { diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 3f95eaa..704fabe 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -109,6 +109,23 @@ "com_config_saving": "Saving...", "com_config_saved": "Changes saved", "com_config_save_error": "Failed to save", + "com_toast_group_created": "Group \"{{name}}\" created", + "com_toast_group_updated": "Group \"{{name}}\" updated", + "com_toast_group_deleted": "Group \"{{name}}\" deleted", + "com_toast_role_created": "Role \"{{name}}\" created", + "com_toast_role_updated": "Role \"{{name}}\" updated", + "com_toast_role_deleted": "Role \"{{name}}\" deleted", + "com_toast_user_invited": "User \"{{name}}\" invited", + "com_toast_user_deleted": "User \"{{name}}\" deleted", + "com_toast_role_assigned": "Added to role \"{{name}}\"", + "com_toast_role_unassigned": "Removed from role \"{{name}}\"", + "com_toast_group_assigned": "Added to group \"{{name}}\"", + "com_toast_group_unassigned": "Removed from group \"{{name}}\"", + "com_toast_user_profile_created": "User profile created for \"{{name}}\"", + "com_toast_user_profile_deleted": "User profile deleted", + "com_toast_capabilities_saved": "Capabilities for \"{{name}}\" saved", + "com_toast_profile_value_saved": "Profile value saved", + "com_toast_profile_value_removed": "Profile value removed", "com_config_import_yaml": "Import YAML", "com_config_import_yaml_title": "Import configuration", "com_config_import_yaml_desc": "Upload or paste a librechat.yaml file to populate configuration values across the UI.", diff --git a/src/styles.css b/src/styles.css index 38ad3fe..88b7c25 100644 --- a/src/styles.css +++ b/src/styles.css @@ -797,66 +797,6 @@ input[type='number'].config-input::-webkit-outer-spin-button { transition: opacity 150ms ease; } -/* ── Toast notification ──────────────────────────────────────── */ - -.config-toast { - position: fixed; - bottom: 1.5rem; - right: 1.5rem; - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.625rem 1rem; - border-radius: 0.5rem; - font-size: 0.8125rem; - font-weight: 500; - box-shadow: 0 8px 30px var(--cui-color-shadow); - z-index: var(--z-toast); - animation: toast-in 200ms ease-out; - pointer-events: none; -} - -.config-toast-info { - background: var(--cui-color-accent-info); - color: var(--cui-color-text-on-accent); -} - -.config-toast-success { - background: var(--cui-color-accent-success); - color: var(--cui-color-text-on-accent); -} - -.config-toast-error { - background: var(--cui-color-accent-danger); - color: var(--cui-color-text-on-accent); -} - -.config-toast-spinner { - width: 14px; - height: 14px; - border: 2px solid color-mix(in srgb, var(--cui-color-text-on-accent) 30%, transparent); - border-top-color: var(--cui-color-text-on-accent); - border-radius: 50%; - animation: toast-spin 600ms linear infinite; -} - -@keyframes toast-in { - from { - opacity: 0; - transform: translateY(8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes toast-spin { - to { - transform: rotate(360deg); - } -} - .modal-frost[role='dialog'] { border-radius: 12px !important; box-shadow: 0 16px 70px 0 var(--cui-color-shadow) !important; diff --git a/src/types/config-ui.ts b/src/types/config-ui.ts index 1503e02..782a263 100644 --- a/src/types/config-ui.ts +++ b/src/types/config-ui.ts @@ -48,12 +48,6 @@ export interface ConfigPageProps { initialScope?: string; } -export type ToastState = - | { type: 'saving' } - | { type: 'saved' } - | { type: 'error'; message: string } - | null; - export interface ConfigTabBarProps { tabs: ConfigTab[]; activeTab: string; diff --git a/src/utils/index.ts b/src/utils/index.ts index 449e2b1..4f887f9 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,3 +2,4 @@ export * from './capabilities'; export * from './cn'; export * from './format'; export * from './interfacePermissions'; +export * from './toast'; diff --git a/src/utils/toast.ts b/src/utils/toast.ts new file mode 100644 index 0000000..4283532 --- /dev/null +++ b/src/utils/toast.ts @@ -0,0 +1,5 @@ +import { createToast } from '@clickhouse/click-ui'; + +export const notifySuccess = (title: string): void => createToast({ type: 'success', title }); + +export const notifyError = (title: string): void => createToast({ type: 'danger', title }); From db90e9b4125f4e4811d38b02db1d09dac78027f0 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 12 Jun 2026 04:11:33 -0700 Subject: [PATCH 2/3] fix: throw instead of silently no-op when target missing in edit mutations EditGroupDialog, EditRoleDialog and EditCapabilitiesDialog mutationFns used to bail with an empty return when their target (group, role, principal) was missing, which React Query treats as success and which caused the new onSuccess handlers to fire a success toast and close the dialog without anything having been persisted. Throw a localised error in the unavailable case so onError fires instead, and add a matching guard at each call site so mutate is never invoked with a missing target in the first place. --- src/components/access/EditGroupDialog.tsx | 3 ++- src/components/access/EditRoleDialog.tsx | 3 ++- src/components/grants/EditCapabilitiesDialog.tsx | 7 +++++-- src/locales/en/translation.json | 3 +++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/access/EditGroupDialog.tsx b/src/components/access/EditGroupDialog.tsx index e4bd622..77c2783 100644 --- a/src/components/access/EditGroupDialog.tsx +++ b/src/components/access/EditGroupDialog.tsx @@ -74,7 +74,7 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog const mutation = useMutation({ mutationFn: async () => { - if (!group) return; + if (!group) throw new Error(localize('com_access_group_unavailable')); if (detailsDirty) { await updateGroupFn({ data: { id: group.id, name, description } }); } @@ -108,6 +108,7 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog const doSubmit = () => { setError(''); + if (!group) return; if (!name.trim()) { setError(localize('com_access_name_required')); setActiveTab('details'); diff --git a/src/components/access/EditRoleDialog.tsx b/src/components/access/EditRoleDialog.tsx index 9c8c1f4..150900b 100644 --- a/src/components/access/EditRoleDialog.tsx +++ b/src/components/access/EditRoleDialog.tsx @@ -92,7 +92,7 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro const updateMutation = useMutation({ mutationFn: async (): Promise => { - if (!role) return ''; + if (!role) throw new Error(localize('com_access_role_unavailable')); let roleId = role.id; if (detailsDirty) { const result = await updateRoleFn({ data: { id: role.id, name, description } }); @@ -149,6 +149,7 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro const doSubmit = () => { setError(''); + if (!role) return; if (!name.trim()) { setError(localize('com_access_name_required')); setActiveTab('details'); diff --git a/src/components/grants/EditCapabilitiesDialog.tsx b/src/components/grants/EditCapabilitiesDialog.tsx index 804cc12..42662e1 100644 --- a/src/components/grants/EditCapabilitiesDialog.tsx +++ b/src/components/grants/EditCapabilitiesDialog.tsx @@ -52,7 +52,9 @@ export function EditCapabilitiesDialog({ const saveMutation = useMutation({ mutationFn: async (vars: { name: string }) => { - if (!principalType || !principalId) return; + if (!principalType || !principalId) { + throw new Error(localize('com_cap_principal_unavailable')); + } const toGrant: string[] = []; const toRevoke: string[] = []; for (const [cap, enabled] of Object.entries(capabilities)) { @@ -82,8 +84,9 @@ export function EditCapabilitiesDialog({ const handleSave = useCallback(() => { setError(''); + if (!principalType || !principalId) return; saveMutation.mutate({ name: principalName }); - }, [saveMutation]); + }, [saveMutation, principalType, principalId, principalName]); const dialogTitle = principalType ? `${localize('com_cap_edit_title', { name: principalName })}` diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 704fabe..7f1540c 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -899,6 +899,8 @@ "com_access_member_ops_failed_other": "{{count}} member operations failed", "com_access_details_saved_permissions_failed": "Details saved, but permissions failed: {{error}}", "com_access_name_required": "Name is required", + "com_access_group_unavailable": "Group is no longer available", + "com_access_role_unavailable": "Role is no longer available", "com_access_col_name": "Name", "com_access_group_name_placeholder": "e.g. Engineering", "com_access_group_desc_placeholder": "Optional description...", @@ -1042,6 +1044,7 @@ "com_access_denied_title": "Access denied", "com_access_denied_description": "You don't have permission to view this page. Contact your administrator.", "com_cap_no_permission": "You need {{cap}} to perform this action", + "com_cap_principal_unavailable": "Principal is no longer available", "com_a11y_cap_filter_changed": "Showing {{count}} principals", "com_nav_audit_log": "Audit log", "com_audit_title": "Audit log", From 8419332bc2f602c13a7514a4c2668918190c955d Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 12 Jun 2026 04:21:58 -0700 Subject: [PATCH 3/3] fix: capture submitted name in mutation variables for create/edit toasts The five create and edit dialogs used to read the resource name from component state inside onSuccess instead of from the value that was submitted with the mutation. If the user edited the name field while the request was in flight (or the dialog reset before the toast fired), the toast could render an empty or wrong name even though the server saved the original value. Pass the submitted name through the mutation variables, use it for the actual API call, and read it back from the mutation's data or variables argument in onSuccess so the toast always reflects what was persisted. --- src/components/access/CreateGroupDialog.tsx | 13 +++++++---- src/components/access/CreateRoleDialog.tsx | 11 +++++---- src/components/access/EditGroupDialog.tsx | 11 +++++---- src/components/access/EditRoleDialog.tsx | 26 +++++++++++++-------- src/components/users/CreateUserDialog.tsx | 11 +++++---- 5 files changed, 43 insertions(+), 29 deletions(-) diff --git a/src/components/access/CreateGroupDialog.tsx b/src/components/access/CreateGroupDialog.tsx index 9bbb965..f383777 100644 --- a/src/components/access/CreateGroupDialog.tsx +++ b/src/components/access/CreateGroupDialog.tsx @@ -27,18 +27,21 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) { }; const mutation = useMutation({ - mutationFn: async () => { - const { group } = await createGroupFn({ data: { name, description } }); + mutationFn: async ({ name: submittedName }: { name: string }) => { + const { group } = await createGroupFn({ + data: { name: submittedName, description }, + }); for (const user of selectedUsers) { await addGroupMemberFn({ data: { groupId: group.id, userId: user.id } }); } + return { name: submittedName }; }, - onSuccess: () => { + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['groups'] }); queryClient.invalidateQueries({ queryKey: ['groupMembers'] }); queryClient.invalidateQueries({ queryKey: ['availableScopes'] }); queryClient.invalidateQueries({ queryKey: ['groupAssignments'] }); - notifySuccess(localize('com_toast_group_created', { name })); + notifySuccess(localize('com_toast_group_created', { name: data.name })); resetAndClose(); }, onError: (err: Error) => notifyError(err.message), @@ -51,7 +54,7 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) { setActiveTab('details'); return; } - mutation.mutate(); + mutation.mutate({ name }); }; const handleSubmit = (e: React.FormEvent) => { diff --git a/src/components/access/CreateRoleDialog.tsx b/src/components/access/CreateRoleDialog.tsx index 64d58be..836f353 100644 --- a/src/components/access/CreateRoleDialog.tsx +++ b/src/components/access/CreateRoleDialog.tsx @@ -31,19 +31,20 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) { }; const mutation = useMutation({ - mutationFn: async () => { - const { role } = await createRoleFn({ data: { name, description } }); + mutationFn: async ({ name: submittedName }: { name: string }) => { + const { role } = await createRoleFn({ data: { name: submittedName, description } }); await updateRolePermissionsFn({ data: { id: role.id, permissions } }); for (const user of selectedUsers) { await addRoleMemberFn({ data: { roleId: role.id, userId: user.id } }); } + return { name: submittedName }; }, - onSuccess: () => { + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['roles'] }); queryClient.invalidateQueries({ queryKey: ['roleMembers'] }); queryClient.invalidateQueries({ queryKey: ['availableScopes'] }); queryClient.invalidateQueries({ queryKey: ['roleAssignments'] }); - notifySuccess(localize('com_toast_role_created', { name })); + notifySuccess(localize('com_toast_role_created', { name: data.name })); resetAndClose(); }, onError: (err: Error) => notifyError(err.message), @@ -56,7 +57,7 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) { setActiveTab('details'); return; } - mutation.mutate(); + mutation.mutate({ name }); }; const handleSubmit = (e: React.FormEvent) => { diff --git a/src/components/access/EditGroupDialog.tsx b/src/components/access/EditGroupDialog.tsx index 77c2783..8c0d6cf 100644 --- a/src/components/access/EditGroupDialog.tsx +++ b/src/components/access/EditGroupDialog.tsx @@ -73,10 +73,10 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog }; const mutation = useMutation({ - mutationFn: async () => { + mutationFn: async ({ name: submittedName }: { name: string }) => { if (!group) throw new Error(localize('com_access_group_unavailable')); if (detailsDirty) { - await updateGroupFn({ data: { id: group.id, name, description } }); + await updateGroupFn({ data: { id: group.id, name: submittedName, description } }); } const memberResults = await Promise.allSettled([ ...pendingAdditions.map((user) => @@ -95,12 +95,13 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog parts.push(localize('com_access_member_ops_failed', { count: failures.length })); throw new Error(parts.join(', ')); } + return { name: submittedName }; }, - onSuccess: () => { + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['groups'] }); queryClient.invalidateQueries({ queryKey: ['groupAssignments'] }); queryClient.invalidateQueries({ queryKey: ['groupMembers', group?.id] }); - notifySuccess(localize('com_toast_group_updated', { name })); + notifySuccess(localize('com_toast_group_updated', { name: data.name })); onClose(); }, onError: (err: Error) => notifyError(err.message), @@ -114,7 +115,7 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog setActiveTab('details'); return; } - mutation.mutate(); + mutation.mutate({ name }); }; const handleSubmit = (e: React.FormEvent) => { diff --git a/src/components/access/EditRoleDialog.tsx b/src/components/access/EditRoleDialog.tsx index 150900b..891c1ac 100644 --- a/src/components/access/EditRoleDialog.tsx +++ b/src/components/access/EditRoleDialog.tsx @@ -91,11 +91,17 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro }; const updateMutation = useMutation({ - mutationFn: async (): Promise => { + mutationFn: async ({ + name: submittedName, + }: { + name: string; + }): Promise<{ roleId: string; name: string }> => { if (!role) throw new Error(localize('com_access_role_unavailable')); let roleId = role.id; if (detailsDirty) { - const result = await updateRoleFn({ data: { id: role.id, name, description } }); + const result = await updateRoleFn({ + data: { id: role.id, name: submittedName, description }, + }); roleId = result.role.id; } if (permissionsDirty && permissions) { @@ -128,20 +134,20 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro parts.push(localize('com_access_member_ops_failed', { count: failures.length })); throw new Error(parts.join(', ')); } - return roleId; + return { roleId, name: submittedName }; }, - onSuccess: (newRoleId) => { + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['roles'] }); queryClient.invalidateQueries({ queryKey: ['role', role?.id] }); - if (newRoleId !== role?.id) { - queryClient.invalidateQueries({ queryKey: ['role', newRoleId] }); + if (data.roleId !== role?.id) { + queryClient.invalidateQueries({ queryKey: ['role', data.roleId] }); } queryClient.invalidateQueries({ queryKey: ['roleAssignments'] }); queryClient.invalidateQueries({ queryKey: ['roleMembers', role?.id] }); - if (newRoleId !== role?.id) { - queryClient.invalidateQueries({ queryKey: ['roleMembers', newRoleId] }); + if (data.roleId !== role?.id) { + queryClient.invalidateQueries({ queryKey: ['roleMembers', data.roleId] }); } - notifySuccess(localize('com_toast_role_updated', { name })); + notifySuccess(localize('com_toast_role_updated', { name: data.name })); onClose(); }, onError: (err: Error) => notifyError(err.message), @@ -155,7 +161,7 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro setActiveTab('details'); return; } - updateMutation.mutate(); + updateMutation.mutate({ name }); }; const handleSubmit = (e: React.FormEvent) => { diff --git a/src/components/users/CreateUserDialog.tsx b/src/components/users/CreateUserDialog.tsx index b830bff..9a2e79a 100644 --- a/src/components/users/CreateUserDialog.tsx +++ b/src/components/users/CreateUserDialog.tsx @@ -16,10 +16,13 @@ export function CreateUserDialog({ open, onClose }: t.CreateUserDialogProps) { const [error, setError] = useState(''); const mutation = useMutation({ - mutationFn: () => createUserFn({ data: { name, email, role } }), - onSuccess: () => { + mutationFn: async ({ name: submittedName }: { name: string }) => { + await createUserFn({ data: { name: submittedName, email, role } }); + return { name: submittedName }; + }, + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['users'] }); - notifySuccess(localize('com_toast_user_invited', { name })); + notifySuccess(localize('com_toast_user_invited', { name: data.name })); resetAndClose(); }, onError: (err: Error) => notifyError(err.message), @@ -43,7 +46,7 @@ export function CreateUserDialog({ open, onClose }: t.CreateUserDialogProps) { setError(localize('com_users_email_required')); return; } - mutation.mutate(); + mutation.mutate({ name }); }; return (