mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user