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:
yehorkardash
2026-08-17 09:27:50 +00:00
committed by GitHub
parent 04e76a3890
commit ca82fd9827
77 changed files with 6096 additions and 459 deletions
@@ -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;
+34
View File
@@ -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) => {
@@ -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,
});
},
);
});
@@ -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 = {
@@ -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,
@@ -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,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;
}
@@ -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', () => {
@@ -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();
});
});
@@ -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>;
@@ -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);
}
@@ -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);
}
}
}
@@ -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}`;
}
}
@@ -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');
@@ -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,
});
});
});
@@ -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();
});
});
@@ -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');
});
});
@@ -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');
});
});
@@ -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();
@@ -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);
});
});
@@ -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>
@@ -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]]);
});
});
@@ -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>
@@ -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>
@@ -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>
@@ -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';
@@ -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([]);
});
});
@@ -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);
}
@@ -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>
@@ -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>
@@ -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 },
);
};
@@ -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,
};
@@ -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
@@ -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(() => {
@@ -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);
@@ -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);
@@ -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"}}',
);
});
});
+1
View File
@@ -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 ||
+10
View File
@@ -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', () => {