improvement(credential-groups): align settings surface with the shared page patterns (#6727)

* improvement(credential-groups): align settings surface with the shared page patterns

- drop the row "..." menu; a row opening a detail page carries the chevron
  only, and Delete moves to the detail header behind a confirm modal
- replace the hand-rolled Save chip with saveDiscardActions, and wire
  useSettingsUnsavedGuard so detail edits survive tab switches
- fix swapped staleTime constants: the list carried Infinity, which combined
  with the app-wide retryOnMount:false to cache one transient failure until
  a full page reload
- evict the detail query on delete, and keep the bots prop referentially
  stable so a refetch cannot drop a queued Slack authorization message
- reset the detail tab param on open and close so a stale link cannot open
  the next group on the previous group's tab
- match peer rows (iconFilled + --text-icon), drop a bespoke max-w and a
  duplicated gap-7, align no-results copy and the Slack modal field gutter

* improvement(credential-groups): hold first paint for a deep-linked group

Matches the data-drains list: a deep link whose id is still resolving no
longer flashes the list chrome before jumping to the detail. Keys the detail
by group id so lifted draft state can never carry across groups.

* fix(credential-groups): await the refetch before clearing the edit buffer

The update mutation fired its invalidations without returning them, so
mutateAsync resolved before the refetch landed. Callers that clear their
draft on success then fell back onto the pre-save cache and flashed the old
name and description until the refetch completed — or kept showing them if
it failed.
This commit is contained in:
Waleed
2026-08-14 20:26:26 -07:00
committed by GitHub
parent a9688d01b9
commit 0ce8ded1e7
6 changed files with 376 additions and 277 deletions
@@ -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<string | null>(null)
const [draftName, setDraftName] = useState<string | null>(null)
const [draftDescription, setDraftDescription] = useState<string | null>(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 (
<>
<SettingsPanel
back={{ text: 'Credential groups', icon: ArrowLeft, onSelect: handleBack }}
back={{
text: 'Credential groups',
icon: ArrowLeft,
onSelect: () => 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")}
</SettingsEmptyState>
) : detail.isPending || !credentialGroup ? null : (
<div className='flex flex-col gap-7'>
<>
<ChipModalTabs
tabs={CREDENTIAL_GROUP_TABS}
value={activeTab}
@@ -207,7 +278,14 @@ export function CredentialGroupDetail({
/>
{activeTab === 'details' && (
<CredentialGroupDetails workspaceId={workspaceId} credentialGroup={credentialGroup} />
<CredentialGroupDetails
workspaceId={workspaceId}
credentialGroup={credentialGroup}
name={name}
onNameChange={setDraftName}
description={description}
onDescriptionChange={setDraftDescription}
/>
)}
{activeTab === 'people' && (
@@ -233,7 +311,8 @@ export function CredentialGroupDetail({
return (
<SettingsResourceRow
key={enrollment.id}
icon={<KeySquare />}
icon={<KeySquare className='text-[var(--text-icon)]' />}
iconFilled
title={enrollment.email}
description={
<EnrollmentConnections connections={enrollment.connections} />
@@ -272,7 +351,7 @@ export function CredentialGroupDetail({
)}
</SettingsSection>
)}
</div>
</>
)}
</SettingsPanel>
{credentialGroup && (
@@ -296,6 +375,27 @@ export function CredentialGroupDetail({
disabled: revoke.isPending,
}}
/>
<ChipConfirmModal
open={showDelete}
onOpenChange={(open) => !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,
}}
/>
<UnsavedChangesModal
open={guard.showUnsavedModal}
onOpenChange={guard.setShowUnsavedModal}
onDiscard={guard.confirmDiscard}
/>
</>
)
}
@@ -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<string | undefined>()
const [slackSetup, setSlackSetup] = useState<{ credentialId?: string } | null>(null)
const [removingProvider, setRemovingProvider] = useState<CredentialGroupProvider | null>(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 (
<>
<div className='flex flex-col gap-7'>
<SettingsSection
label='Group details'
action={
detailsDirty ? (
<Chip
variant='primary'
onClick={() => void handleSaveDetails()}
disabled={!name.trim() || isUpdating}
>
{isUpdating ? 'Saving...' : 'Save changes'}
</Chip>
) : undefined
}
>
<div className='flex max-w-[560px] flex-col gap-4'>
<SettingRow label='Name' htmlFor='credential-group-name'>
<ChipInput
id='credential-group-name'
value={name}
onChange={(event) => setName(event.target.value)}
error={!name.trim()}
/>
</SettingRow>
<SettingRow label='Description' optional htmlFor='credential-group-description'>
<ChipTextarea
id='credential-group-description'
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder='What these accounts will be used for'
rows={3}
/>
</SettingRow>
</div>
</SettingsSection>
<SettingsSection label='Group details'>
<div className='flex flex-col gap-4'>
<SettingRow
label='Name'
htmlFor='credential-group-name'
error={name.trim() ? undefined : 'Name is required.'}
>
<ChipInput
id='credential-group-name'
value={name}
onChange={(event) => onNameChange(event.target.value)}
error={!name.trim()}
/>
</SettingRow>
<SettingRow label='Description' optional htmlFor='credential-group-description'>
<ChipTextarea
id='credential-group-description'
value={description}
onChange={(event) => onDescriptionChange(event.target.value)}
placeholder='What these accounts will be used for'
rows={3}
/>
</SettingRow>
</div>
</SettingsSection>
<SettingsSection label='Accounts people can connect'>
<div className={RESOURCE_LIST_STACK}>
{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
<SettingsSection label='Accounts people can connect'>
<div className={RESOURCE_LIST_STACK}>
{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 (
<SettingsResourceRow
key={provider}
icon={<ProviderIcon aria-hidden />}
title={service.name}
description={descriptionText}
badge={
option && !slackNeedsSetup ? (
<ChipTag variant='gray'>Connected</ChipTag>
) : undefined
}
trailing={
option ? (
<div className='flex items-center gap-1'>
{slackNeedsSetup && option.provider === 'slack' && slackBot ? (
<Chip onClick={() => openSlackSetup(slackBot.id)} disabled={isUpdating}>
Continue setup
</Chip>
) : null}
<RowActionsMenu
label={`${service.name} actions`}
actions={[
...(provider === 'slack'
? [
{
label: 'Change Slack app',
onSelect: () =>
openSlackSetup(
option?.provider === 'slack'
? option.slackBotCredentialId
: undefined
),
disabled: isUpdating,
},
]
: []),
{
label: 'Remove',
destructive: true,
onSelect: () => setRemovingProvider(provider),
disabled: isUpdating,
},
]}
/>
</div>
) : (
<Chip
onClick={() => handleProviderAction(provider)}
disabled={isUpdating || (provider === 'slack' && slackBots.isPending)}
>
{support.configuration === 'oauth' ? 'Add' : 'Set up'}
</Chip>
)
}
/>
)
})}
</div>
</SettingsSection>
</div>
return (
<SettingsResourceRow
key={provider}
icon={<ProviderIcon aria-hidden />}
title={service.name}
description={descriptionText}
badge={
option && !slackNeedsSetup ? (
<ChipTag variant='gray'>Connected</ChipTag>
) : undefined
}
trailing={
option ? (
<div className='flex items-center gap-1'>
{slackNeedsSetup && option.provider === 'slack' && slackBot ? (
<Chip onClick={() => openSlackSetup(slackBot.id)} disabled={isUpdating}>
Continue setup
</Chip>
) : null}
<RowActionsMenu
label={`${service.name} actions`}
actions={[
...(provider === 'slack'
? [
{
label: 'Change Slack app',
onSelect: () =>
openSlackSetup(
option?.provider === 'slack'
? option.slackBotCredentialId
: undefined
),
disabled: isUpdating,
},
]
: []),
{
label: 'Remove',
destructive: true,
onSelect: () => setRemovingProvider(provider),
disabled: isUpdating,
},
]}
/>
</div>
) : (
<Chip
onClick={() => handleProviderAction(provider)}
disabled={isUpdating || (provider === 'slack' && slackBots.isPending)}
>
{support.configuration === 'oauth' ? 'Add' : 'Set up'}
</Chip>
)
}
/>
)
})}
</div>
</SettingsSection>
<SlackManagedUsersModal
open={slackSetupOpen}
open={slackSetup !== null}
workspaceId={workspaceId}
credentialGroupId={credentialGroup.id}
onOpenChange={(nextOpen) => {
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}
/>
<ChipConfirmModal
@@ -76,8 +76,8 @@ export function CredentialGroupInviteModal({
? `${result.sentCount} sent. ${failures.length} failed: ${failures.map((item) => 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}
/>
<ChipModalError>
{deliveryError ?? (invite.error ? getErrorMessage(invite.error) : null)}
{deliveryError ??
(invite.error ? getErrorMessage(invite.error, 'Failed to send invitations') : null)}
</ChipModalError>
</ChipModalBody>
<ChipModalFooter
@@ -1,15 +1,16 @@
'use client'
import { useState } from 'react'
import { ChipConfirmModal, ChipTag } from '@sim/emcn'
import { ChipTag } from '@sim/emcn'
import { GridOffset, Plus } from '@sim/emcn/icons'
import { getErrorMessage } from '@sim/utils/errors'
import { useQueryState } from 'nuqs'
import {
credentialGroupIdParam,
credentialGroupIdUrlKeys,
credentialGroupTabParam,
credentialGroupTabUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
@@ -20,7 +21,7 @@ import {
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
import { CredentialGroupCreateModal } from '@/ee/credential-groups/components/credential-group-create-modal'
import { CredentialGroupDetail } from '@/ee/credential-groups/components/credential-group-detail'
import { useCredentialGroups, useDeleteCredentialGroup } from '@/hooks/queries/credential-groups'
import { useCredentialGroups } from '@/hooks/queries/credential-groups'
interface CredentialGroupsSettingsProps {
workspaceId: string
@@ -28,15 +29,30 @@ interface CredentialGroupsSettingsProps {
export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettingsProps) {
const { data: groups = [], isPending, error } = useCredentialGroups(workspaceId)
const deleteGroup = useDeleteCredentialGroup()
const [search, setSearch] = useSettingsSearch()
const [showCreate, setShowCreate] = useState(false)
const [deletingGroupId, setDeletingGroupId] = useState<string | 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 (
<CredentialGroupDetail
key={selectedGroup.id}
workspaceId={workspaceId}
groupId={selectedGroup.id}
onBack={() => void setSelectedGroupId(null, { history: 'replace' })}
onBack={closeGroup}
/>
)
}
@@ -97,18 +111,24 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin
) : isPending ? null : groups.length === 0 ? (
<SettingsEmptyState>Click "Create group" above to get started</SettingsEmptyState>
) : filtered.length === 0 ? (
<SettingsEmptyState variant='inline'>No groups match "{search}"</SettingsEmptyState>
<SettingsEmptyState variant='inline'>
No credential groups found matching "{search}"
</SettingsEmptyState>
) : (
<div className={RESOURCE_LIST_STACK}>
{filtered.map((group) => {
const optionCount = group.options.length
const accountTypes = `${optionCount} account type${optionCount === 1 ? '' : 's'}`
return (
<SettingsResourceRow
key={group.id}
icon={<GridOffset />}
icon={<GridOffset className='text-[var(--text-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
<ChipTag variant='gray'>Disabled</ChipTag>
) : undefined
}
trailing={
<RowActionsMenu
label={`${group.name} actions`}
actions={[
{
label: 'Delete',
destructive: true,
onSelect: () => setDeletingGroupId(group.id),
},
]}
/>
}
/>
)
})}
@@ -137,25 +145,9 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin
<CredentialGroupCreateModal
open={showCreate}
onOpenChange={setShowCreate}
onCreated={(groupId) => void setSelectedGroupId(groupId)}
onCreated={openGroup}
workspaceId={workspaceId}
/>
<ChipConfirmModal
open={Boolean(deletingGroup)}
onOpenChange={(open) => !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,
}}
/>
</>
)
}
@@ -94,59 +94,6 @@ export function SlackManagedUsersModal({
const effectiveCredentialId = selectedCredentialId ?? defaultCredentialId
const selectedBot = bots.find((bot) => bot.id === effectiveCredentialId)
useEffect(() => {
if (!open) return
const channel = new BroadcastChannel(CHANNEL_NAME)
channel.onmessage = (event: MessageEvent<unknown>) => {
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()
}
return () => channel.close()
}, [bots, credentialGroupId, onOpenChange, open, queryClient, workspaceId])
useEffect(
() => () => {
if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current)
popup.current?.close()
},
[]
)
const reset = () => {
popup.current?.close()
popup.current = null
@@ -161,6 +108,73 @@ export function SlackManagedUsersModal({
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<unknown>) => {
if (!isSlackManagedUsersMessage(event.data)) return
messageHandler.current(event.data)
}
return () => channel.close()
}, [open])
useEffect(
() => () => {
if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current)
popup.current?.close()
},
[]
)
const handleOpenChange = (nextOpen: boolean) => {
if (pending && !nextOpen) return
onOpenChange(nextOpen)
@@ -247,12 +261,12 @@ export function SlackManagedUsersModal({
</ChipModalHeader>
<ChipModalBody>
{isLoading ? (
<div className='flex flex-col gap-3 px-2 py-1'>
<div className='flex flex-col gap-[9px] px-2'>
<Skeleton className='h-4 w-24 rounded' />
<Skeleton className='h-9 w-full rounded-lg' />
<Skeleton className='h-[30px] w-full rounded-lg' />
</div>
) : noBots ? (
<p className='px-2 py-4 text-center text-[var(--text-muted)] text-sm'>
<p className='px-2 text-[var(--text-muted)] text-small'>
Add a custom Slack app from Integrations before adding Slack to this group.
</p>
) : (
+20 -8
View File
@@ -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<typeof getCredentialGroupContract>) =>
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),
}),
]),
})
}