mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 16:57:08 +02:00
feat(editor): Show Slack docs link for agent setup (no-changelog) (#30608)
This commit is contained in:
parent
87cfbbbc6e
commit
7bdfee9001
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue
Block a user