feat(editor): Show Slack docs link for agent setup (no-changelog) (#30608)

This commit is contained in:
yehorkardash 2026-05-18 12:19:24 +03:00 committed by GitHub
parent 87cfbbbc6e
commit 7bdfee9001
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 293 additions and 967 deletions

View File

@ -6140,6 +6140,7 @@
"agents.builder.addTrigger.slack.hideJson": "Hide JSON",
"agents.builder.addTrigger.slack.copyManifest": "Copy manifest",
"agents.builder.addTrigger.slack.manifestTitle": "Slack App Manifest",
"agents.builder.addTrigger.slack.docsCalloutLink": "See docs",
"agents.builder.addTrigger.telegram.accessMode.label": "Access mode",
"agents.builder.addTrigger.telegram.accessMode.private": "Private",
"agents.builder.addTrigger.telegram.accessMode.public": "Public",

View File

@ -364,12 +364,6 @@ const commonStubs = {
props: ['skill', 'disabled', 'errors'],
emits: ['update:skill'],
},
AgentIntegrationsPanel: {
name: 'AgentIntegrationsPanel',
template: '<div data-testid="stub-agent-integrations-panel" />',
props: ['projectId', 'agentId', 'agentName', 'focusType', 'onlyConnected'],
emits: ['update:connected-triggers', 'trigger-added'],
},
AgentSessionsListView: {
name: 'AgentSessionsListView',
template: '<div data-testid="stub-agent-sessions-list-view" />',
@ -884,23 +878,3 @@ describe('AgentBuilderView — three-column shell', () => {
]);
});
});
/*
* DROPPED SPECS (no UI entry point in PR1):
*
* 1. 'fires User edited agent config with part=name when the agent name is updated'
* relied on AgentHomeContent emitting 'update:name'. AgentHomeContent is
* removed from the three-column shell; name editing has no new UI entry point
* in PR1. Re-add once a name-edit surface lands in the editor column.
*
* 2. 'fires User edited agent config with part=description when the description is updated'
* relied on AgentHomeContent emitting 'update:description'. Same reason as above.
*
* 3. 'fires User edited agent config with part=triggers when the connected-triggers list changes'
* relied on AgentSettingsSidebar emitting 'update:connected-triggers'. The
* integrations panel is deleted in PR1; triggers telemetry has no new entry point.
* Re-add when AgentIntegrationsPanel or equivalent lands.
*
* 4. 'does not fire User edited agent config when the connected-triggers list is unchanged'
* same as above.
*/

View File

@ -3,7 +3,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import AgentAddTriggerModal from '../components/AgentAddTriggerModal.vue';
import AgentIntegrationsPanel from '../components/AgentIntegrationsPanel.vue';
import { clearAgentIntegrationStatusCache } from '../composables/useAgentIntegrationStatus';
const {
@ -220,48 +219,22 @@ describe('agent integration credential picker usage', () => {
);
});
it('uses the shared credential picker in the integrations panel', async () => {
const wrapper = mount(AgentIntegrationsPanel, {
props: {
projectId: 'project-1',
agentId: 'agent-1',
agentName: 'Agent',
isPublished: true,
focusType: 'slack',
},
global: { stubs: globalStubs },
});
await flushPromises();
const picker = wrapper.find('[data-testid="agent-credential-select-stub"]');
expect(picker.exists()).toBe(true);
expect(picker.attributes('data-test-id-prop')).toBe('slack-credential-select');
expect(picker.attributes('data-can-create')).toBe('true');
await wrapper.find('[data-testid="stub-create-credential"]').trigger('click');
expect(openNewCredential).toHaveBeenCalledWith(
'slackApi',
false,
false,
'project-1',
undefined,
undefined,
undefined,
{ hideAskAssistant: true },
);
});
it('passes denied credential creation permission to the shared picker', async () => {
projectState.currentProject = { id: 'project-1', name: 'Project', scopes: [] };
const wrapper = mount(AgentIntegrationsPanel, {
const wrapper = mount(AgentAddTriggerModal, {
props: {
projectId: 'project-1',
agentId: 'agent-1',
agentName: 'Agent',
isPublished: true,
focusType: 'slack',
modalName: 'agentAddTriggerModal',
data: {
projectId: 'project-1',
agentId: 'agent-1',
agentName: 'Agent',
isPublished: true,
initialTriggerType: 'slack',
connectedTriggers: [],
onConnectedTriggersChange: vi.fn(),
onTriggerAdded: vi.fn(),
},
},
global: { stubs: globalStubs },
});
@ -273,13 +246,19 @@ describe('agent integration credential picker usage', () => {
});
it('defaults Telegram setup to private mode and requires a user ID before connecting', async () => {
const wrapper = mount(AgentIntegrationsPanel, {
const wrapper = mount(AgentAddTriggerModal, {
props: {
projectId: 'project-1',
agentId: 'agent-1',
agentName: 'Agent',
isPublished: true,
focusType: 'telegram',
modalName: 'agentAddTriggerModal',
data: {
projectId: 'project-1',
agentId: 'agent-1',
agentName: 'Agent',
isPublished: true,
initialTriggerType: 'telegram',
connectedTriggers: [],
onConnectedTriggersChange: vi.fn(),
onTriggerAdded: vi.fn(),
},
},
global: { stubs: globalStubs },
});
@ -313,13 +292,19 @@ describe('agent integration credential picker usage', () => {
integrations: [{ type: 'telegram', credentialId: 'cred-telegram' }],
});
const wrapper = mount(AgentIntegrationsPanel, {
const wrapper = mount(AgentAddTriggerModal, {
props: {
projectId: 'project-1',
agentId: 'agent-1',
agentName: 'Agent',
isPublished: true,
focusType: 'telegram',
modalName: 'agentAddTriggerModal',
data: {
projectId: 'project-1',
agentId: 'agent-1',
agentName: 'Agent',
isPublished: true,
initialTriggerType: 'telegram',
connectedTriggers: [],
onConnectedTriggersChange: vi.fn(),
onTriggerAdded: vi.fn(),
},
},
global: { stubs: globalStubs },
});
@ -342,13 +327,19 @@ describe('agent integration credential picker usage', () => {
],
});
const wrapper = mount(AgentIntegrationsPanel, {
const wrapper = mount(AgentAddTriggerModal, {
props: {
projectId: 'project-1',
agentId: 'agent-1',
agentName: 'Agent',
isPublished: true,
focusType: 'telegram',
modalName: 'agentAddTriggerModal',
data: {
projectId: 'project-1',
agentId: 'agent-1',
agentName: 'Agent',
isPublished: true,
initialTriggerType: 'telegram',
connectedTriggers: [],
onConnectedTriggersChange: vi.fn(),
onTriggerAdded: vi.fn(),
},
},
global: { stubs: globalStubs },
});
@ -357,6 +348,6 @@ describe('agent integration credential picker usage', () => {
const userIds = wrapper.find('[data-testid="telegram-user-ids"]');
expect(userIds.exists()).toBe(true);
expect(userIds.find('input').attributes('disabled')).toBeDefined();
expect(wrapper.find('[data-testid="telegram-telegram-settings-save"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="telegram-disconnect-button"]').exists()).toBe(true);
});
});

View File

@ -87,7 +87,6 @@ const credentialIdsBeforeNew = ref<Record<string, Set<string>>>({});
const pendingNewCredentialType = ref<string | null>(null);
const linearCopied = ref(false);
const manifestCopied = ref(false);
const SCHEDULE_ICON: IconName = 'clock';
@ -160,109 +159,6 @@ async function copyLinearWebhookUrl() {
}, 2000);
}
const oauthCallbackUrl = computed(() => {
const configured = (rootStore.OAuthCallbackUrls as { oauth2?: string }).oauth2 ?? '';
if (!configured) return '';
try {
// Preserve the configured path (which may include a custom rest endpoint
// or base path) but rebase onto `urlBaseWebhook` so the callback uses
// the publicly reachable host from `WEBHOOK_URL` instead of the local
// browser origin (which is `http://localhost:5678` in dev).
const parsed = new URL(configured);
const base = rootStore.urlBaseWebhook.replace(/\/$/, '');
return `${base}${parsed.pathname}${parsed.search}`;
} catch {
return configured;
}
});
const DEFAULT_SLACK_APP_NAME = 'n8n Agent';
// Slack app names accept letters, digits, spaces, periods, hyphens and
// underscores (max 35 chars per Slack's submission guidelines). Strip anything
// else and fall back to a sensible default if the agent name is empty after
// sanitisation, so the manifest always validates on Slack's side.
function sanitiseSlackAppName(raw: string): string {
const cleaned = raw
.replace(/[^a-zA-Z0-9 ._-]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 35);
return cleaned.length > 0 ? cleaned : DEFAULT_SLACK_APP_NAME;
}
const slackAppManifest = computed(() => {
const agentName = sanitiseSlackAppName(props.data.agentName);
return JSON.stringify(
{
display_information: {
name: agentName,
},
features: {
app_home: {
home_tab_enabled: true,
messages_tab_enabled: false,
messages_tab_read_only_enabled: false,
},
bot_user: {
display_name: agentName,
always_online: true,
},
},
oauth_config: {
redirect_urls: [oauthCallbackUrl.value],
scopes: {
bot: [
'app_mentions:read',
'assistant:write',
'channels:history',
'channels:join',
'channels:manage',
'channels:read',
'chat:write',
'chat:write.customize',
'files:read',
'files:write',
'groups:read',
'im:history',
'im:read',
'im:write',
'mpim:read',
'mpim:write',
'search:read.public',
'users:read',
'users:read.email',
],
},
pkce_enabled: false,
},
settings: {
event_subscriptions: {
request_url: webhookUrlFor('slack'),
bot_events: ['app_mention', 'assistant_thread_context_changed', 'message.im'],
},
interactivity: {
is_enabled: true,
request_url: webhookUrlFor('slack'),
},
org_deploy_enabled: false,
socket_mode_enabled: false,
token_rotation_enabled: false,
},
},
null,
2,
);
});
async function copyManifest() {
await navigator.clipboard.writeText(slackAppManifest.value);
manifestCopied.value = true;
setTimeout(() => {
manifestCopied.value = false;
}, 2000);
}
function computeConnectedTriggers(): string[] {
return Object.keys(statuses.value)
.filter((t) => statuses.value[t] === 'connected')
@ -579,6 +475,9 @@ onMounted(async () => {
:disabled="isConnected(currentIntegration.type) || isLoading(currentIntegration.type)"
:connected="isConnected(currentIntegration.type)"
:saved-settings="integrationSettings[currentIntegration.type]"
:agent-name="data.agentName"
:project-id="data.projectId"
:agent-id="data.agentId"
/>
<N8nText
@ -598,36 +497,6 @@ onMounted(async () => {
>{{ i18n.baseText('agents.builder.addTrigger.editCredential') }}</a
>
</N8nText>
<!-- Slack manifest reference material. Integration actions live
in the modal footer so they stay aligned with other modals. -->
<div v-if="currentIntegration.type === 'slack'" :class="$style.manifestSection">
<N8nText size="small" bold>
{{ i18n.baseText('agents.builder.addTrigger.slack.manifestTitle') }}
</N8nText>
<N8nText :class="$style.manifestHint" size="small">
{{ i18n.baseText('agents.builder.addTrigger.slack.manifestHint') }}
</N8nText>
<div :class="$style.codeBlock">
<N8nButton
variant="outline"
size="small"
:class="$style.codeBlockCopy"
:data-testid="`${currentIntegration.type}-copy-manifest`"
@click="copyManifest"
>
<template #prefix>
<N8nIcon :icon="manifestCopied ? 'check' : 'copy'" size="xsmall" />
</template>
{{
manifestCopied
? i18n.baseText('agents.builder.addTrigger.copied')
: i18n.baseText('agents.builder.addTrigger.copy')
}}
</N8nButton>
<pre :class="$style.manifestCode">{{ slackAppManifest }}</pre>
</div>
</div>
</div>
</div>
</template>
@ -772,50 +641,6 @@ onMounted(async () => {
gap: var(--spacing--sm);
}
.manifestSection {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
.manifestHint {
color: var(--color--text--tint-1);
}
.codeBlock {
position: relative;
margin-top: var(--spacing--3xs);
}
/* Sit on top of the rounded container itself rather than inside the scrolling
<pre>, so the button stays put as the user scrolls and never collides with
the scrollbar groove. The right offset clears typical macOS / overlay
scrollbars (~14px) plus our normal inner padding. */
.codeBlockCopy {
position: absolute;
top: var(--spacing--2xs);
right: var(--spacing--lg);
z-index: 1;
}
.manifestCode {
margin: 0;
padding: var(--spacing--xs);
padding-right: calc(var(--spacing--2xl) + var(--spacing--lg));
background-color: var(--color--foreground--tint-2);
border-radius: var(--radius);
font-size: var(--font-size--2xs);
line-height: var(--line-height--xl);
overflow-x: auto;
max-height: 240px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
white-space: pre;
font-family: monospace;
color: var(--color--text);
}
.errorText {
color: var(--color--danger);
}

View File

@ -4,6 +4,7 @@ import type { AgentIntegrationSettings, AgentTelegramIntegrationSettings } from
import { resolveSavedTelegramSettings } from '../utils/telegramAccessSettings';
import AgentTelegramAccessSettingsForm from './AgentTelegramAccessSettingsForm.vue';
import AgentSlackSettingsForm from './AgentSlackSettingsForm.vue';
const props = withDefaults(
defineProps<{
@ -11,8 +12,18 @@ const props = withDefaults(
disabled?: boolean;
connected?: boolean;
savedSettings?: AgentIntegrationSettings;
agentName?: string;
projectId?: string;
agentId?: string;
}>(),
{ disabled: false, connected: false, savedSettings: undefined },
{
disabled: false,
connected: false,
savedSettings: undefined,
agentName: '',
projectId: '',
agentId: '',
},
);
const telegramFormRef = ref<InstanceType<typeof AgentTelegramAccessSettingsForm>>();
@ -21,8 +32,6 @@ const telegramSavedSettings = computed<AgentTelegramIntegrationSettings | undefi
resolveSavedTelegramSettings(props.savedSettings, props.connected),
);
const hasTelegramForm = computed(() => props.type === 'telegram');
const currentSettings = computed<AgentIntegrationSettings | undefined>(
() => telegramFormRef.value?.currentSettings,
);
@ -45,9 +54,16 @@ defineExpose({ currentSettings, validationError, isDirty });
<template>
<AgentTelegramAccessSettingsForm
v-if="hasTelegramForm"
v-if="props.type === 'telegram'"
ref="telegramFormRef"
:disabled="disabled"
:saved-settings="telegramSavedSettings"
/>
<AgentSlackSettingsForm
v-else-if="props.type === 'slack'"
:agent-name="agentName"
:project-id="projectId"
:agent-id="agentId"
:connected="connected"
/>
</template>

View File

@ -1,701 +0,0 @@
<script setup lang="ts">
import { AGENT_SCHEDULE_TRIGGER_TYPE } from '@n8n/api-types';
import { ref, computed, onMounted, watch } from 'vue';
import { N8nButton, N8nCard, N8nDialog, N8nIcon, N8nText } from '@n8n/design-system';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useUIStore } from '@/app/stores/ui.store';
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { getResourcePermissions } from '@n8n/permissions';
import { useAgentIntegrationStatus } from '../composables/useAgentIntegrationStatus';
import AgentScheduleTriggerCard from './AgentScheduleTriggerCard.vue';
import AgentCredentialSelect, { type AgentCredentialOption } from './AgentCredentialSelect.vue';
import AgentIntegrationSettingsForm from './AgentIntegrationSettingsForm.vue';
const props = withDefaults(
defineProps<{
projectId: string;
agentId: string;
agentName: string;
isPublished?: boolean;
focusType?: string | null;
/**
* When true, hide integrations that aren't currently connected. The
* "Add trigger" flow handles discovery; this panel becomes a list of
* what the agent actually listens on.
*/
onlyConnected?: boolean;
}>(),
{ focusType: null, isPublished: false, onlyConnected: false },
);
const visibleConfigs = computed(() => {
let list = integrationConfigs;
if (props.focusType) list = list.filter((c) => c.type === props.focusType);
if (props.onlyConnected) list = list.filter((c) => isConnected(c.type));
return list;
});
const showScheduleCard = computed(
() =>
(!props.focusType || props.focusType === AGENT_SCHEDULE_TRIGGER_TYPE) &&
(!props.onlyConnected || isConnected(AGENT_SCHEDULE_TRIGGER_TYPE)),
);
const emit = defineEmits<{
'update:connected-triggers': [triggers: string[]];
'trigger-added': [payload: { triggerType: string; triggers: string[] }];
}>();
const rootStore = useRootStore();
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
const projectsStore = useProjectsStore();
interface IntegrationConfig {
type: string;
label: string;
icon: string;
description: string;
connectedDescription: string;
credentialTypes: string[];
noCredentialsMessage: string;
}
const integrationConfigs: IntegrationConfig[] = [
{
type: 'slack',
label: 'Slack',
icon: 'slack',
description:
'Connect a Slack bot credential to allow this agent to receive and respond to Slack messages.',
connectedDescription: 'Your agent is connected to Slack and can receive messages.',
credentialTypes: ['slackApi', 'slackOAuth2Api'],
noCredentialsMessage: 'No Slack credentials found.',
},
{
type: 'telegram',
label: 'Telegram',
icon: 'telegram',
description:
'Connect a Telegram bot credential to allow this agent to receive and respond to Telegram messages.',
connectedDescription: 'Your agent is connected to Telegram and can receive messages.',
credentialTypes: ['telegramApi'],
noCredentialsMessage: 'No Telegram API credentials found.',
},
{
type: 'linear',
label: 'Linear',
icon: 'linear',
description:
'Connect a Linear API credential to let this agent respond to comments in Linear issues. ' +
'Point a Linear webhook at the URL below and paste its signing secret into the credential',
connectedDescription: 'Your agent is connected to Linear and can reply to @-mentions',
credentialTypes: ['linearApi', 'linearOAuth2Api'],
noCredentialsMessage: 'No Linear credentials found.',
},
];
// Connected-integration state lives in a shared composable so the Triggers
// panel and the Add-Trigger modal render off the same source of truth.
const {
statuses,
connectedCredentials,
integrationSettings,
loadingMap,
errorMessages,
errorIsConflict,
fetchStatus: fetchStatusShared,
connect,
disconnect,
isConnected,
} = useAgentIntegrationStatus(props.projectId, props.agentId);
// UI-only state stays local.
const selectedCredentials = ref<Record<string, string>>({});
const credentialsByType = ref<Record<string, AgentCredentialOption[]>>({});
const credentialsLoading = ref(false);
// One ref per integration type keyed by config.type so each card's form is
// read and validated independently.
const settingsFormRefs = ref<
Record<string, InstanceType<typeof AgentIntegrationSettingsForm> | null>
>({});
function getSettingsFormRef(type: string) {
return (el: unknown) => {
settingsFormRefs.value[type] = (el ?? null) as InstanceType<
typeof AgentIntegrationSettingsForm
> | null;
};
}
const copied = ref(false);
const showManifest = ref(false);
const projectForPermissions = computed(() => {
if (projectsStore.currentProject?.id === props.projectId) return projectsStore.currentProject;
if (projectsStore.personalProject?.id === props.projectId) return projectsStore.personalProject;
return projectsStore.myProjects.find((project) => project.id === props.projectId) ?? null;
});
const credentialPermissions = computed(() => {
const permissions = getResourcePermissions(projectForPermissions.value?.scopes).credential;
return { ...permissions, create: !!permissions.create };
});
function isLoading(type: string): boolean {
return loadingMap.value[type] ?? false;
}
function hasError(type: string): boolean {
return (errorMessages.value[type] ?? '').length > 0;
}
// Webhook URLs in the integration manifests must use the instance's configured
// `WEBHOOK_URL` (`urlBaseWebhook`), not the editor URL: in production the
// editor and webhook receiver may be on different hosts, and the chat platform
// (Slack, Linear) needs the publicly reachable webhook host.
function webhookUrlFor(platform: string): string {
const base = rootStore.urlBaseWebhook.replace(/\/$/, '');
return `${base}/rest/projects/${props.projectId}/agents/v2/${props.agentId}/webhooks/${platform}`;
}
const linearCopied = ref(false);
async function copyLinearWebhookUrl() {
await navigator.clipboard.writeText(webhookUrlFor('linear'));
linearCopied.value = true;
setTimeout(() => {
linearCopied.value = false;
}, 2000);
}
const oauthCallbackUrl = computed(
() => (rootStore.OAuthCallbackUrls as { oauth2?: string }).oauth2 ?? '',
);
const slackAppManifest = computed(() =>
JSON.stringify(
{
display_information: {
name: props.agentName || 'n8n Agent',
},
features: {
app_home: {
home_tab_enabled: true,
messages_tab_enabled: false,
messages_tab_read_only_enabled: false,
},
bot_user: {
display_name: props.agentName || 'n8n Agent',
always_online: true,
},
},
oauth_config: {
redirect_urls: [oauthCallbackUrl.value],
scopes: {
bot: [
'app_mentions:read',
'assistant:write',
'channels:history',
'channels:join',
'channels:manage',
'channels:read',
'chat:write',
'chat:write.customize',
'files:read',
'files:write',
'groups:read',
'im:history',
'im:read',
'im:write',
'mpim:read',
'mpim:write',
'search:read.public',
'users:read',
'users:read.email',
],
},
pkce_enabled: false,
},
settings: {
event_subscriptions: {
request_url: webhookUrlFor('slack'),
bot_events: ['app_mention', 'assistant_thread_context_changed', 'message.im'],
},
interactivity: {
is_enabled: true,
request_url: webhookUrlFor('slack'),
},
org_deploy_enabled: false,
socket_mode_enabled: false,
token_rotation_enabled: false,
},
},
null,
2,
),
);
async function copyManifest() {
await navigator.clipboard.writeText(slackAppManifest.value);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2000);
}
function computeConnectedTriggers(): string[] {
return Object.keys(statuses.value)
.filter((t) => statuses.value[t] === 'connected')
.sort();
}
function emitConnectedTriggers() {
emit('update:connected-triggers', computeConnectedTriggers());
}
async function fetchStatus() {
await fetchStatusShared([...integrationConfigs.map((c) => c.type), AGENT_SCHEDULE_TRIGGER_TYPE]);
emitConnectedTriggers();
}
function onScheduleStatusChange(configured: boolean) {
statuses.value[AGENT_SCHEDULE_TRIGGER_TYPE] = configured ? 'connected' : 'disconnected';
connectedCredentials.value[AGENT_SCHEDULE_TRIGGER_TYPE] = '';
emitConnectedTriggers();
}
function onScheduleTriggerAdded() {
emit('trigger-added', {
triggerType: AGENT_SCHEDULE_TRIGGER_TYPE,
triggers: computeConnectedTriggers(),
});
}
async function fetchCredentials() {
credentialsLoading.value = true;
try {
credentialsStore.setCredentials([]);
const allCredentials = await credentialsStore.fetchAllCredentialsForWorkflow({
projectId: props.projectId,
});
for (const config of integrationConfigs) {
credentialsByType.value[config.type] = allCredentials
.filter((c) => config.credentialTypes.includes(c.type))
.map((c) => ({
id: c.id,
name: c.name,
typeDisplayName: credentialsStore.getCredentialTypeByName(c.type)?.displayName,
homeProject: c.homeProject,
}));
}
} catch {
for (const config of integrationConfigs) {
credentialsByType.value[config.type] = [];
}
} finally {
credentialsLoading.value = false;
}
}
async function onConnect(type: string) {
const credId = selectedCredentials.value[type];
if (!credId) return;
if (settingsFormRefs.value[type]?.validationError) return;
const settings = settingsFormRefs.value[type]?.currentSettings;
try {
await connect(type, credId, settings);
const triggers = computeConnectedTriggers();
emit('trigger-added', { triggerType: type, triggers });
emitConnectedTriggers();
} catch {
// Error details already surfaced in the shared state by `connect()`.
}
}
async function onDisconnect(type: string) {
const credId = connectedCredentials.value[type] || selectedCredentials.value[type];
if (!credId) return;
await disconnect(type, credId);
selectedCredentials.value[type] = '';
emitConnectedTriggers();
}
function onCreateCredential(type: string) {
const config = integrationConfigs.find((c) => c.type === type);
if (config) {
uiStore.openNewCredential(
config.credentialTypes[0],
false,
false,
props.projectId,
undefined,
undefined,
undefined,
{
hideAskAssistant: true,
},
);
}
}
function onEditCredential(type: string) {
const credId = selectedCredentials.value[type];
if (credId) {
uiStore.openExistingCredential(credId, { hideAskAssistant: true });
}
}
// Re-fetch credentials when the credential edit modal closes
const credentialModalOpen = computed(
() => uiStore.isModalActiveById[CREDENTIAL_EDIT_MODAL_KEY] ?? false,
);
watch(credentialModalOpen, (isOpen, wasOpen) => {
if (wasOpen && !isOpen) {
void fetchCredentials();
}
});
onMounted(async () => {
await Promise.all([fetchStatus(), fetchCredentials()]);
});
</script>
<template>
<div :class="$style.panel">
<AgentScheduleTriggerCard
v-if="showScheduleCard"
:project-id="projectId"
:agent-id="agentId"
:is-published="isPublished"
@status-change="onScheduleStatusChange"
@trigger-added="onScheduleTriggerAdded"
/>
<N8nCard v-for="config in visibleConfigs" :key="config.type" :class="$style.card">
<template #header>
<div :class="$style.cardHeader">
<div :class="$style.statusRow">
<span
:class="[
$style.statusDot,
isConnected(config.type) ? $style.statusConnected : $style.statusDisconnected,
]"
/>
<N8nText bold>{{ config.label }}</N8nText>
<N8nText :class="$style.statusLabel" size="small">
{{ isConnected(config.type) ? 'Connected' : 'Disconnected' }}
</N8nText>
</div>
</div>
</template>
<div :class="$style.cardBody">
<N8nText :class="$style.description" size="small">
{{ config.description }}
</N8nText>
<!-- Linear webhook URL shown regardless of connection state so users can
configure Linear *before* creating a credential (the signing secret is
only revealed after the webhook is saved). -->
<div v-if="config.type === 'linear'" :class="$style.webhookRow">
<input
:value="webhookUrlFor('linear')"
readonly
:class="$style.webhookInput"
:data-testid="`${config.type}-webhook-url`"
@focus="($event.target as HTMLInputElement).select()"
/>
<N8nButton
variant="outline"
size="small"
:data-testid="`${config.type}-copy-webhook-url`"
@click="copyLinearWebhookUrl"
>
<N8nIcon :icon="linearCopied ? 'check' : 'copy'" :size="14" />
{{ linearCopied ? 'Copied' : 'Copy' }}
</N8nButton>
</div>
<div v-if="!isConnected(config.type)" :class="$style.connectForm">
<label :class="$style.label">
<N8nText size="small" bold>{{ config.label }} Credential</N8nText>
</label>
<div :class="$style.selectRow">
<AgentCredentialSelect
v-model="selectedCredentials[config.type]"
:class="$style.select"
placeholder="Select a credential..."
:credentials="credentialsByType[config.type] ?? []"
:credential-permissions="credentialPermissions"
:loading="credentialsLoading"
:disabled="isLoading(config.type)"
:data-test-id="`${config.type}-credential-select`"
@create="onCreateCredential(config.type)"
/>
<N8nButton
v-if="selectedCredentials[config.type]"
type="tertiary"
size="small"
icon="pen"
aria-label="Edit credential"
:data-testid="`${config.type}-edit-credential`"
@click="onEditCredential(config.type)"
/>
</div>
<div
v-if="!credentialsLoading && (credentialsByType[config.type] ?? []).length === 0"
:class="$style.emptyCredentials"
>
<N8nText size="small">{{ config.noCredentialsMessage }}</N8nText>
</div>
</div>
<div v-else :class="$style.connectedSection">
<N8nText size="small">
{{ config.connectedDescription }}
</N8nText>
</div>
<AgentIntegrationSettingsForm
:ref="getSettingsFormRef(config.type)"
:type="config.type"
:disabled="isConnected(config.type) || isLoading(config.type)"
:connected="isConnected(config.type)"
:saved-settings="integrationSettings[config.type]"
/>
<N8nText
v-if="!isConnected(config.type) && hasError(config.type)"
:class="$style.errorText"
size="small"
>
{{ errorMessages[config.type] }}
<a
v-if="selectedCredentials[config.type] && !errorIsConflict[config.type]"
:class="$style.link"
href="#"
@click.prevent="onEditCredential(config.type)"
>Edit credential</a
>
</N8nText>
<!-- Slack App Manifest (Slack only, shown when connected) -->
<template v-if="isConnected(config.type) && config.type === 'slack'">
<N8nText :class="$style.manifestHint" size="small">
Copy the app manifest and paste it into your Slack app's settings to configure events,
scopes, and interactivity.
<a :class="$style.link" href="#" @click.prevent="showManifest = true">View JSON</a>
</N8nText>
<N8nButton variant="outline" size="small" @click="copyManifest">
<N8nIcon :icon="copied ? 'check' : 'copy'" :size="14" />
{{ copied ? 'Copied' : 'Copy manifest' }}
</N8nButton>
</template>
<div v-if="!isConnected(config.type)" :class="$style.actions">
<N8nButton
:disabled="
!selectedCredentials[config.type] ||
isLoading(config.type) ||
!!settingsFormRefs[config.type]?.validationError
"
:loading="isLoading(config.type)"
size="small"
:data-testid="`${config.type}-connect-button`"
@click="onConnect(config.type)"
>
<N8nIcon icon="plug" :size="14" />
Connect
</N8nButton>
</div>
<N8nButton
v-else
:class="$style.actionButton"
variant="destructive"
:loading="isLoading(config.type)"
size="small"
:data-testid="`${config.type}-disconnect-button`"
@click="onDisconnect(config.type)"
>
<N8nIcon icon="unlink" :size="14" />
Disconnect
</N8nButton>
<!-- Manifest modal (Slack only) -->
<N8nDialog
v-if="config.type === 'slack'"
:open="showManifest"
header="Slack App Manifest"
size="medium"
@update:open="showManifest = $event"
>
<pre :class="$style.manifestCode">{{ slackAppManifest }}</pre>
</N8nDialog>
</div>
</N8nCard>
</div>
</template>
<style module>
.panel {
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
height: 100%;
}
.card {
width: 100%;
}
.card + .card {
margin-top: var(--spacing--sm);
}
.cardHeader {
display: flex;
align-items: center;
justify-content: space-between;
}
.statusRow {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.statusDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.statusConnected {
background-color: var(--color--success);
}
.statusDisconnected {
background-color: var(--color--foreground--shade-1);
}
.statusLabel {
color: var(--color--text--tint-2);
}
.cardBody {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
}
.description {
color: var(--color--text--tint-1);
}
.connectForm {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
.label {
display: block;
}
.selectRow {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
}
.select {
flex: 1;
min-width: 0;
}
.emptyCredentials {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
align-items: flex-start;
}
.actions {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.connectedSection {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
}
.manifestHint {
color: var(--color--text--tint-1);
}
.manifestCode {
margin: 0;
padding: var(--spacing--xs);
background-color: var(--color--foreground--tint-2);
border-radius: var(--radius);
font-size: var(--font-size--2xs);
line-height: var(--line-height--xl);
overflow-x: auto;
max-height: 300px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
white-space: pre;
font-family: monospace;
color: var(--color--text);
}
.errorText {
color: var(--color--danger);
}
.link {
color: var(--color--primary);
text-decoration: underline;
cursor: pointer;
margin-left: var(--spacing--4xs);
}
.actionButton {
align-self: flex-start;
}
.webhookRow {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.webhookInput {
flex: 1;
min-width: 0;
padding: var(--spacing--3xs) var(--spacing--2xs);
background-color: var(--color--foreground--tint-2);
border: var(--border);
border-radius: var(--radius);
font-family: monospace;
font-size: var(--font-size--2xs);
color: var(--color--text);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.webhookInput:focus {
outline: none;
border-color: var(--color--primary);
}
</style>

View File

@ -0,0 +1,220 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
const props = defineProps<{
agentName: string;
projectId: string;
agentId: string;
connected?: boolean;
}>();
const i18n = useI18n();
const rootStore = useRootStore();
const manifestCopied = ref(false);
const DEFAULT_SLACK_APP_NAME = 'n8n Agent';
// Slack app names accept letters, digits, spaces, periods, hyphens and
// underscores (max 35 chars per Slack's submission guidelines). Strip anything
// else and fall back to a sensible default if the agent name is empty after
// sanitisation, so the manifest always validates on Slack's side.
function sanitiseSlackAppName(raw: string): string {
const cleaned = raw
.replace(/[^a-zA-Z0-9 ._-]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 35);
return cleaned.length > 0 ? cleaned : DEFAULT_SLACK_APP_NAME;
}
function slackWebhookUrl(): string {
const base = rootStore.urlBaseWebhook.replace(/\/$/, '');
return `${base}/rest/projects/${props.projectId}/agents/v2/${props.agentId}/webhooks/slack`;
}
const oauthCallbackUrl = computed(() => {
const configured = (rootStore.OAuthCallbackUrls as { oauth2?: string }).oauth2 ?? '';
if (!configured) return '';
try {
// Preserve the configured path (which may include a custom rest endpoint
// or base path) but rebase onto `urlBaseWebhook` so the callback uses
// the publicly reachable host from `WEBHOOK_URL` instead of the local
// browser origin (which is `http://localhost:5678` in dev).
const parsed = new URL(configured);
const base = rootStore.urlBaseWebhook.replace(/\/$/, '');
return `${base}${parsed.pathname}${parsed.search}`;
} catch {
return configured;
}
});
const slackAppManifest = computed(() => {
const agentName = sanitiseSlackAppName(props.agentName);
const webhookUrl = slackWebhookUrl();
return JSON.stringify(
{
display_information: {
name: agentName,
},
features: {
app_home: {
home_tab_enabled: true,
messages_tab_enabled: false,
messages_tab_read_only_enabled: false,
},
bot_user: {
display_name: agentName,
always_online: true,
},
},
oauth_config: {
redirect_urls: [oauthCallbackUrl.value],
scopes: {
bot: [
'app_mentions:read',
'assistant:write',
'channels:history',
'channels:join',
'channels:manage',
'channels:read',
'chat:write',
'chat:write.customize',
'files:read',
'files:write',
'groups:read',
'im:history',
'im:read',
'im:write',
'mpim:read',
'mpim:write',
'search:read.public',
'users:read',
'users:read.email',
],
},
pkce_enabled: false,
},
settings: {
event_subscriptions: {
request_url: webhookUrl,
bot_events: ['app_mention', 'assistant_thread_context_changed', 'message.im'],
},
interactivity: {
is_enabled: true,
request_url: webhookUrl,
},
org_deploy_enabled: false,
socket_mode_enabled: false,
token_rotation_enabled: false,
},
},
null,
2,
);
});
async function copyManifest() {
await navigator.clipboard.writeText(slackAppManifest.value);
manifestCopied.value = true;
setTimeout(() => {
manifestCopied.value = false;
}, 2000);
}
</script>
<template>
<div :class="$style.manifestSection">
<N8nText size="small" bold>
{{ i18n.baseText('agents.builder.addTrigger.slack.manifestTitle') }}
</N8nText>
<N8nText :class="$style.manifestHint" size="small">
{{ i18n.baseText('agents.builder.addTrigger.slack.manifestHint') }}
<a
href="https://docs.slack.dev/app-manifests/configuring-apps-with-app-manifests"
target="_blank"
rel="noopener noreferrer"
:class="$style.docsLink"
>
{{ i18n.baseText('agents.builder.addTrigger.slack.docsCalloutLink') }}
</a>
</N8nText>
<div :class="$style.codeBlock">
<N8nButton
variant="outline"
size="small"
:class="$style.codeBlockCopy"
data-testid="slack-copy-manifest"
@click="copyManifest"
>
<template #prefix>
<N8nIcon :icon="manifestCopied ? 'check' : 'copy'" size="xsmall" />
</template>
{{
manifestCopied
? i18n.baseText('agents.builder.addTrigger.copied')
: i18n.baseText('agents.builder.addTrigger.copy')
}}
</N8nButton>
<pre :class="$style.manifestCode">{{ slackAppManifest }}</pre>
</div>
</div>
</template>
<style module lang="scss">
.manifestSection {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
.docsCallout {
margin-bottom: var(--spacing--3xs);
}
.docsLink {
color: var(--color--primary);
text-decoration: underline;
}
.manifestHint {
color: var(--color--text--tint-1);
}
.codeBlock {
position: relative;
margin-top: var(--spacing--3xs);
}
/* Sit on top of the rounded container itself rather than inside the scrolling
<pre>, so the button stays put as the user scrolls and never collides with
the scrollbar groove. The right offset clears typical macOS / overlay
scrollbars (~14px) plus our normal inner padding. */
.codeBlockCopy {
position: absolute;
top: var(--spacing--2xs);
right: var(--spacing--lg);
z-index: 1;
}
.manifestCode {
margin: 0;
padding: var(--spacing--xs);
padding-right: calc(var(--spacing--2xl) + var(--spacing--lg));
background-color: var(--color--foreground--tint-2);
border-radius: var(--radius);
font-size: var(--font-size--2xs);
line-height: var(--line-height--xl);
overflow-x: auto;
max-height: 240px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
white-space: pre;
font-family: monospace;
color: var(--color--text);
}
</style>