diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx index 3bab5af6bc0..672ed564a66 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -5,6 +5,7 @@ import { Chip, ChipConfirmModal, ChipModalTabs, ChipTag, toast } from '@sim/emcn import { ArrowLeft, KeySquare, Plus } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { useQueryState } from 'nuqs' +import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { CredentialGroupEnrollment, CredentialGroupEnrollmentConnection, @@ -13,6 +14,7 @@ import type { import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { credentialGroupTabParam, credentialGroupTabUrlKeys, @@ -26,12 +28,15 @@ import { SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { CredentialGroupDetails } from '@/ee/credential-groups/components/credential-group-details' import { CredentialGroupInviteModal } from '@/ee/credential-groups/components/credential-group-invite-modal' import { useCredentialGroupDetail, + useDeleteCredentialGroup, useResendCredentialGroupEnrollment, useRevokeCredentialGroupEnrollment, + useUpdateCredentialGroup, } from '@/hooks/queries/credential-groups' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' @@ -120,12 +125,17 @@ export function CredentialGroupDetail({ }) const resend = useResendCredentialGroupEnrollment() const revoke = useRevokeCredentialGroupEnrollment() + const updateGroup = useUpdateCredentialGroup() + const deleteGroup = useDeleteCredentialGroup() const [activeTab, setActiveTab] = useQueryState(credentialGroupTabParam.key, { ...credentialGroupTabParam.parser, ...credentialGroupTabUrlKeys, }) const [showInvite, setShowInvite] = useState(false) + const [showDelete, setShowDelete] = useState(false) const [revokingEnrollmentId, setRevokingEnrollmentId] = useState(null) + const [draftName, setDraftName] = useState(null) + const [draftDescription, setDraftDescription] = useState(null) const credentialGroup = detail.data?.pages[0]?.credentialGroup const enrollments = detail.data?.pages.flatMap((page) => page.enrollments) ?? [] const revokingEnrollment = revokingEnrollmentId @@ -144,14 +154,65 @@ export function CredentialGroupDetail({ slackBots.data?.some((bot) => bot.id === option.slackBotCredentialId)) ) + const name = draftName ?? credentialGroup?.name ?? '' + const description = draftDescription ?? credentialGroup?.description ?? '' + const normalizedDescription = description.trim() || null + const detailsDirty = Boolean( + credentialGroup && + (name.trim() !== credentialGroup.name || + normalizedDescription !== credentialGroup.description) + ) + const guard = useSettingsUnsavedGuard({ isDirty: detailsDirty }) + + const discardDetails = () => { + setDraftName(null) + setDraftDescription(null) + } + + const handleSaveDetails = async () => { + if (!credentialGroup || !name.trim()) return + try { + await updateGroup.mutateAsync({ + workspaceId, + groupId: credentialGroup.id, + body: { name: name.trim(), description: normalizedDescription }, + }) + discardDetails() + toast.success('Details saved') + } catch (error) { + toast.error(getErrorMessage(error, 'Could not save details')) + } + } + + /** + * Each tab owns its own primary action: Details commits the edited name and + * description, People invites more users. Delete is available from both. + */ const actions: SettingsAction[] = credentialGroup ? [ + ...(activeTab === 'details' + ? saveDiscardActions({ + dirty: detailsDirty, + saving: updateGroup.isPending, + onSave: () => void handleSaveDetails(), + onDiscard: discardDetails, + saveDisabled: !name.trim(), + saveTooltip: name.trim() ? undefined : 'Name is required', + }) + : [ + { + text: 'Invite users', + icon: Plus, + variant: 'primary' as const, + onSelect: () => setShowInvite(true), + disabled: credentialGroup.status !== 'active' || !configurationReady, + }, + ]), { - text: 'Invite users', - icon: Plus, - variant: 'primary', - onSelect: () => setShowInvite(true), - disabled: credentialGroup.status !== 'active' || !configurationReady, + id: 'delete', + text: deleteGroup.isPending ? 'Deleting...' : 'Delete', + onSelect: () => setShowDelete(true), + disabled: deleteGroup.isPending, }, ] : [] @@ -180,15 +241,25 @@ export function CredentialGroupDetail({ } } - const handleBack = () => { - void setActiveTab(null, { history: 'replace' }) - onBack() + const handleDelete = async () => { + if (!credentialGroup) return + try { + await deleteGroup.mutateAsync({ workspaceId, groupId }) + setShowDelete(false) + onBack() + } catch (error) { + toast.error(getErrorMessage(error, 'Could not delete credential group')) + } } return ( <> guard.guardBack(onBack), + }} title={credentialGroup?.name ?? 'Credential group'} description={credentialGroup?.description ?? undefined} actions={actions} @@ -198,7 +269,7 @@ export function CredentialGroupDetail({ {getErrorMessage(detail.error, "Couldn't load credential group")} ) : detail.isPending || !credentialGroup ? null : ( -
+ <> {activeTab === 'details' && ( - + )} {activeTab === 'people' && ( @@ -233,7 +311,8 @@ export function CredentialGroupDetail({ return ( } + icon={} + iconFilled title={enrollment.email} description={ @@ -272,7 +351,7 @@ export function CredentialGroupDetail({ )} )} -
+ )}
{credentialGroup && ( @@ -296,6 +375,27 @@ export function CredentialGroupDetail({ disabled: revoke.isPending, }} /> + !open && !deleteGroup.isPending && setShowDelete(false)} + srTitle='Delete credential group' + title='Delete credential group' + text={[ + `Delete ${credentialGroup?.name ?? 'this credential group'}?`, + { text: ' This cannot be undone.', error: true }, + ]} + dismissLabel='Cancel' + confirm={{ + label: deleteGroup.isPending ? 'Deleting...' : 'Delete', + onClick: handleDelete, + disabled: deleteGroup.isPending, + }} + /> + ) } diff --git a/apps/sim/ee/credential-groups/components/credential-group-details.tsx b/apps/sim/ee/credential-groups/components/credential-group-details.tsx index 501f0a7b366..9af6a3da772 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-details.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-details.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { Chip, ChipConfirmModal, ChipInput, ChipTag, ChipTextarea, toast } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' +import type { WorkspaceCredential } from '@/lib/api/contracts' import type { CredentialGroup, CredentialGroupOption, @@ -28,9 +29,17 @@ import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack- import { useUpdateCredentialGroup } from '@/hooks/queries/credential-groups' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' +/** Stable identity so a pending/errored credentials query cannot churn the modal's `bots` prop. */ +const EMPTY_SLACK_BOTS: WorkspaceCredential[] = [] + interface CredentialGroupDetailsProps { credentialGroup: CredentialGroup workspaceId: string + /** Edited name; committed by the panel header's Save action, which owns the dirty state. */ + name: string + onNameChange: (name: string) => void + description: string + onDescriptionChange: (description: string) => void } function toOptionUpdateInput( @@ -52,6 +61,10 @@ function toOptionUpdateInput( export function CredentialGroupDetails({ credentialGroup, workspaceId, + name, + onNameChange, + description, + onDescriptionChange, }: CredentialGroupDetailsProps) { const updateGroup = useUpdateCredentialGroup() const slackBots = useWorkspaceCredentials({ @@ -59,15 +72,9 @@ export function CredentialGroupDetails({ type: 'service_account', providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, }) - const [name, setName] = useState(credentialGroup.name) - const [description, setDescription] = useState(credentialGroup.description ?? '') - const [slackSetupOpen, setSlackSetupOpen] = useState(false) - const [slackSetupCredentialId, setSlackSetupCredentialId] = useState() + const [slackSetup, setSlackSetup] = useState<{ credentialId?: string } | null>(null) const [removingProvider, setRemovingProvider] = useState(null) - const normalizedDescription = description.trim() || null - const detailsDirty = - name.trim() !== credentialGroup.name || normalizedDescription !== credentialGroup.description const isUpdating = updateGroup.isPending const updateOptions = async ( @@ -100,8 +107,7 @@ export function CredentialGroupDetails({ } const openSlackSetup = (credentialId?: string) => { - setSlackSetupCredentialId(credentialId) - setSlackSetupOpen(true) + setSlackSetup({ credentialId }) } const handleProviderAction = (provider: CredentialGroupProvider) => { @@ -117,20 +123,6 @@ export function CredentialGroupDetails({ throw new Error(`Unsupported Credential Group configuration: ${support.configuration}`) } - const handleSaveDetails = async () => { - if (!detailsDirty || !name.trim() || isUpdating) return - try { - await updateGroup.mutateAsync({ - workspaceId, - groupId: credentialGroup.id, - body: { name: name.trim(), description: normalizedDescription }, - }) - toast.success('Details saved') - } catch (error) { - toast.error(getErrorMessage(error, 'Could not save details')) - } - } - const handleRemoveProvider = async () => { if (!removingProvider) return const service = getCredentialGroupProviderService(removingProvider) @@ -142,141 +134,129 @@ export function CredentialGroupDetails({ return ( <> -
- void handleSaveDetails()} - disabled={!name.trim() || isUpdating} - > - {isUpdating ? 'Saving...' : 'Save changes'} - - ) : undefined - } - > -
- - setName(event.target.value)} - error={!name.trim()} - /> - - - setDescription(event.target.value)} - placeholder='What these accounts will be used for' - rows={3} - /> - -
-
+ +
+ + onNameChange(event.target.value)} + error={!name.trim()} + /> + + + onDescriptionChange(event.target.value)} + placeholder='What these accounts will be used for' + rows={3} + /> + +
+
- -
- {CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => { - const service = getCredentialGroupProviderService(provider) - const support = getCredentialGroupProviderSupport(provider) - const option = credentialGroup.options.find( - (candidate) => candidate.provider === provider - ) - const ProviderIcon = service.icon - const slackBot = - provider === 'slack' && option?.provider === 'slack' - ? slackBots.data?.find((bot) => bot.id === option.slackBotCredentialId) - : undefined - const slackNeedsSetup = - provider === 'slack' && - option?.provider === 'slack' && - (!slackBot || option.configurationStatus !== 'ready') - const descriptionText = - provider === 'slack' && option - ? slackBot - ? `${slackBot.displayName}${slackNeedsSetup ? ' needs managed-user setup' : ''}` - : slackBots.isPending - ? 'Loading custom Slack app...' - : 'Custom Slack app unavailable' - : support.description + +
+ {CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => { + const service = getCredentialGroupProviderService(provider) + const support = getCredentialGroupProviderSupport(provider) + const option = credentialGroup.options.find( + (candidate) => candidate.provider === provider + ) + const ProviderIcon = service.icon + const slackBot = + provider === 'slack' && option?.provider === 'slack' + ? slackBots.data?.find((bot) => bot.id === option.slackBotCredentialId) + : undefined + const slackNeedsSetup = + provider === 'slack' && + option?.provider === 'slack' && + (!slackBot || option.configurationStatus !== 'ready') + const descriptionText = + provider === 'slack' && option + ? slackBot + ? `${slackBot.displayName}${slackNeedsSetup ? ' needs managed-user setup' : ''}` + : slackBots.isPending + ? 'Loading custom Slack app...' + : 'Custom Slack app unavailable' + : support.description - return ( - } - title={service.name} - description={descriptionText} - badge={ - option && !slackNeedsSetup ? ( - Connected - ) : undefined - } - trailing={ - option ? ( -
- {slackNeedsSetup && option.provider === 'slack' && slackBot ? ( - openSlackSetup(slackBot.id)} disabled={isUpdating}> - Continue setup - - ) : null} - - openSlackSetup( - option?.provider === 'slack' - ? option.slackBotCredentialId - : undefined - ), - disabled: isUpdating, - }, - ] - : []), - { - label: 'Remove', - destructive: true, - onSelect: () => setRemovingProvider(provider), - disabled: isUpdating, - }, - ]} - /> -
- ) : ( - handleProviderAction(provider)} - disabled={isUpdating || (provider === 'slack' && slackBots.isPending)} - > - {support.configuration === 'oauth' ? 'Add' : 'Set up'} - - ) - } - /> - ) - })} -
-
-
+ return ( + } + title={service.name} + description={descriptionText} + badge={ + option && !slackNeedsSetup ? ( + Connected + ) : undefined + } + trailing={ + option ? ( +
+ {slackNeedsSetup && option.provider === 'slack' && slackBot ? ( + openSlackSetup(slackBot.id)} disabled={isUpdating}> + Continue setup + + ) : null} + + openSlackSetup( + option?.provider === 'slack' + ? option.slackBotCredentialId + : undefined + ), + disabled: isUpdating, + }, + ] + : []), + { + label: 'Remove', + destructive: true, + onSelect: () => setRemovingProvider(provider), + disabled: isUpdating, + }, + ]} + /> +
+ ) : ( + handleProviderAction(provider)} + disabled={isUpdating || (provider === 'slack' && slackBots.isPending)} + > + {support.configuration === 'oauth' ? 'Add' : 'Set up'} + + ) + } + /> + ) + })} +
+ { - setSlackSetupOpen(nextOpen) - if (!nextOpen) setSlackSetupCredentialId(undefined) + if (!nextOpen) setSlackSetup(null) }} - bots={slackBots.data ?? []} + bots={slackBots.data ?? EMPTY_SLACK_BOTS} isLoading={slackBots.isPending} error={slackBots.error} - initialCredentialId={slackSetupCredentialId} + initialCredentialId={slackSetup?.credentialId} /> item.email).join(', ')}` : `No invitations were sent: ${failures.map((item) => `${item.email} (${item.error})`).join(', ')}` ) - } catch (error) { - setDeliveryError(getErrorMessage(error, 'Failed to send invitations')) + } catch { + return } } @@ -100,7 +100,8 @@ export function CredentialGroupInviteModal({ disabled={invite.isPending} /> - {deliveryError ?? (invite.error ? getErrorMessage(invite.error) : null)} + {deliveryError ?? + (invite.error ? getErrorMessage(invite.error, 'Failed to send invitations') : null)} (null) const [selectedGroupId, setSelectedGroupId] = useQueryState(credentialGroupIdParam.key, { ...credentialGroupIdParam.parser, ...credentialGroupIdUrlKeys, }) - const deletingGroup = groups.find((group) => group.id === deletingGroupId) + /** + * The detail view's tab is scoped to one group, so both transitions reset it — + * otherwise a `credential-group-id` that never resolves leaves + * `credential-group-tab` behind and the next group opens on the previous + * group's tab. nuqs batches these same-tick writes into one URL update. + */ + const [, setSelectedTab] = useQueryState(credentialGroupTabParam.key, { + ...credentialGroupTabParam.parser, + ...credentialGroupTabUrlKeys, + }) + const openGroup = (groupId: string) => { + void setSelectedGroupId(groupId) + void setSelectedTab(null) + } + const closeGroup = () => { + void setSelectedGroupId(null, { history: 'replace' }) + void setSelectedTab(null) + } const selectedGroup = selectedGroupId ? groups.find((group) => group.id === selectedGroupId) : undefined @@ -59,22 +75,20 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin }, ] - const handleDelete = async () => { - if (!deletingGroupId) return - try { - await deleteGroup.mutateAsync({ workspaceId, groupId: deletingGroupId }) - setDeletingGroupId(null) - } catch { - return - } - } + /** + * Hold the first paint while a deep-linked id could still resolve, so a valid + * link never flashes the list before jumping to it. A dead id still falls back + * to the list. + */ + if (selectedGroupId !== null && isPending) return null if (selectedGroup) { return ( void setSelectedGroupId(null, { history: 'replace' })} + onBack={closeGroup} /> ) } @@ -97,18 +111,24 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin ) : isPending ? null : groups.length === 0 ? ( Click "Create group" above to get started ) : filtered.length === 0 ? ( - No groups match "{search}" + + No credential groups found matching "{search}" + ) : (
{filtered.map((group) => { const optionCount = group.options.length + const accountTypes = `${optionCount} account type${optionCount === 1 ? '' : 's'}` return ( } + icon={} + iconFilled title={group.name} - description={`${optionCount} account type${optionCount === 1 ? '' : 's'} · ${group.description || 'Managed workspace credentials'}`} - onClick={() => void setSelectedGroupId(group.id)} + description={ + group.description ? `${accountTypes} · ${group.description}` : accountTypes + } + onClick={() => openGroup(group.id)} clickLabel={`Open ${group.name}`} navigable badge={ @@ -116,18 +136,6 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin Disabled ) : undefined } - trailing={ - setDeletingGroupId(group.id), - }, - ]} - /> - } /> ) })} @@ -137,25 +145,9 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin void setSelectedGroupId(groupId)} + onCreated={openGroup} workspaceId={workspaceId} /> - !open && !deleteGroup.isPending && setDeletingGroupId(null)} - srTitle='Delete credential group' - title='Delete credential group' - text={[ - `Delete ${deletingGroup?.name ?? 'this credential group'}?`, - { text: ' This cannot be undone.', error: true }, - ]} - dismissLabel='Cancel' - confirm={{ - label: deleteGroup.isPending ? 'Deleting...' : 'Delete', - onClick: handleDelete, - disabled: deleteGroup.isPending, - }} - /> ) } diff --git a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx index a6bdef02c80..34d3052565c 100644 --- a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx +++ b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx @@ -94,50 +94,78 @@ export function SlackManagedUsersModal({ const effectiveCredentialId = selectedCredentialId ?? defaultCredentialId const selectedBot = bots.find((bot) => bot.id === effectiveCredentialId) + const reset = () => { + popup.current?.close() + popup.current = null + if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) + popupWatcher.current = null + expectedState.current = null + expectedCredentialId.current = null + setSelectedCredentialId(null) + setClientId('') + setClientSecret('') + setPending(false) + startAuthorization.reset() + } + + const handleAuthorizationMessage = (message: SlackManagedUsersMessage) => { + if (!expectedState.current || message.state !== expectedState.current) return + const verifiedCredentialId = expectedCredentialId.current + expectedState.current = null + expectedCredentialId.current = null + if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) + popupWatcher.current = null + popup.current?.close() + popup.current = null + setPending(false) + if (!message.ok) { + const notification = getSlackManagedUsersFailureNotification(message.reason) + if (notification.variant === 'warning') toast.warning(notification.message) + else toast.error(notification.message) + return + } + if ( + message.credentialGroupId !== credentialGroupId || + !verifiedCredentialId || + message.slackBotCredentialId !== verifiedCredentialId + ) { + toast.error('Slack app verification failed. Please try again.') + return + } + if (!bots.some((bot) => bot.id === verifiedCredentialId)) { + toast.error('The verified Slack app is no longer available.') + return + } + void queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.list(workspaceId), + }) + void queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(workspaceId, credentialGroupId), + }) + toast.success('Slack configured') + onOpenChange(false) + reset() + } + + /** + * The subscription's identity is `open` alone. Routing the handler through a + * ref keeps a `bots` refetch from closing and reopening the channel mid-flow, + * which would drop an already-queued authorization message from the popup. + */ + const messageHandler = useRef(handleAuthorizationMessage) + useEffect(() => { + messageHandler.current = handleAuthorizationMessage + }) + useEffect(() => { if (!open) return const channel = new BroadcastChannel(CHANNEL_NAME) channel.onmessage = (event: MessageEvent) => { if (!isSlackManagedUsersMessage(event.data)) return - if (!expectedState.current || event.data.state !== expectedState.current) return - const verifiedCredentialId = expectedCredentialId.current - expectedState.current = null - expectedCredentialId.current = null - if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) - popupWatcher.current = null - popup.current?.close() - popup.current = null - setPending(false) - if (!event.data.ok) { - const notification = getSlackManagedUsersFailureNotification(event.data.reason) - if (notification.variant === 'warning') toast.warning(notification.message) - else toast.error(notification.message) - return - } - if ( - event.data.credentialGroupId !== credentialGroupId || - !verifiedCredentialId || - event.data.slackBotCredentialId !== verifiedCredentialId - ) { - toast.error('Slack app verification failed. Please try again.') - return - } - if (!bots.some((bot) => bot.id === verifiedCredentialId)) { - toast.error('The verified Slack app is no longer available.') - return - } - void queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.list(workspaceId), - }) - void queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.detail(workspaceId, credentialGroupId), - }) - toast.success('Slack configured') - onOpenChange(false) - reset() + messageHandler.current(event.data) } return () => channel.close() - }, [bots, credentialGroupId, onOpenChange, open, queryClient, workspaceId]) + }, [open]) useEffect( () => () => { @@ -147,20 +175,6 @@ export function SlackManagedUsersModal({ [] ) - const reset = () => { - popup.current?.close() - popup.current = null - if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current) - popupWatcher.current = null - expectedState.current = null - expectedCredentialId.current = null - setSelectedCredentialId(null) - setClientId('') - setClientSecret('') - setPending(false) - startAuthorization.reset() - } - const handleOpenChange = (nextOpen: boolean) => { if (pending && !nextOpen) return onOpenChange(nextOpen) @@ -247,12 +261,12 @@ export function SlackManagedUsersModal({ {isLoading ? ( -
+
- +
) : noBots ? ( -

+

Add a custom Slack app from Integrations before adding Slack to this group.

) : ( diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts index d45dbed396d..c231296b2cc 100644 --- a/apps/sim/hooks/queries/credential-groups.ts +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -29,7 +29,7 @@ export function useCredentialGroups(workspaceId?: string) { return fetchCredentialGroupList(workspaceId, signal) }, enabled: Boolean(workspaceId), - staleTime: CREDENTIAL_GROUP_DETAIL_STALE_TIME, + staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, }) } @@ -49,7 +49,10 @@ export function useCredentialGroupDetail(workspaceId?: string, groupId?: string) getNextPageParam: (lastPage: ContractJsonResponse) => lastPage.nextCursor ?? undefined, enabled: Boolean(workspaceId && groupId), - staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, + staleTime: CREDENTIAL_GROUP_DETAIL_STALE_TIME, + // An infinite staleTime never goes stale, so the app-wide `retryOnMount: false` + // would cache one transient failure for the life of the QueryClient. + retryOnMount: true, }) } @@ -78,6 +81,9 @@ export function useDeleteCredentialGroup() { }), onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) }) + queryClient.removeQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }) }, }) } @@ -98,12 +104,18 @@ export function useUpdateCredentialGroup() { params: { id: workspaceId, groupId }, body, }), - onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) }) - queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), - }) - }, + // Returned so `mutateAsync` resolves only once the refetch has landed. Callers + // clear their edit buffer on success, which would otherwise fall back onto the + // pre-save cache and flash the old values. + onSettled: (_data, _error, variables) => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.list(variables.workspaceId), + }), + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), + }), + ]), }) }