mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
feat(core): Add new agent slack integration (#35591)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { UpdateSlackManagedAppSettingsDto } from '../slack';
|
||||
|
||||
describe('UpdateSlackManagedAppSettingsDto', () => {
|
||||
const validSettings = {
|
||||
credentialId: 'bot-credential',
|
||||
name: 'Support Bot',
|
||||
description: 'Handles support requests',
|
||||
alwaysOnline: true,
|
||||
};
|
||||
|
||||
it('accepts valid managed Slack app settings', () => {
|
||||
expect(UpdateSlackManagedAppSettingsDto.safeParse(validSettings).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ ...validSettings, name: '' }, 'name'],
|
||||
[{ ...validSettings, name: 'a'.repeat(81) }, 'name'],
|
||||
[{ ...validSettings, description: '' }, 'description'],
|
||||
[{ ...validSettings, description: 'a'.repeat(141) }, 'description'],
|
||||
])('rejects invalid settings %#', (settings, path) => {
|
||||
const result = UpdateSlackManagedAppSettingsDto.safeParse(settings);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0]?.path).toContain(path);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -235,6 +235,7 @@ export class AgentDisconnectIntegrationDto extends Z.class({
|
||||
type: z.string().min(1),
|
||||
// Empty string targets a draft integration entry (`credentialId: ''`).
|
||||
credentialId: z.string(),
|
||||
deleteExternalResource: z.boolean().optional(),
|
||||
}) {}
|
||||
|
||||
export class PublishAgentDto extends Z.class({
|
||||
|
||||
@@ -5,3 +5,15 @@ import { Z } from '../../zod-class';
|
||||
export class CreateSlackAgentAppDto extends Z.class({
|
||||
appConfigurationToken: z.string().min(1),
|
||||
}) {}
|
||||
|
||||
export class InstallSlackManagedAppDto extends Z.class({
|
||||
managerCredentialId: z.string().min(1),
|
||||
workspaceId: z.string().min(1),
|
||||
}) {}
|
||||
|
||||
export class UpdateSlackManagedAppSettingsDto extends Z.class({
|
||||
credentialId: z.string().min(1),
|
||||
name: z.string().trim().min(1).max(80),
|
||||
description: z.string().trim().min(1).max(140),
|
||||
alwaysOnline: z.boolean(),
|
||||
}) {}
|
||||
|
||||
@@ -3,9 +3,67 @@ export interface CreateSlackAgentAppResponse {
|
||||
installUrl: string;
|
||||
}
|
||||
|
||||
export interface SlackManagerCredentialSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
connected: boolean;
|
||||
reconnectRequired: boolean;
|
||||
workspaces: SlackManagedWorkspaceSummary[];
|
||||
}
|
||||
|
||||
export interface SlackManagedWorkspaceSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
enterpriseId?: string;
|
||||
managedAppId?: string;
|
||||
botCredentialId?: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export interface SlackManagedSetupState {
|
||||
managedSetupAvailable: boolean;
|
||||
managerCredentials: SlackManagerCredentialSummary[];
|
||||
}
|
||||
|
||||
export interface SlackManagedAppSettings {
|
||||
credentialId: string;
|
||||
appId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
alwaysOnline: boolean;
|
||||
appHomeUrl: string;
|
||||
}
|
||||
|
||||
export type SlackManagedAppSettingsErrorCode = 'service_limits_exceeded';
|
||||
|
||||
export type SlackApiErrorMeta = {
|
||||
integrationType: 'slack';
|
||||
code: string;
|
||||
};
|
||||
|
||||
export interface CreateSlackManagerCredentialResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'slackManagerOAuth2Api';
|
||||
isResolvable: false;
|
||||
}
|
||||
|
||||
export type InstallSlackManagedAppResponse =
|
||||
| {
|
||||
status: 'connected';
|
||||
appId: string;
|
||||
credentialId: string;
|
||||
}
|
||||
| {
|
||||
status: 'manual_install_required';
|
||||
appId: string;
|
||||
installUrl: string;
|
||||
};
|
||||
|
||||
export interface SlackAgentAppManifest {
|
||||
display_information: {
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
features: {
|
||||
app_home: {
|
||||
@@ -36,6 +94,10 @@ export interface SlackAgentAppManifest {
|
||||
org_deploy_enabled: boolean;
|
||||
socket_mode_enabled: boolean;
|
||||
token_rotation_enabled: boolean;
|
||||
managed_app_settings?: {
|
||||
is_install_from_slack_disabled: boolean;
|
||||
external_app_management_url: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,21 @@ export interface AgentIntegrationStatusResponse {
|
||||
integrations: AgentIntegrationStatusEntry[];
|
||||
}
|
||||
|
||||
export interface AgentDisconnectIntegrationResponse {
|
||||
status: 'disconnected';
|
||||
warning?: AgentIntegrationDisconnectWarning;
|
||||
}
|
||||
|
||||
export interface AgentIntegrationDisconnectWarning {
|
||||
integrationType: string;
|
||||
code: string;
|
||||
action?: {
|
||||
type: 'open_url';
|
||||
url: string;
|
||||
};
|
||||
details?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AgentSkillReference {
|
||||
path: string;
|
||||
content: string;
|
||||
|
||||
@@ -20,6 +20,7 @@ const publicApiEnabled = process.env.N8N_PUBLIC_API_DISABLED !== 'true';
|
||||
generateUserManagementEmailTemplates();
|
||||
generateTimezoneData();
|
||||
copyInstanceAiExamplesData();
|
||||
copyAgentIntegrationAssets();
|
||||
|
||||
if (publicApiEnabled) {
|
||||
createPublicApiDirectory();
|
||||
@@ -161,6 +162,39 @@ function copyInstanceAiExamplesData() {
|
||||
}
|
||||
}
|
||||
|
||||
function copyAgentIntegrationAssets() {
|
||||
const sourceDir = path.resolve(
|
||||
ROOT_DIR,
|
||||
'src',
|
||||
'modules',
|
||||
'agents',
|
||||
'integrations',
|
||||
'platforms',
|
||||
'slack',
|
||||
'assets',
|
||||
);
|
||||
const destinationDir = path.resolve(
|
||||
ROOT_DIR,
|
||||
'dist',
|
||||
'modules',
|
||||
'agents',
|
||||
'integrations',
|
||||
'platforms',
|
||||
'slack',
|
||||
'assets',
|
||||
);
|
||||
|
||||
if (!existsSync(sourceDir)) {
|
||||
throw new Error(`Agent integration assets directory not found: ${sourceDir}`);
|
||||
}
|
||||
shell.rm('-rf', destinationDir);
|
||||
shell.mkdir('-p', path.dirname(destinationDir));
|
||||
shell.cp('-R', sourceDir, destinationDir);
|
||||
if (!existsSync(destinationDir)) {
|
||||
throw new Error(`Failed to copy agent integration assets to: ${destinationDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
function generateTimezoneData() {
|
||||
const timezones = ['Etc/UTC', 'Etc/GMT', ...rawTimeZones.map((tz) => tz.name)];
|
||||
const data = timezones.sort().reduce((acc, name) => {
|
||||
|
||||
+107
-5
@@ -48,6 +48,7 @@ describe('AgentIntegrationManagementService', () => {
|
||||
credentialTypes: ['slackApi'],
|
||||
});
|
||||
registry.require.mockReturnValue(implementation);
|
||||
registry.get.mockReturnValue(implementation);
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
|
||||
{ id: integration.credentialId, type: 'slackApi' },
|
||||
] as never);
|
||||
@@ -55,7 +56,6 @@ describe('AgentIntegrationManagementService', () => {
|
||||
agent,
|
||||
changed: true,
|
||||
}));
|
||||
|
||||
return {
|
||||
service: new AgentIntegrationManagementService(
|
||||
persistenceService,
|
||||
@@ -383,6 +383,67 @@ describe('AgentIntegrationManagementService', () => {
|
||||
});
|
||||
|
||||
describe('removing a channel', () => {
|
||||
it('runs platform cleanup after durable removal and returns its warning', async () => {
|
||||
const { service, persistenceService, chatService, implementation, agentRepository } =
|
||||
makeService();
|
||||
const agent = makeAgent({ integrations: [integration] });
|
||||
const warning = {
|
||||
integrationType: 'slack',
|
||||
code: 'app_not_deleted',
|
||||
action: { type: 'open_url', url: 'https://example.test/settings' },
|
||||
} as const;
|
||||
const removal = mock<Required<Pick<AgentChatIntegration, 'onRemove'>>>();
|
||||
stubRow(agentRepository, [integration]);
|
||||
removal.onRemove.mockResolvedValue(warning);
|
||||
implementation.onRemove = removal.onRemove;
|
||||
persistenceService.applyIntegrationDelta.mockResolvedValue({
|
||||
agent,
|
||||
changed: true,
|
||||
removed: integration,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.disconnect({
|
||||
agent,
|
||||
user: user as never,
|
||||
type: integration.type,
|
||||
credentialId: integration.credentialId,
|
||||
deleteExternalResource: true,
|
||||
}),
|
||||
).resolves.toMatchObject({ warning });
|
||||
|
||||
expect(order(persistenceService.applyIntegrationDelta)).toBeLessThan(order(removal.onRemove));
|
||||
expect(order(removal.onRemove)).toBeLessThan(order(chatService.disconnectChannel));
|
||||
});
|
||||
|
||||
it('tears down the runtime when platform cleanup fails after durable removal', async () => {
|
||||
const { service, persistenceService, chatService, implementation } = makeService();
|
||||
const agent = makeAgent({ integrations: [integration] });
|
||||
const cleanupError = new Error('Slack cleanup failed');
|
||||
const removal = mock<Required<Pick<AgentChatIntegration, 'onRemove'>>>();
|
||||
removal.onRemove.mockRejectedValue(cleanupError);
|
||||
implementation.onRemove = removal.onRemove;
|
||||
persistenceService.applyIntegrationDelta.mockResolvedValue({
|
||||
agent,
|
||||
changed: true,
|
||||
removed: integration,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.disconnect({
|
||||
agent,
|
||||
user: user as never,
|
||||
type: integration.type,
|
||||
credentialId: integration.credentialId,
|
||||
deleteExternalResource: true,
|
||||
}),
|
||||
).rejects.toBe(cleanupError);
|
||||
|
||||
expect(chatService.disconnectChannel).toHaveBeenCalledWith(agent.id, integration);
|
||||
expect(order(persistenceService.applyIntegrationDelta)).toBeLessThan(order(removal.onRemove));
|
||||
expect(order(removal.onRemove)).toBeLessThan(order(chatService.disconnectChannel));
|
||||
});
|
||||
|
||||
it('removes persistence before tearing down the runtime channel', async () => {
|
||||
const { service, persistenceService, chatService } = makeService();
|
||||
const agent = makeAgent({ integrations: [integration] });
|
||||
@@ -411,10 +472,12 @@ describe('AgentIntegrationManagementService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the channel live when the durable removal fails', async () => {
|
||||
const { service, persistenceService, chatService } = makeService();
|
||||
it('leaves the channel and its managed resources intact when durable removal fails', async () => {
|
||||
const { service, persistenceService, chatService, implementation, agentRepository } =
|
||||
makeService();
|
||||
const removalError = new Error('write failed');
|
||||
persistenceService.applyIntegrationDelta.mockRejectedValue(removalError);
|
||||
stubRow(agentRepository, [integration]);
|
||||
|
||||
await expect(
|
||||
service.disconnect({
|
||||
@@ -422,9 +485,11 @@ describe('AgentIntegrationManagementService', () => {
|
||||
user: user as never,
|
||||
type: integration.type,
|
||||
credentialId: integration.credentialId,
|
||||
deleteExternalResource: true,
|
||||
}),
|
||||
).rejects.toBe(removalError);
|
||||
|
||||
expect(implementation.onRemove).not.toHaveBeenCalled();
|
||||
expect(chatService.disconnectChannel).not.toHaveBeenCalled();
|
||||
expect(chatService.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -515,7 +580,7 @@ describe('AgentIntegrationManagementService', () => {
|
||||
|
||||
describe('replacing a channel', () => {
|
||||
it('starts the new channel, swaps in one write, then releases the old one', async () => {
|
||||
const { service, persistenceService, chatService } = makeService();
|
||||
const { service, persistenceService, chatService, implementation } = makeService();
|
||||
const agent = makeAgent({ integrations: [replaced] });
|
||||
persistenceService.applyIntegrationDelta.mockResolvedValue({
|
||||
agent,
|
||||
@@ -538,6 +603,7 @@ describe('AgentIntegrationManagementService', () => {
|
||||
expect(order(chatService.connect)).toBeLessThan(
|
||||
order(persistenceService.applyIntegrationDelta),
|
||||
);
|
||||
expect(implementation.onRemove).not.toHaveBeenCalled();
|
||||
expect(chatService.disconnectChannel).toHaveBeenCalledWith(agent.id, replaced);
|
||||
expect(order(persistenceService.applyIntegrationDelta)).toBeLessThan(
|
||||
order(chatService.disconnectChannel),
|
||||
@@ -587,7 +653,7 @@ describe('AgentIntegrationManagementService', () => {
|
||||
});
|
||||
|
||||
it('keeps the old channel live when the swap fails to persist', async () => {
|
||||
const { service, persistenceService, chatService } = makeService();
|
||||
const { service, persistenceService, chatService, implementation } = makeService();
|
||||
persistenceService.applyIntegrationDelta.mockRejectedValue(new Error('write failed'));
|
||||
const agent = makeAgent({ integrations: [replaced] });
|
||||
|
||||
@@ -603,8 +669,44 @@ describe('AgentIntegrationManagementService', () => {
|
||||
// Only the connection we just brought up is released, locally, and the old
|
||||
// one stays — on this main and on every peer.
|
||||
expect(chatService.disconnect).toHaveBeenCalledWith(agent.id, integration);
|
||||
expect(implementation.onRemove).not.toHaveBeenCalled();
|
||||
expect(chatService.disconnectChannel).not.toHaveBeenCalled();
|
||||
expect(chatService.broadcastIntegrationChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unpublished', null, undefined, true],
|
||||
['published', 'version-1', undefined, false],
|
||||
['unpublished with an explicit opt-out', null, false, false],
|
||||
['published with an explicit opt-in', 'version-1', true, true],
|
||||
])(
|
||||
'uses the expected external deletion policy for %s agents',
|
||||
async (_scenario, activeVersionId, deleteExternalResource, expected) => {
|
||||
const { service, persistenceService, implementation, agentRepository } = makeService();
|
||||
const connectedAgent = makeAgent({ activeVersionId, integrations: [integration] });
|
||||
stubRow(agentRepository, [integration], activeVersionId);
|
||||
persistenceService.applyIntegrationDelta.mockResolvedValue({
|
||||
agent: connectedAgent,
|
||||
changed: true,
|
||||
removed: integration,
|
||||
});
|
||||
|
||||
await service.disconnect({
|
||||
agent: connectedAgent,
|
||||
user: user as never,
|
||||
type: integration.type,
|
||||
credentialId: integration.credentialId,
|
||||
deleteExternalResource,
|
||||
});
|
||||
|
||||
expect(implementation.onRemove).toHaveBeenCalledWith({
|
||||
agentId: connectedAgent.id,
|
||||
projectId: connectedAgent.projectId,
|
||||
credentialId: integration.credentialId,
|
||||
user,
|
||||
deleteExternalResource: expected,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+18
-2
@@ -6,6 +6,7 @@ import {
|
||||
expectProjectScopedAgentRoutes,
|
||||
getRoutesByHandlerName,
|
||||
} from './test-utils/controller-route-metadata';
|
||||
import type { SlackManagedSetupService } from '../integrations/platforms/slack/slack-managed-setup.service';
|
||||
import type { SlackManualSetupService } from '../integrations/platforms/slack/slack-manual-setup.service';
|
||||
|
||||
const UNAUTHENTICATED_HANDLERS = new Set(['handleSlackAppOAuthCallback']);
|
||||
@@ -18,13 +19,25 @@ describe('AgentSlackIntegrationsController', () => {
|
||||
it.each([
|
||||
['createSlackApp', 'agent:update'],
|
||||
['getSlackAppManifest', 'agent:read'],
|
||||
['getManagedSlackSetup', 'agent:read'],
|
||||
['createManagedSlackCredential', 'agent:update'],
|
||||
['finalizeManagedSlackCredential', 'agent:update'],
|
||||
['installManagedSlackApp', 'agent:update'],
|
||||
['getManagedSlackAppSettings', 'agent:read'],
|
||||
['updateManagedSlackAppSettings', 'agent:update'],
|
||||
])('%s uses %s', (handlerName, scope) => {
|
||||
expect(routes.get(handlerName)?.accessScope?.scope).toBe(scope);
|
||||
});
|
||||
|
||||
it('keeps the manual Slack route contracts', () => {
|
||||
it('keeps the Slack route contracts', () => {
|
||||
expect([...routes.values()].map((route) => route.path).sort()).toEqual([
|
||||
'/:agentId/integrations/slack/app',
|
||||
'/:agentId/integrations/slack/managed/credentials',
|
||||
'/:agentId/integrations/slack/managed/credentials/:credentialId/finalize',
|
||||
'/:agentId/integrations/slack/managed/install',
|
||||
'/:agentId/integrations/slack/managed/settings',
|
||||
'/:agentId/integrations/slack/managed/settings/:credentialId',
|
||||
'/:agentId/integrations/slack/managed/setup',
|
||||
'/:agentId/integrations/slack/manifest',
|
||||
'/:agentId/integrations/slack/oauth/callback',
|
||||
]);
|
||||
@@ -32,7 +45,10 @@ describe('AgentSlackIntegrationsController', () => {
|
||||
|
||||
it('binds the callback state to the route project and agent', async () => {
|
||||
const manualSetup = mock<SlackManualSetupService>();
|
||||
const controller = new AgentSlackIntegrationsController(manualSetup);
|
||||
const controller = new AgentSlackIntegrationsController(
|
||||
manualSetup,
|
||||
mock<SlackManagedSetupService>(),
|
||||
);
|
||||
const response = mock<{ render: (template: string, data?: unknown) => void }>();
|
||||
|
||||
await controller.handleSlackAppOAuthCallback(
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { AgentIntegrationSchema, type AgentIntegrationConfig } from '@n8n/api-types';
|
||||
import {
|
||||
AgentIntegrationSchema,
|
||||
type AgentIntegrationConfig,
|
||||
type AgentIntegrationDisconnectWarning,
|
||||
} from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
@@ -33,13 +37,12 @@ export class AgentIntegrationManagementService {
|
||||
private readonly agentRepository: AgentRepository,
|
||||
) {}
|
||||
|
||||
async validateConfig(input: unknown): Promise<AgentIntegrationConfig> {
|
||||
const parsed = await AgentIntegrationSchema.safeParseAsync(input);
|
||||
async validateConfig(integration: unknown): Promise<AgentIntegrationConfig> {
|
||||
const parsed = await AgentIntegrationSchema.safeParseAsync(integration);
|
||||
if (!parsed.success) throw new BadRequestError(parsed.error.message);
|
||||
|
||||
const integration = parsed.data;
|
||||
this.registry.require(integration.type).validateConfig?.(integration);
|
||||
return integration;
|
||||
const result = parsed.data;
|
||||
this.registry.require(result.type).validateConfig?.(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,16 +78,22 @@ export class AgentIntegrationManagementService {
|
||||
user: User;
|
||||
type: string;
|
||||
credentialId: string;
|
||||
deleteExternalResource?: boolean;
|
||||
modifiedBy?: AgentActor;
|
||||
}): Promise<{ savedAgent: Agent }> {
|
||||
}): Promise<{ savedAgent: Agent; warning?: AgentIntegrationDisconnectWarning }> {
|
||||
const result = await this.applyChange({
|
||||
agent: options.agent,
|
||||
user: options.user,
|
||||
remove: { type: options.type, credentialId: options.credentialId },
|
||||
cleanupRemovedIntegration: true,
|
||||
deleteExternalResource: options.deleteExternalResource,
|
||||
modifiedBy: options.modifiedBy ?? 'user',
|
||||
});
|
||||
|
||||
return { savedAgent: result.agent };
|
||||
return {
|
||||
savedAgent: result.agent,
|
||||
...(result.warning ? { warning: result.warning } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,8 +111,10 @@ export class AgentIntegrationManagementService {
|
||||
user: User;
|
||||
add?: AgentIntegrationConfig;
|
||||
remove?: IntegrationRef;
|
||||
cleanupRemovedIntegration?: boolean;
|
||||
deleteExternalResource?: boolean;
|
||||
modifiedBy: AgentActor;
|
||||
}): Promise<IntegrationDeltaResult> {
|
||||
}): Promise<IntegrationDeltaResult & { warning?: AgentIntegrationDisconnectWarning }> {
|
||||
return await this.serializePerAgent(
|
||||
options.agent.id,
|
||||
async () => await this.runChange(options),
|
||||
@@ -136,8 +147,10 @@ export class AgentIntegrationManagementService {
|
||||
user: User;
|
||||
add?: AgentIntegrationConfig;
|
||||
remove?: IntegrationRef;
|
||||
cleanupRemovedIntegration?: boolean;
|
||||
deleteExternalResource?: boolean;
|
||||
modifiedBy: AgentActor;
|
||||
}): Promise<IntegrationDeltaResult> {
|
||||
}): Promise<IntegrationDeltaResult & { warning?: AgentIntegrationDisconnectWarning }> {
|
||||
const { agent, add } = options;
|
||||
// "Replace this channel with itself" is just a connect. Left as a removal,
|
||||
// step 3 would release the connection step 1 just brought up, because both
|
||||
@@ -151,7 +164,10 @@ export class AgentIntegrationManagementService {
|
||||
// for both decisions that depend on it: whether to connect at all, and what
|
||||
// a rollback would restore to. The write does its own read and reconciles
|
||||
// anything that lands after this one.
|
||||
const state = add ? await this.agentRepository.findIntegrationState(agent.id) : null;
|
||||
const state =
|
||||
add || options.cleanupRemovedIntegration
|
||||
? await this.agentRepository.findIntegrationState(agent.id)
|
||||
: null;
|
||||
const publishedBefore = state ? state.activeVersionId !== null : agent.activeVersionId !== null;
|
||||
|
||||
// `connect` restarts a connection that is already live — a settings-only
|
||||
@@ -206,12 +222,30 @@ export class AgentIntegrationManagementService {
|
||||
);
|
||||
}
|
||||
|
||||
if (remove) await this.releaseRemoved(agent, remove, result);
|
||||
const isPublished = result.published ?? publishedBefore;
|
||||
let warning: AgentIntegrationDisconnectWarning | undefined;
|
||||
try {
|
||||
warning =
|
||||
result.removed && options.cleanupRemovedIntegration
|
||||
? await this.registry.get(result.removed.type)?.onRemove?.({
|
||||
agentId: agent.id,
|
||||
projectId: agent.projectId,
|
||||
credentialId: result.removed.credentialId,
|
||||
user: options.user,
|
||||
deleteExternalResource:
|
||||
// if not published, by default delete the external resource
|
||||
options.deleteExternalResource ?? !isPublished,
|
||||
})
|
||||
: undefined;
|
||||
} finally {
|
||||
if (remove) await this.releaseRemoved(agent, remove, result);
|
||||
}
|
||||
|
||||
if (connected && add) {
|
||||
await this.chatService.broadcastIntegrationChange(agent.id, add, 'connect');
|
||||
}
|
||||
|
||||
return result;
|
||||
return { ...result, ...(warning ? { warning } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -365,6 +399,7 @@ export class AgentIntegrationManagementService {
|
||||
integration: AgentIntegrationConfig,
|
||||
): Promise<void> {
|
||||
const implementation = this.registry.require(integration.type);
|
||||
|
||||
const usableCredentials = await this.credentialsService.getCredentialsAUserCanUseInAWorkflow(
|
||||
user,
|
||||
{ projectId: agent.projectId },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AgentConnectIntegrationDto,
|
||||
AgentDisconnectIntegrationDto,
|
||||
type AgentDisconnectIntegrationResponse,
|
||||
isDraftIntegration,
|
||||
type AgentIntegrationStatusResponse,
|
||||
} from '@n8n/api-types';
|
||||
@@ -8,14 +9,14 @@ import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Body, Get, Param, Post, ProjectScope, RestController } from '@n8n/decorators';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
import { AgentIntegrationManagementService } from './agent-integration-management.service';
|
||||
import { ChatIntegrationRegistry } from './integrations/agent-chat-integration';
|
||||
import { ChatIntegrationService } from './integrations/chat-integration.service';
|
||||
import { channelIntegrationRecorder } from './integrations/recording/channel-integration-recorder';
|
||||
import { AgentRepository } from './repositories/agent.repository';
|
||||
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
@RestController('/projects/:projectId/agents/v2')
|
||||
export class AgentIntegrationsController {
|
||||
constructor(
|
||||
@@ -56,18 +57,19 @@ export class AgentIntegrationsController {
|
||||
_res: Response,
|
||||
@Param('agentId') agentId: string,
|
||||
@Body payload: AgentDisconnectIntegrationDto,
|
||||
) {
|
||||
const { type, credentialId } = payload;
|
||||
): Promise<AgentDisconnectIntegrationResponse> {
|
||||
const { type, credentialId, deleteExternalResource } = payload;
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId);
|
||||
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
|
||||
await this.integrationManagementService.disconnect({
|
||||
const { warning } = await this.integrationManagementService.disconnect({
|
||||
agent,
|
||||
user: req.user,
|
||||
type,
|
||||
credentialId,
|
||||
deleteExternalResource,
|
||||
});
|
||||
|
||||
return { status: 'disconnected' };
|
||||
return { status: 'disconnected', ...(warning ? { warning } : {}) };
|
||||
}
|
||||
|
||||
@Get('/:agentId/integrations/status')
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import {
|
||||
CreateSlackAgentAppDto,
|
||||
type CreateSlackAgentAppResponse,
|
||||
type CreateSlackManagerCredentialResponse,
|
||||
InstallSlackManagedAppDto,
|
||||
type InstallSlackManagedAppResponse,
|
||||
type SlackAgentAppManifestResponse,
|
||||
type SlackManagedAppSettings,
|
||||
type SlackManagedSetupState,
|
||||
UpdateSlackManagedAppSettingsDto,
|
||||
} from '@n8n/api-types';
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Body, Get, Param, Post, ProjectScope, RestController } from '@n8n/decorators';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { SlackManagedSetupService } from './integrations/platforms/slack/slack-managed-setup.service';
|
||||
import { SlackManualSetupService } from './integrations/platforms/slack/slack-manual-setup.service';
|
||||
|
||||
@RestController('/projects/:projectId/agents/v2')
|
||||
export class AgentSlackIntegrationsController {
|
||||
constructor(private readonly manualSetup: SlackManualSetupService) {}
|
||||
constructor(
|
||||
private readonly manualSetup: SlackManualSetupService,
|
||||
private readonly managedSetup: SlackManagedSetupService,
|
||||
) {}
|
||||
|
||||
@Post('/:agentId/integrations/slack/app')
|
||||
@ProjectScope('agent:update')
|
||||
@@ -36,9 +46,102 @@ export class AgentSlackIntegrationsController {
|
||||
_res: Response,
|
||||
@Param('agentId') agentId: string,
|
||||
): Promise<SlackAgentAppManifestResponse> {
|
||||
return await this.manualSetup.getManifest({
|
||||
return await this.manualSetup.getManifest({ projectId: req.params.projectId, agentId });
|
||||
}
|
||||
|
||||
@Get('/:agentId/integrations/slack/managed/setup')
|
||||
@ProjectScope('agent:read')
|
||||
async getManagedSlackSetup(
|
||||
req: AuthenticatedRequest<{ projectId: string }>,
|
||||
_res: Response,
|
||||
@Param('agentId') agentId: string,
|
||||
): Promise<SlackManagedSetupState> {
|
||||
return await this.managedSetup.getSetupState({
|
||||
projectId: req.params.projectId,
|
||||
agentId,
|
||||
user: req.user,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('/:agentId/integrations/slack/managed/credentials')
|
||||
@ProjectScope('agent:update')
|
||||
async createManagedSlackCredential(
|
||||
req: AuthenticatedRequest<{ projectId: string }>,
|
||||
_res: Response,
|
||||
@Param('agentId') agentId: string,
|
||||
): Promise<CreateSlackManagerCredentialResponse> {
|
||||
return await this.managedSetup.createManagerCredential({
|
||||
projectId: req.params.projectId,
|
||||
agentId,
|
||||
user: req.user,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('/:agentId/integrations/slack/managed/credentials/:credentialId/finalize')
|
||||
@ProjectScope('agent:update')
|
||||
async finalizeManagedSlackCredential(
|
||||
req: AuthenticatedRequest<{ projectId: string }>,
|
||||
_res: Response,
|
||||
@Param('agentId') agentId: string,
|
||||
@Param('credentialId') credentialId: string,
|
||||
): Promise<void> {
|
||||
await this.managedSetup.finalizeManagerCredential({
|
||||
projectId: req.params.projectId,
|
||||
agentId,
|
||||
credentialId,
|
||||
user: req.user,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('/:agentId/integrations/slack/managed/install')
|
||||
@ProjectScope('agent:update')
|
||||
async installManagedSlackApp(
|
||||
req: AuthenticatedRequest<{ projectId: string }>,
|
||||
_res: Response,
|
||||
@Param('agentId') agentId: string,
|
||||
@Body payload: InstallSlackManagedAppDto,
|
||||
): Promise<InstallSlackManagedAppResponse> {
|
||||
return await this.managedSetup.installApp({
|
||||
projectId: req.params.projectId,
|
||||
agentId,
|
||||
user: req.user,
|
||||
managerCredentialId: payload.managerCredentialId,
|
||||
workspaceId: payload.workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('/:agentId/integrations/slack/managed/settings/:credentialId')
|
||||
@ProjectScope('agent:read')
|
||||
async getManagedSlackAppSettings(
|
||||
req: AuthenticatedRequest<{ projectId: string }>,
|
||||
_res: Response,
|
||||
@Param('agentId') agentId: string,
|
||||
@Param('credentialId') credentialId: string,
|
||||
): Promise<SlackManagedAppSettings> {
|
||||
return await this.managedSetup.getAppSettings({
|
||||
projectId: req.params.projectId,
|
||||
agentId,
|
||||
credentialId,
|
||||
user: req.user,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('/:agentId/integrations/slack/managed/settings')
|
||||
@ProjectScope('agent:update')
|
||||
async updateManagedSlackAppSettings(
|
||||
req: AuthenticatedRequest<{ projectId: string }>,
|
||||
_res: Response,
|
||||
@Param('agentId') agentId: string,
|
||||
@Body payload: UpdateSlackManagedAppSettingsDto,
|
||||
): Promise<SlackManagedAppSettings> {
|
||||
return await this.managedSetup.updateAppSettings({
|
||||
projectId: req.params.projectId,
|
||||
agentId,
|
||||
user: req.user,
|
||||
credentialId: payload.credentialId,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
alwaysOnline: payload.alwaysOnline,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,10 +161,7 @@ export class AgentSlackIntegrationsController {
|
||||
const { code, state, error, error_description: errorDescription } = req.query;
|
||||
if (error) {
|
||||
return res.render('oauth-error-callback', {
|
||||
error: {
|
||||
message: error,
|
||||
...(errorDescription ? { reason: errorDescription } : {}),
|
||||
},
|
||||
error: { message: error, ...(errorDescription ? { reason: errorDescription } : {}) },
|
||||
});
|
||||
}
|
||||
if (!code || !state) {
|
||||
@@ -79,10 +179,11 @@ export class AgentSlackIntegrationsController {
|
||||
});
|
||||
return res.render('oauth-callback');
|
||||
} catch (callbackError) {
|
||||
const message =
|
||||
callbackError instanceof Error ? callbackError.message : 'Slack app setup failed';
|
||||
return res.render('oauth-error-callback', {
|
||||
error: { message },
|
||||
error: {
|
||||
message:
|
||||
callbackError instanceof Error ? callbackError.message : 'Slack app setup failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Container } from '@n8n/di';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { type Logger } from 'n8n-workflow';
|
||||
|
||||
import type { AgentRepository } from '../../repositories/agent.repository';
|
||||
import { AgentChatBridge } from '../agent-chat-bridge';
|
||||
import {
|
||||
AgentChatIntegration,
|
||||
@@ -237,7 +238,7 @@ describe('AgentChatBridge — consumeStream', () => {
|
||||
registry.register(new BufferingTestIntegration());
|
||||
registry.register(new StreamingTestIntegration());
|
||||
registry.register(new FormattedBufferedTestIntegration());
|
||||
registry.register(new SlackIntegration());
|
||||
registry.register(new SlackIntegration(mock<AgentRepository>()));
|
||||
Container.set(ChatIntegrationRegistry, registry);
|
||||
});
|
||||
|
||||
@@ -1881,7 +1882,7 @@ describe('AgentChatBridge — Slack thread history', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
const registry = new ChatIntegrationRegistry();
|
||||
registry.register(new SlackIntegration());
|
||||
registry.register(new SlackIntegration(mock<AgentRepository>()));
|
||||
Container.set(ChatIntegrationRegistry, registry);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { Mock } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { AgentRepository } from '../../repositories/agent.repository';
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument, @typescript-eslint/naming-convention -- mocks the Slack-style SDK (PascalCase components) and intentionally uses any-based factory wrappers */
|
||||
// Define mocks inline inside the factory to avoid vi.mock hoisting issues
|
||||
type MockFn = Mock<(...args: any[]) => any>;
|
||||
@@ -465,7 +468,7 @@ describe('ComponentMapper', () => {
|
||||
|
||||
it('should preserve radio_select components for Slack cards', async () => {
|
||||
const registry = new ChatIntegrationRegistry();
|
||||
registry.register(new SlackIntegration());
|
||||
registry.register(new SlackIntegration(mock<AgentRepository>()));
|
||||
Container.set(ChatIntegrationRegistry, registry);
|
||||
|
||||
const payload = {
|
||||
|
||||
+3
-1
@@ -2,7 +2,9 @@ import type { StreamChunk } from '@n8n/agents';
|
||||
import type { AgentIntegrationConfig } from '@n8n/api-types';
|
||||
import nock from 'nock';
|
||||
import type { Mock } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { AgentRepository } from '../../../../repositories/agent.repository';
|
||||
import type { ChatInstance } from '../../../chat-integration.service';
|
||||
import { ComponentMapper } from '../../../component-mapper';
|
||||
import type {
|
||||
@@ -216,7 +218,7 @@ export async function createSlackReplayContext(
|
||||
const integration: AgentIntegrationConfig = { type: 'slack', credentialId: 'cred-slack' };
|
||||
const setup = createReplayContextSetup({
|
||||
chat: chat as never,
|
||||
integrationImpl: new SlackIntegration(),
|
||||
integrationImpl: new SlackIntegration(mock<AgentRepository>()),
|
||||
integration,
|
||||
componentMapper: new ComponentMapper(),
|
||||
stream: options.stream,
|
||||
|
||||
+2
-1
@@ -48,6 +48,7 @@ import type { OutboundHttp } from '@n8n/backend-network';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { AgentRepository } from '../../repositories/agent.repository';
|
||||
import {
|
||||
AgentChatIntegration,
|
||||
ChatIntegrationRegistry,
|
||||
@@ -106,7 +107,7 @@ class ShortCallbackTelegramIntegration extends AgentChatIntegration {
|
||||
|
||||
function buildRegistry(): ChatIntegrationRegistry {
|
||||
const registry = new ChatIntegrationRegistry();
|
||||
registry.register(new SlackIntegration());
|
||||
registry.register(new SlackIntegration(mock<AgentRepository>()));
|
||||
registry.register(new LinearIntegration(mock<Logger>(), mock<OutboundHttp>()));
|
||||
return registry;
|
||||
}
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ import type { Logger } from '@n8n/backend-common';
|
||||
import type { OutboundHttp } from '@n8n/backend-network';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { AgentRepository } from '../../repositories/agent.repository';
|
||||
import { ChatIntegrationRegistry } from '../agent-chat-integration';
|
||||
import type { ChatIntegrationService, ChatInstance } from '../chat-integration.service';
|
||||
import { ChatIntegrationContextQueryExecutor } from '../integration-context-query-executor';
|
||||
@@ -22,7 +23,7 @@ const linear: AgentIntegrationConfig = {
|
||||
|
||||
function buildRegistry(): ChatIntegrationRegistry {
|
||||
const registry = new ChatIntegrationRegistry();
|
||||
registry.register(new SlackIntegration());
|
||||
registry.register(new SlackIntegration(mock<AgentRepository>()));
|
||||
registry.register(new LinearIntegration(mock<Logger>(), mock<OutboundHttp>()));
|
||||
return registry;
|
||||
}
|
||||
|
||||
+1538
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ describe('SlackIntegration', () => {
|
||||
let integration: SlackIntegration;
|
||||
|
||||
beforeEach(() => {
|
||||
integration = new SlackIntegration();
|
||||
integration = new SlackIntegration(mock<AgentRepository>());
|
||||
});
|
||||
|
||||
it('advertises Slack messaging and reaction actions', () => {
|
||||
|
||||
+22
-33
@@ -5,6 +5,7 @@ import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { CacheService } from '@/services/cache/cache.service';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import type { ProjectService } from '@/services/project.service.ee';
|
||||
|
||||
import { SlackManualSetupService } from '../platforms/slack/slack-manual-setup.service';
|
||||
import type { SlackMethodsService } from '../platforms/slack/slack-methods.service';
|
||||
@@ -15,17 +16,26 @@ describe('SlackManualSetupService', () => {
|
||||
const userRepository = mock<UserRepository>();
|
||||
const cacheService = mock<CacheService>();
|
||||
const cipher = mock<Cipher>();
|
||||
const projectService = mock<ProjectService>();
|
||||
projectService.getProjectWithScope.mockResolvedValue({ id: 'project-1' } as never);
|
||||
return {
|
||||
service: new SlackManualSetupService(methods, userRepository, cacheService, cipher),
|
||||
service: new SlackManualSetupService(
|
||||
methods,
|
||||
userRepository,
|
||||
cacheService,
|
||||
cipher,
|
||||
projectService,
|
||||
),
|
||||
methods,
|
||||
userRepository,
|
||||
cacheService,
|
||||
cipher,
|
||||
projectService,
|
||||
};
|
||||
}
|
||||
|
||||
it('creates a manual app and stores encrypted callback state', async () => {
|
||||
const { service, methods, cacheService, cipher } = makeService();
|
||||
const { service, methods } = makeService();
|
||||
const user = { id: 'user-1' };
|
||||
const redirectUrl = 'https://n8n.example/callback';
|
||||
methods.getAgent.mockResolvedValue({ id: 'agent-1', name: 'Support Agent' } as never);
|
||||
@@ -43,19 +53,12 @@ describe('SlackManualSetupService', () => {
|
||||
signing_secret: 'signing-1',
|
||||
},
|
||||
});
|
||||
methods.childRecord.mockImplementation((record, key) => record[key] as never);
|
||||
methods.stringProperty.mockImplementation((record, key) => {
|
||||
const value = record?.[key];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
});
|
||||
methods.installUrl.mockImplementation((oauthUrl, state, callbackUrl) => {
|
||||
const installUrl = new URL(oauthUrl);
|
||||
installUrl.searchParams.set('state', state);
|
||||
installUrl.searchParams.set('redirect_uri', callbackUrl);
|
||||
return installUrl.toString();
|
||||
});
|
||||
cipher.encryptV2.mockResolvedValue('encrypted-session-without-secrets');
|
||||
|
||||
const result = await service.createApp({
|
||||
projectId: 'project-1',
|
||||
agentId: 'agent-1',
|
||||
@@ -70,22 +73,7 @@ describe('SlackManualSetupService', () => {
|
||||
expect(methods.buildManifest).toHaveBeenCalledWith('Support Agent', 'project-1', 'agent-1', {
|
||||
redirectUrl,
|
||||
});
|
||||
expect(cacheService.set).toHaveBeenCalledWith(
|
||||
`agents:slack-app-setup:${state}`,
|
||||
'encrypted-session-without-secrets',
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
const session = JSON.parse(cipher.encryptV2.mock.calls[0]?.[0] as string) as {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
userId: string;
|
||||
appId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
signingSecret: string;
|
||||
redirectUrl: string;
|
||||
};
|
||||
expect(session).toEqual({
|
||||
expect(methods.storeSession).toHaveBeenCalledWith(state, {
|
||||
projectId: 'project-1',
|
||||
agentId: 'agent-1',
|
||||
userId: 'user-1',
|
||||
@@ -120,8 +108,11 @@ describe('SlackManualSetupService', () => {
|
||||
cipher.decryptV2.mockResolvedValue(JSON.stringify(session));
|
||||
userRepository.findOne.mockResolvedValue(user as never);
|
||||
methods.getAgent.mockResolvedValue(agent as never);
|
||||
methods.callSlackApi.mockResolvedValue({ ok: true, access_token: 'xoxb-token' });
|
||||
methods.stringProperty.mockReturnValue('xoxb-token');
|
||||
methods.callSlackApi.mockResolvedValue({
|
||||
ok: true,
|
||||
access_token: 'xoxb-token',
|
||||
team: { name: 'Example workspace' },
|
||||
});
|
||||
|
||||
await service.completeInstall({
|
||||
projectId: 'project-1',
|
||||
@@ -132,11 +123,9 @@ describe('SlackManualSetupService', () => {
|
||||
|
||||
expect(cacheService.take).toHaveBeenCalledWith('agents:slack-app-setup:state-1');
|
||||
expect(cacheService.delete).not.toHaveBeenCalled();
|
||||
expect(methods.createAndConnectBotCredential).toHaveBeenCalledWith({
|
||||
agent,
|
||||
user,
|
||||
accessToken: 'xoxb-token',
|
||||
signingSecret: 'signing-1',
|
||||
expect(methods.connectBotCredential).toHaveBeenCalledWith(agent, user, 'xoxb-token', {
|
||||
...session,
|
||||
teamName: 'Example workspace',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,6 +155,6 @@ describe('SlackManualSetupService', () => {
|
||||
).rejects.toThrow(BadRequestError);
|
||||
|
||||
expect(methods.callSlackApi).not.toHaveBeenCalled();
|
||||
expect(methods.createAndConnectBotCredential).not.toHaveBeenCalled();
|
||||
expect(methods.connectBotCredential).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+13
-5
@@ -1,8 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */
|
||||
import type { OutboundHttp } from '@n8n/backend-network';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import type { Cipher } from 'n8n-core';
|
||||
|
||||
import type { CredentialsService } from '@/credentials/credentials.service';
|
||||
import type { CacheService } from '@/services/cache/cache.service';
|
||||
import type { UrlService } from '@/services/url.service';
|
||||
|
||||
import type { AgentIntegrationManagementService } from '../../agent-integration-management.service';
|
||||
@@ -23,13 +25,15 @@ describe('SlackMethodsService', () => {
|
||||
managementService,
|
||||
urlService,
|
||||
mock<OutboundHttp>(),
|
||||
mock<CacheService>(),
|
||||
mock<Cipher>(),
|
||||
),
|
||||
credentialsService,
|
||||
managementService,
|
||||
};
|
||||
}
|
||||
|
||||
it('creates a bot credential and delegates published activation to integration management', async () => {
|
||||
it('creates a bot credential and delegates activation to integration management', async () => {
|
||||
const { service, credentialsService, managementService } = makeService();
|
||||
const agent = {
|
||||
id: 'agent-1',
|
||||
@@ -45,11 +49,15 @@ describe('SlackMethodsService', () => {
|
||||
integration: { type: 'slack', credentialId: 'credential-1' },
|
||||
savedAgent: agent,
|
||||
});
|
||||
await service.createAndConnectBotCredential({
|
||||
agent,
|
||||
user: user as never,
|
||||
accessToken: 'xoxb-token',
|
||||
await service.connectBotCredential(agent, user as never, 'xoxb-token', {
|
||||
projectId: 'project-1',
|
||||
agentId: 'agent-1',
|
||||
userId: 'user-1',
|
||||
appId: 'app-1',
|
||||
clientId: 'client-1',
|
||||
clientSecret: 'client-secret',
|
||||
signingSecret: 'signing-secret',
|
||||
redirectUrl: 'https://n8n.example/callback',
|
||||
});
|
||||
|
||||
expect(credentialsService.createUnmanagedCredential).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { AgentIntegrationConfig, type RichCardComponentType } from '@n8n/api-types';
|
||||
import {
|
||||
AgentIntegrationConfig,
|
||||
type AgentIntegrationDisconnectWarning,
|
||||
type RichCardComponentType,
|
||||
} from '@n8n/api-types';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { Thread, Author, Message } from 'chat';
|
||||
import type { Logger } from 'n8n-workflow';
|
||||
@@ -32,6 +37,14 @@ export interface AgentChatIntegrationContext {
|
||||
webhookUrlFor: (platform: string) => string;
|
||||
}
|
||||
|
||||
export interface AgentIntegrationRemovalContext {
|
||||
agentId: string;
|
||||
projectId: string;
|
||||
credentialId: string;
|
||||
user: User;
|
||||
deleteExternalResource?: boolean;
|
||||
}
|
||||
|
||||
/** Response shape returned by `handleUnauthenticatedWebhook`. */
|
||||
export interface UnauthenticatedWebhookResponse {
|
||||
status: number;
|
||||
@@ -259,7 +272,7 @@ export abstract class AgentChatIntegration {
|
||||
/** Build the Chat SDK adapter for this platform. */
|
||||
abstract createAdapter(ctx: AgentChatIntegrationContext): Promise<unknown>;
|
||||
|
||||
/** Validate platform-specific configuration before credentials or persistence are touched. */
|
||||
/** Validate platform settings before credentials or persistence are touched. */
|
||||
validateConfig?(integration: AgentIntegrationConfig): void;
|
||||
|
||||
/**
|
||||
@@ -316,8 +329,16 @@ export abstract class AgentChatIntegration {
|
||||
onBeforeDisconnect?(ctx: AgentChatIntegrationContext): Promise<void>;
|
||||
|
||||
/**
|
||||
* Prepare a thread created or selected by an outbound send. Platforms can
|
||||
* use this to receive follow-up messages in that thread.
|
||||
* Cleanup performed only when a user explicitly removes a persisted
|
||||
* integration. This is deliberately separate from runtime disconnect hooks.
|
||||
*/
|
||||
onRemove?(
|
||||
ctx: AgentIntegrationRemovalContext,
|
||||
): Promise<AgentIntegrationDisconnectWarning | undefined>;
|
||||
|
||||
/**
|
||||
* Prepare a thread created or selected by an outbound send. Slack uses this
|
||||
* to subscribe the bot so follow-up messages reach the agent.
|
||||
*/
|
||||
prepareSentThread?(thread: Thread<unknown, unknown>): Promise<void>;
|
||||
|
||||
|
||||
+4
-1
@@ -1,3 +1,6 @@
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { AgentRepository } from '../../../../repositories/agent.repository';
|
||||
import { createSlackReplayContext } from '../../../__tests__/helpers/slack/replay-test-context';
|
||||
import {
|
||||
slackEventCallback,
|
||||
@@ -8,7 +11,7 @@ import { SlackIntegration } from '../../slack/slack-integration';
|
||||
|
||||
describe('Slack channel integration scenarios', () => {
|
||||
it('handles Slack URL verification without an active connection', () => {
|
||||
const integration = new SlackIntegration();
|
||||
const integration = new SlackIntegration(mock<AgentRepository>());
|
||||
|
||||
expect(
|
||||
integration.handleUnauthenticatedWebhook({
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
@@ -1,5 +1,5 @@
|
||||
import { Service } from '@n8n/di';
|
||||
import type { RichCardComponentType } from '@n8n/api-types';
|
||||
import type { AgentIntegrationDisconnectWarning, RichCardComponentType } from '@n8n/api-types';
|
||||
import { Container, Service } from '@n8n/di';
|
||||
import type { Thread } from 'chat';
|
||||
|
||||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
@@ -8,6 +8,7 @@ import { AgentRepository } from '../../../repositories/agent.repository';
|
||||
import {
|
||||
AgentChatIntegration,
|
||||
type AgentChatIntegrationContext,
|
||||
type AgentIntegrationRemovalContext,
|
||||
type BridgeExecutionContext,
|
||||
type BridgeMessageContextParams,
|
||||
type BridgeResumeExecutionContext,
|
||||
@@ -17,11 +18,11 @@ import {
|
||||
} from '../../agent-chat-integration';
|
||||
import type { ChatInstance } from '../../chat-integration.service';
|
||||
import { loadSlackAdapter } from '../../esm-loader';
|
||||
import { connectionUnavailable } from '../../integration-helpers';
|
||||
import {
|
||||
resolveIntegrationActionDefinitions,
|
||||
resolveIntegrationContextQueryDefinitions,
|
||||
} from '../../integration-tool-definitions';
|
||||
import { connectionUnavailable } from '../../integration-helpers';
|
||||
import type { ReplyExpectation } from '../../integration-tools';
|
||||
import {
|
||||
createSlackBridgeExecutionContext,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
getSlackReplyExpectation,
|
||||
prepareSlackInboundText,
|
||||
} from './slack-bridge-behavior';
|
||||
import { SlackManagedSetupService } from './slack-managed-setup.service';
|
||||
import { executeSlackContextQuery, subscribeSlackThread } from './slack-operations';
|
||||
|
||||
/**
|
||||
@@ -39,7 +41,7 @@ import { executeSlackContextQuery, subscribeSlackThread } from './slack-operatio
|
||||
*/
|
||||
@Service()
|
||||
export class SlackIntegration extends AgentChatIntegration {
|
||||
constructor(private readonly agentRepository?: AgentRepository) {
|
||||
constructor(private readonly agentRepository: AgentRepository) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -99,7 +101,6 @@ export class SlackIntegration extends AgentChatIntegration {
|
||||
]);
|
||||
|
||||
async onBeforeConnect(ctx: AgentChatIntegrationContext): Promise<void> {
|
||||
if (!this.agentRepository) return;
|
||||
const others = await this.agentRepository.findByIntegrationCredential(
|
||||
this.type,
|
||||
ctx.credentialId,
|
||||
@@ -111,6 +112,12 @@ export class SlackIntegration extends AgentChatIntegration {
|
||||
}
|
||||
}
|
||||
|
||||
async onRemove(
|
||||
ctx: AgentIntegrationRemovalContext,
|
||||
): Promise<AgentIntegrationDisconnectWarning | undefined> {
|
||||
return await Container.get(SlackManagedSetupService).deleteAppForCredential(ctx);
|
||||
}
|
||||
|
||||
async prepareSentThread(thread: Thread<unknown, unknown>): Promise<void> {
|
||||
await subscribeSlackThread(thread);
|
||||
}
|
||||
|
||||
+911
@@ -0,0 +1,911 @@
|
||||
import type {
|
||||
AgentIntegrationConfig,
|
||||
CreateSlackManagerCredentialResponse,
|
||||
InstallSlackManagedAppResponse,
|
||||
SlackManagedAppSettings,
|
||||
SlackManagedSetupState,
|
||||
SlackManagedWorkspaceSummary,
|
||||
SlackManagerCredentialSummary,
|
||||
} from '@n8n/api-types';
|
||||
import type { CredentialsEntity, User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { CredentialsOverwrites } from '@/credentials-overwrites';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
|
||||
import { SlackMethodsService } from './slack-methods.service';
|
||||
import { childRecord, SLACK_BOT_SCOPES, type SlackAppSetupSession } from './slack-setup.types';
|
||||
import type { Agent } from '../../../entities/agent.entity';
|
||||
import { AgentRepository } from '../../../repositories/agent.repository';
|
||||
import { stringProperty } from '../../integration-helpers';
|
||||
|
||||
const SLACK_MANAGED_APP_CACHE_PREFIX = 'agents:slack-managed-app:';
|
||||
const SLACK_APP_SETUP_TTL_MS = 60 * 60 * 1000;
|
||||
const SLACK_CREDENTIAL_TYPE = 'slackApi';
|
||||
const SLACK_MANAGER_CREDENTIAL_TYPE = 'slackManagerOAuth2Api';
|
||||
const DEFAULT_SLACK_MANAGER_CREDENTIAL_NAME = 'Workspace credentials';
|
||||
const REQUIRED_MANAGER_SCOPES = [
|
||||
'app_configurations:read',
|
||||
'app_configurations:write',
|
||||
'managed_apps:install',
|
||||
] as const;
|
||||
const MANAGED_INSTALL_FALLBACK_ERRORS = new Set([
|
||||
'installation_denied',
|
||||
'app_approval_request_eligible',
|
||||
'manager_app_not_eligible',
|
||||
]);
|
||||
const MANAGED_INSTALL_APPROVAL_ERRORS = new Set([
|
||||
'app_approval_request_pending',
|
||||
'app_approval_request_denied',
|
||||
]);
|
||||
|
||||
export interface GetManagedSetupStateOptions {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface InstallManagedSlackAppOptions extends GetManagedSetupStateOptions {
|
||||
managerCredentialId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface FinalizeSlackManagerCredentialOptions extends GetManagedSetupStateOptions {
|
||||
credentialId: string;
|
||||
}
|
||||
|
||||
export interface GetManagedSlackAppSettingsOptions extends GetManagedSetupStateOptions {
|
||||
credentialId: string;
|
||||
}
|
||||
|
||||
export interface UpdateManagedSlackAppSettingsOptions extends GetManagedSlackAppSettingsOptions {
|
||||
name: string;
|
||||
description: string;
|
||||
alwaysOnline: boolean;
|
||||
}
|
||||
|
||||
export interface DeleteManagedSlackAppOptions {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
credentialId: string;
|
||||
user: User;
|
||||
deleteExternalResource?: boolean;
|
||||
}
|
||||
|
||||
export interface ManagedSlackAppDeletionWarning {
|
||||
integrationType: 'slack';
|
||||
code: 'app_not_deleted';
|
||||
action: {
|
||||
type: 'open_url';
|
||||
url: string;
|
||||
};
|
||||
details: {
|
||||
appId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ManagedSlackAppSession extends SlackAppSetupSession {
|
||||
managerCredentialId: string;
|
||||
teamId: string;
|
||||
oauthAuthorizeUrl: string;
|
||||
}
|
||||
|
||||
interface ManagerCredentialContext {
|
||||
credential: CredentialsEntity;
|
||||
rawData: ICredentialDataDecryptedObject;
|
||||
oauthTokenData: Record<string, unknown>;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
interface ManagedBotCredentialContext {
|
||||
credential: CredentialsEntity;
|
||||
rawData: ICredentialDataDecryptedObject;
|
||||
managedAppId: string;
|
||||
managerCredentialId: string;
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
type RefreshTokenResponse = {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
app_id: string;
|
||||
scope: string;
|
||||
user_id: string;
|
||||
team: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
enterprise: Record<string, unknown> | null;
|
||||
is_enterprise_install: boolean;
|
||||
};
|
||||
|
||||
type SlackApiParams = Record<string, string> | FormData;
|
||||
type SlackApiParamsFactory = (accessToken: string) => SlackApiParams;
|
||||
|
||||
function stringsFromScope(value: unknown): Set<string> {
|
||||
if (typeof value !== 'string') return new Set();
|
||||
return new Set(value.split(/[\s,]+/).filter(Boolean));
|
||||
}
|
||||
|
||||
function hasSessionShape(value: unknown): value is SlackAppSetupSession {
|
||||
const keys: Array<keyof SlackAppSetupSession> = [
|
||||
'projectId',
|
||||
'agentId',
|
||||
'userId',
|
||||
'appId',
|
||||
'clientId',
|
||||
'clientSecret',
|
||||
'signingSecret',
|
||||
'redirectUrl',
|
||||
];
|
||||
return isRecord(value) && keys.every((key) => typeof value[key] === 'string');
|
||||
}
|
||||
|
||||
function hasManagedSessionShape(value: unknown): value is ManagedSlackAppSession {
|
||||
return (
|
||||
hasSessionShape(value) &&
|
||||
isRecord(value) &&
|
||||
typeof value.managerCredentialId === 'string' &&
|
||||
typeof value.teamId === 'string' &&
|
||||
typeof value.oauthAuthorizeUrl === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class SlackManagedSetupService {
|
||||
constructor(
|
||||
private readonly methods: SlackMethodsService,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly cipher: Cipher,
|
||||
private readonly credentialsService: CredentialsService,
|
||||
private readonly credentialsFinderService: CredentialsFinderService,
|
||||
private readonly credentialsOverwrites: CredentialsOverwrites,
|
||||
private readonly agentRepository: AgentRepository,
|
||||
) {}
|
||||
|
||||
isSetupAvailable(): boolean {
|
||||
const overwrite = this.credentialsOverwrites.getOverwrites(SLACK_MANAGER_CREDENTIAL_TYPE);
|
||||
if (
|
||||
typeof overwrite?.clientId !== 'string' ||
|
||||
overwrite.clientId.trim().length === 0 ||
|
||||
typeof overwrite.clientSecret !== 'string' ||
|
||||
overwrite.clientSecret.trim().length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async getSetupState(options: GetManagedSetupStateOptions): Promise<SlackManagedSetupState> {
|
||||
if (!this.isSetupAvailable()) {
|
||||
return { managedSetupAvailable: false, managerCredentials: [] };
|
||||
}
|
||||
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(
|
||||
options.agentId,
|
||||
options.projectId,
|
||||
);
|
||||
const integrations = agent?.integrations ?? [];
|
||||
const usableCredentials = await this.credentialsService.getCredentialsAUserCanUseInAWorkflow(
|
||||
options.user,
|
||||
{ projectId: options.projectId },
|
||||
);
|
||||
const managerCredentials: SlackManagerCredentialSummary[] = [];
|
||||
|
||||
for (const usableCredential of usableCredentials) {
|
||||
if (usableCredential.type !== SLACK_MANAGER_CREDENTIAL_TYPE) continue;
|
||||
const credential = await this.credentialsFinderService.findCredentialForUser(
|
||||
usableCredential.id,
|
||||
options.user,
|
||||
['credential:read'],
|
||||
);
|
||||
if (!credential) continue;
|
||||
|
||||
const rawData = await this.credentialsService.decrypt(credential, true);
|
||||
if (!this.usesManagedSlackAuth(rawData)) continue;
|
||||
const oauthTokenData = childRecord(rawData, 'oauthTokenData');
|
||||
const authedUser = oauthTokenData ? childRecord(oauthTokenData, 'authed_user') : undefined;
|
||||
const accessToken = stringProperty(authedUser, 'access_token');
|
||||
const grantedScopes = stringsFromScope(
|
||||
stringProperty(authedUser, 'scope') ?? stringProperty(oauthTokenData, 'scope'),
|
||||
);
|
||||
const reconnectRequired =
|
||||
!!accessToken && REQUIRED_MANAGER_SCOPES.some((scope) => !grantedScopes.has(scope));
|
||||
const workspaces =
|
||||
accessToken && oauthTokenData
|
||||
? await this.getWorkspacesFromContext(
|
||||
{ credential, rawData, oauthTokenData, accessToken },
|
||||
integrations,
|
||||
options.user,
|
||||
false,
|
||||
)
|
||||
: [];
|
||||
managerCredentials.push({
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
connected: !!accessToken,
|
||||
reconnectRequired,
|
||||
workspaces,
|
||||
});
|
||||
}
|
||||
|
||||
return { managedSetupAvailable: true, managerCredentials };
|
||||
}
|
||||
|
||||
async createManagerCredential(
|
||||
options: GetManagedSetupStateOptions,
|
||||
): Promise<CreateSlackManagerCredentialResponse> {
|
||||
this.assertSetupAvailable();
|
||||
await this.methods.getAgent(options.agentId, options.projectId);
|
||||
const credential = await this.credentialsService.createUnmanagedCredential(
|
||||
{
|
||||
name: DEFAULT_SLACK_MANAGER_CREDENTIAL_NAME,
|
||||
type: SLACK_MANAGER_CREDENTIAL_TYPE,
|
||||
data: {},
|
||||
projectId: options.projectId,
|
||||
},
|
||||
options.user,
|
||||
);
|
||||
return {
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
type: SLACK_MANAGER_CREDENTIAL_TYPE,
|
||||
isResolvable: false,
|
||||
};
|
||||
}
|
||||
|
||||
async finalizeManagerCredential(options: FinalizeSlackManagerCredentialOptions): Promise<void> {
|
||||
await this.methods.getAgent(options.agentId, options.projectId);
|
||||
const manager = await this.getManagerCredentialContext(
|
||||
options.credentialId,
|
||||
options.projectId,
|
||||
options.user,
|
||||
);
|
||||
if (manager.credential.name !== DEFAULT_SLACK_MANAGER_CREDENTIAL_NAME) return;
|
||||
|
||||
const response = await this.callManagerSlackApi(manager, 'auth.test', {});
|
||||
if (!response.ok) return;
|
||||
const userName = stringProperty(response, 'user');
|
||||
if (!userName) return;
|
||||
const teamName = stringProperty(response, 'team');
|
||||
const name = [
|
||||
DEFAULT_SLACK_MANAGER_CREDENTIAL_NAME,
|
||||
teamName ? `${userName} @ ${teamName}` : userName,
|
||||
]
|
||||
.join(' - ')
|
||||
.slice(0, 128);
|
||||
const encrypted = await this.credentialsService.createEncryptedData({
|
||||
id: manager.credential.id,
|
||||
name,
|
||||
type: manager.credential.type,
|
||||
data: manager.rawData,
|
||||
});
|
||||
await this.credentialsService.update(options.credentialId, encrypted, manager.rawData);
|
||||
}
|
||||
|
||||
async installApp(
|
||||
options: InstallManagedSlackAppOptions,
|
||||
): Promise<InstallSlackManagedAppResponse> {
|
||||
this.assertSetupAvailable();
|
||||
const agent = await this.methods.getAgent(options.agentId, options.projectId);
|
||||
const existing = await this.findManagedBotCredential(
|
||||
agent.integrations ?? [],
|
||||
options.workspaceId,
|
||||
options.managerCredentialId,
|
||||
options.user,
|
||||
);
|
||||
if (existing) {
|
||||
return { status: 'connected', appId: existing.appId, credentialId: existing.credentialId };
|
||||
}
|
||||
|
||||
const manager = await this.getManagerCredentialContext(
|
||||
options.managerCredentialId,
|
||||
options.projectId,
|
||||
options.user,
|
||||
);
|
||||
const workspaces = await this.getWorkspacesFromContext(
|
||||
manager,
|
||||
agent.integrations ?? [],
|
||||
options.user,
|
||||
);
|
||||
const workspace = workspaces.find(({ id }) => id === options.workspaceId);
|
||||
if (!workspace) {
|
||||
throw new NotFoundError('Slack workspace is not available to this credential');
|
||||
}
|
||||
|
||||
const { session, created } = await this.getOrCreateManagedAppSession(
|
||||
options,
|
||||
agent,
|
||||
manager,
|
||||
workspace.name,
|
||||
);
|
||||
const response = await this.callManagerSlackApi(manager, 'apps.managedInstall', {
|
||||
app_id: session.appId,
|
||||
team_id: options.workspaceId,
|
||||
bot_scopes: SLACK_BOT_SCOPES.join(','),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const botAccessToken = stringProperty(
|
||||
childRecord(response, 'api_access_tokens'),
|
||||
'bot_access_token',
|
||||
);
|
||||
if (!botAccessToken?.startsWith('xoxb-')) {
|
||||
throw new BadRequestError('Slack did not return a Bot User OAuth Token');
|
||||
}
|
||||
const credentialId = await this.methods.connectBotCredential(
|
||||
agent,
|
||||
options.user,
|
||||
botAccessToken,
|
||||
session,
|
||||
);
|
||||
return { status: 'connected', appId: session.appId, credentialId };
|
||||
}
|
||||
|
||||
const error = stringProperty(response, 'error') ?? 'unknown_error';
|
||||
if (MANAGED_INSTALL_APPROVAL_ERRORS.has(error)) {
|
||||
throw this.methods.slackError('install the Slack app', response);
|
||||
}
|
||||
const responseOauthAuthorizeUrl = stringProperty(response, 'oauth_authorize_url');
|
||||
if (responseOauthAuthorizeUrl || MANAGED_INSTALL_FALLBACK_ERRORS.has(error)) {
|
||||
const oauthAuthorizeUrl = responseOauthAuthorizeUrl ?? session.oauthAuthorizeUrl;
|
||||
const teamId = stringProperty(response, 'team_id') ?? session.teamId;
|
||||
const updatedSession: ManagedSlackAppSession = { ...session, oauthAuthorizeUrl, teamId };
|
||||
const state = randomBytes(32).toString('hex');
|
||||
await this.methods.storeSession(state, updatedSession);
|
||||
return {
|
||||
status: 'manual_install_required',
|
||||
appId: session.appId,
|
||||
installUrl: this.methods.installUrl(oauthAuthorizeUrl, state, session.redirectUrl),
|
||||
};
|
||||
}
|
||||
|
||||
if (created) {
|
||||
const cleanupResponse = await this.callManagerSlackApi(manager, 'apps.manifest.delete', {
|
||||
app_id: session.appId,
|
||||
});
|
||||
if (cleanupResponse.ok) {
|
||||
await this.cacheService.delete(
|
||||
this.managedAppCacheKey({ ...options, userId: options.user.id }),
|
||||
);
|
||||
}
|
||||
}
|
||||
throw this.methods.slackError('install the Slack app', response);
|
||||
}
|
||||
|
||||
async getAppSettings(
|
||||
options: GetManagedSlackAppSettingsOptions,
|
||||
): Promise<SlackManagedAppSettings> {
|
||||
this.assertSetupAvailable();
|
||||
const bot = await this.getManagedBotCredentialContext(options);
|
||||
const manager = await this.getManagerCredentialContext(
|
||||
bot.managerCredentialId,
|
||||
options.projectId,
|
||||
options.user,
|
||||
);
|
||||
const manifest = await this.exportManagedAppManifest(manager, bot.managedAppId);
|
||||
return this.managedAppSettingsFromManifest(options.credentialId, bot.managedAppId, manifest);
|
||||
}
|
||||
|
||||
async updateAppSettings(
|
||||
options: UpdateManagedSlackAppSettingsOptions,
|
||||
): Promise<SlackManagedAppSettings> {
|
||||
this.assertSetupAvailable();
|
||||
const bot = await this.getManagedBotCredentialContext(options);
|
||||
const manager = await this.getManagerCredentialContext(
|
||||
bot.managerCredentialId,
|
||||
options.projectId,
|
||||
options.user,
|
||||
);
|
||||
const manifest = await this.exportManagedAppManifest(manager, bot.managedAppId);
|
||||
const displayInformation = childRecord(manifest, 'display_information') ?? {};
|
||||
const features = childRecord(manifest, 'features') ?? {};
|
||||
const appHome = childRecord(features, 'app_home') ?? {};
|
||||
const botUser = childRecord(features, 'bot_user') ?? {};
|
||||
const updatedManifest = {
|
||||
...manifest,
|
||||
display_information: { ...displayInformation, description: options.description },
|
||||
features: {
|
||||
...features,
|
||||
app_home: { ...appHome, home_tab_enabled: false },
|
||||
bot_user: {
|
||||
...botUser,
|
||||
display_name: options.name,
|
||||
always_online: options.alwaysOnline,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const updateResponse = await this.callManagerSlackApi(manager, 'apps.manifest.update', {
|
||||
app_id: bot.managedAppId,
|
||||
manifest: JSON.stringify(updatedManifest),
|
||||
});
|
||||
if (!updateResponse.ok) {
|
||||
throw this.methods.slackError('update the Slack app', updateResponse);
|
||||
}
|
||||
|
||||
return this.managedAppSettingsFromManifest(
|
||||
options.credentialId,
|
||||
bot.managedAppId,
|
||||
updatedManifest,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAppForCredential(
|
||||
options: DeleteManagedSlackAppOptions,
|
||||
): Promise<ManagedSlackAppDeletionWarning | undefined> {
|
||||
const credential = await this.credentialsFinderService.findCredentialForUser(
|
||||
options.credentialId,
|
||||
options.user,
|
||||
['credential:read'],
|
||||
);
|
||||
if (!credential || credential.type !== SLACK_CREDENTIAL_TYPE) return;
|
||||
|
||||
const data = await this.credentialsService.decrypt(credential, true);
|
||||
const managedAppId = stringProperty(data, 'managedAppId');
|
||||
const managerCredentialId = stringProperty(data, 'managerCredentialId');
|
||||
const shouldDeleteExternalResource = options.deleteExternalResource === true;
|
||||
if (managedAppId && !managerCredentialId && shouldDeleteExternalResource) {
|
||||
throw new BadRequestError('The managed Slack app is missing its manager credential');
|
||||
}
|
||||
|
||||
let warning: ManagedSlackAppDeletionWarning | undefined;
|
||||
if (managedAppId && managerCredentialId && shouldDeleteExternalResource) {
|
||||
try {
|
||||
const manager = await this.getManagerCredentialContext(
|
||||
managerCredentialId,
|
||||
options.projectId,
|
||||
options.user,
|
||||
);
|
||||
const response = await this.callManagerSlackApi(manager, 'apps.manifest.delete', {
|
||||
app_id: managedAppId,
|
||||
});
|
||||
if (!response.ok && stringProperty(response, 'error') !== 'app_not_found') {
|
||||
throw this.methods.slackError('delete the Slack app', response);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof NotFoundError)) throw error;
|
||||
warning = {
|
||||
integrationType: 'slack',
|
||||
code: 'app_not_deleted',
|
||||
action: {
|
||||
type: 'open_url',
|
||||
url: `https://api.slack.com/apps/${encodeURIComponent(managedAppId)}`,
|
||||
},
|
||||
details: { appId: managedAppId },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (managerCredentialId && typeof data.teamId === 'string') {
|
||||
await this.cacheService.delete(
|
||||
this.managedAppCacheKey({
|
||||
projectId: options.projectId,
|
||||
agentId: options.agentId,
|
||||
managerCredentialId,
|
||||
workspaceId: data.teamId,
|
||||
userId: options.user.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (credential.isManaged || managedAppId) {
|
||||
await this.credentialsService.delete(options.user, credential.id);
|
||||
}
|
||||
return warning;
|
||||
}
|
||||
|
||||
private assertSetupAvailable(): void {
|
||||
if (!this.isSetupAvailable()) {
|
||||
throw new NotFoundError('Managed Slack setup is not available');
|
||||
}
|
||||
}
|
||||
|
||||
private async getManagedBotCredentialContext(
|
||||
options: GetManagedSlackAppSettingsOptions,
|
||||
): Promise<ManagedBotCredentialContext> {
|
||||
const agent = await this.methods.getAgent(options.agentId, options.projectId);
|
||||
const integration = agent.integrations?.find(
|
||||
(item) => item.type === 'slack' && item.credentialId === options.credentialId,
|
||||
);
|
||||
if (!integration) throw new NotFoundError('Managed Slack connection not found');
|
||||
|
||||
const credential = await this.credentialsFinderService.findCredentialForUser(
|
||||
options.credentialId,
|
||||
options.user,
|
||||
['credential:read'],
|
||||
);
|
||||
if (!credential || credential.type !== SLACK_CREDENTIAL_TYPE) {
|
||||
throw new NotFoundError(`Credential "${options.credentialId}" not found`);
|
||||
}
|
||||
const rawData = await this.credentialsService.decrypt(credential, true);
|
||||
const managedAppId = stringProperty(rawData, 'managedAppId');
|
||||
const managerCredentialId = stringProperty(rawData, 'managerCredentialId');
|
||||
const teamId = stringProperty(rawData, 'teamId');
|
||||
if (!managedAppId || !managerCredentialId || !teamId) {
|
||||
throw new BadRequestError('The Slack connection is not managed by n8n');
|
||||
}
|
||||
return { credential, rawData, managedAppId, managerCredentialId, teamId };
|
||||
}
|
||||
|
||||
private async exportManagedAppManifest(
|
||||
manager: ManagerCredentialContext,
|
||||
managedAppId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const response = await this.callManagerSlackApi(manager, 'apps.manifest.export', {
|
||||
app_id: managedAppId,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw this.methods.slackError('load the Slack app settings', response);
|
||||
}
|
||||
const manifest = childRecord(response, 'manifest');
|
||||
if (!manifest) throw new BadRequestError('Slack returned an incomplete app manifest');
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private managedAppSettingsFromManifest(
|
||||
credentialId: string,
|
||||
appId: string,
|
||||
manifest: Record<string, unknown>,
|
||||
): SlackManagedAppSettings {
|
||||
const displayInformation = childRecord(manifest, 'display_information');
|
||||
const features = childRecord(manifest, 'features');
|
||||
const botUser = features ? childRecord(features, 'bot_user') : undefined;
|
||||
const name = stringProperty(botUser, 'display_name');
|
||||
const description = stringProperty(displayInformation, 'description');
|
||||
if (!name || !description || typeof botUser?.always_online !== 'boolean') {
|
||||
throw new BadRequestError('Slack returned incomplete managed app settings');
|
||||
}
|
||||
return {
|
||||
credentialId,
|
||||
appId,
|
||||
name,
|
||||
description,
|
||||
alwaysOnline: botUser.always_online,
|
||||
appHomeUrl: `https://api.slack.com/apps/${encodeURIComponent(appId)}/app-home`,
|
||||
};
|
||||
}
|
||||
|
||||
private async getManagerCredentialContext(
|
||||
credentialId: string,
|
||||
projectId: string,
|
||||
user: User,
|
||||
): Promise<ManagerCredentialContext> {
|
||||
const usableCredentials = await this.credentialsService.getCredentialsAUserCanUseInAWorkflow(
|
||||
user,
|
||||
{ projectId },
|
||||
);
|
||||
if (
|
||||
!usableCredentials.some(
|
||||
(credential) =>
|
||||
credential.id === credentialId && credential.type === SLACK_MANAGER_CREDENTIAL_TYPE,
|
||||
)
|
||||
) {
|
||||
throw new NotFoundError(`Credential "${credentialId}" not found`);
|
||||
}
|
||||
const credential = await this.credentialsFinderService.findCredentialForUser(
|
||||
credentialId,
|
||||
user,
|
||||
['credential:read'],
|
||||
);
|
||||
if (!credential) throw new NotFoundError(`Credential "${credentialId}" not found`);
|
||||
const rawData = await this.credentialsService.decrypt(credential, true);
|
||||
if (!this.usesManagedSlackAuth(rawData)) {
|
||||
throw new BadRequestError('The selected Slack credential does not use managed OAuth');
|
||||
}
|
||||
const oauthTokenData = childRecord(rawData, 'oauthTokenData');
|
||||
const authedUser = oauthTokenData ? childRecord(oauthTokenData, 'authed_user') : undefined;
|
||||
const accessToken = stringProperty(authedUser, 'access_token');
|
||||
if (!oauthTokenData || !accessToken) {
|
||||
throw new BadRequestError('The selected Slack credential is not connected');
|
||||
}
|
||||
const grantedScopes = stringsFromScope(
|
||||
stringProperty(authedUser, 'scope') ?? stringProperty(oauthTokenData, 'scope'),
|
||||
);
|
||||
if (REQUIRED_MANAGER_SCOPES.some((scope) => !grantedScopes.has(scope))) {
|
||||
throw new BadRequestError('Reconnect the Slack credential to grant managed app access');
|
||||
}
|
||||
return { credential, rawData, oauthTokenData, accessToken };
|
||||
}
|
||||
|
||||
private usesManagedSlackAuth(data: ICredentialDataDecryptedObject): boolean {
|
||||
const hasCustomClient =
|
||||
(typeof data.clientId === 'string' && data.clientId.trim().length > 0) ||
|
||||
(typeof data.clientSecret === 'string' && data.clientSecret.trim().length > 0);
|
||||
return (
|
||||
!hasCustomClient &&
|
||||
this.credentialsOverwrites.usesManagedAuth(SLACK_MANAGER_CREDENTIAL_TYPE, data)
|
||||
);
|
||||
}
|
||||
|
||||
private async getWorkspacesFromContext(
|
||||
manager: ManagerCredentialContext,
|
||||
integrations: readonly AgentIntegrationConfig[],
|
||||
user: User,
|
||||
allowRefresh = true,
|
||||
): Promise<SlackManagedWorkspaceSummary[]> {
|
||||
const team = childRecord(manager.oauthTokenData, 'team');
|
||||
const enterprise = childRecord(manager.oauthTokenData, 'enterprise');
|
||||
const isEnterpriseInstall = manager.oauthTokenData.is_enterprise_install === true;
|
||||
const workspaceRecords: Array<Record<string, unknown>> = [];
|
||||
|
||||
if (isEnterpriseInstall) {
|
||||
let cursor = '';
|
||||
do {
|
||||
const response = allowRefresh
|
||||
? await this.callManagerSlackApi(manager, 'auth.teams.list', {
|
||||
limit: '100',
|
||||
...(cursor ? { cursor } : {}),
|
||||
})
|
||||
: await this.methods.callSlackApi('auth.teams.list', {
|
||||
token: manager.accessToken,
|
||||
limit: '100',
|
||||
...(cursor ? { cursor } : {}),
|
||||
});
|
||||
if (!response.ok) break;
|
||||
if (Array.isArray(response.teams)) {
|
||||
workspaceRecords.push(...response.teams.filter(isRecord));
|
||||
}
|
||||
cursor =
|
||||
stringProperty(childRecord(response, 'response_metadata'), 'next_cursor')?.trim() ?? '';
|
||||
} while (cursor);
|
||||
} else if (team) {
|
||||
workspaceRecords.push(team);
|
||||
}
|
||||
|
||||
const enterpriseId = stringProperty(enterprise, 'id');
|
||||
const result: SlackManagedWorkspaceSummary[] = [];
|
||||
for (const workspace of workspaceRecords) {
|
||||
const id = stringProperty(workspace, 'id');
|
||||
if (!id) continue;
|
||||
const existing = await this.findManagedBotCredential(
|
||||
integrations,
|
||||
id,
|
||||
manager.credential.id,
|
||||
user,
|
||||
);
|
||||
result.push({
|
||||
id,
|
||||
name: stringProperty(workspace, 'name') ?? stringProperty(workspace, 'domain') ?? id,
|
||||
...(enterpriseId ? { enterpriseId } : {}),
|
||||
...(existing
|
||||
? {
|
||||
managedAppId: existing.appId,
|
||||
botCredentialId: existing.credentialId,
|
||||
connected: true,
|
||||
}
|
||||
: { connected: false }),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async findManagedBotCredential(
|
||||
integrations: readonly AgentIntegrationConfig[],
|
||||
workspaceId: string,
|
||||
managerCredentialId: string,
|
||||
user: User,
|
||||
): Promise<{ appId: string; credentialId: string } | undefined> {
|
||||
for (const integration of integrations) {
|
||||
if (integration.type !== 'slack' || !integration.credentialId) continue;
|
||||
const credential = await this.credentialsFinderService.findCredentialForUser(
|
||||
integration.credentialId,
|
||||
user,
|
||||
['credential:read'],
|
||||
);
|
||||
if (!credential || credential.type !== SLACK_CREDENTIAL_TYPE) continue;
|
||||
const data = await this.credentialsService.decrypt(credential, true);
|
||||
if (
|
||||
data.teamId === workspaceId &&
|
||||
data.managerCredentialId === managerCredentialId &&
|
||||
typeof data.managedAppId === 'string'
|
||||
) {
|
||||
return { appId: data.managedAppId, credentialId: credential.id };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async getOrCreateManagedAppSession(
|
||||
options: InstallManagedSlackAppOptions,
|
||||
agent: Agent,
|
||||
manager: ManagerCredentialContext,
|
||||
workspaceName: string,
|
||||
): Promise<{ session: ManagedSlackAppSession; created: boolean }> {
|
||||
const key = this.managedAppCacheKey({ ...options, userId: options.user.id });
|
||||
const cached = await this.cacheService.get<unknown>(key);
|
||||
if (typeof cached === 'string') {
|
||||
const session = await this.decryptManagedAppSession(cached);
|
||||
if (session) {
|
||||
return {
|
||||
session: { ...session, teamName: session.teamName ?? workspaceName },
|
||||
created: false,
|
||||
};
|
||||
}
|
||||
// invalid session, delete it
|
||||
await this.cacheService.delete(key);
|
||||
}
|
||||
|
||||
const redirectUrl = this.methods.callbackUrl(options.projectId, options.agentId);
|
||||
const manifest = this.methods.buildManifest(agent.name, options.projectId, options.agentId, {
|
||||
redirectUrl,
|
||||
managed: true,
|
||||
});
|
||||
const response = await this.callManagerSlackApi(manager, 'apps.manifest.create', {
|
||||
manifest: JSON.stringify(manifest),
|
||||
team_id: options.workspaceId,
|
||||
});
|
||||
if (!response.ok) throw this.methods.slackError('create the Slack app', response);
|
||||
|
||||
const credentials = childRecord(response, 'credentials');
|
||||
const appId = stringProperty(response, 'app_id');
|
||||
const clientId = stringProperty(credentials, 'client_id');
|
||||
const clientSecret = stringProperty(credentials, 'client_secret');
|
||||
const signingSecret = stringProperty(credentials, 'signing_secret');
|
||||
const oauthAuthorizeUrl = stringProperty(response, 'oauth_authorize_url');
|
||||
try {
|
||||
if (!appId || !clientId || !clientSecret || !signingSecret || !oauthAuthorizeUrl) {
|
||||
throw new BadRequestError('Slack returned an incomplete app setup response');
|
||||
}
|
||||
await this.setManagedAppIcon(manager, appId);
|
||||
|
||||
const session = {
|
||||
projectId: options.projectId,
|
||||
agentId: options.agentId,
|
||||
userId: options.user.id,
|
||||
appId,
|
||||
clientId,
|
||||
clientSecret,
|
||||
signingSecret,
|
||||
redirectUrl,
|
||||
managerCredentialId: options.managerCredentialId,
|
||||
teamId: options.workspaceId,
|
||||
teamName: workspaceName,
|
||||
oauthAuthorizeUrl,
|
||||
} satisfies ManagedSlackAppSession;
|
||||
await this.cacheService.set(
|
||||
key,
|
||||
await this.cipher.encryptV2(JSON.stringify(session)),
|
||||
SLACK_APP_SETUP_TTL_MS,
|
||||
);
|
||||
return { session, created: true };
|
||||
} catch (error) {
|
||||
if (appId) {
|
||||
await Promise.allSettled([
|
||||
this.callManagerSlackApi(manager, 'apps.manifest.delete', { app_id: appId }),
|
||||
this.cacheService.delete(key),
|
||||
]);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async decryptManagedAppSession(
|
||||
value: string,
|
||||
): Promise<ManagedSlackAppSession | undefined> {
|
||||
try {
|
||||
const decrypted = await this.cipher.decryptV2(value);
|
||||
const session = jsonParse<unknown>(decrypted, { fallbackValue: null });
|
||||
if (hasManagedSessionShape(session)) return session;
|
||||
} catch {
|
||||
// Ignore stale or undecryptable managed setup state.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async callManagerSlackApi<T extends { [key: string]: unknown }>(
|
||||
manager: ManagerCredentialContext,
|
||||
method: string,
|
||||
params: Record<string, string> | SlackApiParamsFactory,
|
||||
): Promise<({ ok: true } & T) | { ok: false; error: string }> {
|
||||
const paramsForToken = (accessToken: string): SlackApiParams =>
|
||||
typeof params === 'function' ? params(accessToken) : { ...params, token: accessToken };
|
||||
let response = await this.methods.callSlackApi(method, paramsForToken(manager.accessToken));
|
||||
const error = stringProperty(response, 'error');
|
||||
if (!['invalid_auth', 'token_expired'].includes(error ?? '')) return response;
|
||||
|
||||
const authedUser = childRecord(manager.oauthTokenData, 'authed_user');
|
||||
const refreshToken = stringProperty(authedUser, 'refresh_token');
|
||||
const overwrite = this.credentialsOverwrites.getOverwrites(SLACK_MANAGER_CREDENTIAL_TYPE);
|
||||
if (
|
||||
!refreshToken ||
|
||||
typeof overwrite?.clientId !== 'string' ||
|
||||
typeof overwrite.clientSecret !== 'string'
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const refreshResponse = await this.methods.callSlackApi<RefreshTokenResponse>(
|
||||
'oauth.v2.access',
|
||||
{
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: overwrite.clientId,
|
||||
client_secret: overwrite.clientSecret,
|
||||
},
|
||||
);
|
||||
const refreshedAccessToken = stringProperty(refreshResponse, 'access_token');
|
||||
if (!refreshResponse.ok || !refreshedAccessToken) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const refreshedAuthedUser: Record<string, unknown> = {
|
||||
...authedUser,
|
||||
access_token: refreshedAccessToken,
|
||||
};
|
||||
const refreshedUserId = stringProperty(refreshResponse, 'user_id');
|
||||
const refreshedScope = stringProperty(refreshResponse, 'scope');
|
||||
const refreshedRefreshToken = stringProperty(refreshResponse, 'refresh_token');
|
||||
const refreshedTokenType = stringProperty(refreshResponse, 'token_type');
|
||||
|
||||
if (refreshedUserId !== undefined) refreshedAuthedUser.id = refreshedUserId;
|
||||
if (refreshedScope !== undefined) refreshedAuthedUser.scope = refreshedScope;
|
||||
if (refreshedRefreshToken !== undefined) {
|
||||
refreshedAuthedUser.refresh_token = refreshedRefreshToken;
|
||||
}
|
||||
if (refreshedTokenType !== undefined) refreshedAuthedUser.token_type = refreshedTokenType;
|
||||
if (typeof refreshResponse.expires_in === 'number') {
|
||||
refreshedAuthedUser.expires_in = refreshResponse.expires_in;
|
||||
}
|
||||
const oauthTokenData = {
|
||||
...manager.oauthTokenData,
|
||||
authed_user: refreshedAuthedUser,
|
||||
};
|
||||
const updatedData = { ...manager.rawData, oauthTokenData };
|
||||
const encrypted = await this.credentialsService.createEncryptedData({
|
||||
id: manager.credential.id,
|
||||
name: manager.credential.name,
|
||||
type: manager.credential.type,
|
||||
data: updatedData,
|
||||
});
|
||||
await this.credentialsService.update(manager.credential.id, encrypted, updatedData);
|
||||
manager.oauthTokenData = oauthTokenData;
|
||||
manager.accessToken = refreshedAccessToken;
|
||||
response = await this.methods.callSlackApi(method, paramsForToken(refreshedAccessToken));
|
||||
return response;
|
||||
}
|
||||
|
||||
private managedAppCacheKey(options: {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
managerCredentialId: string;
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
}): string {
|
||||
return `${SLACK_MANAGED_APP_CACHE_PREFIX}${options.projectId}:${options.agentId}:${options.managerCredentialId}:${options.workspaceId}:${options.userId}`;
|
||||
}
|
||||
|
||||
private async setManagedAppIcon(
|
||||
manager: ManagerCredentialContext,
|
||||
managedAppId: string,
|
||||
): Promise<void> {
|
||||
const image = await readFile(join(__dirname, 'assets', 'n8n-bot-icon.png'));
|
||||
const response = await this.callManagerSlackApi(manager, 'apps.icon.set', (accessToken) => {
|
||||
const formData = new FormData();
|
||||
formData.set('token', accessToken);
|
||||
formData.set('app_id', managedAppId);
|
||||
formData.set(
|
||||
'file',
|
||||
new Blob([new Uint8Array(image)], { type: 'image/png' }),
|
||||
'n8n-bot-icon.png',
|
||||
);
|
||||
return formData;
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw this.methods.slackError('set the Slack app icon', response);
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
-43
@@ -1,5 +1,3 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import type { CreateSlackAgentAppResponse, SlackAgentAppManifestResponse } from '@n8n/api-types';
|
||||
import type { User } from '@n8n/db';
|
||||
import { UserRepository } from '@n8n/db';
|
||||
@@ -7,46 +5,37 @@ import { Service } from '@n8n/di';
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
|
||||
import { SlackMethodsService } from './slack-methods.service';
|
||||
import { childRecord, type SlackAppSetupSession, slackSetupCacheKey } from './slack-setup.types';
|
||||
import { stringProperty } from '../../integration-helpers';
|
||||
|
||||
const SLACK_APP_SETUP_CACHE_PREFIX = 'agents:slack-app-setup:';
|
||||
const SLACK_APP_SETUP_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
interface CreateSlackAppOptions {
|
||||
export interface CreateSlackAppOptions {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
appConfigurationToken: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface GetSlackAppManifestOptions {
|
||||
export interface GetSlackAppManifestOptions {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface CompleteSlackAppInstallOptions {
|
||||
export interface CompleteSlackAppInstallOptions {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
code: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
interface SlackAppSetupSession {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
userId: string;
|
||||
appId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
signingSecret: string;
|
||||
redirectUrl: string;
|
||||
}
|
||||
|
||||
function hasSessionShape(value: unknown): value is SlackAppSetupSession {
|
||||
const keys: Array<keyof SlackAppSetupSession> = [
|
||||
'projectId',
|
||||
@@ -68,6 +57,7 @@ export class SlackManualSetupService {
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly cipher: Cipher,
|
||||
private readonly projectService: ProjectService,
|
||||
) {}
|
||||
|
||||
async createApp(options: CreateSlackAppOptions): Promise<CreateSlackAgentAppResponse> {
|
||||
@@ -85,16 +75,16 @@ export class SlackManualSetupService {
|
||||
token: appConfigurationToken,
|
||||
manifest: JSON.stringify(manifest),
|
||||
});
|
||||
if (response.ok !== true) {
|
||||
if (!response.ok) {
|
||||
throw this.methods.slackError('create the Slack app', response);
|
||||
}
|
||||
|
||||
const credentials = this.methods.childRecord(response, 'credentials');
|
||||
const appId = this.methods.stringProperty(response, 'app_id');
|
||||
const clientId = this.methods.stringProperty(credentials, 'client_id');
|
||||
const clientSecret = this.methods.stringProperty(credentials, 'client_secret');
|
||||
const signingSecret = this.methods.stringProperty(credentials, 'signing_secret');
|
||||
const oauthAuthorizeUrl = this.methods.stringProperty(response, 'oauth_authorize_url');
|
||||
const credentials = childRecord(response, 'credentials');
|
||||
const appId = stringProperty(response, 'app_id');
|
||||
const clientId = stringProperty(credentials, 'client_id');
|
||||
const clientSecret = stringProperty(credentials, 'client_secret');
|
||||
const signingSecret = stringProperty(credentials, 'signing_secret');
|
||||
const oauthAuthorizeUrl = stringProperty(response, 'oauth_authorize_url');
|
||||
if (!appId || !clientId || !clientSecret || !signingSecret || !oauthAuthorizeUrl) {
|
||||
throw new BadRequestError('Slack returned an incomplete app setup response');
|
||||
}
|
||||
@@ -110,11 +100,7 @@ export class SlackManualSetupService {
|
||||
signingSecret,
|
||||
redirectUrl,
|
||||
} satisfies SlackAppSetupSession;
|
||||
await this.cacheService.set(
|
||||
this.cacheKey(state),
|
||||
await this.cipher.encryptV2(JSON.stringify(setupSession)),
|
||||
SLACK_APP_SETUP_TTL_MS,
|
||||
);
|
||||
await this.methods.storeSession(state, setupSession);
|
||||
|
||||
return {
|
||||
appId,
|
||||
@@ -140,6 +126,13 @@ export class SlackManualSetupService {
|
||||
relations: ['role'],
|
||||
});
|
||||
if (!user) throw new NotFoundError(`User "${session.userId}" not found`);
|
||||
const project = await this.projectService.getProjectWithScope(user, session.projectId, [
|
||||
'agent:update',
|
||||
'credential:create',
|
||||
]);
|
||||
if (!project) {
|
||||
throw new ForbiddenError('You do not have permission to complete Slack app setup');
|
||||
}
|
||||
|
||||
const agent = await this.methods.getAgent(session.agentId, session.projectId);
|
||||
const tokenResponse = await this.methods.callSlackApi(
|
||||
@@ -154,25 +147,25 @@ export class SlackManualSetupService {
|
||||
)}`,
|
||||
},
|
||||
);
|
||||
if (tokenResponse.ok !== true) {
|
||||
if (!tokenResponse.ok) {
|
||||
throw this.methods.slackError('finish Slack app installation', tokenResponse);
|
||||
}
|
||||
|
||||
const accessToken = this.methods.stringProperty(tokenResponse, 'access_token');
|
||||
const accessToken = stringProperty(tokenResponse, 'access_token');
|
||||
if (!accessToken?.startsWith('xoxb-')) {
|
||||
throw new BadRequestError('Slack did not return a Bot User OAuth Token');
|
||||
}
|
||||
|
||||
await this.methods.createAndConnectBotCredential({
|
||||
agent,
|
||||
user,
|
||||
accessToken,
|
||||
signingSecret: session.signingSecret,
|
||||
const team = childRecord(tokenResponse, 'team');
|
||||
const teamName = stringProperty(team, 'name');
|
||||
await this.methods.connectBotCredential(agent, user, accessToken, {
|
||||
...session,
|
||||
...(teamName ? { teamName } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
private async consumeSession(state: string): Promise<SlackAppSetupSession> {
|
||||
const cached = await this.cacheService.take<unknown>(this.cacheKey(state));
|
||||
const cached = await this.cacheService.take<unknown>(slackSetupCacheKey(state));
|
||||
if (typeof cached !== 'string') {
|
||||
throw new BadRequestError('Slack app setup state has expired or is invalid');
|
||||
}
|
||||
@@ -187,8 +180,4 @@ export class SlackManualSetupService {
|
||||
|
||||
throw new BadRequestError('Slack app setup state has expired or is invalid');
|
||||
}
|
||||
|
||||
private cacheKey(state: string): string {
|
||||
return `${SLACK_APP_SETUP_CACHE_PREFIX}${state}`;
|
||||
}
|
||||
}
|
||||
|
||||
+124
-74
@@ -1,18 +1,32 @@
|
||||
import type { AgentIntegrationConfig, SlackAgentAppManifest } from '@n8n/api-types';
|
||||
import type {
|
||||
AgentIntegrationConfig,
|
||||
SlackAgentAppManifest,
|
||||
SlackApiErrorMeta,
|
||||
} from '@n8n/api-types';
|
||||
import { OutboundHttp } from '@n8n/backend-network';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
import { Cipher } from 'n8n-core';
|
||||
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
|
||||
import {
|
||||
SLACK_BOT_SCOPES,
|
||||
type SlackAppSetupSession,
|
||||
slackSetupCacheKey,
|
||||
} from './slack-setup.types';
|
||||
import { AgentIntegrationManagementService } from '../../../agent-integration-management.service';
|
||||
import type { Agent } from '../../../entities/agent.entity';
|
||||
import { AgentRepository } from '../../../repositories/agent.repository';
|
||||
import { stringProperty } from '../../integration-helpers';
|
||||
|
||||
const SLACK_MANAGED_APP_CACHE_PREFIX = 'agents:slack-managed-app:';
|
||||
const SLACK_APP_SETUP_TTL_MS = 60 * 60 * 1000;
|
||||
const DEFAULT_SLACK_APP_NAME = 'n8n Agent';
|
||||
const SLACK_CREDENTIAL_TYPE = 'slackApi';
|
||||
|
||||
@@ -26,30 +40,17 @@ const REQUIRED_BOT_EVENTS = [
|
||||
'message.mpim',
|
||||
] as const;
|
||||
|
||||
const REQUIRED_BOT_SCOPES = [
|
||||
'app_mentions:read',
|
||||
'assistant:write',
|
||||
'channels:history',
|
||||
'channels:join',
|
||||
'channels:manage',
|
||||
'channels:read',
|
||||
'chat:write',
|
||||
'chat:write.customize',
|
||||
'files:read',
|
||||
'files:write',
|
||||
'groups:history',
|
||||
'groups:read',
|
||||
'im:history',
|
||||
'im:read',
|
||||
'im:write',
|
||||
'mpim:history',
|
||||
'mpim:read',
|
||||
'mpim:write',
|
||||
'reactions:write',
|
||||
'search:read.public',
|
||||
'users:read',
|
||||
'users:read.email',
|
||||
] as const;
|
||||
const isLocalhost = (hostname: string): boolean =>
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
|
||||
|
||||
class SlackApiError extends BadRequestError {
|
||||
override readonly meta: SlackApiErrorMeta;
|
||||
|
||||
constructor(action: string, code: string) {
|
||||
super(`Slack could not ${action}: ${code}`);
|
||||
this.meta = { integrationType: 'slack', code };
|
||||
}
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class SlackMethodsService {
|
||||
@@ -59,35 +60,43 @@ export class SlackMethodsService {
|
||||
private readonly integrationManagementService: AgentIntegrationManagementService,
|
||||
private readonly urlService: UrlService,
|
||||
private readonly outboundHttp: OutboundHttp,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly cipher: Cipher,
|
||||
) {}
|
||||
|
||||
async callSlackApi(
|
||||
async callSlackApi<T extends { [key: string]: unknown }>(
|
||||
method: string,
|
||||
params: Record<string, string>,
|
||||
params: Record<string, string> | FormData,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
): Promise<({ ok: true } & T) | { ok: false; error: string }> {
|
||||
try {
|
||||
const response = await this.outboundHttp.requests({ ssrf: 'disabled' }).request({
|
||||
method: 'POST',
|
||||
url: `https://slack.com/api/${method}`,
|
||||
headers: {
|
||||
...headers,
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params,
|
||||
returnFullResponse: true,
|
||||
ignoreHttpStatusErrors: true,
|
||||
});
|
||||
const requestHeaders = { ...headers };
|
||||
if (!(params instanceof FormData)) {
|
||||
requestHeaders['content-type'] = 'application/x-www-form-urlencoded';
|
||||
}
|
||||
const response = await this.outboundHttp
|
||||
.requests({
|
||||
ssrf: 'disabled',
|
||||
})
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: `https://slack.com/api/${method}`,
|
||||
headers: requestHeaders,
|
||||
body: params,
|
||||
returnFullResponse: true,
|
||||
ignoreHttpStatusErrors: true,
|
||||
});
|
||||
const data: unknown = response.body;
|
||||
return isRecord(data) ? data : { ok: false, error: 'invalid_response' };
|
||||
return isRecord(data)
|
||||
? (data as ({ ok: true } & T) | { ok: false; error: string })
|
||||
: { ok: false, error: 'invalid_response' };
|
||||
} catch {
|
||||
return { ok: false, error: 'slack_request_failed' };
|
||||
}
|
||||
}
|
||||
|
||||
slackError(action: string, response: Record<string, unknown>): BadRequestError {
|
||||
const error = this.stringProperty(response, 'error') ?? 'unknown_error';
|
||||
return new BadRequestError(`Slack could not ${action}: ${error}`);
|
||||
return new SlackApiError(action, stringProperty(response, 'error') ?? 'unknown_error');
|
||||
}
|
||||
|
||||
async getAgent(agentId: string, projectId: string): Promise<Agent> {
|
||||
@@ -100,12 +109,17 @@ export class SlackMethodsService {
|
||||
agentName: string,
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
options: { redirectUrl?: string } = {},
|
||||
options: { redirectUrl?: string; managed?: boolean } = {},
|
||||
): SlackAgentAppManifest {
|
||||
const slackAppName = this.sanitiseSlackAppName(agentName);
|
||||
const webhookUrl = this.webhookUrl(projectId, agentId);
|
||||
return {
|
||||
display_information: { name: slackAppName },
|
||||
display_information: {
|
||||
name: slackAppName,
|
||||
...(options.managed
|
||||
? { description: `Work with ${slackAppName}, your n8n AI agent, in Slack.` }
|
||||
: {}),
|
||||
},
|
||||
features: {
|
||||
app_home: {
|
||||
home_tab_enabled: false,
|
||||
@@ -119,7 +133,7 @@ export class SlackMethodsService {
|
||||
},
|
||||
oauth_config: {
|
||||
...(options.redirectUrl ? { redirect_urls: [options.redirectUrl] } : {}),
|
||||
scopes: { bot: [...REQUIRED_BOT_SCOPES] },
|
||||
scopes: { bot: [...SLACK_BOT_SCOPES] },
|
||||
},
|
||||
settings: {
|
||||
event_subscriptions: {
|
||||
@@ -133,6 +147,14 @@ export class SlackMethodsService {
|
||||
org_deploy_enabled: false,
|
||||
socket_mode_enabled: false,
|
||||
token_rotation_enabled: false,
|
||||
...(options.managed
|
||||
? {
|
||||
managed_app_settings: {
|
||||
is_install_from_slack_disabled: true,
|
||||
external_app_management_url: this.managedAppUrl(projectId, agentId),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -152,52 +174,80 @@ export class SlackMethodsService {
|
||||
}
|
||||
}
|
||||
|
||||
async createAndConnectBotCredential(options: {
|
||||
agent: Agent;
|
||||
user: User;
|
||||
accessToken: string;
|
||||
signingSecret: string;
|
||||
}): Promise<string> {
|
||||
const credential = await this.credentialsService.createUnmanagedCredential(
|
||||
{
|
||||
name: this.credentialName(options.agent.name),
|
||||
type: SLACK_CREDENTIAL_TYPE,
|
||||
data: {
|
||||
accessToken: options.accessToken,
|
||||
signatureSecret: options.signingSecret,
|
||||
},
|
||||
projectId: options.agent.projectId,
|
||||
},
|
||||
options.user,
|
||||
async storeSession(state: string, session: SlackAppSetupSession): Promise<void> {
|
||||
await this.cacheService.set(
|
||||
slackSetupCacheKey(state),
|
||||
await this.cipher.encryptV2(JSON.stringify(session)),
|
||||
SLACK_APP_SETUP_TTL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
async connectBotCredential(
|
||||
agent: Agent,
|
||||
user: User,
|
||||
accessToken: string,
|
||||
session: SlackAppSetupSession,
|
||||
): Promise<string> {
|
||||
const credentialData = {
|
||||
name: this.credentialName(session.teamName, agent.name),
|
||||
type: SLACK_CREDENTIAL_TYPE,
|
||||
data: {
|
||||
accessToken,
|
||||
signatureSecret: session.signingSecret,
|
||||
...(session.managerCredentialId
|
||||
? {
|
||||
managedAppId: session.appId,
|
||||
teamId: session.teamId,
|
||||
managerCredentialId: session.managerCredentialId,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
projectId: session.projectId,
|
||||
};
|
||||
const credential = session.managerCredentialId
|
||||
? await this.credentialsService.createManagedCredential(credentialData, user)
|
||||
: await this.credentialsService.createUnmanagedCredential(credentialData, user);
|
||||
const integration = {
|
||||
type: 'slack',
|
||||
credentialId: credential.id,
|
||||
} satisfies AgentIntegrationConfig;
|
||||
await this.integrationManagementService.connect({
|
||||
agent: options.agent,
|
||||
user: options.user,
|
||||
agent,
|
||||
user,
|
||||
integration,
|
||||
});
|
||||
await this.clearManagedAppSession(session);
|
||||
return credential.id;
|
||||
}
|
||||
|
||||
childRecord(record: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
|
||||
const child = record[key];
|
||||
return isRecord(child) ? child : undefined;
|
||||
}
|
||||
|
||||
stringProperty(record: Record<string, unknown> | undefined, key: string): string | undefined {
|
||||
const value = record?.[key];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
private async clearManagedAppSession(session: SlackAppSetupSession): Promise<void> {
|
||||
if (session.managerCredentialId && session.teamId) {
|
||||
await this.cacheService.delete(
|
||||
`${SLACK_MANAGED_APP_CACHE_PREFIX}${session.projectId}:${session.agentId}:${session.managerCredentialId}:${session.teamId}:${session.userId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private webhookUrl(projectId: string, agentId: string): string {
|
||||
return `${this.urlService.getWebhookBaseUrl()}rest/projects/${projectId}/agents/v2/${agentId}/webhooks/slack`;
|
||||
}
|
||||
|
||||
private credentialName(agentName: string): string {
|
||||
return `Slack - ${agentName || DEFAULT_SLACK_APP_NAME}`.slice(0, 128);
|
||||
private managedAppUrl(projectId: string, agentId: string): string {
|
||||
const url = new URL(
|
||||
`/projects/${projectId}/agents/${agentId}`,
|
||||
`${this.urlService.getInstanceBaseUrl()}/`,
|
||||
);
|
||||
if (process.env.NODE_ENV === 'development' && isLocalhost(url.hostname)) {
|
||||
url.protocol = 'https:';
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private credentialName(workspaceName: string | undefined, agentName: string): string {
|
||||
return [workspaceName ?? 'Slack', agentName || DEFAULT_SLACK_APP_NAME]
|
||||
.filter(Boolean)
|
||||
.join(' - ')
|
||||
.slice(0, 128);
|
||||
}
|
||||
|
||||
private sanitiseSlackAppName(raw: string): string {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
|
||||
const SLACK_APP_SETUP_CACHE_PREFIX = 'agents:slack-app-setup:';
|
||||
|
||||
export const SLACK_BOT_SCOPES = [
|
||||
'app_mentions:read',
|
||||
'assistant:write',
|
||||
'channels:history',
|
||||
'channels:join',
|
||||
'channels:read',
|
||||
'chat:write',
|
||||
'files:read',
|
||||
'files:write',
|
||||
'groups:history',
|
||||
'groups:read',
|
||||
'im:history',
|
||||
'im:read',
|
||||
'im:write',
|
||||
'mpim:history',
|
||||
'mpim:read',
|
||||
'mpim:write',
|
||||
'reactions:write',
|
||||
'users:read',
|
||||
'users:read.email',
|
||||
] as const;
|
||||
|
||||
export interface SlackAppSetupSession {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
userId: string;
|
||||
appId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
signingSecret: string;
|
||||
redirectUrl: string;
|
||||
managerCredentialId?: string;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
}
|
||||
|
||||
export function slackSetupCacheKey(state: string): string {
|
||||
return `${SLACK_APP_SETUP_CACHE_PREFIX}${state}`;
|
||||
}
|
||||
|
||||
export function childRecord(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const child = record[key];
|
||||
return isRecord(child) ? child : undefined;
|
||||
}
|
||||
@@ -1648,7 +1648,7 @@ export class McpAgentToolsService {
|
||||
}
|
||||
|
||||
private async disconnectIntegration(user: User, input: UpdateIntegrationInput, agent: Agent) {
|
||||
const { savedAgent: saved } = await this.integrationManagementService.disconnect({
|
||||
const { savedAgent: saved, warning } = await this.integrationManagementService.disconnect({
|
||||
agent,
|
||||
user,
|
||||
type: input.type,
|
||||
@@ -1660,6 +1660,7 @@ export class McpAgentToolsService {
|
||||
agentId: input.agentId,
|
||||
integration: { type: input.type, credentialId: input.credentialId },
|
||||
connected: false,
|
||||
...(warning ? { warning } : {}),
|
||||
published: saved.activeVersionId !== null,
|
||||
activeVersionId: saved.activeVersionId,
|
||||
configHash: getAgentConfigHash(this.configFromEntity(saved)),
|
||||
@@ -1691,7 +1692,6 @@ export class McpAgentToolsService {
|
||||
configHash: getAgentConfigHash(this.configFromEntity(saved)),
|
||||
};
|
||||
if (saved.activeVersionId === null) return { ...result, connected: false };
|
||||
|
||||
return {
|
||||
...result,
|
||||
connected: true,
|
||||
|
||||
@@ -138,6 +138,15 @@ for (const backend of ['memory', 'redis'] as const) {
|
||||
});
|
||||
});
|
||||
|
||||
describe('take', () => {
|
||||
test('should return a value only once', async () => {
|
||||
await cacheService.set('single-use', 'value');
|
||||
|
||||
await expect(cacheService.take('single-use')).resolves.toBe('value');
|
||||
await expect(cacheService.take('single-use')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
const createRefreshFn = () => vi.fn(async () => await Promise.resolve('refreshValue'));
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ export interface DialogProps {
|
||||
* Only rendered when header prop is also provided.
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* Render above another open dialog
|
||||
*/
|
||||
stacked?: boolean;
|
||||
}
|
||||
|
||||
export interface DialogEmits {
|
||||
@@ -91,6 +95,7 @@ withDefaults(defineProps<DialogProps>(), {
|
||||
trapFocus: true,
|
||||
disableOutsidePointerEvents: true,
|
||||
showCloseButton: true,
|
||||
stacked: false,
|
||||
});
|
||||
const emit = defineEmits<DialogEmits>();
|
||||
|
||||
@@ -107,7 +112,7 @@ const handleOpenChange = (value: boolean) => {
|
||||
@update:open="handleOpenChange"
|
||||
>
|
||||
<DialogPortal>
|
||||
<N8nDialogOverlay />
|
||||
<N8nDialogOverlay :stacked="stacked" />
|
||||
<N8nDialogContent
|
||||
:size="size"
|
||||
:force-mount="forceMount"
|
||||
@@ -116,6 +121,7 @@ const handleOpenChange = (value: boolean) => {
|
||||
:show-close-button="showCloseButton"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-description="ariaDescription"
|
||||
:stacked="stacked"
|
||||
@escape-key-down="emit('escapeKeyDown', $event)"
|
||||
@interact-outside="emit('interactOutside', $event)"
|
||||
@open-auto-focus="emit('openAutoFocus', $event)"
|
||||
|
||||
@@ -47,6 +47,10 @@ export interface DialogContentProps {
|
||||
* Accessible description for the dialog (used when DialogDescription is not provided)
|
||||
*/
|
||||
ariaDescription?: string;
|
||||
/**
|
||||
* Render above another open dialog
|
||||
*/
|
||||
stacked?: boolean;
|
||||
}
|
||||
|
||||
export interface DialogContentEmits {
|
||||
@@ -106,7 +110,7 @@ function handleInteractOutside(e: Event) {
|
||||
:force-mount="forceMount"
|
||||
:trap-focus="trapFocus"
|
||||
:disable-outside-pointer-events="disableOutsidePointerEvents"
|
||||
:class="[$style.content, sizeClass]"
|
||||
:class="[$style.content, sizeClass, stacked && $style.stacked]"
|
||||
@escape-key-down="emit('escapeKeyDown', $event)"
|
||||
@interact-outside="handleInteractOutside"
|
||||
@open-auto-focus="emit('openAutoFocus', $event)"
|
||||
@@ -174,6 +178,10 @@ function handleInteractOutside(e: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
.stacked {
|
||||
z-index: 1952; // Higher than default .content z-index to ensure the dialog is always on top.
|
||||
}
|
||||
|
||||
.content[data-state='open'] {
|
||||
animation: dialogFadeIn var(--animation--duration--snappy) ease-out;
|
||||
}
|
||||
|
||||
@@ -6,13 +6,17 @@ export interface DialogOverlayProps {
|
||||
* Force mount for animation control
|
||||
*/
|
||||
forceMount?: boolean;
|
||||
/**
|
||||
* Render above another open dialog
|
||||
*/
|
||||
stacked?: boolean;
|
||||
}
|
||||
|
||||
defineProps<DialogOverlayProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay :class="$style.overlay" />
|
||||
<DialogOverlay :class="[$style.overlay, stacked && $style.stacked]" />
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
@@ -42,6 +46,10 @@ defineProps<DialogOverlayProps>();
|
||||
z-index: 1949; // See APP_Z_INDEXES in useStyles.ts
|
||||
}
|
||||
|
||||
.stacked {
|
||||
z-index: 1951;
|
||||
}
|
||||
|
||||
.overlay[data-state='open'] {
|
||||
animation: overlayFadeIn var(--animation--duration--snappy) ease;
|
||||
}
|
||||
|
||||
@@ -7970,7 +7970,42 @@
|
||||
"agents.channels.slack.setup.installApp.title": "Install app",
|
||||
"agents.channels.slack.setup.installApp.description": "Install the Slack app and connect it to this agent.",
|
||||
"agents.channels.slack.setup.installApp.button": "Install app",
|
||||
"agents.channels.slack.setup.installApp.error": "Could not install the Slack app.",
|
||||
"agents.channels.slack.setup.installApp.error": "Could not install the Slack app",
|
||||
"agents.channels.slack.managed.addToSlack": "Add to Slack",
|
||||
"agents.channels.slack.managed.connect.title": "Connect to Slack",
|
||||
"agents.channels.slack.managed.connect.description": "Authenticate with Slack in one click.",
|
||||
"agents.channels.slack.managed.connect.button": "Connect to Slack",
|
||||
"agents.channels.slack.managed.connect.error": "Slack couldn't be connected. Check the popup and try again.",
|
||||
"agents.channels.slack.setupKind.recommended": "Recommended setup",
|
||||
"agents.channels.slack.setupKind.manual": "Manual setup",
|
||||
"agents.channels.slack.managed.credential.placeholder": "Select a Slack credential",
|
||||
"agents.channels.slack.managed.connected": "Connected",
|
||||
"agents.channels.slack.managed.reconnect": "Reconnect",
|
||||
"agents.channels.slack.managed.install.title": "Install app",
|
||||
"agents.channels.slack.managed.install.description": "Choose a workspace and install your Slack app.",
|
||||
"agents.channels.slack.managed.install.button": "Install app",
|
||||
"agents.channels.slack.managed.install.connectFirst": "Connect to Slack to proceed.",
|
||||
"agents.channels.slack.managed.install.error": "The Slack app couldn't be installed. Check Slack approval settings and try again.",
|
||||
"agents.channels.slack.managed.install.approvalPending": "This Slack app is waiting for admin approval. Ask a Slack admin to approve it, then try again.",
|
||||
"agents.channels.slack.managed.install.approvalDenied": "A Slack admin denied this app request. Ask an admin to review the app approval settings before trying again.",
|
||||
"agents.channels.slack.managed.workspace.placeholder": "Select a workspace",
|
||||
"agents.channels.slack.managed.settings.name": "Agent name",
|
||||
"agents.channels.slack.managed.settings.nameDescription": "The name displayed for this agent in Slack",
|
||||
"agents.channels.slack.managed.settings.description": "Description",
|
||||
"agents.channels.slack.managed.settings.alwaysOnline": "Always show my bot as online",
|
||||
"agents.channels.slack.managed.settings.openSlack": "Open app settings in Slack",
|
||||
"agents.channels.slack.managed.settings.loadError": "Slack app settings couldn't be loaded. Try again.",
|
||||
"agents.channels.slack.managed.serviceLimitsExceeded.message": "This Slack workspace has reached its app limit. Remove another agent, then try again.",
|
||||
"agents.channels.slack.managed.serviceLimitsExceeded.openDashboard": "Open the Slack app dashboard",
|
||||
"agents.channels.slack.managed.settings.nameRequired": "Enter a bot name.",
|
||||
"agents.channels.slack.managed.settings.nameTooLong": "Bot name must be 80 characters or fewer.",
|
||||
"agents.channels.slack.managed.settings.descriptionRequired": "Enter a description.",
|
||||
"agents.channels.slack.managed.settings.descriptionTooLong": "Description must be 140 characters or fewer.",
|
||||
"agents.channels.slack.managed.remove.title": "Remove Slack?",
|
||||
"agents.channels.slack.managed.remove.description": "Your agent will be disconnected from Slack.",
|
||||
"agents.channels.slack.managed.remove.deleteApp": "Delete all direct messages between users and this agent.",
|
||||
"agents.channels.slack.managed.remove.messagesUnaffected": "Channel messages will not be affected.",
|
||||
"agents.channels.slack.managed.remove.confirm": "Yes, remove",
|
||||
"agents.channels.slack.manualSetup.title": "Configure manually",
|
||||
"agents.channels.linear.setup.createOAuthApplication.title": "Create OAuth application",
|
||||
"agents.channels.linear.setup.createOAuthApplication.description": "Go to Linear and create a new OAuth application. You will need admin permissions.",
|
||||
@@ -8096,6 +8131,10 @@
|
||||
"agents.channels.modal.configured": "Configured",
|
||||
"agents.channels.modal.connected": "Connected",
|
||||
"agents.channels.modal.removeChannel": "Remove channel",
|
||||
"agents.channels.modal.removeChannelError": "Channel couldn't be removed. Try again.",
|
||||
"agents.channels.modal.slackAppNotDeleted.title": "Slack app wasn't removed",
|
||||
"agents.channels.modal.slackAppNotDeleted.message": "The channel and credential were removed from n8n. Remove the app from Slack manually.",
|
||||
"agents.channels.modal.slackAppNotDeleted.link": "Open Slack app settings",
|
||||
"nodeCreator.sectionNames.includedInN8n": "Included in n8n",
|
||||
"contextMenu.nodeGroup": "group",
|
||||
"contextMenu.group": "Group node | Group nodes",
|
||||
|
||||
@@ -2778,6 +2778,11 @@ describe('AgentBuilderView — three-column shell', () => {
|
||||
skill,
|
||||
versionId: 'v2',
|
||||
});
|
||||
getAgentMock.mockResolvedValueOnce(
|
||||
makeAgentResponse({
|
||||
skills: { skill_0Ab9ZkLm3Pq7Xy2N: skill },
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper = await renderView();
|
||||
wrapper.findComponent({ name: 'AgentCapabilitiesSection' }).vm.$emit('add-skill');
|
||||
|
||||
+189
-17
@@ -10,16 +10,24 @@ const mocks = vi.hoisted(() => ({
|
||||
fetchStatus: vi.fn(),
|
||||
beforeSave: vi.fn(),
|
||||
ensureAgentPersisted: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
showMessage: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
}));
|
||||
|
||||
const catalog = ref([
|
||||
{
|
||||
type: 'example',
|
||||
label: 'Example',
|
||||
icon: 'zap',
|
||||
credentialTypes: ['exampleApi'],
|
||||
},
|
||||
]);
|
||||
const exampleIntegration = {
|
||||
type: 'example',
|
||||
label: 'Example',
|
||||
icon: 'zap',
|
||||
credentialTypes: ['exampleApi'],
|
||||
};
|
||||
const catalog = ref([exampleIntegration]);
|
||||
const slackIntegration = {
|
||||
type: 'slack',
|
||||
label: 'Slack',
|
||||
icon: 'slack',
|
||||
credentialTypes: ['slackApi'],
|
||||
};
|
||||
const statuses = ref<Record<string, 'configured' | 'connected' | 'disconnected'>>({});
|
||||
const connectedCredentials = ref<Record<string, string>>({});
|
||||
const selectedCredentials = ref<Record<string, string>>({});
|
||||
@@ -29,9 +37,14 @@ vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({ baseText: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('../channels/registry', () => {
|
||||
vi.mock('@n8n/composables/useToast', () => ({
|
||||
useToast: () => ({ showMessage: mocks.showMessage, showError: mocks.showError }),
|
||||
}));
|
||||
|
||||
vi.mock('../channels/registry', async () => {
|
||||
const { ref, defineComponent } = await import('vue');
|
||||
const platformView = {
|
||||
props: ['modelValue', 'mode', 'isPublished'],
|
||||
props: ['modelValue', 'mode', 'isPublished', 'runtime'],
|
||||
emits: ['update:modelValue', 'connect'],
|
||||
setup: () => ({
|
||||
currentSettings: { accessMode: 'all' },
|
||||
@@ -43,26 +56,67 @@ vi.mock('../channels/registry', () => {
|
||||
data-testid="platform-view"
|
||||
:data-mode="mode"
|
||||
:data-published="isPublished"
|
||||
:data-setup-kind="runtime.setupKind?.value"
|
||||
>
|
||||
<button data-testid="select-credential" @click="$emit('update:modelValue', 'credential-new')" />
|
||||
<button data-testid="connect-channel" @click="$emit('connect')" />
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
const platform = {
|
||||
type: 'example',
|
||||
const disconnectConfirmation = {
|
||||
props: ['open', 'loading'],
|
||||
emits: ['cancel', 'confirm'],
|
||||
template: `
|
||||
<div v-if="open" data-testid="disconnect-confirmation">
|
||||
<button data-testid="confirm-keep-app" @click="$emit('confirm', false)" />
|
||||
<button data-testid="confirm-delete-app" @click="$emit('confirm', true)" />
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
const slackHeaderContent = defineComponent({
|
||||
props: ['runtime', 'disabled'],
|
||||
template: `
|
||||
<select
|
||||
data-testid="slack-setup-kind-selector"
|
||||
:disabled="disabled"
|
||||
@change="runtime.setupKind.value = $event.target.value"
|
||||
>
|
||||
<option value="managed">agents.channels.slack.setupKind.recommended</option>
|
||||
<option value="manual">agents.channels.slack.setupKind.manual</option>
|
||||
</select>
|
||||
`,
|
||||
});
|
||||
const basePlatform = {
|
||||
setupComponent: platformView,
|
||||
editComponent: platformView,
|
||||
disconnectConfirmationComponent: disconnectConfirmation,
|
||||
shouldConfirmDisconnect: (
|
||||
_runtime: unknown,
|
||||
credentialId: string,
|
||||
{ isPublished }: { isPublished: boolean },
|
||||
) => isPublished && credentialId === 'credential-managed',
|
||||
getConnectAction: () => ({ label: 'Connect example', icon: 'zap' }),
|
||||
getConnectedDescription: () => 'Example connected',
|
||||
presentDisconnectWarning: (warning: { code: string }) =>
|
||||
warning.code === 'cleanup_incomplete'
|
||||
? { title: 'Cleanup incomplete', message: 'Open provider settings' }
|
||||
: null,
|
||||
};
|
||||
const examplePlatform = { ...basePlatform, type: 'example' };
|
||||
const slackPlatform = {
|
||||
...basePlatform,
|
||||
type: 'slack',
|
||||
headerContent: { setupModal: slackHeaderContent },
|
||||
};
|
||||
const runtime = {
|
||||
loading: { value: false },
|
||||
loading: ref(false),
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
setup: ref({ managedSetupAvailable: true, managerCredentials: [] }),
|
||||
setupKind: ref<'managed' | 'manual'>('managed'),
|
||||
};
|
||||
return {
|
||||
agentChannelPlatforms: { example: platform },
|
||||
getAgentChannelPlatform: () => platform,
|
||||
agentChannelPlatforms: { example: examplePlatform, slack: slackPlatform },
|
||||
getAgentChannelPlatform: (type: string) => (type === 'slack' ? slackPlatform : examplePlatform),
|
||||
createAgentChannelRuntime: () => runtime,
|
||||
};
|
||||
});
|
||||
@@ -87,6 +141,7 @@ vi.mock('../composables/useAgentIntegrationStatus', () => ({
|
||||
['configured', 'connected'].includes(statuses.value[type] ?? 'disconnected'),
|
||||
connect: mocks.connect,
|
||||
disconnect: mocks.disconnect,
|
||||
clearError: mocks.clearError,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -139,7 +194,7 @@ function mountModal(view: ChannelView = 'example_setup', isPublished = false) {
|
||||
N8nText: { template: '<span><slot /></span>' },
|
||||
AgentChannelListItem: {
|
||||
props: ['integration', 'configured', 'connected', 'connectAction'],
|
||||
emits: ['setup'],
|
||||
emits: ['setup', 'disconnect'],
|
||||
template: `
|
||||
<li
|
||||
data-testid="channel-list-item"
|
||||
@@ -147,7 +202,8 @@ function mountModal(view: ChannelView = 'example_setup', isPublished = false) {
|
||||
:data-configured="configured"
|
||||
:data-connected="connected"
|
||||
>
|
||||
<button @click="$emit('setup', integration.type)" />
|
||||
<button data-testid="setup-channel" @click="$emit('setup', integration.type)" />
|
||||
<button data-testid="disconnect-channel" @click="$emit('disconnect', integration.type)" />
|
||||
</li>
|
||||
`,
|
||||
},
|
||||
@@ -159,6 +215,7 @@ function mountModal(view: ChannelView = 'example_setup', isPublished = false) {
|
||||
describe('AgentChannelModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
catalog.value = [exampleIntegration];
|
||||
statuses.value = {};
|
||||
connectedCredentials.value = {};
|
||||
selectedCredentials.value = {};
|
||||
@@ -189,6 +246,22 @@ describe('AgentChannelModal', () => {
|
||||
expect(setup.get('[data-testid="platform-view"]').attributes('data-mode')).toBe('setup');
|
||||
});
|
||||
|
||||
it('switches Slack setup kind from the modal header selector', async () => {
|
||||
catalog.value = [exampleIntegration, slackIntegration];
|
||||
const wrapper = mountModal('slack_setup');
|
||||
await flushPromises();
|
||||
|
||||
const selector = wrapper.get('[data-testid="slack-setup-kind-selector"]');
|
||||
expect(selector.text()).toContain('agents.channels.slack.setupKind.recommended');
|
||||
expect(selector.text()).toContain('agents.channels.slack.setupKind.manual');
|
||||
|
||||
await selector.setValue('manual');
|
||||
|
||||
expect(wrapper.get('[data-testid="platform-view"]').attributes('data-setup-kind')).toBe(
|
||||
'manual',
|
||||
);
|
||||
});
|
||||
|
||||
it('presents configured and connected as distinct list states', async () => {
|
||||
statuses.value.example = 'configured';
|
||||
const configured = mountModal('list');
|
||||
@@ -227,6 +300,30 @@ describe('AgentChannelModal', () => {
|
||||
expect(wrapper.emitted('agent-changed')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('clears a stale integration error when the edit modal reopens', async () => {
|
||||
connectedCredentials.value.example = 'credential-old';
|
||||
const wrapper = mountModal('example_edit');
|
||||
await flushPromises();
|
||||
mocks.clearError.mockClear();
|
||||
|
||||
await wrapper.setProps({ open: false });
|
||||
await wrapper.setProps({ open: true });
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.clearError).toHaveBeenCalledWith('example');
|
||||
});
|
||||
|
||||
it('clears a stale integration error when the selected credential changes', async () => {
|
||||
connectedCredentials.value.example = 'credential-old';
|
||||
const wrapper = mountModal('example_edit');
|
||||
await flushPromises();
|
||||
mocks.clearError.mockClear();
|
||||
|
||||
await wrapper.get('[data-testid="select-credential"]').trigger('click');
|
||||
|
||||
expect(mocks.clearError).toHaveBeenCalledWith('example');
|
||||
});
|
||||
|
||||
it('swaps a credential in one request instead of a follow-up disconnect', async () => {
|
||||
statuses.value.example = 'connected';
|
||||
connectedCredentials.value.example = 'credential-old';
|
||||
@@ -247,4 +344,79 @@ describe('AgentChannelModal', () => {
|
||||
// modal must not issue a disconnect that could strand it.
|
||||
expect(mocks.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates disconnect warning presentation to the platform', async () => {
|
||||
statuses.value.example = 'connected';
|
||||
connectedCredentials.value.example = 'credential-old';
|
||||
mocks.disconnect.mockResolvedValueOnce({
|
||||
status: 'disconnected',
|
||||
warning: {
|
||||
integrationType: 'example',
|
||||
code: 'cleanup_incomplete',
|
||||
action: { type: 'open_url', url: 'https://example.test/settings' },
|
||||
},
|
||||
});
|
||||
const wrapper = mountModal('example_edit');
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.get('[data-testid="agent-channel-remove-channel"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.showMessage).toHaveBeenCalledWith({
|
||||
type: 'warning',
|
||||
title: 'Cleanup incomplete',
|
||||
message: 'Open provider settings',
|
||||
duration: 0,
|
||||
});
|
||||
expect(wrapper.emitted('agent-changed')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('confirms managed removal from the edit view and keeps the Slack app when unchecked', async () => {
|
||||
statuses.value.example = 'configured';
|
||||
connectedCredentials.value.example = 'credential-managed';
|
||||
const wrapper = mountModal('example_edit', true);
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.get('[data-testid="agent-channel-remove-channel"]').trigger('click');
|
||||
expect(mocks.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
await wrapper.get('[data-testid="confirm-keep-app"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.disconnect).toHaveBeenCalledWith('example', 'credential-managed', {
|
||||
deleteExternalResource: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the same managed removal confirmation from the list menu', async () => {
|
||||
statuses.value.example = 'connected';
|
||||
connectedCredentials.value.example = 'credential-managed';
|
||||
const wrapper = mountModal('list', true);
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.get('[data-testid="disconnect-channel"]').trigger('click');
|
||||
expect(mocks.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
await wrapper.get('[data-testid="confirm-delete-app"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.disconnect).toHaveBeenCalledWith('example', 'credential-managed', {
|
||||
deleteExternalResource: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('disconnects managed credentials without confirmation when the agent is unpublished', async () => {
|
||||
statuses.value.example = 'configured';
|
||||
connectedCredentials.value.example = 'credential-managed';
|
||||
const wrapper = mountModal('example_edit');
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.get('[data-testid="agent-channel-remove-channel"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="disconnect-confirmation"]').exists()).toBe(false);
|
||||
expect(mocks.disconnect).toHaveBeenCalledWith('example', 'credential-managed', {
|
||||
deleteExternalResource: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import AgentChannelSlackManagedSettings from '../components/AgentChannelSlackManagedSettings.vue';
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({ baseText: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const settings = {
|
||||
credentialId: 'bot-credential',
|
||||
appId: 'A123',
|
||||
name: 'Support Bot',
|
||||
description: 'Handles support requests',
|
||||
alwaysOnline: true,
|
||||
appHomeUrl: 'https://api.slack.com/apps/A123/app-home',
|
||||
};
|
||||
|
||||
function mountForm(
|
||||
overrides: { error?: boolean; saveError?: 'service_limits_exceeded' | null } = {},
|
||||
) {
|
||||
return mount(AgentChannelSlackManagedSettings, {
|
||||
props: {
|
||||
settings,
|
||||
loading: false,
|
||||
error: overrides.error ?? false,
|
||||
saveError: overrides.saveError,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
FormInput: {
|
||||
props: ['modelValue', 'name', 'label', 'infoText'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<input :data-testid="$attrs[\'data-testid\']" :data-label="label" :data-info="infoText" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
},
|
||||
Switch: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<button data-testid="slack-managed-app-always-online" @click="$emit(\'update:modelValue\', !modelValue)" />',
|
||||
},
|
||||
N8nLink: {
|
||||
props: ['href', 'bold'],
|
||||
template: '<a :href="href" :data-bold="bold"><slot /></a>',
|
||||
},
|
||||
N8nIcon: { template: '<i data-testid="external-link-icon" />' },
|
||||
N8nText: { template: '<span><slot /></span>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('AgentChannelSlackManagedSettings', () => {
|
||||
it('shows exported settings and links to the managed app home', () => {
|
||||
const wrapper = mountForm();
|
||||
|
||||
expect(wrapper.get('[data-testid="slack-managed-app-name"]').attributes('value')).toBe(
|
||||
'Support Bot',
|
||||
);
|
||||
expect(wrapper.get('[data-testid="slack-managed-app-name"]').attributes()).toMatchObject({
|
||||
'data-label': 'agents.channels.slack.managed.settings.name',
|
||||
'data-info': 'agents.channels.slack.managed.settings.nameDescription',
|
||||
});
|
||||
expect(wrapper.get('[data-testid="slack-managed-app-description"]').attributes('value')).toBe(
|
||||
'Handles support requests',
|
||||
);
|
||||
expect(wrapper.get('a').attributes('href')).toBe('https://api.slack.com/apps/A123/app-home');
|
||||
expect(wrapper.find('[data-testid="external-link-icon"]').exists()).toBe(true);
|
||||
expect(wrapper.vm.validationError).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes edited settings for save and validates required fields', async () => {
|
||||
const wrapper = mountForm();
|
||||
|
||||
await wrapper.get('[data-testid="slack-managed-app-name"]').setValue('Updated Bot');
|
||||
await wrapper.get('[data-testid="slack-managed-app-description"]').setValue('');
|
||||
await wrapper.get('[data-testid="slack-managed-app-always-online"]').trigger('click');
|
||||
|
||||
expect(wrapper.vm.currentSettings).toEqual({
|
||||
credentialId: 'bot-credential',
|
||||
name: 'Updated Bot',
|
||||
description: '',
|
||||
alwaysOnline: false,
|
||||
});
|
||||
expect(wrapper.vm.validationError).toBe(
|
||||
'agents.channels.slack.managed.settings.descriptionRequired',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the Slack app limit error returned while saving', () => {
|
||||
const wrapper = mountForm({ saveError: 'service_limits_exceeded' });
|
||||
|
||||
expect(wrapper.get('[data-testid="slack-managed-service-limit-error"]').text()).toContain(
|
||||
'agents.channels.slack.managed.serviceLimitsExceeded.message',
|
||||
);
|
||||
expect(wrapper.get('[data-testid="slack-managed-service-limit-link"]').attributes('href')).toBe(
|
||||
'https://api.slack.com/apps',
|
||||
);
|
||||
expect(
|
||||
wrapper.get('[data-testid="slack-managed-service-limit-link"]').attributes('target'),
|
||||
).toBe('_blank');
|
||||
expect(wrapper.vm.validationError).toBeNull();
|
||||
});
|
||||
});
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { ResponseError } from '@n8n/rest-api-client';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import AgentChannelSlackManagedSetup from '../components/AgentChannelSlackManagedSetup.vue';
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({ baseText: (key: string) => key }),
|
||||
}));
|
||||
|
||||
function mountSetup(workspaceCount: number, managerCredentialCount = 1, installError?: Error) {
|
||||
const connectManager = vi.fn().mockResolvedValue(true);
|
||||
const editManager = vi.fn();
|
||||
const installApp = installError
|
||||
? vi.fn().mockRejectedValue(installError)
|
||||
: vi.fn().mockResolvedValue(true);
|
||||
const workspaces = Array.from({ length: workspaceCount }, (_, index) => ({
|
||||
id: `T${index + 1}`,
|
||||
name: `Workspace ${index + 1}`,
|
||||
connected: false,
|
||||
}));
|
||||
const wrapper = mount(AgentChannelSlackManagedSetup, {
|
||||
props: {
|
||||
setup: {
|
||||
managedSetupAvailable: true,
|
||||
managerCredentials: Array.from({ length: managerCredentialCount }, (_, index) => ({
|
||||
id: index === 0 ? 'manager' : `manager-${index + 1}`,
|
||||
name: `Slack manager ${index + 1}`,
|
||||
connected: true,
|
||||
reconnectRequired: false,
|
||||
workspaces,
|
||||
})),
|
||||
},
|
||||
loading: false,
|
||||
credentialPermissions: { create: true },
|
||||
connectManager,
|
||||
editManager,
|
||||
installApp,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
N8nStepper: {
|
||||
template:
|
||||
'<div><slot :step="{ id: \'connect\' }" /><slot :step="{ id: \'install\' }" /></div>',
|
||||
},
|
||||
N8nSelect: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<select><slot /></select>',
|
||||
},
|
||||
N8nOption: { template: '<option />' },
|
||||
CredentialsDropdown: {
|
||||
emits: ['credentialSelected', 'newCredential'],
|
||||
template:
|
||||
'<div><button data-testid="select-manager" @click="$emit(\'credentialSelected\', \'manager\')" /><button data-testid="new-manager" @click="$emit(\'newCredential\')" /></div>',
|
||||
},
|
||||
N8nButton: {
|
||||
emits: ['click'],
|
||||
template: '<button @click="$emit(\'click\')"><slot /></button>',
|
||||
},
|
||||
N8nIcon: { template: '<span />' },
|
||||
N8nIconButton: {
|
||||
emits: ['click'],
|
||||
template: '<button @click="$emit(\'click\')" />',
|
||||
},
|
||||
N8nText: { template: '<span><slot /></span>' },
|
||||
N8nTooltip: { template: '<div><slot /></div>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
return { wrapper, connectManager, editManager, installApp };
|
||||
}
|
||||
|
||||
describe('AgentChannelSlackManagedSetup', () => {
|
||||
it('selects the only workspace and installs it', async () => {
|
||||
const { wrapper, installApp } = mountSetup(1);
|
||||
|
||||
expect(wrapper.find('[data-testid="slack-managed-workspace-select"]').exists()).toBe(true);
|
||||
await wrapper.get('[data-testid="slack-managed-install"]').trigger('click');
|
||||
|
||||
expect(installApp).toHaveBeenCalledWith('manager', 'T1');
|
||||
});
|
||||
|
||||
it('shows the workspace selector when multiple workspaces are available', () => {
|
||||
const { wrapper } = mountSetup(2);
|
||||
|
||||
expect(wrapper.find('[data-testid="slack-managed-workspace-select"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the Slack app limit message when installation exceeds service limits', async () => {
|
||||
const error = new ResponseError('Slack could not install the Slack app', {
|
||||
httpStatusCode: 400,
|
||||
meta: {
|
||||
integrationType: 'slack',
|
||||
code: 'service_limits_exceeded',
|
||||
},
|
||||
});
|
||||
const { wrapper } = mountSetup(1, 1, error);
|
||||
|
||||
await wrapper.get('[data-testid="slack-managed-install"]').trigger('click');
|
||||
|
||||
expect(wrapper.get('[data-testid="slack-managed-service-limit-error"]').text()).toContain(
|
||||
'agents.channels.slack.managed.serviceLimitsExceeded.message',
|
||||
);
|
||||
expect(wrapper.get('[data-testid="slack-managed-service-limit-link"]').attributes('href')).toBe(
|
||||
'https://api.slack.com/apps',
|
||||
);
|
||||
expect(
|
||||
wrapper.get('[data-testid="slack-managed-service-limit-link"]').attributes('target'),
|
||||
).toBe('_blank');
|
||||
expect(wrapper.text()).not.toContain('agents.channels.slack.managed.install.error');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'app_approval_request_pending',
|
||||
'slack-managed-approval-pending-error',
|
||||
'agents.channels.slack.managed.install.approvalPending',
|
||||
],
|
||||
[
|
||||
'app_approval_request_denied',
|
||||
'slack-managed-approval-denied-error',
|
||||
'agents.channels.slack.managed.install.approvalDenied',
|
||||
],
|
||||
])('shows a specific message for %s', async (code, testId, messageKey) => {
|
||||
const error = new ResponseError('Slack could not install the Slack app', {
|
||||
httpStatusCode: 400,
|
||||
meta: { integrationType: 'slack', code },
|
||||
});
|
||||
const { wrapper } = mountSetup(1, 1, error);
|
||||
|
||||
await wrapper.get('[data-testid="slack-managed-install"]').trigger('click');
|
||||
|
||||
expect(wrapper.get(`[data-testid="${testId}"]`).text()).toContain(messageKey);
|
||||
expect(wrapper.text()).not.toContain('agents.channels.slack.managed.install.error');
|
||||
});
|
||||
|
||||
it('uses the credential dropdown new-credential action to connect another workspace', async () => {
|
||||
const { wrapper, connectManager } = mountSetup(1);
|
||||
|
||||
await wrapper.get('[data-testid="new-manager"]').trigger('click');
|
||||
|
||||
expect(connectManager).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('shows the Slack quick-connect button when no manager credential exists', async () => {
|
||||
const { wrapper, connectManager } = mountSetup(0, 0);
|
||||
|
||||
expect(wrapper.find('[data-test-id="slack-manager-credential-select"]').exists()).toBe(false);
|
||||
const connectButton = wrapper.get('[data-testid="slack-manager-connect"]');
|
||||
expect(connectButton.attributes('icon')).toBe('slack');
|
||||
expect(connectButton.text()).toBe('agents.channels.slack.managed.connect.button');
|
||||
await connectButton.trigger('click');
|
||||
|
||||
expect(connectManager).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('shows the credential selector and edit button when a manager credential exists', async () => {
|
||||
const { wrapper, editManager } = mountSetup(1);
|
||||
|
||||
expect(wrapper.find('[data-testid="slack-manager-connect"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-test-id="slack-manager-credential-select"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="select-manager"]').exists()).toBe(true);
|
||||
await wrapper.get('[data-testid="slack-manager-edit"]').trigger('click');
|
||||
|
||||
expect(editManager).toHaveBeenCalledWith('manager');
|
||||
});
|
||||
});
|
||||
+16
-3
@@ -31,18 +31,20 @@ vi.mock('@n8n/i18n', () => ({
|
||||
vi.mock('../components/AgentChannelModal.vue', () => ({
|
||||
default: {
|
||||
name: 'AgentChannelModal',
|
||||
props: ['simpleSetup'],
|
||||
template: '<div data-testid="agent-channel-modal-stub" :data-simple-setup="simpleSetup" />',
|
||||
props: ['simpleSetup', 'isPublished'],
|
||||
template:
|
||||
'<div data-testid="agent-channel-modal-stub" :data-simple-setup="simpleSetup" :data-is-published="isPublished" />',
|
||||
},
|
||||
}));
|
||||
|
||||
function mountSection(simpleChannelSetup?: boolean) {
|
||||
function mountSection(simpleChannelSetup?: boolean, isPublished = false) {
|
||||
return mount(AgentChannelsSection, {
|
||||
props: {
|
||||
connectedTriggers: [],
|
||||
projectId: 'project-id',
|
||||
agentId: 'agent-id',
|
||||
simpleChannelSetup,
|
||||
isPublished,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
@@ -77,4 +79,15 @@ describe('AgentChannelsSection', () => {
|
||||
expect(modal.attributes('data-simple-setup')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards publication state to the channel modal', async () => {
|
||||
const wrapper = mountSection(undefined, true);
|
||||
|
||||
await wrapper.find('[data-testid="agent-channels-add-channel"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-testid="agent-channel-modal-stub"]').attributes('data-is-published'),
|
||||
).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
+18
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ResponseError } from '@n8n/rest-api-client';
|
||||
|
||||
import {
|
||||
clearAgentIntegrationStatusCache,
|
||||
@@ -84,4 +85,21 @@ describe('useAgentIntegrationStatus', () => {
|
||||
telegram: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears a cached integration error', async () => {
|
||||
apiMocks.connectIntegration.mockRejectedValue(
|
||||
new ResponseError('Slack credential is already connected', { httpStatusCode: 409 }),
|
||||
);
|
||||
const status = useAgentIntegrationStatus(projectId, agentId);
|
||||
await expect(status.connect('slack', 'cred-slack')).rejects.toThrow(
|
||||
'Slack credential is already connected',
|
||||
);
|
||||
expect(status.errorMessages.value.slack).toBe('Slack credential is already connected');
|
||||
expect(status.errorIsConflict.value.slack).toBe(true);
|
||||
|
||||
status.clearError('slack');
|
||||
|
||||
expect(status.errorMessages.value.slack).toBe('');
|
||||
expect(status.errorIsConflict.value.slack).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,19 +20,59 @@ describe('agent channel platform registry', () => {
|
||||
expect(platform.editComponent).toBeDefined();
|
||||
});
|
||||
|
||||
it('uses manual-only Slack list metadata', () => {
|
||||
it('narrows registered platform keys', () => {
|
||||
expect(isRegisteredAgentChannelPlatform('slack')).toBe(true);
|
||||
expect(isRegisteredAgentChannelPlatform('future-channel')).toBe(false);
|
||||
});
|
||||
|
||||
it('derives Slack list metadata from its local runtime state', () => {
|
||||
const platform = getAgentChannelPlatform('slack');
|
||||
const runtime = {
|
||||
loading: ref(false),
|
||||
load: async () => {},
|
||||
setup: ref({ managedSetupAvailable: true, managerCredentials: [] }),
|
||||
};
|
||||
const action = platform.getConnectAction({ text }, runtime);
|
||||
|
||||
expect(action).toEqual({ label: 'generic.connect' });
|
||||
expect(action).toEqual({
|
||||
label: 'agents.channels.slack.managed.addToSlack',
|
||||
icon: 'slack',
|
||||
});
|
||||
});
|
||||
|
||||
it('narrows registered platform keys without casting', () => {
|
||||
expect(isRegisteredAgentChannelPlatform('slack')).toBe(true);
|
||||
expect(isRegisteredAgentChannelPlatform('future-channel')).toBe(false);
|
||||
it('confirms removal only for managed Slack credentials on published agents', () => {
|
||||
const platform = getAgentChannelPlatform('slack');
|
||||
const runtime = {
|
||||
loading: ref(false),
|
||||
load: async () => {},
|
||||
setup: ref({ managedSetupAvailable: true, managerCredentials: [] }),
|
||||
isManagedCredential: (credentialId: string) => credentialId === 'managed',
|
||||
};
|
||||
|
||||
expect(platform.shouldConfirmDisconnect?.(runtime, 'managed', { isPublished: true })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(platform.shouldConfirmDisconnect?.(runtime, 'managed', { isPublished: false })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(platform.shouldConfirmDisconnect?.(runtime, 'manual', { isPublished: true })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(platform.disconnectConfirmationComponent).toBeDefined();
|
||||
});
|
||||
|
||||
it('presents the generic Slack disconnect warning contract', () => {
|
||||
const platform = getAgentChannelPlatform('slack');
|
||||
const presentation = platform.presentDisconnectWarning?.(
|
||||
{
|
||||
integrationType: 'slack',
|
||||
code: 'app_not_deleted',
|
||||
action: { type: 'open_url', url: 'https://api.slack.com/apps/A123' },
|
||||
},
|
||||
{ text },
|
||||
);
|
||||
|
||||
expect(presentation?.title).toBe('agents.channels.modal.slackAppNotDeleted.title');
|
||||
expect(presentation?.message).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readonly, ref } from 'vue';
|
||||
import type { AgentIntegrationDisconnectWarning } from '@n8n/api-types';
|
||||
import { h, readonly, ref } from 'vue';
|
||||
|
||||
import AgentChannelDiscordSetup from '../components/AgentChannelDiscordSetup.vue';
|
||||
import AgentChannelDiscordEditView from './discord/AgentChannelDiscordEditView.vue';
|
||||
@@ -6,14 +7,16 @@ import AgentChannelFallbackView from './fallback/AgentChannelFallbackView.vue';
|
||||
import AgentChannelLinearEditView from './linear/AgentChannelLinearEditView.vue';
|
||||
import AgentChannelLinearSetup from './linear/AgentChannelLinearSetup.vue';
|
||||
import AgentChannelSlackEditView from './slack/AgentChannelSlackEditView.vue';
|
||||
import AgentChannelSlackRemoveConfirmation from './slack/AgentChannelSlackRemoveConfirmation.vue';
|
||||
import AgentChannelSlackSetupKindSelector from './slack/AgentChannelSlackSetupKindSelector.vue';
|
||||
import AgentChannelSlackSetupView from './slack/AgentChannelSlackSetupView.vue';
|
||||
import { useSlackChannelRuntime } from './slack/useSlackChannelRuntime';
|
||||
import { isSlackChannelRuntime, useSlackChannelRuntime } from './slack/useSlackChannelRuntime';
|
||||
import AgentChannelTelegramEditView from './telegram/AgentChannelTelegramEditView.vue';
|
||||
import AgentChannelTelegramSetup from './telegram/AgentChannelTelegramSetup.vue';
|
||||
import type {
|
||||
AgentChannelPlatform,
|
||||
AgentChannelRuntime,
|
||||
AgentChannelRuntimeContext,
|
||||
ChannelPlatformDefinition,
|
||||
} from './types';
|
||||
|
||||
function createDefaultRuntime(): AgentChannelRuntime {
|
||||
@@ -21,7 +24,18 @@ function createDefaultRuntime(): AgentChannelRuntime {
|
||||
return { load: async () => {}, loading: readonly(loading) };
|
||||
}
|
||||
|
||||
const fallbackPlatform: ChannelPlatformDefinition = {
|
||||
const isSlackNotDeletedWarning = (
|
||||
warning: AgentIntegrationDisconnectWarning,
|
||||
): warning is AgentIntegrationDisconnectWarning & { action: { url: string } } => {
|
||||
return (
|
||||
warning.integrationType === 'slack' &&
|
||||
warning.code === 'app_not_deleted' &&
|
||||
warning.action?.type === 'open_url' &&
|
||||
!!warning.action?.url
|
||||
);
|
||||
};
|
||||
|
||||
const fallbackPlatform: AgentChannelPlatform = {
|
||||
type: 'unknown',
|
||||
setupComponent: AgentChannelFallbackView,
|
||||
editComponent: AgentChannelFallbackView,
|
||||
@@ -33,8 +47,44 @@ const platforms = {
|
||||
type: 'slack',
|
||||
setupComponent: AgentChannelSlackSetupView,
|
||||
editComponent: AgentChannelSlackEditView,
|
||||
headerContent: {
|
||||
setupModal: AgentChannelSlackSetupKindSelector,
|
||||
},
|
||||
disconnectConfirmationComponent: AgentChannelSlackRemoveConfirmation,
|
||||
createRuntime: useSlackChannelRuntime,
|
||||
getConnectAction: ({ text }) => ({ label: text('generic.connect') }),
|
||||
shouldConfirmDisconnect: (runtime, credentialId, { isPublished }) =>
|
||||
isPublished && isSlackChannelRuntime(runtime) && runtime.isManagedCredential(credentialId),
|
||||
getConnectAction: ({ text }, runtime) => {
|
||||
const managedSetupAvailable =
|
||||
isSlackChannelRuntime(runtime) && runtime.setup.value.managedSetupAvailable;
|
||||
return {
|
||||
label: text(
|
||||
managedSetupAvailable ? 'agents.channels.slack.managed.addToSlack' : 'generic.connect',
|
||||
),
|
||||
icon: managedSetupAvailable ? 'slack' : undefined,
|
||||
};
|
||||
},
|
||||
presentDisconnectWarning: (warning, { text }) => {
|
||||
if (!isSlackNotDeletedWarning(warning)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: text('agents.channels.modal.slackAppNotDeleted.title'),
|
||||
message: h('span', [
|
||||
text('agents.channels.modal.slackAppNotDeleted.message'),
|
||||
' ',
|
||||
h(
|
||||
'a',
|
||||
{
|
||||
href: warning.action.url,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
text('agents.channels.modal.slackAppNotDeleted.link'),
|
||||
),
|
||||
]),
|
||||
};
|
||||
},
|
||||
},
|
||||
linear: {
|
||||
type: 'linear',
|
||||
@@ -56,18 +106,18 @@ const platforms = {
|
||||
editComponent: AgentChannelDiscordEditView,
|
||||
getConnectAction: ({ text }) => ({ label: text('generic.connect') }),
|
||||
},
|
||||
} satisfies Record<string, ChannelPlatformDefinition>;
|
||||
} satisfies Record<string, AgentChannelPlatform>;
|
||||
|
||||
export function isRegisteredAgentChannelPlatform(type: string): type is keyof typeof platforms {
|
||||
return Object.hasOwn(platforms, type);
|
||||
}
|
||||
|
||||
export function getAgentChannelPlatform(type: string): ChannelPlatformDefinition {
|
||||
export function getAgentChannelPlatform(type: string): AgentChannelPlatform {
|
||||
return isRegisteredAgentChannelPlatform(type) ? platforms[type] : fallbackPlatform;
|
||||
}
|
||||
|
||||
export function createAgentChannelRuntime(
|
||||
platform: ChannelPlatformDefinition,
|
||||
platform: AgentChannelPlatform,
|
||||
context: AgentChannelRuntimeContext,
|
||||
): AgentChannelRuntime {
|
||||
return platform.createRuntime?.(context) ?? createDefaultRuntime();
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import AgentChannelSlackEditView from './AgentChannelSlackEditView.vue';
|
||||
import type { SlackChannelRuntime } from './useSlackChannelRuntime';
|
||||
|
||||
describe('AgentChannelSlackEditView', () => {
|
||||
it('exposes managed settings save activity as loading', async () => {
|
||||
const settingsLoading = ref(false);
|
||||
const runtime = {
|
||||
loading: ref(false),
|
||||
settingsLoading,
|
||||
settings: ref(null),
|
||||
settingsError: ref(false),
|
||||
settingsSaveError: ref(null),
|
||||
isManagedCredential: () => true,
|
||||
} as unknown as SlackChannelRuntime;
|
||||
const wrapper = mount(AgentChannelSlackEditView, {
|
||||
props: {
|
||||
modelValue: 'slack-credential',
|
||||
mode: 'edit',
|
||||
integration: {
|
||||
type: 'slack',
|
||||
label: 'Slack',
|
||||
icon: 'slack',
|
||||
credentialTypes: ['slackApi'],
|
||||
},
|
||||
credentials: [],
|
||||
credentialPermissions: { create: true },
|
||||
credentialsLoading: false,
|
||||
loading: false,
|
||||
connected: true,
|
||||
connectedDescription: '',
|
||||
errorMessage: '',
|
||||
errorIsConflict: false,
|
||||
isPublished: true,
|
||||
agentName: 'Agent',
|
||||
projectId: 'project-1',
|
||||
agentId: 'agent-1',
|
||||
forceNewCredential: false,
|
||||
simpleSetup: false,
|
||||
runtime,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
AgentIntegrationCredentialConnection: true,
|
||||
AgentChannelSlackManagedSettings: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.vm.loading).toBe(false);
|
||||
|
||||
settingsLoading.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.loading).toBe(true);
|
||||
});
|
||||
});
|
||||
+55
-18
@@ -1,34 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import AgentIntegrationCredentialConnection from '../../components/AgentIntegrationCredentialConnection.vue';
|
||||
import AgentChannelSlackManagedSettings from '../../components/AgentChannelSlackManagedSettings.vue';
|
||||
import type { AgentChannelViewProps } from '../types';
|
||||
import type { SlackChannelRuntime } from './useSlackChannelRuntime';
|
||||
|
||||
const credentialId = defineModel<string>({ default: '' });
|
||||
const props = defineProps<AgentChannelViewProps>();
|
||||
const props = defineProps<
|
||||
Omit<AgentChannelViewProps, 'runtime'> & { runtime: SlackChannelRuntime }
|
||||
>();
|
||||
const emit = defineEmits<{
|
||||
create: [];
|
||||
edit: [];
|
||||
}>();
|
||||
|
||||
const loading = computed(() => props.loading || props.runtime.loading.value);
|
||||
const settingsRef = ref<InstanceType<typeof AgentChannelSlackManagedSettings>>();
|
||||
const managed = computed(() => props.runtime.isManagedCredential(credentialId.value));
|
||||
const validationError = computed(() =>
|
||||
managed.value ? (settingsRef.value?.validationError ?? null) : null,
|
||||
);
|
||||
const loading = computed(
|
||||
() => props.loading || props.runtime.loading.value || props.runtime.settingsLoading.value,
|
||||
);
|
||||
|
||||
defineExpose({ validationError: null, loading });
|
||||
async function beforeSave() {
|
||||
if (!managed.value) return;
|
||||
const settings = settingsRef.value?.currentSettings;
|
||||
if (!settings || settingsRef.value?.validationError) return;
|
||||
await props.runtime.saveSettings(settings);
|
||||
}
|
||||
|
||||
defineExpose({ validationError, loading, beforeSave });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AgentIntegrationCredentialConnection
|
||||
v-model="credentialId"
|
||||
:integration-type="integration.type"
|
||||
:integration-label="integration.label"
|
||||
:credentials="credentials"
|
||||
:credential-permissions="credentialPermissions"
|
||||
:credentials-loading="credentialsLoading"
|
||||
:disabled="loading"
|
||||
:loading="loading"
|
||||
:error-message="errorMessage"
|
||||
:error-is-conflict="errorIsConflict"
|
||||
@create="emit('create')"
|
||||
@edit="emit('edit')"
|
||||
/>
|
||||
<div :class="$style.editView">
|
||||
<AgentIntegrationCredentialConnection
|
||||
v-model="credentialId"
|
||||
:integration-type="integration.type"
|
||||
:integration-label="integration.label"
|
||||
:credentials="credentials"
|
||||
:credential-permissions="credentialPermissions"
|
||||
:credentials-loading="credentialsLoading"
|
||||
:disabled="loading"
|
||||
:loading="loading"
|
||||
:error-message="errorMessage"
|
||||
:error-is-conflict="errorIsConflict"
|
||||
@create="emit('create')"
|
||||
@edit="emit('edit')"
|
||||
/>
|
||||
<AgentChannelSlackManagedSettings
|
||||
v-if="managed"
|
||||
ref="settingsRef"
|
||||
:settings="runtime.settings.value"
|
||||
:loading="runtime.settingsLoading.value"
|
||||
:error="runtime.settingsError.value"
|
||||
:save-error="runtime.settingsSaveError.value"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.editView {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--md);
|
||||
}
|
||||
</style>
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import AgentChannelSlackRemoveConfirmation from './AgentChannelSlackRemoveConfirmation.vue';
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({ baseText: (key: string) => key }),
|
||||
}));
|
||||
|
||||
function mountConfirmation() {
|
||||
return mount(AgentChannelSlackRemoveConfirmation, {
|
||||
props: { open: true, loading: false },
|
||||
global: {
|
||||
stubs: {
|
||||
Dialog: {
|
||||
props: ['open', 'stacked', 'size'],
|
||||
template: '<div v-if="open" :data-stacked="stacked" :data-size="size"><slot /></div>',
|
||||
},
|
||||
DialogHeader: { template: '<header><slot /></header>' },
|
||||
DialogTitle: { template: '<h2><slot /></h2>' },
|
||||
DialogFooter: { template: '<footer><slot /></footer>' },
|
||||
N8nText: { template: '<span><slot /></span>' },
|
||||
N8nButton: {
|
||||
emits: ['click'],
|
||||
template: '<button @click="$emit(\'click\')"><slot /></button>',
|
||||
},
|
||||
N8nCheckbox: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<div>
|
||||
<button
|
||||
data-testid="slack-managed-remove-delete-app"
|
||||
@click="$emit('update:modelValue', !modelValue)"
|
||||
/>
|
||||
<slot name="label" />
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('AgentChannelSlackRemoveConfirmation', () => {
|
||||
it('controls whether the managed Slack app is deleted', async () => {
|
||||
const wrapper = mountConfirmation();
|
||||
|
||||
expect(wrapper.attributes()).toHaveProperty('data-stacked');
|
||||
expect(wrapper.attributes('data-size')).toBe('medium');
|
||||
await wrapper.get('[data-testid="slack-managed-remove-confirm"]').trigger('click');
|
||||
expect(wrapper.emitted('confirm')).toEqual([[true]]);
|
||||
|
||||
await wrapper.get('[data-testid="slack-managed-remove-delete-app"]').trigger('click');
|
||||
await wrapper.get('[data-testid="slack-managed-remove-confirm"]').trigger('click');
|
||||
expect(wrapper.emitted('confirm')).toEqual([[true], [false]]);
|
||||
});
|
||||
});
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
N8nButton,
|
||||
N8nCheckbox,
|
||||
N8nDialog,
|
||||
N8nDialogFooter,
|
||||
N8nDialogHeader,
|
||||
N8nDialogTitle,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: [];
|
||||
confirm: [deleteExternalResource: boolean];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const deleteExternalResource = ref(true);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) deleteExternalResource.value = true;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nDialog
|
||||
:open="open"
|
||||
size="medium"
|
||||
stacked
|
||||
:show-close-button="!loading"
|
||||
@update:open="(value) => !value && !loading && emit('cancel')"
|
||||
>
|
||||
<N8nDialogHeader>
|
||||
<N8nDialogTitle>
|
||||
{{ i18n.baseText('agents.channels.slack.managed.remove.title') }}
|
||||
</N8nDialogTitle>
|
||||
</N8nDialogHeader>
|
||||
<div :class="$style.content">
|
||||
<N8nText size="medium">
|
||||
{{ i18n.baseText('agents.channels.slack.managed.remove.description') }}
|
||||
</N8nText>
|
||||
<N8nCheckbox
|
||||
v-model="deleteExternalResource"
|
||||
:disabled="loading"
|
||||
data-testid="slack-managed-remove-delete-app"
|
||||
>
|
||||
<template #label>
|
||||
<N8nText size="medium">
|
||||
{{ i18n.baseText('agents.channels.slack.managed.remove.deleteApp') }}
|
||||
<N8nText size="medium" :bold="true">
|
||||
{{ i18n.baseText('agents.channels.slack.managed.remove.messagesUnaffected') }}
|
||||
</N8nText>
|
||||
</N8nText>
|
||||
</template>
|
||||
</N8nCheckbox>
|
||||
</div>
|
||||
<N8nDialogFooter>
|
||||
<div :class="$style.actions">
|
||||
<N8nButton variant="outline" :disabled="loading" @click="emit('cancel')">
|
||||
{{ i18n.baseText('generic.cancel') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
variant="destructive"
|
||||
:loading="loading"
|
||||
data-testid="slack-managed-remove-confirm"
|
||||
@click="emit('confirm', deleteExternalResource)"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.slack.managed.remove.confirm') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</N8nDialogFooter>
|
||||
</N8nDialog>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
margin-top: var(--spacing--md);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing--xs);
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { N8nLink, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
import { SLACK_APP_DASHBOARD_URL } from './constants';
|
||||
|
||||
const i18n = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nText size="small" :class="$style.error" data-testid="slack-managed-service-limit-error">
|
||||
{{ i18n.baseText('agents.channels.slack.managed.serviceLimitsExceeded.message') }}
|
||||
<N8nLink
|
||||
:href="SLACK_APP_DASHBOARD_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="small"
|
||||
data-testid="slack-managed-service-limit-link"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.slack.managed.serviceLimitsExceeded.openDashboard') }}
|
||||
</N8nLink>
|
||||
</N8nText>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.error {
|
||||
color: var(--text-color--danger);
|
||||
}
|
||||
</style>
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { N8nOption, N8nSelect } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { AgentChannelRuntime } from '../types';
|
||||
import { isSlackChannelRuntime, type SlackSetupKind } from './useSlackChannelRuntime';
|
||||
|
||||
const props = defineProps<{
|
||||
runtime: AgentChannelRuntime;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const visible = computed(
|
||||
() => isSlackChannelRuntime(props.runtime) && props.runtime.setup.value.managedSetupAvailable,
|
||||
);
|
||||
|
||||
const setupKind = computed<SlackSetupKind>({
|
||||
get: () => (isSlackChannelRuntime(props.runtime) ? props.runtime.setupKind.value : 'managed'),
|
||||
set: (value) => {
|
||||
if (isSlackChannelRuntime(props.runtime)) {
|
||||
props.runtime.setupKind.value = value;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nSelect
|
||||
v-if="visible"
|
||||
v-model="setupKind"
|
||||
:class="$style.slackSetupKindSelector"
|
||||
:disabled="disabled"
|
||||
size="medium"
|
||||
:teleported="false"
|
||||
data-testid="slack-setup-kind-selector"
|
||||
>
|
||||
<N8nOption
|
||||
value="managed"
|
||||
:label="i18n.baseText('agents.channels.slack.setupKind.recommended')"
|
||||
/>
|
||||
<N8nOption value="manual" :label="i18n.baseText('agents.channels.slack.setupKind.manual')" />
|
||||
</N8nSelect>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.slackSetupKindSelector {
|
||||
--input--radius: var(--radius--xs);
|
||||
|
||||
width: 240px;
|
||||
}
|
||||
</style>
|
||||
+98
-28
@@ -1,12 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { N8nLoading } from '@n8n/design-system';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import AgentChannelSlackManagedSetup from '../../components/AgentChannelSlackManagedSetup.vue';
|
||||
import AgentChannelSlackSetup from '../../components/AgentChannelSlackSetup.vue';
|
||||
import type { AgentChannelViewExpose, AgentChannelViewProps } from '../types';
|
||||
import { isSlackChannelRuntime } from './useSlackChannelRuntime';
|
||||
import type { SlackChannelRuntime } from './useSlackChannelRuntime';
|
||||
|
||||
const credentialId = defineModel<string>({ default: '' });
|
||||
const props = defineProps<AgentChannelViewProps>();
|
||||
const props = defineProps<
|
||||
Omit<AgentChannelViewProps, 'runtime'> & {
|
||||
runtime: SlackChannelRuntime;
|
||||
}
|
||||
>();
|
||||
const emit = defineEmits<{
|
||||
create: [];
|
||||
edit: [];
|
||||
@@ -15,40 +21,104 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const manualRef = ref<AgentChannelViewExpose>();
|
||||
const managedActionInFlight = ref(false);
|
||||
const validationError = computed(() => manualRef.value?.validationError ?? null);
|
||||
const loading = computed(() => props.loading || props.runtime.loading.value);
|
||||
const loading = computed(
|
||||
() =>
|
||||
props.loading ||
|
||||
props.runtime.loading.value ||
|
||||
managedActionInFlight.value ||
|
||||
manualRef.value?.loading === true,
|
||||
);
|
||||
|
||||
async function setupApp(token: string) {
|
||||
if (props.disabled) return false;
|
||||
if (!isSlackChannelRuntime(props.runtime)) {
|
||||
throw new Error('Slack channel runtime is unavailable');
|
||||
}
|
||||
return await props.runtime.setupApp(token, () => emit('connected'));
|
||||
}
|
||||
|
||||
async function connectManagerCredential(credentialId?: string) {
|
||||
if (props.disabled || managedActionInFlight.value) return false;
|
||||
managedActionInFlight.value = true;
|
||||
try {
|
||||
return await props.runtime.connectManagerCredential(credentialId);
|
||||
} finally {
|
||||
managedActionInFlight.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function installManagedApp(managerCredentialId: string, workspaceId: string) {
|
||||
if (props.disabled || managedActionInFlight.value) return false;
|
||||
managedActionInFlight.value = true;
|
||||
try {
|
||||
return await props.runtime.installManagedApp(managerCredentialId, workspaceId, () =>
|
||||
emit('connected'),
|
||||
);
|
||||
} finally {
|
||||
managedActionInFlight.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.projectId, props.agentId] as const,
|
||||
() => {
|
||||
props.runtime.setupKind.value = 'managed';
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
defineExpose({ validationError, loading });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AgentChannelSlackSetup
|
||||
ref="manualRef"
|
||||
v-model="credentialId"
|
||||
:connected="connected"
|
||||
:setup-slack-app="setupApp"
|
||||
:project-id="projectId"
|
||||
:agent-id="agentId"
|
||||
:integration="integration"
|
||||
:credentials="credentials"
|
||||
:credential-permissions="credentialPermissions"
|
||||
:credentials-loading="credentialsLoading"
|
||||
:loading="loading"
|
||||
:disabled="disabled"
|
||||
:error-message="errorMessage"
|
||||
:error-is-conflict="errorIsConflict"
|
||||
:force-new-credential="forceNewCredential"
|
||||
:setup-mode="simpleSetup ? 'simple' : 'advanced'"
|
||||
@create="emit('create')"
|
||||
@edit="emit('edit')"
|
||||
@connect="emit('connect')"
|
||||
/>
|
||||
<div :class="$style.view">
|
||||
<div
|
||||
v-if="runtime.loading.value"
|
||||
:class="$style.skeleton"
|
||||
data-testid="slack-managed-setup-skeleton"
|
||||
>
|
||||
<N8nLoading variant="p" :rows="4" />
|
||||
</div>
|
||||
<AgentChannelSlackManagedSetup
|
||||
v-else-if="runtime.setup.value.managedSetupAvailable && runtime.setupKind.value === 'managed'"
|
||||
:setup="runtime.setup.value"
|
||||
:loading="loading"
|
||||
:credential-permissions="credentialPermissions"
|
||||
:connect-manager="connectManagerCredential"
|
||||
:edit-manager="runtime.editManagerCredential"
|
||||
:install-app="installManagedApp"
|
||||
/>
|
||||
<AgentChannelSlackSetup
|
||||
v-else
|
||||
ref="manualRef"
|
||||
v-model="credentialId"
|
||||
:connected="connected"
|
||||
:is-published="isPublished"
|
||||
:setup-slack-app="setupApp"
|
||||
:project-id="projectId"
|
||||
:agent-id="agentId"
|
||||
:integration="integration"
|
||||
:credentials="credentials"
|
||||
:credential-permissions="credentialPermissions"
|
||||
:credentials-loading="credentialsLoading"
|
||||
:loading="loading"
|
||||
:disabled="disabled"
|
||||
:error-message="errorMessage"
|
||||
:error-is-conflict="errorIsConflict"
|
||||
:force-new-credential="forceNewCredential"
|
||||
:setup-mode="simpleSetup ? 'simple' : 'advanced'"
|
||||
@create="emit('create')"
|
||||
@edit="emit('edit')"
|
||||
@connect="emit('connect')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.view {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
padding-block: var(--spacing--xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ResponseError } from '@n8n/rest-api-client';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getSlackApiErrorCode } from './api';
|
||||
|
||||
describe('getSlackApiErrorCode', () => {
|
||||
it('returns the code from a Slack API response error', () => {
|
||||
const error = new ResponseError('Slack request failed', {
|
||||
httpStatusCode: 400,
|
||||
meta: {
|
||||
integrationType: 'slack',
|
||||
code: 'service_limits_exceeded',
|
||||
},
|
||||
});
|
||||
|
||||
expect(getSlackApiErrorCode(error)).toBe('service_limits_exceeded');
|
||||
});
|
||||
|
||||
it('rejects metadata from another integration', () => {
|
||||
const error = new ResponseError('Integration request failed', {
|
||||
httpStatusCode: 400,
|
||||
meta: {
|
||||
integrationType: 'linear',
|
||||
code: 'service_limits_exceeded',
|
||||
},
|
||||
});
|
||||
|
||||
expect(getSlackApiErrorCode(error)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects Slack-shaped metadata outside a response error', () => {
|
||||
const error = {
|
||||
meta: {
|
||||
integrationType: 'slack',
|
||||
code: 'service_limits_exceeded',
|
||||
},
|
||||
};
|
||||
|
||||
expect(getSlackApiErrorCode(error)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,34 @@
|
||||
import type { CreateSlackAgentAppResponse, SlackAgentAppManifestResponse } from '@n8n/api-types';
|
||||
import type {
|
||||
CreateSlackAgentAppResponse,
|
||||
CreateSlackManagerCredentialResponse,
|
||||
InstallSlackManagedAppResponse,
|
||||
SlackAgentAppManifestResponse,
|
||||
SlackApiErrorMeta,
|
||||
SlackManagedAppSettings,
|
||||
SlackManagedSetupState,
|
||||
} from '@n8n/api-types';
|
||||
import type { IRestApiContext } from '@n8n/rest-api-client';
|
||||
import { makeRestApiRequest } from '@n8n/rest-api-client';
|
||||
import { makeRestApiRequest, ResponseError } from '@n8n/rest-api-client';
|
||||
|
||||
const integrationPath = (projectId: string, agentId: string) =>
|
||||
`/projects/${projectId}/agents/v2/${agentId}/integrations/slack`;
|
||||
|
||||
function isSlackApiErrorMeta(value: unknown): value is SlackApiErrorMeta {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'integrationType' in value &&
|
||||
value.integrationType === 'slack' &&
|
||||
'code' in value &&
|
||||
typeof value.code === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function getSlackApiErrorCode(error: unknown): string | undefined {
|
||||
const meta = error instanceof ResponseError ? error.meta : undefined;
|
||||
return isSlackApiErrorMeta(meta) ? meta.code : undefined;
|
||||
}
|
||||
|
||||
export const createSlackAgentApp = async (
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
@@ -21,3 +45,72 @@ export const getSlackAgentAppManifest = async (
|
||||
agentId: string,
|
||||
): Promise<SlackAgentAppManifestResponse> =>
|
||||
await makeRestApiRequest(context, 'GET', `${integrationPath(projectId, agentId)}/manifest`);
|
||||
|
||||
export const getSlackManagedSetup = async (
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
): Promise<SlackManagedSetupState> =>
|
||||
await makeRestApiRequest(context, 'GET', `${integrationPath(projectId, agentId)}/managed/setup`);
|
||||
|
||||
export const createSlackManagerCredential = async (
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
): Promise<CreateSlackManagerCredentialResponse> =>
|
||||
await makeRestApiRequest(
|
||||
context,
|
||||
'POST',
|
||||
`${integrationPath(projectId, agentId)}/managed/credentials`,
|
||||
);
|
||||
|
||||
export const finalizeSlackManagerCredential = async (
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
credentialId: string,
|
||||
): Promise<void> =>
|
||||
await makeRestApiRequest(
|
||||
context,
|
||||
'POST',
|
||||
`${integrationPath(projectId, agentId)}/managed/credentials/${credentialId}/finalize`,
|
||||
);
|
||||
|
||||
export const installSlackManagedApp = async (
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
managerCredentialId: string,
|
||||
workspaceId: string,
|
||||
): Promise<InstallSlackManagedAppResponse> =>
|
||||
await makeRestApiRequest(
|
||||
context,
|
||||
'POST',
|
||||
`${integrationPath(projectId, agentId)}/managed/install`,
|
||||
{ managerCredentialId, workspaceId },
|
||||
);
|
||||
|
||||
export const getSlackManagedAppSettings = async (
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
credentialId: string,
|
||||
): Promise<SlackManagedAppSettings> =>
|
||||
await makeRestApiRequest(
|
||||
context,
|
||||
'GET',
|
||||
`${integrationPath(projectId, agentId)}/managed/settings/${credentialId}`,
|
||||
);
|
||||
|
||||
export const updateSlackManagedAppSettings = async (
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
settings: Pick<SlackManagedAppSettings, 'credentialId' | 'name' | 'description' | 'alwaysOnline'>,
|
||||
): Promise<SlackManagedAppSettings> =>
|
||||
await makeRestApiRequest(
|
||||
context,
|
||||
'POST',
|
||||
`${integrationPath(projectId, agentId)}/managed/settings`,
|
||||
settings,
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const SLACK_APP_DASHBOARD_URL = 'https://api.slack.com/apps';
|
||||
+225
-47
@@ -1,71 +1,249 @@
|
||||
import { flushPromises } from '@vue/test-utils';
|
||||
import { ResponseError } from '@n8n/rest-api-client';
|
||||
import { ref } from 'vue';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useSlackChannelRuntime } from './useSlackChannelRuntime';
|
||||
|
||||
const { createSlackAgentApp } = vi.hoisted(() => ({
|
||||
createSlackAgentApp: vi.fn().mockResolvedValue({ installUrl: 'https://slack.test/install' }),
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getSetup: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
install: vi.fn(),
|
||||
fetchCredentials: vi.fn(),
|
||||
createManager: vi.fn(),
|
||||
finalizeManager: vi.fn(),
|
||||
authorize: vi.fn(),
|
||||
authorizeNewCredential: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/stores/useRootStore', () => ({
|
||||
useRootStore: () => ({ restApiContext: {} }),
|
||||
useRootStore: () => ({ restApiContext: {}, urlBaseEditor: 'https://n8n.test' }),
|
||||
}));
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
createSlackAgentApp,
|
||||
vi.mock('@/app/stores/ui.store', () => ({
|
||||
useUIStore: () => ({ openExistingCredential: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/credentials/credentials.store', () => ({
|
||||
useCredentialsStore: () => ({
|
||||
setCredentials: vi.fn(),
|
||||
fetchAllCredentialsForWorkflow: mocks.fetchCredentials,
|
||||
deleteCredential: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/credentials/composables/useCredentialOAuth', () => ({
|
||||
useCredentialOAuth: () => ({
|
||||
authorize: mocks.authorize,
|
||||
authorizeNewCredential: mocks.authorizeNewCredential,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./api', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./api')>()),
|
||||
createSlackAgentApp: vi.fn(),
|
||||
createSlackManagerCredential: mocks.createManager,
|
||||
finalizeSlackManagerCredential: mocks.finalizeManager,
|
||||
getSlackManagedSetup: mocks.getSetup,
|
||||
getSlackManagedAppSettings: mocks.getSettings,
|
||||
updateSlackManagedAppSettings: mocks.updateSettings,
|
||||
installSlackManagedApp: mocks.install,
|
||||
}));
|
||||
|
||||
function createRuntime(
|
||||
selectedCredentialId = '',
|
||||
credentialModalOpen = ref(false),
|
||||
ensureAgentPersisted = vi.fn().mockResolvedValue(undefined),
|
||||
) {
|
||||
return useSlackChannelRuntime({
|
||||
projectId: ref('project-1'),
|
||||
agentId: ref('agent-1'),
|
||||
selectedCredentialId: ref(selectedCredentialId),
|
||||
credentialModalOpen,
|
||||
fetchStatus: vi.fn().mockResolvedValue(undefined),
|
||||
isConnected: () => false,
|
||||
isConfigured: () => false,
|
||||
ensureAgentPersisted,
|
||||
});
|
||||
}
|
||||
|
||||
describe('useSlackChannelRuntime', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getSetup.mockResolvedValue({
|
||||
managedSetupAvailable: true,
|
||||
managerCredentials: [
|
||||
{
|
||||
id: 'manager',
|
||||
name: 'Manager',
|
||||
connected: true,
|
||||
reconnectRequired: false,
|
||||
workspaces: [{ id: 'T1', name: 'Workspace', botCredentialId: 'bot' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.getSettings.mockResolvedValue({
|
||||
credentialId: 'bot',
|
||||
appId: 'A1',
|
||||
name: 'Bot',
|
||||
description: 'Description',
|
||||
alwaysOnline: true,
|
||||
appHomeUrl: 'https://api.slack.com/apps/A1/app-home',
|
||||
});
|
||||
mocks.updateSettings.mockResolvedValue({
|
||||
credentialId: 'bot',
|
||||
appId: 'A1',
|
||||
name: 'Updated',
|
||||
description: 'Updated description',
|
||||
alwaysOnline: false,
|
||||
appHomeUrl: 'https://api.slack.com/apps/A1/app-home',
|
||||
});
|
||||
mocks.install.mockResolvedValue({
|
||||
status: 'connected',
|
||||
appId: 'A1',
|
||||
credentialId: 'bot',
|
||||
});
|
||||
mocks.createManager.mockResolvedValue({
|
||||
id: 'manager',
|
||||
name: 'Slack workspace manager',
|
||||
type: 'slackManagerOAuth2Api',
|
||||
isResolvable: false,
|
||||
});
|
||||
mocks.fetchCredentials.mockResolvedValue([
|
||||
{
|
||||
id: 'manager',
|
||||
name: 'Slack workspace manager',
|
||||
type: 'slackManagerOAuth2Api',
|
||||
},
|
||||
]);
|
||||
mocks.authorize.mockResolvedValue(true);
|
||||
mocks.authorizeNewCredential.mockResolvedValue(true);
|
||||
mocks.finalizeManager.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('completes manual setup when a final poll confirms the connection', async () => {
|
||||
vi.useFakeTimers();
|
||||
it('loads managed setup and settings for the selected managed credential', async () => {
|
||||
const runtime = createRuntime('bot');
|
||||
|
||||
class FakeBroadcastChannel {
|
||||
addEventListener() {}
|
||||
close() {}
|
||||
}
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
|
||||
await runtime.load();
|
||||
|
||||
const popup = { closed: false, close: vi.fn() };
|
||||
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window);
|
||||
expect(runtime.setup.value.managedSetupAvailable).toBe(true);
|
||||
expect(runtime.setupKind.value).toBe('managed');
|
||||
expect(runtime.settings.value?.credentialId).toBe('bot');
|
||||
expect(runtime.isManagedCredential('bot')).toBe(true);
|
||||
expect(mocks.getSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
let resolveFirstPoll!: () => void;
|
||||
const firstPoll = new Promise<void>((resolve) => {
|
||||
resolveFirstPoll = resolve;
|
||||
});
|
||||
let isConfigured = false;
|
||||
const fetchStatus = vi.fn().mockImplementation(async () => {
|
||||
if (fetchStatus.mock.calls.length === 1) await firstPoll;
|
||||
});
|
||||
const ensureAgentPersisted = vi.fn().mockResolvedValue(undefined);
|
||||
it.each([
|
||||
['new', undefined, mocks.authorizeNewCredential],
|
||||
['existing', 'manager', mocks.authorize],
|
||||
])(
|
||||
'finalizes a %s manager credential after OAuth',
|
||||
async (_scenario, credentialId, authorize) => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
await expect(runtime.connectManagerCredential(credentialId)).resolves.toBe(true);
|
||||
|
||||
const expectedCredential = expect.objectContaining({ id: 'manager' });
|
||||
if (credentialId) {
|
||||
expect(authorize).toHaveBeenCalledWith(expectedCredential, undefined, {
|
||||
abortOnPopupClose: true,
|
||||
});
|
||||
} else {
|
||||
expect(authorize).toHaveBeenCalledWith(expectedCredential, {
|
||||
abortOnPopupClose: true,
|
||||
});
|
||||
}
|
||||
expect(mocks.finalizeManager).toHaveBeenCalledWith({}, 'project-1', 'agent-1', 'manager');
|
||||
},
|
||||
);
|
||||
|
||||
it('saves managed app settings locally through the Slack API', async () => {
|
||||
const runtime = createRuntime('bot');
|
||||
const settings = {
|
||||
credentialId: 'bot',
|
||||
name: 'Updated',
|
||||
description: 'Updated description',
|
||||
alwaysOnline: false,
|
||||
};
|
||||
|
||||
await runtime.saveSettings(settings);
|
||||
|
||||
expect(mocks.updateSettings).toHaveBeenCalledWith({}, 'project-1', 'agent-1', settings);
|
||||
expect(runtime.settings.value?.name).toBe('Updated');
|
||||
});
|
||||
|
||||
it('keeps settings valid so a failed save can be retried', async () => {
|
||||
const runtime = createRuntime('bot');
|
||||
const settings = {
|
||||
credentialId: 'bot',
|
||||
name: 'Updated',
|
||||
description: 'Updated description',
|
||||
alwaysOnline: false,
|
||||
};
|
||||
mocks.updateSettings.mockRejectedValueOnce(new Error('Slack unavailable'));
|
||||
|
||||
await expect(runtime.saveSettings(settings)).rejects.toThrow('Slack unavailable');
|
||||
|
||||
expect(runtime.settingsLoading.value).toBe(false);
|
||||
expect(runtime.settingsError.value).toBe(false);
|
||||
expect(runtime.settingsSaveError.value).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes Slack service limit errors returned when saving', async () => {
|
||||
const runtime = createRuntime('bot');
|
||||
mocks.updateSettings.mockRejectedValueOnce(
|
||||
new ResponseError('Slack could not update the Slack app: service_limits_exceeded', {
|
||||
httpStatusCode: 400,
|
||||
meta: {
|
||||
integrationType: 'slack',
|
||||
code: 'service_limits_exceeded',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runtime.saveSettings({
|
||||
credentialId: 'bot',
|
||||
name: 'Updated',
|
||||
description: 'Updated description',
|
||||
alwaysOnline: false,
|
||||
}),
|
||||
).rejects.toThrow('service_limits_exceeded');
|
||||
|
||||
expect(runtime.settingsSaveError.value).toBe('service_limits_exceeded');
|
||||
});
|
||||
|
||||
it('completes managed installation and refreshes Slack state', async () => {
|
||||
const onConnected = vi.fn();
|
||||
const runtime = useSlackChannelRuntime({
|
||||
projectId: ref('project-1'),
|
||||
agentId: ref('agent-1'),
|
||||
selectedCredentialId: ref(''),
|
||||
credentialModalOpen: ref(false),
|
||||
fetchStatus,
|
||||
isConnected: () => false,
|
||||
isConfigured: () => isConfigured,
|
||||
ensureAgentPersisted,
|
||||
});
|
||||
const ensureAgentPersisted = vi.fn().mockResolvedValue(undefined);
|
||||
const runtime = createRuntime('', ref(false), ensureAgentPersisted);
|
||||
|
||||
const setupPromise = runtime.setupApp('token', onConnected);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await runtime.installManagedApp('manager', 'T1', onConnected);
|
||||
|
||||
popup.closed = true;
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
isConfigured = true;
|
||||
resolveFirstPoll();
|
||||
|
||||
await expect(setupPromise).resolves.toBe(true);
|
||||
expect(ensureAgentPersisted).toHaveBeenCalledOnce();
|
||||
expect(createSlackAgentApp).toHaveBeenCalledWith({}, 'project-1', 'agent-1', 'token');
|
||||
expect(mocks.install).toHaveBeenCalledWith({}, 'project-1', 'agent-1', 'manager', 'T1');
|
||||
expect(ensureAgentPersisted.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.install.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(mocks.getSetup).toHaveBeenCalled();
|
||||
expect(onConnected).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('refreshes managed setup after the credential modal closes', async () => {
|
||||
const credentialModalOpen = ref(false);
|
||||
const runtime = createRuntime('', credentialModalOpen);
|
||||
await runtime.load();
|
||||
mocks.getSetup.mockResolvedValue({
|
||||
managedSetupAvailable: true,
|
||||
managerCredentials: [],
|
||||
});
|
||||
|
||||
credentialModalOpen.value = true;
|
||||
await flushPromises();
|
||||
credentialModalOpen.value = false;
|
||||
await flushPromises();
|
||||
|
||||
expect(runtime.setup.value.managerCredentials).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+257
-70
@@ -1,35 +1,141 @@
|
||||
import type {
|
||||
SlackManagedAppSettings,
|
||||
SlackManagedAppSettingsErrorCode,
|
||||
SlackManagedSetupState,
|
||||
} from '@n8n/api-types';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { readonly, ref } from 'vue';
|
||||
import { computed, ref, watch, type Ref } from 'vue';
|
||||
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import {
|
||||
getTrustedOAuthOrigins,
|
||||
waitForOAuthCallback,
|
||||
} from '@/features/credentials/composables/oauthCallback';
|
||||
import { useCredentialOAuth } from '@/features/credentials/composables/useCredentialOAuth';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
|
||||
import type { AgentChannelRuntime, AgentChannelRuntimeContext } from '../types';
|
||||
import { createSlackAgentApp } from './api';
|
||||
import {
|
||||
createSlackAgentApp,
|
||||
createSlackManagerCredential,
|
||||
finalizeSlackManagerCredential,
|
||||
getSlackApiErrorCode,
|
||||
getSlackManagedAppSettings,
|
||||
getSlackManagedSetup,
|
||||
installSlackManagedApp,
|
||||
updateSlackManagedAppSettings,
|
||||
} from './api';
|
||||
|
||||
const SLACK_APP_SETUP_POLL_INTERVAL_MS = 2000;
|
||||
const SLACK_APP_SETUP_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
const SLACK_MANAGER_CREDENTIAL_TYPE = 'slackManagerOAuth2Api';
|
||||
export type SlackSetupKind = 'managed' | 'manual';
|
||||
|
||||
export interface SlackChannelRuntime extends AgentChannelRuntime {
|
||||
setup: Ref<SlackManagedSetupState>;
|
||||
setupKind: Ref<SlackSetupKind>;
|
||||
settings: Ref<SlackManagedAppSettings | null>;
|
||||
settingsLoading: Ref<boolean>;
|
||||
settingsError: Ref<boolean>;
|
||||
settingsSaveError: Ref<SlackManagedAppSettingsErrorCode | null>;
|
||||
isManagedCredential: (credentialId: string) => boolean;
|
||||
setupApp: (
|
||||
appConfigurationToken: string,
|
||||
onConnected: () => void | Promise<void>,
|
||||
) => Promise<boolean>;
|
||||
connectManagerCredential: (credentialId?: string) => Promise<boolean>;
|
||||
editManagerCredential: (credentialId: string) => void;
|
||||
installManagedApp: (
|
||||
managerCredentialId: string,
|
||||
workspaceId: string,
|
||||
onConnected: () => void | Promise<void>,
|
||||
) => Promise<boolean>;
|
||||
saveSettings: (
|
||||
settings: Pick<
|
||||
SlackManagedAppSettings,
|
||||
'credentialId' | 'name' | 'description' | 'alwaysOnline'
|
||||
>,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export function isSlackChannelRuntime(
|
||||
runtime: AgentChannelRuntime,
|
||||
): runtime is SlackChannelRuntime {
|
||||
return 'setupApp' in runtime && typeof runtime.setupApp === 'function';
|
||||
return 'setup' in runtime;
|
||||
}
|
||||
|
||||
export function useSlackChannelRuntime(context: AgentChannelRuntimeContext): SlackChannelRuntime {
|
||||
const rootStore = useRootStore();
|
||||
const loading = ref(false);
|
||||
const uiStore = useUIStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const credentialOAuth = useCredentialOAuth();
|
||||
|
||||
const setup = ref<SlackManagedSetupState>({
|
||||
managedSetupAvailable: false,
|
||||
managerCredentials: [],
|
||||
});
|
||||
const setupKind = ref<SlackSetupKind>('managed');
|
||||
const loading = ref(true);
|
||||
const settings = ref<SlackManagedAppSettings | null>(null);
|
||||
const settingsLoading = ref(false);
|
||||
const settingsError = ref(false);
|
||||
const settingsSaveError = ref<SlackManagedAppSettingsErrorCode | null>(null);
|
||||
let settingsRequestId = 0;
|
||||
|
||||
function isManagedCredential(credentialId: string): boolean {
|
||||
return setup.value.managerCredentials.some((manager) =>
|
||||
manager.workspaces.some((workspace) => workspace.botCredentialId === credentialId),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadSettings(credentialId: string) {
|
||||
const requestId = ++settingsRequestId;
|
||||
if (!credentialId || !isManagedCredential(credentialId)) {
|
||||
settings.value = null;
|
||||
settingsError.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
settingsLoading.value = true;
|
||||
settingsError.value = false;
|
||||
try {
|
||||
const result = await getSlackManagedAppSettings(
|
||||
rootStore.restApiContext,
|
||||
context.projectId.value,
|
||||
context.agentId.value,
|
||||
credentialId,
|
||||
);
|
||||
if (requestId === settingsRequestId) settings.value = result;
|
||||
} catch {
|
||||
if (requestId === settingsRequestId) {
|
||||
settings.value = null;
|
||||
settingsError.value = true;
|
||||
}
|
||||
} finally {
|
||||
if (requestId === settingsRequestId) settingsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
setup.value = await getSlackManagedSetup(
|
||||
rootStore.restApiContext,
|
||||
context.projectId.value,
|
||||
context.agentId.value,
|
||||
);
|
||||
} catch {
|
||||
setup.value = { managedSetupAvailable: false, managerCredentials: [] };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
await loadSettings(context.selectedCredentialId.value);
|
||||
}
|
||||
|
||||
function openAuthorizationPopup(installUrl: string): Window {
|
||||
const parsedUrl = new URL(installUrl);
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||
throw new Error('Invalid Slack installation URL');
|
||||
}
|
||||
|
||||
const popup = window.open(
|
||||
parsedUrl.toString(),
|
||||
'Slack App Authorization',
|
||||
@@ -40,87 +146,168 @@ export function useSlackChannelRuntime(context: AgentChannelRuntimeContext): Sla
|
||||
}
|
||||
|
||||
async function waitForSetupCompletion(popup: Window): Promise<boolean> {
|
||||
return await new Promise((resolve) => {
|
||||
const oauthChannel = new BroadcastChannel('oauth-callback');
|
||||
let activePoll: Promise<void> | null = null;
|
||||
let settled = false;
|
||||
|
||||
const closePopup = () => {
|
||||
try {
|
||||
popup.close();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const settle = (success: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.clearInterval(pollInterval);
|
||||
window.clearTimeout(timeout);
|
||||
oauthChannel.close();
|
||||
if (success) closePopup();
|
||||
resolve(success);
|
||||
};
|
||||
|
||||
const pollStatus = async () => {
|
||||
if (activePoll || settled) return;
|
||||
activePoll = (async () => {
|
||||
try {
|
||||
await context.fetchStatus(['slack']);
|
||||
if (context.isConfigured('slack')) settle(true);
|
||||
} finally {
|
||||
activePoll = null;
|
||||
}
|
||||
})();
|
||||
await activePoll;
|
||||
};
|
||||
|
||||
const pollInterval = window.setInterval(() => {
|
||||
if (popup.closed) {
|
||||
void (activePoll ?? Promise.resolve())
|
||||
.catch(() => {})
|
||||
.then(pollStatus)
|
||||
.finally(() => settle(false));
|
||||
return;
|
||||
}
|
||||
void pollStatus();
|
||||
}, SLACK_APP_SETUP_POLL_INTERVAL_MS);
|
||||
const timeout = window.setTimeout(() => settle(false), SLACK_APP_SETUP_TIMEOUT_MS);
|
||||
|
||||
oauthChannel.addEventListener('message', (event: MessageEvent) => {
|
||||
settle(event.data === 'success');
|
||||
});
|
||||
|
||||
void pollStatus();
|
||||
const outcome = await waitForOAuthCallback({
|
||||
popup,
|
||||
trustedOrigins: getTrustedOAuthOrigins(rootStore.urlBaseEditor),
|
||||
verifyConnected: async () => {
|
||||
await context.fetchStatus(['slack']);
|
||||
return context.isConfigured('slack');
|
||||
},
|
||||
timeoutMs: SLACK_APP_SETUP_TIMEOUT_MS,
|
||||
abortOnPopupClose: true,
|
||||
});
|
||||
popup.close();
|
||||
return outcome === 'success';
|
||||
}
|
||||
|
||||
async function setupApp(
|
||||
appConfigurationToken: string,
|
||||
onConnected: () => void | Promise<void>,
|
||||
): Promise<boolean> {
|
||||
loading.value = true;
|
||||
try {
|
||||
await context.ensureAgentPersisted?.();
|
||||
const { installUrl } = await createSlackAgentApp(
|
||||
await context.ensureAgentPersisted?.();
|
||||
const { installUrl } = await createSlackAgentApp(
|
||||
rootStore.restApiContext,
|
||||
context.projectId.value,
|
||||
context.agentId.value,
|
||||
appConfigurationToken,
|
||||
);
|
||||
const connected = await waitForSetupCompletion(openAuthorizationPopup(installUrl));
|
||||
if (!connected) throw new Error('Slack app installation was not completed');
|
||||
await context.fetchStatus(['slack']);
|
||||
await onConnected();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function finalizeConnectedManagerCredential(
|
||||
credentialId: string,
|
||||
connected: boolean,
|
||||
): Promise<boolean> {
|
||||
if (!connected) return false;
|
||||
await finalizeSlackManagerCredential(
|
||||
rootStore.restApiContext,
|
||||
context.projectId.value,
|
||||
context.agentId.value,
|
||||
credentialId,
|
||||
).catch(() => {});
|
||||
await load();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function connectManagerCredential(credentialId?: string): Promise<boolean> {
|
||||
await context.ensureAgentPersisted?.();
|
||||
let id = credentialId;
|
||||
let createdCredentialId: string | undefined;
|
||||
let authorizationStarted = false;
|
||||
if (!id) {
|
||||
const created = await createSlackManagerCredential(
|
||||
rootStore.restApiContext,
|
||||
context.projectId.value,
|
||||
context.agentId.value,
|
||||
appConfigurationToken,
|
||||
);
|
||||
const connected = await waitForSetupCompletion(openAuthorizationPopup(installUrl));
|
||||
if (!connected) throw new Error('Slack app installation was not completed');
|
||||
id = created.id;
|
||||
createdCredentialId = created.id;
|
||||
}
|
||||
|
||||
await context.fetchStatus(['slack']);
|
||||
await onConnected();
|
||||
return true;
|
||||
try {
|
||||
credentialsStore.setCredentials([]);
|
||||
const credentials = await credentialsStore.fetchAllCredentialsForWorkflow({
|
||||
projectId: context.projectId.value,
|
||||
});
|
||||
const credential = credentials.find(
|
||||
(item) => item.id === id && item.type === SLACK_MANAGER_CREDENTIAL_TYPE,
|
||||
);
|
||||
if (!credential) throw new Error('Slack manager credential could not be loaded');
|
||||
if (createdCredentialId) {
|
||||
authorizationStarted = true;
|
||||
const connected = await credentialOAuth.authorizeNewCredential(credential, {
|
||||
abortOnPopupClose: true,
|
||||
});
|
||||
return await finalizeConnectedManagerCredential(id, connected);
|
||||
}
|
||||
const connected = await credentialOAuth.authorize(credential, undefined, {
|
||||
abortOnPopupClose: true,
|
||||
});
|
||||
return await finalizeConnectedManagerCredential(id, connected);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (createdCredentialId && !authorizationStarted) {
|
||||
await credentialsStore.deleteCredential({ id: createdCredentialId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function editManagerCredential(credentialId: string) {
|
||||
uiStore.openExistingCredential(credentialId, {
|
||||
hideAskAssistant: true,
|
||||
appendToBody: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function installManagedApp(
|
||||
managerCredentialId: string,
|
||||
workspaceId: string,
|
||||
onConnected: () => void | Promise<void>,
|
||||
): Promise<boolean> {
|
||||
await context.ensureAgentPersisted?.();
|
||||
const result = await installSlackManagedApp(
|
||||
rootStore.restApiContext,
|
||||
context.projectId.value,
|
||||
context.agentId.value,
|
||||
managerCredentialId,
|
||||
workspaceId,
|
||||
);
|
||||
if (result.status === 'manual_install_required') {
|
||||
const connected = await waitForSetupCompletion(openAuthorizationPopup(result.installUrl));
|
||||
if (!connected) throw new Error('Slack app installation was not completed');
|
||||
}
|
||||
await context.fetchStatus(['slack']);
|
||||
await load();
|
||||
await onConnected();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function saveSettings(
|
||||
update: Pick<SlackManagedAppSettings, 'credentialId' | 'name' | 'description' | 'alwaysOnline'>,
|
||||
) {
|
||||
settingsLoading.value = true;
|
||||
settingsError.value = false;
|
||||
settingsSaveError.value = null;
|
||||
try {
|
||||
settings.value = await updateSlackManagedAppSettings(
|
||||
rootStore.restApiContext,
|
||||
context.projectId.value,
|
||||
context.agentId.value,
|
||||
update,
|
||||
);
|
||||
} catch (error) {
|
||||
const code = getSlackApiErrorCode(error);
|
||||
if (code === 'service_limits_exceeded') {
|
||||
settingsSaveError.value = code;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
settingsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(context.selectedCredentialId, (credentialId) => void loadSettings(credentialId));
|
||||
watch(context.credentialModalOpen, (isOpen, wasOpen) => {
|
||||
if (wasOpen && !isOpen) void load();
|
||||
});
|
||||
|
||||
return {
|
||||
load: async () => {},
|
||||
loading: readonly(loading),
|
||||
setup,
|
||||
setupKind,
|
||||
settings,
|
||||
loading: computed(() => loading.value),
|
||||
settingsLoading,
|
||||
settingsError,
|
||||
settingsSaveError,
|
||||
load,
|
||||
isManagedCredential,
|
||||
setupApp,
|
||||
connectManagerCredential,
|
||||
editManagerCredential,
|
||||
installManagedApp,
|
||||
saveSettings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { AgentIntegrationSettings, ChatIntegrationDescriptor } from '@n8n/api-types';
|
||||
import type { IconName } from '@n8n/design-system';
|
||||
import type {
|
||||
AgentIntegrationDisconnectWarning,
|
||||
AgentIntegrationSettings,
|
||||
ChatIntegrationDescriptor,
|
||||
} from '@n8n/api-types';
|
||||
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
|
||||
import type { BaseTextKey } from '@n8n/i18n';
|
||||
import type { PermissionsRecord } from '@n8n/permissions';
|
||||
import type { Component, Ref } from 'vue';
|
||||
import type { Component, Ref, VNode } from 'vue';
|
||||
|
||||
import type { AgentCredentialOption } from '../components/AgentCredentialSelect.vue';
|
||||
|
||||
@@ -64,7 +68,21 @@ export interface AgentChannelPresentationContext {
|
||||
text: (key: BaseTextKey) => string;
|
||||
}
|
||||
|
||||
export interface ChannelPlatformDefinition {
|
||||
export interface AgentChannelWarningPresentation {
|
||||
title: string;
|
||||
message: string | VNode;
|
||||
}
|
||||
|
||||
export interface AgentChannelDisconnectContext {
|
||||
isPublished: boolean;
|
||||
}
|
||||
|
||||
export interface AgentChannelHeaderContentProps {
|
||||
runtime: AgentChannelRuntime;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentChannelPlatform {
|
||||
type: string;
|
||||
setupComponent: Component;
|
||||
editComponent: Component;
|
||||
@@ -74,4 +92,18 @@ export interface ChannelPlatformDefinition {
|
||||
runtime: AgentChannelRuntime,
|
||||
) => AgentChannelConnectAction;
|
||||
getConnectedDescription?: (context: AgentChannelPresentationContext) => string;
|
||||
headerContent?: {
|
||||
setupModal?: Component;
|
||||
editModal?: Component;
|
||||
};
|
||||
disconnectConfirmationComponent?: Component;
|
||||
shouldConfirmDisconnect?: (
|
||||
runtime: AgentChannelRuntime,
|
||||
credentialId: string,
|
||||
context: AgentChannelDisconnectContext,
|
||||
) => boolean;
|
||||
presentDisconnectWarning?: (
|
||||
warning: AgentIntegrationDisconnectWarning,
|
||||
context: AgentChannelPresentationContext,
|
||||
) => AgentChannelWarningPresentation | null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nDialog,
|
||||
@@ -44,6 +45,7 @@ interface Props {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isPublished: false,
|
||||
simpleSetup: false,
|
||||
ensureAgentPersisted: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -55,6 +57,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const { catalog, ensureLoaded } = useAgentIntegrationsCatalog();
|
||||
const {
|
||||
fetchStatus,
|
||||
@@ -67,11 +70,17 @@ const {
|
||||
isConfigured: isIntegrationConfigured,
|
||||
connect,
|
||||
disconnect,
|
||||
clearError: clearIntegrationError,
|
||||
} = useAgentIntegrationStatus(props.projectId, props.agentId);
|
||||
|
||||
const currentView = ref<ChannelView>(props.view);
|
||||
const viewSession = ref(0);
|
||||
const credentialIdAtEditOpen = ref('');
|
||||
const pendingDisconnect = ref<{
|
||||
channelType: string;
|
||||
credentialId: string;
|
||||
closeAfter: boolean;
|
||||
} | null>(null);
|
||||
|
||||
function channelTypeFromView(view: ChannelView): string | null {
|
||||
if (view === 'list') return null;
|
||||
@@ -115,6 +124,25 @@ const {
|
||||
fetchStatus,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => {
|
||||
const type = selectedChannelType.value;
|
||||
return {
|
||||
type,
|
||||
credentialId: type ? selectedCredentials.value[type] : undefined,
|
||||
};
|
||||
},
|
||||
(current, previous) => {
|
||||
if (
|
||||
current.type &&
|
||||
current.type === previous.type &&
|
||||
current.credentialId !== previous.credentialId
|
||||
) {
|
||||
clearIntegrationError(current.type);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const projectIdRef = computed(() => props.projectId);
|
||||
const agentIdRef = computed(() => props.agentId);
|
||||
const runtimes: Record<string, AgentChannelRuntime> = Object.fromEntries(
|
||||
@@ -148,13 +176,43 @@ const currentPlatform = computed(() =>
|
||||
);
|
||||
const currentRuntime = computed(() => runtimeFor(selectedChannelType.value ?? 'unknown'));
|
||||
const channelViewRef = ref<AgentChannelViewExpose>();
|
||||
const channelViewLoading = computed(() => channelViewRef.value?.loading === true);
|
||||
const listLoading = computed(() =>
|
||||
Object.values(runtimes).some((runtime) => runtime.loading.value),
|
||||
);
|
||||
const disconnectConfirmationComponent = computed(() => {
|
||||
const pending = pendingDisconnect.value;
|
||||
return pending
|
||||
? getAgentChannelPlatform(pending.channelType).disconnectConfirmationComponent
|
||||
: undefined;
|
||||
});
|
||||
const disconnectConfirmationLoading = computed(() => {
|
||||
const pending = pendingDisconnect.value;
|
||||
return pending ? isLoading(pending.channelType) : false;
|
||||
});
|
||||
|
||||
const headerContentDisabled = computed(
|
||||
() => currentRuntime.value.loading.value || channelViewLoading.value,
|
||||
);
|
||||
const headerContentComponent = computed(() => {
|
||||
if (isSetupMode.value) {
|
||||
return currentPlatform.value.headerContent?.setupModal;
|
||||
}
|
||||
if (isEditMode.value) {
|
||||
return currentPlatform.value.headerContent?.editModal;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const canClose = computed(
|
||||
() =>
|
||||
!channelViewLoading.value &&
|
||||
!(selectedChannelType.value ? isLoading(selectedChannelType.value) : false),
|
||||
);
|
||||
function prepareChannelEdit(channelType: string | null) {
|
||||
captureConnectedCredential(channelType);
|
||||
if (channelType && credentialIdAtEditOpen.value) {
|
||||
if (!channelType) return;
|
||||
clearIntegrationError(channelType);
|
||||
if (credentialIdAtEditOpen.value) {
|
||||
selectedCredentials.value[channelType] = credentialIdAtEditOpen.value;
|
||||
}
|
||||
}
|
||||
@@ -176,7 +234,7 @@ const canSaveChannelConfig = computed(() => {
|
||||
return (
|
||||
selectedChannelType.value !== null &&
|
||||
currentChannelCredentialId.value.length > 0 &&
|
||||
!channelViewRef.value?.loading &&
|
||||
!channelViewLoading.value &&
|
||||
!channelViewRef.value?.validationError
|
||||
);
|
||||
});
|
||||
@@ -227,6 +285,7 @@ function connectAction(channelType: string) {
|
||||
}
|
||||
|
||||
function goToSetup(channelType: string) {
|
||||
clearIntegrationError(channelType);
|
||||
currentView.value = `${channelType}_setup`;
|
||||
}
|
||||
|
||||
@@ -236,13 +295,18 @@ function goToEdit(channelType: string) {
|
||||
}
|
||||
|
||||
function goBackToList() {
|
||||
if (selectedChannelType.value ? isLoading(selectedChannelType.value) : false) return;
|
||||
if (
|
||||
channelViewLoading.value ||
|
||||
(selectedChannelType.value ? isLoading(selectedChannelType.value) : false)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
captureConnectedCredential(null);
|
||||
currentView.value = 'list';
|
||||
}
|
||||
|
||||
function handleListDisconnect(channelType: string) {
|
||||
void handleDisconnected(channelType);
|
||||
requestDisconnect(channelType, connectedCredentials.value[channelType] ?? '', false);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
@@ -251,7 +315,11 @@ function closeModal() {
|
||||
}
|
||||
|
||||
function handleModalOpenUpdate(isOpen: boolean) {
|
||||
if (!isOpen && (selectedChannelType.value ? isLoading(selectedChannelType.value) : false)) {
|
||||
if (
|
||||
!isOpen &&
|
||||
(channelViewLoading.value ||
|
||||
(selectedChannelType.value ? isLoading(selectedChannelType.value) : false))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
emit('update:open', isOpen);
|
||||
@@ -291,26 +359,90 @@ function handlePlatformConnected() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
async function handleDisconnected(channelType: string, credentialId?: string) {
|
||||
async function handleDisconnected(
|
||||
channelType: string,
|
||||
credentialId?: string,
|
||||
options: { deleteExternalResource?: boolean } = {},
|
||||
) {
|
||||
// Draft channels (configured but missing a credential) have no connected
|
||||
// credential — send '' so the backend removes the draft entry by type.
|
||||
await disconnect(channelType, credentialId ?? connectedCredentials.value[channelType] ?? '');
|
||||
const result = await disconnect(
|
||||
channelType,
|
||||
credentialId ?? connectedCredentials.value[channelType] ?? '',
|
||||
options,
|
||||
);
|
||||
await fetchStatus([channelType]);
|
||||
if (!isIntegrationConfigured(channelType)) {
|
||||
emit('channel-disconnected', channelType);
|
||||
}
|
||||
emit('agent-changed');
|
||||
return result;
|
||||
}
|
||||
|
||||
async function removeCurrentChannel() {
|
||||
const channelType = selectedChannelType.value;
|
||||
if (!channelType || isLoading(channelType)) return;
|
||||
async function disconnectChannel(
|
||||
channelType: string,
|
||||
credentialId: string,
|
||||
closeAfter: boolean,
|
||||
deleteExternalResource?: boolean,
|
||||
) {
|
||||
try {
|
||||
const result = await handleDisconnected(channelType, credentialId, {
|
||||
deleteExternalResource,
|
||||
});
|
||||
if (result.warning) {
|
||||
const presentation = getAgentChannelPlatform(channelType).presentDisconnectWarning?.(
|
||||
result.warning,
|
||||
{ text: (key) => i18n.baseText(key) },
|
||||
);
|
||||
if (presentation) {
|
||||
toast.showMessage({
|
||||
type: 'warning',
|
||||
title: presentation.title,
|
||||
message: presentation.message,
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
pendingDisconnect.value = null;
|
||||
if (closeAfter) closeModal();
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('agents.channels.modal.removeChannelError'));
|
||||
}
|
||||
}
|
||||
|
||||
await handleDisconnected(
|
||||
function requestDisconnect(channelType: string, credentialId: string, closeAfter: boolean) {
|
||||
if (isLoading(channelType)) return;
|
||||
const platform = getAgentChannelPlatform(channelType);
|
||||
if (
|
||||
platform.shouldConfirmDisconnect?.(runtimeFor(channelType), credentialId, {
|
||||
isPublished: props.isPublished,
|
||||
})
|
||||
) {
|
||||
pendingDisconnect.value = { channelType, credentialId, closeAfter };
|
||||
return;
|
||||
}
|
||||
void disconnectChannel(channelType, credentialId, closeAfter);
|
||||
}
|
||||
|
||||
function confirmDisconnect(deleteExternalResource: boolean) {
|
||||
const pending = pendingDisconnect.value;
|
||||
if (!pending) return;
|
||||
void disconnectChannel(
|
||||
pending.channelType,
|
||||
pending.credentialId,
|
||||
pending.closeAfter,
|
||||
deleteExternalResource,
|
||||
);
|
||||
}
|
||||
|
||||
function removeCurrentChannel() {
|
||||
const channelType = selectedChannelType.value;
|
||||
if (!channelType) return;
|
||||
requestDisconnect(
|
||||
channelType,
|
||||
credentialIdAtEditOpen.value || connectedCredentials.value[channelType] || '',
|
||||
true,
|
||||
);
|
||||
closeModal();
|
||||
}
|
||||
|
||||
async function loadChannelState() {
|
||||
@@ -345,7 +477,7 @@ watch(
|
||||
size="2xlarge"
|
||||
:trap-focus="!credentialModalOpen"
|
||||
:disable-outside-pointer-events="!credentialModalOpen"
|
||||
:show-close-button="!(selectedChannelType ? isLoading(selectedChannelType) : false)"
|
||||
:show-close-button="false"
|
||||
@interact-outside="(e) => e.preventDefault()"
|
||||
@update:open="handleModalOpenUpdate"
|
||||
>
|
||||
@@ -371,7 +503,9 @@ watch(
|
||||
size="small"
|
||||
icon-size="medium"
|
||||
icon="arrow-left"
|
||||
:disabled="selectedChannelType ? isLoading(selectedChannelType) : false"
|
||||
:disabled="
|
||||
channelViewLoading || (selectedChannelType ? isLoading(selectedChannelType) : false)
|
||||
"
|
||||
:class="$style.backButton"
|
||||
@click="goBackToList"
|
||||
>
|
||||
@@ -387,8 +521,27 @@ watch(
|
||||
/>
|
||||
<N8nDialogTitle>{{ headerText }}</N8nDialogTitle>
|
||||
</div>
|
||||
<div :class="$style.headerActions">
|
||||
<component
|
||||
:is="headerContentComponent"
|
||||
v-if="headerContentComponent"
|
||||
:runtime="currentRuntime"
|
||||
:disabled="headerContentDisabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<N8nIconButton
|
||||
variant="ghost"
|
||||
size="small"
|
||||
icon-size="medium"
|
||||
icon="x"
|
||||
:class="$style.closeButton"
|
||||
aria-label="Close dialog"
|
||||
data-test-id="dialog-close-button"
|
||||
:disabled="!canClose"
|
||||
@click="closeModal"
|
||||
/>
|
||||
</N8nDialogHeader>
|
||||
|
||||
<div data-testid="agent-channel-modal" :class="$style.container">
|
||||
@@ -453,7 +606,7 @@ watch(
|
||||
<N8nDialogFooter v-if="showFooterActions" :class="$style.customFooter">
|
||||
<div :class="$style.footer">
|
||||
<N8nButton
|
||||
variant="destructive"
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
:loading="selectedChannelType ? isLoading(selectedChannelType) : false"
|
||||
:disabled="selectedChannelType ? isLoading(selectedChannelType) : true"
|
||||
@@ -464,9 +617,11 @@ watch(
|
||||
</N8nButton>
|
||||
<div :class="$style.footerActions">
|
||||
<N8nButton
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="medium"
|
||||
:disabled="selectedChannelType ? isLoading(selectedChannelType) : false"
|
||||
:disabled="
|
||||
channelViewLoading || (selectedChannelType ? isLoading(selectedChannelType) : false)
|
||||
"
|
||||
@click="closeModal"
|
||||
>
|
||||
{{ i18n.baseText('generic.cancel') }}
|
||||
@@ -491,6 +646,14 @@ watch(
|
||||
</div>
|
||||
</N8nDialogFooter>
|
||||
</Transition>
|
||||
<component
|
||||
:is="disconnectConfirmationComponent"
|
||||
v-if="pendingDisconnect && disconnectConfirmationComponent"
|
||||
:open="true"
|
||||
:loading="disconnectConfirmationLoading"
|
||||
@cancel="pendingDisconnect = null"
|
||||
@confirm="confirmDisconnect"
|
||||
/>
|
||||
</N8nDialog>
|
||||
</template>
|
||||
|
||||
@@ -531,6 +694,8 @@ body:has([data-testid='agent-channel-modal'])
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--md);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
@@ -540,6 +705,17 @@ body:has([data-testid='agent-channel-modal'])
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--xs);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.listView {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -572,6 +748,7 @@ body:has([data-testid='agent-channel-modal'])
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: var(--spacing--2xs);
|
||||
height: var(--height--md);
|
||||
}
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import type { SlackManagedAppSettings, SlackManagedAppSettingsErrorCode } from '@n8n/api-types';
|
||||
import { N8nFormInput, N8nIcon, N8nLink, N8nSwitch2, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import AgentChannelSlackServiceLimitError from '../channels/slack/AgentChannelSlackServiceLimitError.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
settings: SlackManagedAppSettings | null;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
saveError?: SlackManagedAppSettingsErrorCode | null;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const name = ref('');
|
||||
const description = ref('');
|
||||
const alwaysOnline = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.settings,
|
||||
(settings) => {
|
||||
name.value = settings?.name ?? '';
|
||||
description.value = settings?.description ?? '';
|
||||
alwaysOnline.value = settings?.alwaysOnline ?? false;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const validationError = computed(() => {
|
||||
if (!props.settings || props.loading || props.error) {
|
||||
return props.error ? i18n.baseText('agents.channels.slack.managed.settings.loadError') : null;
|
||||
}
|
||||
if (!name.value.trim()) {
|
||||
return i18n.baseText('agents.channels.slack.managed.settings.nameRequired');
|
||||
}
|
||||
if (name.value.length > 80) {
|
||||
return i18n.baseText('agents.channels.slack.managed.settings.nameTooLong');
|
||||
}
|
||||
if (!description.value.trim()) {
|
||||
return i18n.baseText('agents.channels.slack.managed.settings.descriptionRequired');
|
||||
}
|
||||
if (description.value.length > 140) {
|
||||
return i18n.baseText('agents.channels.slack.managed.settings.descriptionTooLong');
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const currentSettings = computed(() =>
|
||||
props.settings
|
||||
? {
|
||||
credentialId: props.settings.credentialId,
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim(),
|
||||
alwaysOnline: alwaysOnline.value,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
defineExpose({ currentSettings, validationError });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.settings" data-testid="slack-managed-app-settings">
|
||||
<template v-if="settings">
|
||||
<N8nFormInput
|
||||
v-model="name"
|
||||
name="slackManagedBotName"
|
||||
:label="i18n.baseText('agents.channels.slack.managed.settings.name')"
|
||||
:info-text="i18n.baseText('agents.channels.slack.managed.settings.nameDescription')"
|
||||
:maxlength="80"
|
||||
required
|
||||
:disabled="disabled || loading"
|
||||
data-testid="slack-managed-app-name"
|
||||
/>
|
||||
<N8nFormInput
|
||||
v-model="description"
|
||||
name="slackManagedAppDescription"
|
||||
type="textarea"
|
||||
:label="i18n.baseText('agents.channels.slack.managed.settings.description')"
|
||||
:maxlength="140"
|
||||
required
|
||||
:disabled="disabled || loading"
|
||||
data-testid="slack-managed-app-description"
|
||||
/>
|
||||
<div :class="$style.switchRow">
|
||||
<N8nSwitch2
|
||||
:model-value="alwaysOnline"
|
||||
:disabled="disabled || loading"
|
||||
data-testid="slack-managed-app-always-online"
|
||||
:label="i18n.baseText('agents.channels.slack.managed.settings.alwaysOnline')"
|
||||
@update:model-value="alwaysOnline = Boolean($event)"
|
||||
/>
|
||||
</div>
|
||||
<N8nLink :href="settings.appHomeUrl" target="_blank" rel="noopener noreferrer" size="small">
|
||||
<span :class="$style.linkContent">
|
||||
{{ i18n.baseText('agents.channels.slack.managed.settings.openSlack') }}
|
||||
<N8nIcon icon="external-link" size="xsmall" />
|
||||
</span>
|
||||
</N8nLink>
|
||||
<N8nText v-if="validationError" size="small" :class="$style.error">
|
||||
{{ validationError }}
|
||||
</N8nText>
|
||||
<AgentChannelSlackServiceLimitError v-else-if="saveError === 'service_limits_exceeded'" />
|
||||
</template>
|
||||
|
||||
<N8nText v-else-if="error" size="small" :class="$style.error">
|
||||
{{ i18n.baseText('agents.channels.slack.managed.settings.loadError') }}
|
||||
</N8nText>
|
||||
<N8nText v-else size="small" color="text-light">
|
||||
{{ i18n.baseText('generic.loading') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.switchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.linkContent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--text-color--danger);
|
||||
}
|
||||
</style>
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
import type { SlackManagedSetupState } from '@n8n/api-types';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nIconButton,
|
||||
N8nOption,
|
||||
N8nSelect,
|
||||
N8nStepper,
|
||||
N8nText,
|
||||
N8nTooltip,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { PermissionsRecord } from '@n8n/permissions';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import CredentialsDropdown, {
|
||||
type CredentialOption,
|
||||
} from '@/features/credentials/components/CredentialPicker/CredentialsDropdown.vue';
|
||||
import AgentChannelSlackServiceLimitError from '../channels/slack/AgentChannelSlackServiceLimitError.vue';
|
||||
import { getSlackApiErrorCode } from '../channels/slack/api';
|
||||
|
||||
const props = defineProps<{
|
||||
setup: SlackManagedSetupState;
|
||||
loading: boolean;
|
||||
credentialPermissions: PermissionsRecord['credential'];
|
||||
connectManager: (credentialId?: string) => Promise<boolean>;
|
||||
editManager: (credentialId: string) => void;
|
||||
installApp: (managerCredentialId: string, workspaceId: string) => Promise<boolean>;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const selectedCredentialId = ref('');
|
||||
const selectedWorkspaceId = ref('');
|
||||
const connecting = ref(false);
|
||||
const installing = ref(false);
|
||||
const error = ref<
|
||||
| 'connect'
|
||||
| 'install'
|
||||
| 'service_limits_exceeded'
|
||||
| 'app_approval_request_pending'
|
||||
| 'app_approval_request_denied'
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const steps = computed(() => [
|
||||
{
|
||||
id: 'connect',
|
||||
title: i18n.baseText('agents.channels.slack.managed.connect.title'),
|
||||
description: i18n.baseText('agents.channels.slack.managed.connect.description'),
|
||||
},
|
||||
{
|
||||
id: 'install',
|
||||
title: i18n.baseText('agents.channels.slack.managed.install.title'),
|
||||
description: i18n.baseText('agents.channels.slack.managed.install.description'),
|
||||
},
|
||||
]);
|
||||
|
||||
const selectedCredential = computed(() =>
|
||||
props.setup.managerCredentials.find((credential) => credential.id === selectedCredentialId.value),
|
||||
);
|
||||
const credentialOptions = computed<CredentialOption[]>(() =>
|
||||
props.setup.managerCredentials.map((credential) => ({
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
typeDisplayName: undefined,
|
||||
})),
|
||||
);
|
||||
const managerConnected = computed(
|
||||
() => selectedCredential.value?.connected === true && !selectedCredential.value.reconnectRequired,
|
||||
);
|
||||
const workspaces = computed(() => selectedCredential.value?.workspaces ?? []);
|
||||
const hasManagerCredentials = computed(() => props.setup.managerCredentials.length > 0);
|
||||
|
||||
watch(
|
||||
() => props.setup.managerCredentials,
|
||||
(credentials) => {
|
||||
if (!credentials.some(({ id }) => id === selectedCredentialId.value)) {
|
||||
selectedCredentialId.value =
|
||||
credentials.find(({ connected }) => connected)?.id ?? credentials[0]?.id ?? '';
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
workspaces,
|
||||
(availableWorkspaces) => {
|
||||
if (!availableWorkspaces.some(({ id }) => id === selectedWorkspaceId.value)) {
|
||||
selectedWorkspaceId.value = availableWorkspaces.length === 1 ? availableWorkspaces[0].id : '';
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function createCredential() {
|
||||
connecting.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const connected = await props.connectManager();
|
||||
if (!connected) error.value = 'connect';
|
||||
} catch {
|
||||
error.value = 'connect';
|
||||
} finally {
|
||||
connecting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function install() {
|
||||
if (!selectedCredentialId.value || !selectedWorkspaceId.value) return;
|
||||
installing.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const installed = await props.installApp(selectedCredentialId.value, selectedWorkspaceId.value);
|
||||
if (!installed) error.value = 'install';
|
||||
} catch (installError) {
|
||||
const errorCode = getSlackApiErrorCode(installError);
|
||||
error.value =
|
||||
errorCode === 'service_limits_exceeded' ||
|
||||
errorCode === 'app_approval_request_pending' ||
|
||||
errorCode === 'app_approval_request_denied'
|
||||
? errorCode
|
||||
: 'install';
|
||||
} finally {
|
||||
installing.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.setup" data-testid="slack-managed-setup">
|
||||
<N8nStepper :steps="steps">
|
||||
<template #default="{ step }">
|
||||
<div :class="$style.stepContent">
|
||||
<template v-if="step.id === 'connect'">
|
||||
<div :class="$style.connectRow">
|
||||
<N8nButton
|
||||
v-if="!hasManagerCredentials"
|
||||
variant="outline"
|
||||
size="large"
|
||||
icon="slack"
|
||||
:loading="connecting"
|
||||
:disabled="loading"
|
||||
data-testid="slack-manager-connect"
|
||||
@click="createCredential"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.slack.managed.connect.button') }}
|
||||
</N8nButton>
|
||||
<div v-else :class="$style.actionRow">
|
||||
<CredentialsDropdown
|
||||
:credential-options="credentialOptions"
|
||||
:selected-credential-id="selectedCredentialId || null"
|
||||
:permissions="credentialPermissions"
|
||||
:disabled="loading || connecting"
|
||||
:loading="loading"
|
||||
size="medium"
|
||||
:placeholder="
|
||||
i18n.baseText('agents.channels.slack.managed.credential.placeholder')
|
||||
"
|
||||
data-test-id="slack-manager-credential-select"
|
||||
@credential-selected="selectedCredentialId = $event"
|
||||
@new-credential="createCredential"
|
||||
/>
|
||||
<N8nTooltip :content="i18n.baseText('generic.edit')" placement="top">
|
||||
<N8nIconButton
|
||||
variant="ghost"
|
||||
size="large"
|
||||
icon-size="medium"
|
||||
icon="pen"
|
||||
:disabled="loading || connecting || !selectedCredentialId"
|
||||
:aria-label="i18n.baseText('generic.edit')"
|
||||
data-testid="slack-manager-edit"
|
||||
@click="editManager(selectedCredentialId)"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<N8nText v-if="error === 'connect'" size="small" :class="$style.error">
|
||||
{{ i18n.baseText('agents.channels.slack.managed.connect.error') }}
|
||||
</N8nText>
|
||||
</template>
|
||||
|
||||
<template v-else-if="step.id === 'install'">
|
||||
<div v-if="managerConnected" :class="$style.actionRow">
|
||||
<N8nSelect
|
||||
v-model="selectedWorkspaceId"
|
||||
:disabled="installing"
|
||||
:placeholder="i18n.baseText('agents.channels.slack.managed.workspace.placeholder')"
|
||||
data-testid="slack-managed-workspace-select"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="workspace in workspaces"
|
||||
:key="workspace.id"
|
||||
:value="workspace.id"
|
||||
:label="workspace.name"
|
||||
/>
|
||||
</N8nSelect>
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
:loading="installing"
|
||||
:disabled="!selectedWorkspaceId"
|
||||
data-testid="slack-managed-install"
|
||||
@click="install"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.slack.managed.install.button') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<N8nText v-else size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.channels.slack.managed.install.connectFirst') }}
|
||||
</N8nText>
|
||||
<N8nText v-if="error === 'install'" size="small" :class="$style.error">
|
||||
{{ i18n.baseText('agents.channels.slack.managed.install.error') }}
|
||||
</N8nText>
|
||||
<AgentChannelSlackServiceLimitError v-else-if="error === 'service_limits_exceeded'" />
|
||||
<N8nText
|
||||
v-else-if="error === 'app_approval_request_pending'"
|
||||
size="small"
|
||||
:class="$style.error"
|
||||
data-testid="slack-managed-approval-pending-error"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.slack.managed.install.approvalPending') }}
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-else-if="error === 'app_approval_request_denied'"
|
||||
size="small"
|
||||
:class="$style.error"
|
||||
data-testid="slack-managed-approval-denied-error"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.slack.managed.install.approvalDenied') }}
|
||||
</N8nText>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</N8nStepper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.setup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stepContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--2xs);
|
||||
padding-top: var(--spacing--xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.connectRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
width: 100%;
|
||||
|
||||
> :first-child {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--text-color--danger);
|
||||
}
|
||||
</style>
|
||||
+19
-11
@@ -175,7 +175,7 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
defineExpose({ credentialId, validationError: null });
|
||||
defineExpose({ credentialId, validationError: null, loading: setupLoading });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -184,16 +184,18 @@ defineExpose({ credentialId, validationError: null });
|
||||
<template #default="{ step }">
|
||||
<div :class="$style.stepContent">
|
||||
<div v-if="step.id === 'create-token'" :class="$style.createTokenContainer">
|
||||
<N8nButton
|
||||
href="https://api.slack.com/apps"
|
||||
target="_blank"
|
||||
variant="subtle"
|
||||
size="medium"
|
||||
icon="slack"
|
||||
data-testid="slack-app-configuration-token-link"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.slack.setup.createToken.link') }}
|
||||
</N8nButton>
|
||||
<div :class="$style.dashboardRow">
|
||||
<N8nButton
|
||||
href="https://api.slack.com/apps"
|
||||
target="_blank"
|
||||
variant="subtle"
|
||||
size="medium"
|
||||
icon="slack"
|
||||
data-testid="slack-app-configuration-token-link"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.slack.setup.createToken.link') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<AgentChannelSlackSetupSnapshots />
|
||||
</div>
|
||||
|
||||
@@ -402,6 +404,12 @@ defineExpose({ credentialId, validationError: null });
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboardRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.manualPanel {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ const props = withDefaults(
|
||||
isPublished: false,
|
||||
validationIssues: () => [],
|
||||
simpleChannelSetup: false,
|
||||
ensureAgentPersisted: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -122,6 +123,7 @@ async function loadChannelDetails() {
|
||||
|
||||
onMounted(() => {
|
||||
void loadChannelDetails();
|
||||
agentsEventBus.on('agentUpdated', onExternalAgentUpdated);
|
||||
});
|
||||
|
||||
function onChannelSetup(event: { agentId?: string; source?: string } | undefined) {
|
||||
@@ -137,6 +139,17 @@ watch([() => props.projectId, () => props.agentId], () => {
|
||||
void loadChannelDetails();
|
||||
});
|
||||
|
||||
// After IAI builds an agent we shold refetch credentials and channels
|
||||
function onExternalAgentUpdated(event?: { agentId?: string; source?: string }) {
|
||||
if (event?.source === 'agent-builder') return;
|
||||
if (event?.agentId && event.agentId !== props.agentId) return;
|
||||
void loadChannelDetails();
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
agentsEventBus.off('agentUpdated', onExternalAgentUpdated);
|
||||
});
|
||||
|
||||
function openChannelModal() {
|
||||
channelModalView.value = 'list';
|
||||
channelModalOpen.value = true;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AgentCapabilitySummary,
|
||||
AgentChatMessagesResponse,
|
||||
AgentConfigValidationResponse,
|
||||
AgentDisconnectIntegrationResponse,
|
||||
AgentFileDto,
|
||||
AgentIntegrationStatusResponse,
|
||||
AgentJsonVectorStoreConfig,
|
||||
@@ -206,12 +207,13 @@ export const disconnectIntegration = async (
|
||||
agentId: string,
|
||||
type: string,
|
||||
credentialId: string,
|
||||
): Promise<{ status: string }> => {
|
||||
return await makeRestApiRequest<{ status: string }>(
|
||||
deleteExternalResource?: boolean,
|
||||
): Promise<AgentDisconnectIntegrationResponse> => {
|
||||
return await makeRestApiRequest<AgentDisconnectIntegrationResponse>(
|
||||
context,
|
||||
'POST',
|
||||
`/projects/${projectId}/agents/v2/${agentId}/integrations/disconnect`,
|
||||
{ type, credentialId },
|
||||
{ type, credentialId, deleteExternalResource },
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+21
-2
@@ -1,5 +1,6 @@
|
||||
import { ref, type Ref } from 'vue';
|
||||
import type {
|
||||
AgentDisconnectIntegrationResponse,
|
||||
AgentIntegrationStatusEntry,
|
||||
AgentIntegrationStatusResponse,
|
||||
AgentIntegrationSettings,
|
||||
@@ -158,13 +159,25 @@ export function useAgentIntegrationStatus(projectId: string, agentId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect(type: string, credId: string): Promise<void> {
|
||||
async function disconnect(
|
||||
type: string,
|
||||
credId: string,
|
||||
options: { deleteExternalResource?: boolean } = {},
|
||||
): Promise<AgentDisconnectIntegrationResponse> {
|
||||
state.loadingMap.value[type] = true;
|
||||
try {
|
||||
await disconnectIntegration(rootStore.restApiContext, projectId, agentId, type, credId);
|
||||
const result = await disconnectIntegration(
|
||||
rootStore.restApiContext,
|
||||
projectId,
|
||||
agentId,
|
||||
type,
|
||||
credId,
|
||||
options.deleteExternalResource,
|
||||
);
|
||||
state.statuses.value[type] = 'disconnected';
|
||||
state.connectedCredentials.value[type] = '';
|
||||
state.integrationSettings.value[type] = undefined;
|
||||
return result;
|
||||
} finally {
|
||||
state.loadingMap.value[type] = false;
|
||||
}
|
||||
@@ -178,6 +191,11 @@ export function useAgentIntegrationStatus(projectId: string, agentId: string) {
|
||||
return ['configured', 'connected'].includes(state.statuses.value[type]);
|
||||
}
|
||||
|
||||
function clearError(type: string): void {
|
||||
state.errorMessages.value[type] = '';
|
||||
state.errorIsConflict.value[type] = false;
|
||||
}
|
||||
|
||||
return {
|
||||
statuses: state.statuses,
|
||||
connectedCredentials: state.connectedCredentials,
|
||||
@@ -188,6 +206,7 @@ export function useAgentIntegrationStatus(projectId: string, agentId: string) {
|
||||
fetchStatus,
|
||||
connect,
|
||||
disconnect,
|
||||
clearError,
|
||||
isConnected,
|
||||
isConfigured,
|
||||
};
|
||||
|
||||
+199
@@ -37,6 +37,12 @@ const mocks = vi.hoisted(() => {
|
||||
isConfigured: vi.fn(),
|
||||
getAgent: vi.fn(),
|
||||
createSlackAgentApp: vi.fn(),
|
||||
createSlackManagerCredential: vi.fn(),
|
||||
finalizeSlackManagerCredential: vi.fn(),
|
||||
getSlackManagedSetup: vi.fn(),
|
||||
installSlackManagedApp: vi.fn(),
|
||||
authorizeNewCredential: vi.fn(),
|
||||
fetchCredentials: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -90,8 +96,27 @@ vi.mock('@/features/agents/composables/useAgentApi', () => ({
|
||||
getAgent: mocks.getAgent,
|
||||
}));
|
||||
|
||||
vi.mock('@/features/credentials/credentials.store', () => ({
|
||||
useCredentialsStore: () => ({
|
||||
setCredentials: vi.fn(),
|
||||
fetchAllCredentialsForWorkflow: mocks.fetchCredentials,
|
||||
deleteCredential: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/credentials/composables/useCredentialOAuth', () => ({
|
||||
useCredentialOAuth: () => ({
|
||||
authorize: vi.fn(),
|
||||
authorizeNewCredential: mocks.authorizeNewCredential,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/agents/channels/slack/api', () => ({
|
||||
createSlackAgentApp: mocks.createSlackAgentApp,
|
||||
createSlackManagerCredential: mocks.createSlackManagerCredential,
|
||||
finalizeSlackManagerCredential: mocks.finalizeSlackManagerCredential,
|
||||
getSlackManagedSetup: mocks.getSlackManagedSetup,
|
||||
installSlackManagedApp: mocks.installSlackManagedApp,
|
||||
}));
|
||||
|
||||
vi.mock('@/features/agents/components/AgentChannelSlackSetup.vue', () => ({
|
||||
@@ -134,6 +159,28 @@ vi.mock('@/features/agents/components/AgentChannelSlackSetup.vue', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/agents/components/AgentChannelSlackManagedSetup.vue', () => ({
|
||||
default: {
|
||||
props: ['setup', 'connectManager', 'installApp'],
|
||||
setup(props: {
|
||||
connectManager?: (credentialId?: string) => Promise<boolean>;
|
||||
installApp?: (managerCredentialId: string, workspaceId: string) => Promise<boolean>;
|
||||
}) {
|
||||
async function connectManager() {
|
||||
await props.connectManager?.();
|
||||
}
|
||||
async function install() {
|
||||
await props.installApp?.('manager-credential', 'T123');
|
||||
}
|
||||
return { connectManager, install };
|
||||
},
|
||||
template: `<div data-testid="mock-slack-managed-setup">
|
||||
<button data-testid="mock-slack-manager-connect" @click="connectManager">Connect manager</button>
|
||||
<button data-testid="mock-slack-managed-install" @click="install">Install</button>
|
||||
</div>`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/agents/channels/linear/AgentChannelLinearSetup.vue', () => ({
|
||||
default: {
|
||||
props: ['connectedDescription'],
|
||||
@@ -187,6 +234,30 @@ describe('ChannelSetupCard', () => {
|
||||
mocks.isConfigured.mockReturnValue(false);
|
||||
mocks.getAgent.mockResolvedValue({ name: 'Agent', id: 'agent-1' });
|
||||
mocks.createSlackAgentApp.mockResolvedValue({ installUrl: 'https://slack.com/oauth/install' });
|
||||
mocks.createSlackManagerCredential.mockResolvedValue({
|
||||
id: 'manager-credential',
|
||||
name: 'Slack manager',
|
||||
type: 'slackManagerOAuth2Api',
|
||||
isResolvable: false,
|
||||
});
|
||||
mocks.fetchCredentials.mockResolvedValue([
|
||||
{
|
||||
id: 'manager-credential',
|
||||
name: 'Slack manager',
|
||||
type: 'slackManagerOAuth2Api',
|
||||
},
|
||||
]);
|
||||
mocks.authorizeNewCredential.mockResolvedValue(true);
|
||||
mocks.finalizeSlackManagerCredential.mockResolvedValue(undefined);
|
||||
mocks.getSlackManagedSetup.mockResolvedValue({
|
||||
managedSetupAvailable: false,
|
||||
managerCredentials: [],
|
||||
});
|
||||
mocks.installSlackManagedApp.mockResolvedValue({
|
||||
status: 'connected',
|
||||
appId: 'A123',
|
||||
credentialId: 'slack-credential',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the setup UI for the requested integration type', async () => {
|
||||
@@ -198,6 +269,30 @@ describe('ChannelSetupCard', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a loading skeleton until managed Slack setup availability is known', async () => {
|
||||
let resolveManagedSetup: (value: {
|
||||
managedSetupAvailable: boolean;
|
||||
managerCredentials: [];
|
||||
}) => void = () => {};
|
||||
mocks.getSlackManagedSetup.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveManagedSetup = resolve;
|
||||
}),
|
||||
);
|
||||
const wrapper = mountCard();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="channel-setup-catalog-loading"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="mock-slack-setup"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="mock-slack-managed-setup"]').exists()).toBe(false);
|
||||
|
||||
resolveManagedSetup({ managedSetupAvailable: false, managerCredentials: [] });
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="channel-setup-catalog-loading"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="mock-slack-setup"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not describe a configured draft integration as connected', async () => {
|
||||
mocks.isConfigured.mockReturnValue(true);
|
||||
const wrapper = mountCard({ integrationType: 'linear' });
|
||||
@@ -219,6 +314,110 @@ describe('ChannelSetupCard', () => {
|
||||
expect(wrapper.emitted('resolve')).toEqual([[{ approved: true }]]);
|
||||
});
|
||||
|
||||
it('uses managed Slack setup and resolves after the app installs', async () => {
|
||||
mocks.getSlackManagedSetup.mockResolvedValueOnce({
|
||||
managedSetupAvailable: true,
|
||||
managerCredentials: [
|
||||
{
|
||||
id: 'manager-credential',
|
||||
name: 'Slack manager',
|
||||
connected: true,
|
||||
reconnectRequired: false,
|
||||
workspaces: [{ id: 'T123', name: 'Workspace', connected: false }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mountCard();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="mock-slack-managed-setup"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="mock-slack-setup"]').exists()).toBe(false);
|
||||
|
||||
await wrapper.find('[data-testid="mock-slack-managed-install"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.installSlackManagedApp).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'project-1',
|
||||
'agent-1',
|
||||
'manager-credential',
|
||||
'T123',
|
||||
);
|
||||
expect(wrapper.emitted('resolve')).toEqual([[{ approved: true }]]);
|
||||
});
|
||||
|
||||
it('keeps skip disabled while managed Slack authorization is in flight', async () => {
|
||||
let resolveAuthorization: (connected: boolean) => void = () => {};
|
||||
mocks.authorizeNewCredential.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveAuthorization = resolve;
|
||||
}),
|
||||
);
|
||||
mocks.getSlackManagedSetup.mockResolvedValue({
|
||||
managedSetupAvailable: true,
|
||||
managerCredentials: [],
|
||||
});
|
||||
const wrapper = mountCard();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.get('[data-testid="mock-slack-manager-connect"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
const skipButton = wrapper.get('[data-testid="channel-setup-card-skip"]');
|
||||
expect(skipButton.attributes('disabled')).toBeDefined();
|
||||
await skipButton.trigger('click');
|
||||
expect(wrapper.emitted('resolve')).toBeUndefined();
|
||||
|
||||
resolveAuthorization(true);
|
||||
await flushPromises();
|
||||
|
||||
expect(skipButton.attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps skip disabled while managed Slack installation is in flight', async () => {
|
||||
let resolveInstall: (result: {
|
||||
status: 'connected';
|
||||
appId: string;
|
||||
credentialId: string;
|
||||
}) => void = () => {};
|
||||
mocks.installSlackManagedApp.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveInstall = resolve;
|
||||
}),
|
||||
);
|
||||
mocks.getSlackManagedSetup.mockResolvedValue({
|
||||
managedSetupAvailable: true,
|
||||
managerCredentials: [
|
||||
{
|
||||
id: 'manager-credential',
|
||||
name: 'Slack manager',
|
||||
connected: true,
|
||||
reconnectRequired: false,
|
||||
workspaces: [{ id: 'T123', name: 'Workspace', connected: false }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mountCard();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.get('[data-testid="mock-slack-managed-install"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
const skipButton = wrapper.get('[data-testid="channel-setup-card-skip"]');
|
||||
expect(skipButton.attributes('disabled')).toBeDefined();
|
||||
await skipButton.trigger('click');
|
||||
expect(wrapper.emitted('resolve')).toBeUndefined();
|
||||
|
||||
resolveInstall({
|
||||
status: 'connected',
|
||||
appId: 'A123',
|
||||
credentialId: 'slack-credential',
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('resolve')).toEqual([[{ approved: true }]]);
|
||||
});
|
||||
|
||||
it('notifies agent surfaces on the event bus after a successful connect', async () => {
|
||||
const onAgentUpdated = vi.fn();
|
||||
agentsEventBus.on('agentUpdated', onAgentUpdated);
|
||||
|
||||
@@ -130,10 +130,13 @@ const fallbackRuntime = createAgentChannelRuntime(getAgentChannelPlatform('unkno
|
||||
});
|
||||
const currentPlatform = computed(() => getAgentChannelPlatform(props.integrationType));
|
||||
const currentRuntime = computed(() => runtimes[props.integrationType] ?? fallbackRuntime);
|
||||
const channelActionInFlight = computed(
|
||||
() => connectionInFlight.value || currentRuntime.value.loading.value,
|
||||
);
|
||||
const channelViewRef = ref<AgentChannelViewExpose>();
|
||||
const channelActionInFlight = computed(
|
||||
() =>
|
||||
connectionInFlight.value ||
|
||||
currentRuntime.value.loading.value ||
|
||||
channelViewRef.value?.loading === true,
|
||||
);
|
||||
const integrationLabel = computed(() => currentIntegration.value.label);
|
||||
|
||||
const connectedDescription = computed(() => {
|
||||
|
||||
@@ -59,6 +59,7 @@ function isSupported(name: string): boolean {
|
||||
|
||||
const checkedCredType = credentialsStore.getCredentialTypeByName(name);
|
||||
if (!checkedCredType) return false;
|
||||
if (checkedCredType.hidden) return false;
|
||||
|
||||
// Exclude credentials that opt into node-restriction when the current
|
||||
// node is not in their supportedNodes list. Mirrors the server-side
|
||||
|
||||
+4
-2
@@ -48,9 +48,11 @@ onMounted(async () => {
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Exclude purpose built credentials for ChatHub
|
||||
// Exclude hidden and purpose-built credentials for ChatHub
|
||||
const allSelectableCredentialTypes = computed(() =>
|
||||
credentialsStore.allCredentialTypes.filter((c) => !c.name.startsWith('chatHub')),
|
||||
credentialsStore.allCredentialTypes.filter(
|
||||
(credentialType) => !credentialType.hidden && !credentialType.name.startsWith('chatHub'),
|
||||
),
|
||||
);
|
||||
|
||||
const selectableCredentialTypes = computed(() => {
|
||||
|
||||
+30
@@ -228,6 +228,36 @@ describe('oauthCallback', () => {
|
||||
await expect(promise).resolves.toBe(OAUTH_CALLBACK_SUCCESS);
|
||||
});
|
||||
|
||||
it('resolves as aborted when popup-close cancellation is enabled', async () => {
|
||||
const verifyConnected = vi.fn<() => Promise<boolean>>().mockResolvedValue(false);
|
||||
const promise = waitForOAuthCallback({
|
||||
popup: createPopup(true),
|
||||
trustedOrigins,
|
||||
verifyConnected,
|
||||
abortOnPopupClose: true,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
await expect(promise).resolves.toBe('aborted');
|
||||
expect(verifyConnected).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('resolves as successful when verification succeeds after an opted-in popup closes', async () => {
|
||||
const verifyConnected = vi.fn<() => Promise<boolean>>().mockResolvedValue(true);
|
||||
const promise = waitForOAuthCallback({
|
||||
popup: createPopup(true),
|
||||
trustedOrigins,
|
||||
verifyConnected,
|
||||
abortOnPopupClose: true,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
await expect(promise).resolves.toBe(OAUTH_CALLBACK_SUCCESS);
|
||||
expect(verifyConnected).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps waiting for verifyConnected while it reports not connected', async () => {
|
||||
const verifyConnected = vi.fn<() => Promise<boolean>>().mockResolvedValue(false);
|
||||
|
||||
|
||||
+23
@@ -849,6 +849,7 @@ describe('useCredentialOAuth', () => {
|
||||
mockShowError.mockClear();
|
||||
mockPopup = { closed: false, close: vi.fn(), location: { href: '' } };
|
||||
MockBroadcastChannel.silent = false;
|
||||
mockedStore(useCredentialsStore).deleteCredential.mockResolvedValue();
|
||||
vi.stubGlobal('BroadcastChannel', MockBroadcastChannel);
|
||||
vi.stubGlobal('open', vi.fn().mockReturnValue(mockPopup));
|
||||
});
|
||||
@@ -878,6 +879,28 @@ describe('useCredentialOAuth', () => {
|
||||
return credentialsStore;
|
||||
}
|
||||
|
||||
it('should delete a newly created credential when authorization fails', async () => {
|
||||
const credentialsStore = setupFailedOAuthFlow();
|
||||
const { authorizeNewCredential } = useCredentialOAuth();
|
||||
|
||||
await expect(authorizeNewCredential(createdCredential)).resolves.toBe(false);
|
||||
|
||||
expect(credentialsStore.deleteCredential).toHaveBeenCalledWith({
|
||||
id: createdCredential.id,
|
||||
});
|
||||
expect(credentialsStore.upsertCredential).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should keep a newly created credential when authorization succeeds', async () => {
|
||||
const credentialsStore = setupSuccessfulOAuthFlow();
|
||||
const { authorizeNewCredential } = useCredentialOAuth();
|
||||
|
||||
await expect(authorizeNewCredential(createdCredential)).resolves.toBe(true);
|
||||
|
||||
expect(credentialsStore.upsertCredential).toHaveBeenCalledWith(createdCredential);
|
||||
expect(credentialsStore.deleteCredential).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not set allowedHttpRequestDomains for hidden property', async () => {
|
||||
const credentialsStore = setupSuccessfulOAuthFlow();
|
||||
credentialsStore.state.credentialTypes.mcpOAuth2Api = mcpOAuth2ApiWithNoVisibleProps;
|
||||
|
||||
@@ -82,6 +82,14 @@ export interface WaitForOAuthCallbackOptions {
|
||||
*/
|
||||
verifyConnected?: () => Promise<boolean>;
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* Resolve as aborted when `popup.closed` becomes true and a final
|
||||
* `verifyConnected` check does not confirm success. Enable this only for
|
||||
* flows where prompt cancellation is more important than COOP compatibility:
|
||||
* browsers also report COOP-severed popups as closed while they remain open.
|
||||
*
|
||||
*/
|
||||
abortOnPopupClose?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,7 +101,9 @@ export interface WaitForOAuthCallbackOptions {
|
||||
* window is still open and the user is still authorizing. When the popup
|
||||
* reads as closed we instead start polling `verifyConnected` (when given) and
|
||||
* keep listening for callback messages until `timeoutMs` elapses or `signal`
|
||||
* aborts.
|
||||
* aborts. Callers can opt into treating this signal as cancellation with
|
||||
* `abortOnPopupClose`, accepting that COOP-severed popups are indistinguishable
|
||||
* from popups the user actually closed.
|
||||
*/
|
||||
export async function waitForOAuthCallback({
|
||||
popup,
|
||||
@@ -101,6 +111,7 @@ export async function waitForOAuthCallback({
|
||||
signal,
|
||||
verifyConnected,
|
||||
timeoutMs = OAUTH_FLOW_TIMEOUT,
|
||||
abortOnPopupClose = false,
|
||||
}: WaitForOAuthCallbackOptions): Promise<OAuthFlowOutcome> {
|
||||
return await new Promise((resolve) => {
|
||||
const oauthChannel = new BroadcastChannel('oauth-callback');
|
||||
@@ -140,7 +151,12 @@ export async function waitForOAuthCallback({
|
||||
if (verifyInFlight || settled || !verifyConnected) return;
|
||||
verifyInFlight = true;
|
||||
try {
|
||||
if (await verifyConnected()) settle(OAUTH_CALLBACK_SUCCESS);
|
||||
const connected = await verifyConnected();
|
||||
if (connected) {
|
||||
settle(OAUTH_CALLBACK_SUCCESS);
|
||||
} else {
|
||||
if (abortOnPopupClose && popup.closed) settle('aborted');
|
||||
}
|
||||
} catch {
|
||||
// Treat verification errors as "not connected yet" and keep waiting.
|
||||
} finally {
|
||||
@@ -154,6 +170,8 @@ export async function waitForOAuthCallback({
|
||||
if (verifyConnected) {
|
||||
void verify();
|
||||
verifyTimer = setInterval(() => void verify(), VERIFY_CONNECTED_INTERVAL);
|
||||
} else if (abortOnPopupClose) {
|
||||
settle('aborted');
|
||||
}
|
||||
}, POPUP_CLOSED_POLL_INTERVAL);
|
||||
|
||||
|
||||
+36
-3
@@ -19,6 +19,11 @@ import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { getTrustedOAuthOrigins, hasOAuthTokenData, waitForOAuthCallback } from './oauthCallback';
|
||||
|
||||
interface OAuthAuthorizationOptions {
|
||||
abortOnPopupClose?: boolean;
|
||||
preopenedPopup?: Window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for OAuth credential type detection and authorization.
|
||||
* Used by NodeCredentials for the quick connect OAuth flow.
|
||||
@@ -231,14 +236,14 @@ export function useCredentialOAuth() {
|
||||
async function authorize(
|
||||
credential: ICredentialsResponse,
|
||||
signal?: AbortSignal,
|
||||
preopenedPopup?: Window,
|
||||
options: OAuthAuthorizationOptions = {},
|
||||
): Promise<boolean> {
|
||||
// window.open must run within the click's transient user activation:
|
||||
// opening after the network round trips below gets the popup blocked on
|
||||
// slow connections (Chrome expires activation after ~5s) and in stricter
|
||||
// browsers (Safari) regardless of timing. Open a blank window now and
|
||||
// navigate it once the authorization URL is known.
|
||||
const popup = preopenedPopup ?? openOAuthPopup('about:blank', signal);
|
||||
const popup = options.preopenedPopup ?? openOAuthPopup('about:blank', signal);
|
||||
if (!popup) {
|
||||
showPopupBlockedError();
|
||||
return false;
|
||||
@@ -273,6 +278,7 @@ export function useCredentialOAuth() {
|
||||
verifyConnected: canVerifyConnected
|
||||
? async () => await isConnected(credential.id)
|
||||
: undefined,
|
||||
abortOnPopupClose: options.abortOnPopupClose,
|
||||
});
|
||||
|
||||
// Timeout and abort can race the backend committing the token: authorization
|
||||
@@ -308,6 +314,32 @@ export function useCredentialOAuth() {
|
||||
return outcome === 'success';
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a credential that was just created. Keeps it out of the store
|
||||
* until OAuth succeeds and removes it when authorization is not completed.
|
||||
*/
|
||||
async function authorizeNewCredential(
|
||||
credential: ICredentialsResponse,
|
||||
options: OAuthAuthorizationOptions = {},
|
||||
): Promise<boolean> {
|
||||
const controller = new AbortController();
|
||||
oauthAbortController.value = controller;
|
||||
let success = false;
|
||||
|
||||
try {
|
||||
success = await authorize(credential, controller.signal, options);
|
||||
if (success) {
|
||||
credentialsStore.upsertCredential(credential);
|
||||
}
|
||||
return success;
|
||||
} finally {
|
||||
oauthAbortController.value = null;
|
||||
if (!success) {
|
||||
await credentialsStore.deleteCredential({ id: credential.id }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new OAuth credential and run the full authorization flow.
|
||||
* Returns the credential on success, null on failure (cleans up automatically).
|
||||
@@ -373,7 +405,7 @@ export function useCredentialOAuth() {
|
||||
|
||||
pendingCredentialId.value = credential.id;
|
||||
|
||||
const success = await authorize(credential, controller.signal, popup);
|
||||
const success = await authorize(credential, controller.signal, { preopenedPopup: popup });
|
||||
|
||||
oauthAbortController.value = null;
|
||||
pendingCredentialId.value = null;
|
||||
@@ -432,6 +464,7 @@ export function useCredentialOAuth() {
|
||||
canOAuthCredentialQuickConnect,
|
||||
hasManualCredentialInputFields,
|
||||
authorize,
|
||||
authorizeNewCredential,
|
||||
createAndAuthorize,
|
||||
cancelAuthorize,
|
||||
};
|
||||
|
||||
@@ -30,6 +30,24 @@ export class SlackApi implements ICredentialType {
|
||||
description:
|
||||
'The signature secret is used to verify the authenticity of requests sent by Slack.',
|
||||
},
|
||||
{
|
||||
displayName: 'Managed App ID',
|
||||
name: 'managedAppId',
|
||||
type: 'hidden',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Slack Team ID',
|
||||
name: 'teamId',
|
||||
type: 'hidden',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Manager Credential ID',
|
||||
name: 'managerCredentialId',
|
||||
type: 'hidden',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'We strongly recommend setting up a <a href="https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.slacktrigger/#verify-the-webhook" target="_blank">signing secret</a> to ensure the authenticity of requests.',
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
const scopes = ['app_configurations:read', 'app_configurations:write', 'managed_apps:install'];
|
||||
|
||||
export class SlackManagerOAuth2Api implements ICredentialType {
|
||||
name = 'slackManagerOAuth2Api';
|
||||
|
||||
extends = ['oAuth2Api'];
|
||||
|
||||
displayName = 'Slack Manager OAuth2 API';
|
||||
|
||||
icon = 'file:../nodes/Slack/slack.svg' as const;
|
||||
|
||||
documentationUrl = 'slack';
|
||||
|
||||
hideDomainRestrictionFields = true;
|
||||
|
||||
hidden = true;
|
||||
|
||||
restrictToSupportedNodes = true as const;
|
||||
|
||||
supportedNodes = [];
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Grant Type',
|
||||
name: 'grantType',
|
||||
type: 'hidden',
|
||||
default: 'authorizationCode',
|
||||
},
|
||||
{
|
||||
displayName: 'Authorization URL',
|
||||
name: 'authUrl',
|
||||
type: 'hidden',
|
||||
default: 'https://slack.com/oauth/v2/authorize',
|
||||
},
|
||||
{
|
||||
displayName: 'Access Token URL',
|
||||
name: 'accessTokenUrl',
|
||||
type: 'hidden',
|
||||
default: 'https://slack.com/api/oauth.v2.access',
|
||||
},
|
||||
{
|
||||
displayName: 'Scope',
|
||||
name: 'scope',
|
||||
type: 'hidden',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Auth URI Query Parameters',
|
||||
name: 'authQueryParameters',
|
||||
type: 'hidden',
|
||||
default: `={{"user_scope=${scopes.join(' ')}"}}`,
|
||||
},
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'hidden',
|
||||
default: 'body',
|
||||
},
|
||||
{
|
||||
displayName: 'Allowed Domains',
|
||||
name: 'allowedHttpRequestDomains',
|
||||
type: 'hidden',
|
||||
default: 'none',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { SlackManagerOAuth2Api } from '../SlackManagerOAuth2Api.credentials';
|
||||
|
||||
describe('SlackManagerOAuth2Api Credential', () => {
|
||||
const credential = new SlackManagerOAuth2Api();
|
||||
|
||||
it('uses the dedicated manager credential type and configured user scopes', () => {
|
||||
expect(credential.name).toBe('slackManagerOAuth2Api');
|
||||
expect(credential.extends).toEqual(['oAuth2Api']);
|
||||
expect(credential.properties.find(({ name }) => name === 'authUrl')?.default).toBe(
|
||||
'https://slack.com/oauth/v2/authorize',
|
||||
);
|
||||
expect(credential.properties.find(({ name }) => name === 'accessTokenUrl')?.default).toBe(
|
||||
'https://slack.com/api/oauth.v2.access',
|
||||
);
|
||||
expect(credential.properties.find(({ name }) => name === 'authQueryParameters')?.default).toBe(
|
||||
'={{"user_scope=app_configurations:read app_configurations:write managed_apps:install"}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -359,6 +359,7 @@
|
||||
"dist/credentials/ShopifyOAuth2Api.credentials.js",
|
||||
"dist/credentials/Signl4Api.credentials.js",
|
||||
"dist/credentials/SlackApi.credentials.js",
|
||||
"dist/credentials/SlackManagerOAuth2Api.credentials.js",
|
||||
"dist/credentials/SlackOAuth2Api.credentials.js",
|
||||
"dist/credentials/Sms77Api.credentials.js",
|
||||
"dist/credentials/Smtp.credentials.js",
|
||||
|
||||
@@ -73,6 +73,9 @@ function readAllowedDomainsField(
|
||||
}
|
||||
|
||||
function shouldInjectDomainRestrictionFields(credentialType: ICredentialType): boolean {
|
||||
if (credentialType.hideDomainRestrictionFields) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
credentialType.authenticate !== undefined ||
|
||||
credentialType.genericAuth === true ||
|
||||
|
||||
@@ -413,6 +413,16 @@ export interface ICredentialType {
|
||||
* Opt-in. Existing credentials without this flag are unaffected.
|
||||
*/
|
||||
restrictToSupportedNodes?: true;
|
||||
|
||||
/**
|
||||
* If `true`, the domain restriction fields will not be shown in the credential type properties.
|
||||
*/
|
||||
hideDomainRestrictionFields?: boolean;
|
||||
|
||||
/**
|
||||
* If `true`, the credential type will not be shown in the credentials add modal
|
||||
*/
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export interface ICredentialTypes {
|
||||
|
||||
@@ -629,6 +629,17 @@ describe('injectDomainRestrictionFields', () => {
|
||||
);
|
||||
expect(properties).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not inject fields when hideDomainRestrictionFields is true', () => {
|
||||
const result = injectDomainRestrictionFields(
|
||||
baseCredential({
|
||||
extends: ['oAuth2Api'],
|
||||
hideDomainRestrictionFields: true,
|
||||
}),
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe(dummyField);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when injection is not required', () => {
|
||||
|
||||
Reference in New Issue
Block a user