fix(editor): Let the Assistant chat finish the Microsoft Teams setup (no-changelog) (#41188)

This commit is contained in:
Bernhard Wittmann 2026-10-16 09:38:51 +00:00 committed by GitHub
parent 8bee1eeb20
commit aadbb7c498
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 539 additions and 181 deletions

View File

@ -24,7 +24,7 @@ describe('TeamsCleanupService', () => {
await expect(service.describeLeftovers(ctx)).resolves.toEqual({
integrationType: 'teams',
code: 'resources_not_deleted',
action: { type: 'open_url', url: expect.stringContaining('entra.microsoft.com') },
action: { type: 'open_url', url: expect.stringContaining('admin.teams.microsoft.com') },
details: { appId: 'app-1' },
});
});

View File

@ -6,8 +6,7 @@ import { AgentCredentialLookupService } from '../../agent-credential-lookup.serv
import { stringProperty } from '../../integration-helpers';
const BOT_CREDENTIAL_TYPE = 'microsoftEntraServicePrincipalApi';
const ENTRA_APPS_URL =
'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade';
const TEAMS_MANAGE_APPS_URL = 'https://admin.teams.microsoft.com/policies/manage-apps';
export interface TeamsCleanupContext {
user: User;
@ -48,7 +47,7 @@ export class TeamsCleanupService {
return {
integrationType: 'teams',
code: 'resources_not_deleted',
action: { type: 'open_url', url: ENTRA_APPS_URL },
action: { type: 'open_url', url: TEAMS_MANAGE_APPS_URL },
details: {
appId: stringProperty(data, 'clientId') ?? '',
},

View File

@ -0,0 +1 @@
<svg viewBox="0 0 21 21" xmlns="http://www.w3.org/2000/svg"><path fill="#F25022" d="M0 0h10v10H0z"/><path fill="#7FBA00" d="M11 0h10v10H11z"/><path fill="#00A4EF" d="M0 11h10v10H0z"/><path fill="#FFB900" d="M11 11h10v10H11z"/></svg>

After

Width:  |  Height:  |  Size: 233 B

View File

@ -17,6 +17,7 @@ import GripLinesVertical from './custom/grip-lines-vertical.svg';
import Linear from './custom/linear.svg';
import Lovable from './custom/lovable.svg';
import Mcp from './custom/mcp.svg';
import Microsoft from './custom/microsoft.svg';
import NodeDirty from './custom/node-dirty.svg';
import NodeEllipsis from './custom/brand/dots-horizontal--solid-16.svg';
import NodeExecutionError from './custom/node-execution-error.svg';
@ -575,6 +576,7 @@ export const updatedIconSet = {
teams: Teams,
azure: Azure,
entra: Entra,
microsoft: Microsoft,
spinner: Spinner,
'node-dirty': NodeDirty,
'node-ellipsis': NodeEllipsis,

View File

@ -9408,6 +9408,7 @@
"agents.channels.teams.setup.availability.fileAccessPermission": "To read files that people attach in channels and group chats, the app needs the Microsoft Graph permission Files.Read.All. An administrator adds it under API permissions › Microsoft Graph › Application permissions, then selects Grant admin consent. Do not use Delegated permissions: the agent cannot use them. Send this link to an administrator if you are not one. After approval, it can take up to an hour to apply. Direct messages and pasted images work without it.",
"agents.channels.teams.setup.availability.fileAccessApproved": "File access approved.",
"agents.channels.teams.setup.availability.fileAccessNotApproved": "The agent can’t read files in channels and group chats until an admin approves.",
"agents.channels.teams.setup.availability.fileAccessNotApprovedOwnApp": "The agent can’t read files in channels and group chats until an admin grants the Files.Read.All application permission.",
"agents.channels.teams.setup.availability.fileAccessApprovedHint": "It can take up to an hour to apply.",
"agents.channels.teams.setup.availability.summarySeparator": " · ",
"agents.channels.teams.setup.install.identityTooltip": "Name and avatar come from the agent. To change them, edit the agent and download the package again.",
@ -9429,7 +9430,6 @@
"agents.channels.teams.setup.install.button": "Download app package",
"agents.channels.teams.setup.install.hint": "No Upload an app option? Ask your Teams admin to allow custom app uploads.",
"agents.channels.teams.setup.install.downloadFailed": "Could not download the app package. Try again.",
"agents.channels.teams.setup.publishNotice": "Publish this agent before you message it in Teams.",
"agents.channels.telegram.setup.credential.title": "Add credential",
"agents.channels.telegram.setup.access.title": "Set access and restrictions",
"agents.channels.telegram.setup.connect.title": "Connect Telegram",
@ -9797,7 +9797,7 @@
"agents.channels.teams.managed.connect.lockedBySignIn": "Sign in with Microsoft again to continue.",
"agents.channels.teams.managed.connect.button": "Sign in with Microsoft",
"agents.channels.teams.managed.connect.reconnect": "This sign-in is missing a permission the setup now needs. Sign in again to continue.",
"agents.channels.teams.managed.connect.failed": "The Microsoft sign-in did not finish. If it stopped at a permissions screen, an administrator has to approve n8n for your organization once before anyone can use this setup.",
"agents.channels.teams.managed.connect.failed": "Sign-in didn't finish. If you saw “Need admin approval”, an admin in your organization needs to approve n8n once.",
"agents.channels.teams.managed.connect.edit": "Edit this sign-in",
"agents.channels.teams.managed.createApp.title": "Create Teams app",
"agents.channels.teams.managed.createApp.description": "Registers this agent with Microsoft, so Teams knows who it is.",
@ -9865,14 +9865,14 @@
"agents.channels.teams.managed.errors.signInFirst": "Sign in with Microsoft first.",
"agents.channels.teams.managed.errors.createAppFirst": "Create the Teams app first.",
"agents.channels.teams.managed.errors.credentialMissing": "The Microsoft sign-in could not be loaded. Try again.",
"agents.channels.modal.teamsResourcesNotDeleted.title": "Some Microsoft resources are still there",
"agents.channels.modal.teamsResourcesNotDeleted.message": "n8n did not delete the app registration, the bot or the Teams app, because other people may still have the app installed. Remove them yourself if you no longer need them:",
"agents.channels.modal.teamsResourcesNotDeleted.link": "Open Microsoft Entra",
"agents.channels.modal.teamsResourcesNotDeleted.title": "The agent is still in Teams but won't respond",
"agents.channels.modal.teamsResourcesNotDeleted.message": "Remove the Teams app, Azure Bot and app registration manually.",
"agents.channels.modal.teamsResourcesNotDeleted.link": "Open Teams",
"agents.channels.teams.remove.title": "Remove Microsoft Teams?",
"agents.channels.teams.remove.description": "Your agent will be disconnected from Microsoft Teams. Messages already sent will remain in Teams. To reconnect, you'll need to set up Microsoft Teams again.",
"agents.channels.teams.remove.confirm": "Yes, remove",
"agents.channels.teams.managed.connect.adminConsent": "Sign-in blocked by a permissions screen?",
"agents.channels.teams.managed.connect.adminConsentHint": "A Microsoft administrator approves n8n once for the whole organization. After that, signing in just works.",
"agents.channels.teams.managed.connect.adminConsent": "Need admin approval? An admin in your organization needs to approve n8n once.",
"agents.channels.teams.managed.connect.adminConsentLink": "Copy link for an admin",
"agents.channels.teams.managed.createBot.checking": "Looking for an Azure subscription n8n can use…",
"agents.channels.teams.managed.createBot.retry": "Check again for an Azure subscription"
}

View File

@ -189,6 +189,19 @@ describe('AgentModal', () => {
expect(document.activeElement).toBe(wrapper.get('[data-test-id="first-body-field"]').element);
});
it('focuses a marked container instead of the field inside it', async () => {
const wrapper = mountModal(
{},
{
default:
'<div data-test-id="marked" tabindex="-1" data-agent-modal-autofocus><input /></div>',
},
);
await flushPromises();
expect(document.activeElement).toBe(wrapper.get('[data-test-id="marked"]').element);
});
it('uses the dialog fallback focus when the body has no interactive control', async () => {
const wrapper = mountModal(
{ editableTitle: true },

View File

@ -2,7 +2,7 @@
export const ActionDropdownTestStub = {
name: 'ActionDropdown',
template:
'<div><slot name="activator" /><button v-for="item in items" :key="item.id" :data-testid="item.testId" @click="$emit(\'select\', item.id)">{{ item.label }} {{ item.description }}</button></div>',
'<div><slot name="activator" /><button v-for="item in items" :key="item.id" :data-testid="item.testId" :disabled="item.disabled" @click="$emit(\'select\', item.id)">{{ item.label }} {{ item.description }}</button></div>',
props: ['items', 'placement', 'teleported'],
emits: ['select'],
};

View File

@ -23,7 +23,8 @@ defineExpose({ currentSettings, validationError, saveLabel, beforeSave, afterSav
</script>
<template>
<div :class="$style.editView">
<!-- The modal focuses this rather than the credential select, which opens on focus. -->
<div :class="$style.editView" tabindex="-1" data-agent-modal-autofocus>
<AgentIntegrationCredentialConnection
v-model="credentialId"
:integration-type="integration.type"
@ -66,5 +67,9 @@ defineExpose({ currentSettings, validationError, saveLabel, beforeSave, afterSav
display: flex;
flex-direction: column;
gap: var(--spacing--md);
&:focus {
outline: none;
}
}
</style>

View File

@ -1,3 +1,4 @@
import { mount } from '@vue/test-utils';
import { ref } from 'vue';
import type { BaseTextKey } from '@n8n/i18n';
import { describe, expect, it } from 'vitest';
@ -72,6 +73,26 @@ describe('agent channel platform registry', () => {
expect(platform.disconnectConfirmationComponent).toBeDefined();
});
it('points a Teams removal that leaves the app behind to the Teams admin center', () => {
const platform = getAgentChannelPlatform('teams');
const presentation = platform.presentDisconnectWarning?.(
{
integrationType: 'teams',
code: 'resources_not_deleted',
action: { type: 'open_url', url: 'https://admin.teams.microsoft.com/policies/manage-apps' },
},
{ text },
);
expect(presentation?.title).toBe('agents.channels.modal.teamsResourcesNotDeleted.title');
const wrapper = mount(() => presentation?.message);
expect(wrapper.text()).toContain('agents.channels.modal.teamsResourcesNotDeleted.message');
expect(wrapper.get('a').attributes('href')).toBe(
'https://admin.teams.microsoft.com/policies/manage-apps',
);
expect(wrapper.get('a').text()).toBe('agents.channels.modal.teamsResourcesNotDeleted.link');
});
it('registers n8n Chat with its "Make available" connect action', () => {
const platform = getAgentChannelPlatform('n8n_chat');
const action = platform.getConnectAction(

View File

@ -1,4 +1,5 @@
import { N8N_CHAT_INTEGRATION_TYPE, type AgentIntegrationDisconnectWarning } from '@n8n/api-types';
import { N8nButton } from '@n8n/design-system';
import { h, readonly, ref } from 'vue';
import AgentChannelDiscordSetup from '../components/AgentChannelDiscordSetup.vue';
@ -138,6 +139,7 @@ const platforms = {
editComponent: AgentChannelTeamsEditView,
headerContent: {
setupModal: AgentChannelTeamsSetupKindSelector,
setupCard: AgentChannelTeamsSetupKindSelector,
},
disconnectConfirmationComponent: AgentChannelTeamsRemoveConfirmation,
shouldConfirmDisconnect: () => true,
@ -149,19 +151,30 @@ const platforms = {
}
return {
title: text('agents.channels.modal.teamsResourcesNotDeleted.title'),
message: h('span', [
text('agents.channels.modal.teamsResourcesNotDeleted.message'),
' ',
h(
'a',
{
href: warning.action.url,
target: '_blank',
rel: 'noopener noreferrer',
message: h(
'div',
{
style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 'var(--spacing--xs)',
},
text('agents.channels.modal.teamsResourcesNotDeleted.link'),
),
]),
},
[
h('span', text('agents.channels.modal.teamsResourcesNotDeleted.message')),
h(
N8nButton,
{
href: warning.action.url,
target: '_blank',
variant: 'outline',
size: 'small',
},
() => text('agents.channels.modal.teamsResourcesNotDeleted.link'),
),
],
),
};
},
},

View File

@ -73,9 +73,10 @@ describe('AgentChannelTeamsAvailability', () => {
props: { modelValue: availabilityFrom({ groupChats: true }), appId: 'app-1' },
});
expect(getByTestId('teams-file-access')).toHaveTextContent('Files.Read.All');
// The portal opens on Delegated permissions, which the agent cannot use.
expect(getByTestId('teams-file-access')).toHaveTextContent('Application permissions');
expect(getByTestId('teams-file-access')).toHaveTextContent(
'Files.Read.All application permission',
);
expect(await openedUrl({ getByTestId })).toContain('/CallAnAPI/appId/app-1');
});

View File

@ -117,7 +117,7 @@ const readsSharedConversations = computed(() => readsShared(value.value));
* Files attached in a shared conversation live in SharePoint or OneDrive, which
* only an app permission that an administrator grants can read. An app the
* recommended setup made takes a one-step consent; one made by hand needs the
* permission added first, so it gets the full instructions.
* permission added first, so it names the permission.
*/
const fileAccess = computed(() => {
if (props.hideFileAccess || !readsSharedConversations.value) return null;
@ -130,7 +130,7 @@ const fileAccess = computed(() => {
};
}
return {
hint: 'agents.channels.teams.setup.availability.fileAccessPermission' as const,
hint: 'agents.channels.teams.setup.availability.fileAccessNotApprovedOwnApp' as const,
url: props.appId ? entraApiPermissionsUrl(props.appId) : ENTRA_APP_REGISTRATIONS_URL,
};
});

View File

@ -70,6 +70,8 @@ const i18n = useI18n();
<style module lang="scss">
.identity {
display: flex;
/* Narrow containers, like the Assistant chat, move the action below the text. */
flex-wrap: wrap;
align-items: center;
gap: var(--spacing--xs);
width: 100%;
@ -103,7 +105,7 @@ const i18n = useI18n();
display: flex;
flex-direction: column;
gap: var(--spacing--5xs);
flex: 1;
flex: 1 1 10rem;
min-width: 0;
}

View File

@ -369,7 +369,7 @@ describe('AgentChannelTeamsManagedApp', () => {
});
expect(getByTestId('teams-managed-app-bot')).toHaveTextContent(
'agents.channels.teams.settings.linkedBot',
'agents.channels.teams.settings.linkedBot agents.channels.teams.settings.findInAzure',
);
expect(getByTestId('teams-managed-app-bot-link')).toHaveAttribute(
'href',

View File

@ -213,7 +213,7 @@ watch(
<template>
<div :class="$style.field" data-testid="teams-managed-app">
<N8nText size="small" bold>
<N8nText step="sm" bold>
{{ i18n.baseText('agents.channels.teams.settings.identityLabel') }}
</N8nText>
@ -270,7 +270,7 @@ watch(
i18n.baseText('agents.channels.teams.settings.linkedBot', {
interpolate: { name: azureBot.name },
})
}}
}}{{ ' ' }}
</template>
<N8nLink
:href="azureBot?.portalUrl ?? AZURE_BOTS_URL"

View File

@ -142,10 +142,18 @@ describe('AgentChannelTeamsManagedSetup', () => {
* one resource — so the single prompt covering both is the administrator
* consent, offered here.
*/
it('offers one administrator prompt covering both Microsoft APIs', () => {
it('copies one administrator prompt covering both Microsoft APIs', async () => {
const { getByTestId } = render();
expect(getByTestId('teams-admin-consent').getAttribute('href')).toContain('adminconsent');
await fireEvent.click(getByTestId('teams-admin-consent'));
expect(copySpy).toHaveBeenCalledWith(expect.stringContaining('adminconsent'));
expect(showMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
title: 'agents.channels.teams.managed.fileAccess.copied',
}),
);
});
it('offers no administrator prompt where the setup cannot run', () => {
@ -293,6 +301,19 @@ describe('AgentChannelTeamsManagedSetup', () => {
});
});
it('offers the admin link inside the error once a sign-in did not finish', async () => {
const { getByTestId, queryByTestId } = render({
runtime: buildRuntime({ connectManagerCredential: vi.fn().mockResolvedValue(false) }),
});
await fireEvent.click(getByTestId('teams-manager-connect'));
await waitFor(() => expect(getByTestId('teams-manager-error')).toBeVisible());
expect(queryByTestId('teams-admin-consent')).not.toBeInTheDocument();
await fireEvent.click(getByTestId('teams-manager-error-admin-consent'));
expect(copySpy).toHaveBeenCalledWith(expect.stringContaining('adminconsent'));
});
it('reports a sign-in that threw', async () => {
const { getByTestId } = render({
runtime: buildRuntime({
@ -1074,6 +1095,7 @@ describe('AgentChannelTeamsManagedSetup', () => {
await fireEvent.click(getByTestId('teams-publish'));
await waitFor(() => expect(getByTestId('teams-install-progress')).toBeVisible());
expect(getByTestId('teams-install-menu')).toHaveAttribute('aria-busy', 'true');
expect(queryByTestId('teams-catalog-state')).not.toBeInTheDocument();
finishPublish();
@ -1165,6 +1187,8 @@ describe('AgentChannelTeamsManagedSetup', () => {
expect(getByText('agents.channels.teams.managed.finish.published Acme Corp')).toBeVisible(),
);
expect(getByTestId('teams-finish-download-package')).toBeVisible();
expect(getByTestId('teams-install-menu')).toBeVisible();
expect(getByTestId('teams-publish')).toBeDisabled();
// Nothing was added to anyone's Teams, so nothing says it was.
expect(
queryByText('agents.channels.teams.setup.install.description'),

View File

@ -10,7 +10,6 @@ import {
N8nLink,
N8nOption,
N8nSelect,
N8nSpinner,
N8nStepper,
N8nText,
} from '@n8n/design-system';
@ -162,32 +161,29 @@ const hasPublished = ref(false);
/**
* The two routes are independent, so downloading the package once must not take
* publishing away. Only a pending review drops an item: it is the one state
* that cannot take another submission.
* publishing away. Publishing is offered once: a pending review cannot take
* another submission, and a published app is already in the catalog.
*/
const installMenuItems = computed(() => {
const items = [
{
id: 'for-me',
testId: 'teams-install-download-package',
label: i18n.baseText(
installRoute.value === 'downloaded'
? 'agents.channels.teams.managed.install.downloadAgain'
: 'agents.channels.teams.managed.install.forMe',
),
description: i18n.baseText('agents.channels.teams.managed.install.forMeHint'),
},
];
if (catalogState.value?.status !== 'submitted') {
items.push({
id: 'publish',
testId: 'teams-publish',
label: i18n.baseText('agents.channels.teams.managed.install.publish'),
description: i18n.baseText('agents.channels.teams.managed.install.publishHintShort'),
});
}
return items;
});
const installMenuItems = computed(() => [
{
id: 'for-me',
testId: 'teams-install-download-package',
label: i18n.baseText(
installRoute.value === 'downloaded'
? 'agents.channels.teams.managed.install.downloadAgain'
: 'agents.channels.teams.managed.install.forMe',
),
description: i18n.baseText('agents.channels.teams.managed.install.forMeHint'),
},
{
id: 'publish',
testId: 'teams-publish',
label: i18n.baseText('agents.channels.teams.managed.install.publish'),
description: i18n.baseText('agents.channels.teams.managed.install.publishHintShort'),
disabled:
hasPublished.value || ['published', 'submitted'].includes(catalogState.value?.status ?? ''),
},
]);
const onInstallMenu = async (id: string) =>
id === 'publish' ? await publishApp() : await downloadPackage();
@ -564,6 +560,13 @@ async function copyApprovalRequest() {
requestCopied.value = true;
}
/** The person signing in is often not the admin who can approve n8n. */
const { onSelect: onAdminConsentMenu } = useTeamsFileAccessMenu({
url: () => props.setup.adminConsentUrl ?? '',
organization: '',
});
const copyAdminConsentLink = async () => await onAdminConsentMenu('copy');
/**
* Hands the setup to the manual flow, for the walls this one cannot climb: a
* tenant that will not let this account register an app, or an account with no
@ -654,7 +657,7 @@ const downloadPackage = async () =>
<N8nButton
variant="outline"
size="medium"
icon="teams"
icon="microsoft"
:loading="busy === 'connect'"
:disabled="loading || busy !== null"
data-testid="teams-manager-connect"
@ -666,19 +669,17 @@ const downloadPackage = async () =>
The one prompt that covers Graph and Azure together. A sign-in cannot:
a code is redeemed for one resource at a time.
-->
<N8nButton
v-if="setup.adminConsentUrl"
:href="setup.adminConsentUrl"
tag="a"
target="_blank"
variant="ghost"
size="small"
data-testid="teams-admin-consent"
>
<N8nText v-if="setup.adminConsentUrl && !connectFailed" size="small" color="text-light">
{{ i18n.baseText('agents.channels.teams.managed.connect.adminConsent') }}
</N8nButton>
<N8nText v-if="setup.adminConsentUrl" size="small" color="text-light">
{{ i18n.baseText('agents.channels.teams.managed.connect.adminConsentHint') }}
<N8nLink
theme="text"
size="small"
underline
data-testid="teams-admin-consent"
@click="copyAdminConsentLink"
>
{{ i18n.baseText('agents.channels.teams.managed.connect.adminConsentLink') }}
</N8nLink>
</N8nText>
<N8nText
v-if="connectFailed"
@ -687,6 +688,16 @@ const downloadPackage = async () =>
data-testid="teams-manager-error"
>
{{ i18n.baseText('agents.channels.teams.managed.connect.failed') }}
<N8nLink
v-if="setup.adminConsentUrl"
theme="danger"
size="small"
underline
data-testid="teams-manager-error-admin-consent"
@click="copyAdminConsentLink"
>
{{ i18n.baseText('agents.channels.teams.managed.connect.adminConsentLink') }}
</N8nLink>
</N8nText>
</div>
@ -707,24 +718,26 @@ const downloadPackage = async () =>
>
{{ i18n.baseText('agents.channels.teams.managed.useOwnApp') }}
</N8nButton>
<N8nButton
variant="outline"
size="medium"
:loading="busy === 'create-app'"
:disabled="busy !== null"
data-testid="teams-create-app"
@click="provisionApp"
>
{{ i18n.baseText('agents.channels.teams.managed.createApp.button') }}
</N8nButton>
<N8nText
v-if="busy === 'create-app'"
size="small"
color="text-light"
data-testid="teams-create-app-progress"
>
{{ i18n.baseText('agents.channels.teams.managed.createApp.progress') }}
</N8nText>
<div :class="$style.actionWithProgress">
<N8nButton
variant="outline"
size="medium"
:loading="busy === 'create-app'"
:disabled="busy !== null"
data-testid="teams-create-app"
@click="provisionApp"
>
{{ i18n.baseText('agents.channels.teams.managed.createApp.button') }}
</N8nButton>
<N8nText
v-if="busy === 'create-app'"
size="small"
color="text-light"
data-testid="teams-create-app-progress"
>
{{ i18n.baseText('agents.channels.teams.managed.createApp.progress') }}
</N8nText>
</div>
</div>
<div
@ -775,24 +788,26 @@ const downloadPackage = async () =>
/>
</N8nSelect>
</N8nInputLabel>
<N8nButton
variant="outline"
size="medium"
:loading="busy === 'create-bot'"
:disabled="busy !== null || !subscriptionId"
data-testid="teams-create-bot"
@click="provisionBot"
>
{{ i18n.baseText('agents.channels.teams.managed.createBot.create') }}
</N8nButton>
<N8nText
v-if="busy === 'create-bot'"
size="small"
color="text-light"
data-testid="teams-create-bot-progress"
>
{{ i18n.baseText('agents.channels.teams.managed.createBot.progress') }}
</N8nText>
<div :class="$style.actionWithProgress">
<N8nButton
variant="outline"
size="medium"
:loading="busy === 'create-bot'"
:disabled="busy !== null || !subscriptionId"
data-testid="teams-create-bot"
@click="provisionBot"
>
{{ i18n.baseText('agents.channels.teams.managed.createBot.create') }}
</N8nButton>
<N8nText
v-if="busy === 'create-bot'"
size="small"
color="text-light"
data-testid="teams-create-bot-progress"
>
{{ i18n.baseText('agents.channels.teams.managed.createBot.progress') }}
</N8nText>
</div>
</template>
<template v-else>
@ -855,7 +870,7 @@ const downloadPackage = async () =>
</div>
<div
v-else-if="step.id === 'install' && statusOf[step.id] === 'active'"
v-else-if="step.id === 'install' && statusOf[step.id] !== 'locked'"
:class="$style.stepContent"
>
<N8nText
@ -874,11 +889,18 @@ const downloadPackage = async () =>
ready
>
<template #action>
<N8nSpinner
<!-- Outside the menu: its trigger lays the button out as flex, which
pushes the loading spinner to the left edge. -->
<N8nButton
v-if="installProgress"
variant="solid"
size="medium"
data-testid="teams-install-spinner"
/>
loading
data-testid="teams-install-menu"
>
{{ i18n.baseText('agents.channels.teams.managed.install.menu') }}
<N8nIcon icon="chevron-down" size="small" />
</N8nButton>
<N8nActionDropdown
v-else
:items="installMenuItems"
@ -890,6 +912,7 @@ const downloadPackage = async () =>
<AgentChannelTeamsOptionLabel
:label="item.label"
:description="item.description"
:disabled="item.disabled"
/>
</template>
<template #activator>
@ -1119,6 +1142,14 @@ const downloadPackage = async () =>
width: 100%;
}
/* The progress line sits beside the button and drops below it when narrow, as in the Assistant chat. */
.actionWithProgress {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing--2xs) var(--spacing--sm);
}
.stepContent {
display: flex;
flex-direction: column;

View File

@ -1,13 +1,15 @@
<script setup lang="ts">
import { N8nText } from '@n8n/design-system';
defineProps<{ label: string; description?: string }>();
defineProps<{ label: string; description?: string; disabled?: boolean }>();
</script>
<template>
<span :class="$style.option">
<N8nText size="small" bold color="text-dark">{{ label }}</N8nText>
<N8nText v-if="description" size="xsmall" color="text-light">{{ description }}</N8nText>
<N8nText size="small" bold :color="disabled ? 'text-xlight' : 'text-dark'">{{ label }}</N8nText>
<N8nText v-if="description" size="xsmall" :color="disabled ? 'text-xlight' : 'text-light'">
{{ description }}
</N8nText>
</span>
</template>

View File

@ -420,6 +420,23 @@ describe('AgentChannelTeamsSetup', () => {
expect(checkedSwitch(getByTestId('teams-scope-channels'))).toBe(true);
expect(checkedSwitch(getByTestId('teams-read-channels'))).toBe(true);
});
it('adds a file access step once the agent works where files are shared', async () => {
withBot();
const { getByTestId, queryByTestId, getByText } = renderComponent({
props: props({ modelValue: 'cred-1' }),
});
await openAvailability(getByTestId);
expect(queryByTestId('teams-file-access-step')).toBeNull();
await fireEvent.click(getByTestId('teams-scope-channels'));
await waitFor(() => expect(getByTestId('teams-file-access-step')).toBeVisible());
expect(getByTestId('teams-file-access-menu')).toBeVisible();
expect(
getByText('agents.channels.teams.setup.availability.fileAccessPermission'),
).toBeVisible();
expect(queryByTestId('teams-file-access')).toBeNull();
});
});
describe('step 5, connect', () => {
@ -721,27 +738,7 @@ describe('AgentChannelTeamsSetup', () => {
expect(getByTestId('teams-done')).toBeDisabled();
});
it('asks to publish a connected agent that is not published, next to Done', async () => {
withBot();
const { getByTestId } = renderComponent({
props: props({ modelValue: 'cred-1', connected: true, isPublished: false }),
});
await waitFor(() => expect(getByTestId('teams-publish-notice')).toBeVisible());
expect(getByTestId('teams-install-step')).toContainElement(
getByTestId('teams-publish-notice'),
);
});
it('locks the availability once connected, so a new download matches what was saved', async () => {
const { getByTestId, rerender } = await downloadAndConnect();
await rerender(props({ modelValue: 'cred-1', connected: true }));
await waitFor(() => expect(getByTestId('teams-availability-step')).toHaveAttribute('inert'));
});
it('locks the availability from the download on, even when connecting fails', async () => {
it('locks the availability only while the package downloads', async () => {
withBot();
let release: ((blob: Blob) => void) | undefined;
vi.mocked(fetchTeamsAppPackage).mockImplementation(
@ -758,10 +755,38 @@ describe('AgentChannelTeamsSetup', () => {
release?.(new Blob(['zip']));
await waitFor(() => expect(emitted().connect).toBeTruthy());
await rerender(props({ modelValue: 'cred-1', errorMessage: 'Bot rejected' }));
await rerender(props({ modelValue: 'cred-1', connected: true }));
await waitFor(() => expect(getByTestId('teams-connect-retry')).toBeVisible());
expect(getByTestId('teams-availability-step')).toHaveAttribute('inert');
await waitFor(() =>
expect(getByTestId('teams-availability-step')).not.toHaveAttribute('inert'),
);
});
it('keeps Retry for the downloaded package only', async () => {
const { getByTestId, emitted, rerender } = await downloadAndConnect();
await rerender(props({ modelValue: 'cred-1', errorMessage: 'Bot rejected' }));
await waitFor(() => expect(getByTestId('teams-connect-retry')).toBeEnabled());
expect(getByTestId('teams-availability-step')).not.toHaveAttribute('inert');
await openAvailability(getByTestId);
await fireEvent.click(getByTestId('teams-scope-channels'));
expect(getByTestId('teams-connect-retry')).toBeDisabled();
await fireEvent.click(getByTestId('teams-download-package'));
await waitFor(() => expect(emitted().connect).toHaveLength(2));
});
it('asks for a new download before Done once the availability changed', async () => {
const { getByTestId, rerender } = await downloadAndConnect();
await rerender(props({ modelValue: 'cred-1', connected: true }));
await waitFor(() => expect(getByTestId('teams-done')).toBeEnabled());
await openAvailability(getByTestId);
await fireEvent.click(getByTestId('teams-scope-channels'));
expect(getByTestId('teams-done')).toBeDisabled();
await fireEvent.click(getByTestId('teams-download-package'));
await waitFor(() => expect(getByTestId('teams-done')).toBeEnabled());
});
it('does not count a package for a credential that was swapped since', async () => {

View File

@ -1,7 +1,15 @@
<script setup lang="ts">
import { computed, inject, onBeforeUnmount, ref, watch } from 'vue';
import { saveAs } from 'file-saver';
import { N8nButton, N8nCopyInput, N8nIcon, N8nLink, N8nStepper, N8nText } from '@n8n/design-system';
import {
N8nActionDropdown,
N8nButton,
N8nCopyInput,
N8nIcon,
N8nLink,
N8nStepper,
N8nText,
} from '@n8n/design-system';
import {
resolveAgentPersonalisation,
type AgentTeamsIntegrationSettings,
@ -27,12 +35,15 @@ import { useTeamsUploadInstruction } from './useTeamsUploadInstruction';
import {
availabilityFrom,
AZURE_BOTS_URL,
ENTRA_APP_REGISTRATIONS_URL,
entraApiPermissionsUrl,
readsSharedConversations as readsShared,
TEAMS_PACKAGE_FILENAME,
type TeamsAvailability,
} from './constants';
import AgentChannelTeamsIdentityCard from './AgentChannelTeamsIdentityCard.vue';
import { fileAccessJustApproved } from './fileAccessConsent';
import { useTeamsFileAccessMenu } from './useTeamsFileAccessMenu';
import { useAgentTelemetry } from '../../composables/useAgentTelemetry';
import {
checkTeamsCredential,
@ -53,7 +64,6 @@ const props = withDefaults(
credentialsLoading?: boolean;
loading?: boolean;
connected?: boolean;
isPublished?: boolean;
errorMessage?: string;
errorIsConflict?: boolean;
projectId: string;
@ -74,7 +84,6 @@ const props = withDefaults(
credentialsLoading: false,
loading: false,
connected: false,
isPublished: true,
errorMessage: '',
errorIsConflict: false,
forceNewCredential: false,
@ -213,6 +222,8 @@ const availabilityTouched = ref(false);
function editAvailability(value: TeamsAvailability) {
availability.value = value;
availabilityTouched.value = true;
// The downloaded package carries the old scopes.
downloadedFor.value = null;
}
/**
@ -354,15 +365,9 @@ async function downloadAndConnect() {
if (!props.connected) emit('connect');
}
// The package fixes these settings, and connecting saves them, so they lock from the download on.
// Channel settings is where they change after that.
const availabilityLocked = computed(
() =>
!ready.value ||
props.connected ||
downloading.value ||
downloadedFor.value === credentialId.value,
);
// Locked only while a package is built from them. An edit after that invalidates the
// package, and Done saves the form, so the new download and n8n agree again.
const availabilityLocked = computed(() => !ready.value || downloading.value);
const canFinish = computed(() => downloadedFor.value === credentialId.value && props.connected);
@ -475,6 +480,19 @@ const fileAccessState = computed<'request' | 'approved' | 'hidden'>(() => {
: 'hidden';
});
/** Without a consent link, an admin grants the permission by hand in Entra. */
const fileAccessUrl = computed(() => {
if (setupState.value?.fileAccessConsentUrl) return setupState.value.fileAccessConsentUrl;
return setupState.value?.botId
? entraApiPermissionsUrl(setupState.value.botId)
: ENTRA_APP_REGISTRATIONS_URL;
});
const { items: fileAccessMenuItems, onSelect: onFileAccessMenu } = useTeamsFileAccessMenu({
url: fileAccessUrl,
organization: '',
});
// Saving the picked credential keeps its ID, so the watch above does not see the edit.
listenForCredentialChanges({
store: useCredentialsStore(),
@ -516,6 +534,15 @@ const steps = computed(() => [
id: 'download',
title: i18n.baseText('agents.channels.teams.setup.download.title'),
},
...(readsSharedConversations.value
? [
{
id: 'file-access',
title: i18n.baseText('agents.channels.teams.managed.fileAccess.title'),
description: i18n.baseText('agents.channels.teams.managed.fileAccess.description'),
},
]
: []),
{
id: 'install',
title: i18n.baseText('agents.channels.teams.setup.install.title'),
@ -630,7 +657,10 @@ defineExpose({
</N8nButton>
</div>
<div v-else-if="step.id === 'create-credential'" :class="$style.stepStack">
<div
v-else-if="step.id === 'create-credential'"
:class="[$style.stepStack, $style.fieldStack]"
>
<AgentIntegrationCredentialConnection
v-if="!connected"
v-model="credentialId"
@ -784,12 +814,41 @@ defineExpose({
<AgentChannelTeamsAvailability
:model-value="availability"
start-collapsed
:app-id="setupState?.botId"
:file-access-consent-url="setupState?.fileAccessConsentUrl"
hide-file-access
@update:model-value="editAvailability"
/>
</div>
<div
v-else-if="step.id === 'file-access'"
:class="[$style.stepStack, { [$style.locked]: !ready }]"
:inert="!ready || undefined"
data-testid="teams-file-access-step"
>
<N8nText :class="$style.hint" size="small">
{{
i18n.baseText(
setupState?.fileAccessConsentUrl
? 'agents.channels.teams.managed.fileAccess.hint'
: 'agents.channels.teams.setup.availability.fileAccessPermission',
)
}}
</N8nText>
<N8nActionDropdown
:items="fileAccessMenuItems"
placement="bottom-start"
:teleported="false"
@select="onFileAccessMenu"
>
<template #activator>
<N8nButton variant="outline" size="medium" data-testid="teams-file-access-menu">
{{ i18n.baseText('agents.channels.teams.managed.fileAccess.button') }}
<N8nIcon icon="chevron-down" size="small" />
</N8nButton>
</template>
</N8nActionDropdown>
</div>
<div v-else-if="step.id === 'download'" :class="$style.stepStack">
<AgentChannelTeamsIdentityCard
:name="defaultDisplayName"
@ -845,25 +904,17 @@ defineExpose({
})
}}
</N8nText>
<!-- The package is already downloaded, so a retry only connects. -->
<!-- A retry only connects, so it needs a package that matches the form. -->
<N8nButton
variant="ghost"
size="small"
:disabled="loading"
:disabled="loading || downloadedFor !== credentialId"
data-testid="teams-connect-retry"
@click="emit('connect')"
>
{{ i18n.baseText('generic.retry') }}
</N8nButton>
</div>
<N8nText
v-if="connected && !isPublished"
:class="$style.hint"
size="small"
data-testid="teams-publish-notice"
>
{{ i18n.baseText('agents.channels.teams.setup.publishNotice') }}
</N8nText>
<N8nButton
variant="outline"
size="medium"
@ -880,7 +931,7 @@ defineExpose({
<div v-else :class="$style.formContent">
<div :class="$style.field" data-testid="teams-availability-field">
<N8nText size="small" bold>
<N8nText step="sm" bold>
{{ i18n.baseText('agents.channels.teams.settings.availabilityLabel') }}
</N8nText>
<AgentChannelTeamsAvailability
@ -923,7 +974,7 @@ defineExpose({
/>
<template v-else>
<div :class="$style.field" data-testid="teams-identity-field">
<N8nText size="small" bold>
<N8nText step="sm" bold>
{{ i18n.baseText('agents.channels.teams.settings.identityLabel') }}
</N8nText>
<AgentChannelTeamsIdentityCard
@ -961,7 +1012,6 @@ defineExpose({
:href="AZURE_BOTS_URL"
target="_blank"
rel="noopener noreferrer"
size="small"
:class="$style.azureLink"
data-testid="teams-azure-bot-link"
>
@ -1009,6 +1059,11 @@ defineExpose({
width: 100%;
}
/* Matches a labelled input: the credential's status sits right under the select. */
.fieldStack {
gap: var(--spacing--2xs);
}
.hint {
color: var(--text-color--subtler);
}

View File

@ -151,7 +151,6 @@ defineExpose({
v-model="credentialId"
:mode="mode"
:connected="connected"
:is-published="isPublished"
:project-id="projectId"
:agent-id="agentId"
:integration="integration"

View File

@ -122,6 +122,8 @@ export interface AgentChannelPlatform {
getConnectedDescription?: (context: AgentChannelPresentationContext) => string;
headerContent?: {
setupModal?: Component;
/** The Assistant's setup card. Separate from the modal, so each platform opts in. */
setupCard?: Component;
editModal?: Component;
};
disconnectConfirmationComponent?: Component;

View File

@ -95,7 +95,7 @@ function handleToggle(value: boolean) {
<div :class="$style.toggleRow">
<div :class="$style.toggleText">
<label :for="toggleId">
<N8nText size="small" :bold="true">
<N8nText step="sm" bold>
{{ i18n.baseText('agents.channels.approval.label') }}
</N8nText>
</label>

View File

@ -66,7 +66,7 @@ const connectDisabled = computed(() => !props.modelValue || props.loading || pro
<template>
<div :class="$style.connectForm">
<label :class="$style.label">
<N8nText size="small" bold>
<N8nText step="sm" bold>
{{ integrationLabel }}
{{ i18n.baseText('agents.builder.addTrigger.credential') }}
</N8nText>
@ -167,7 +167,7 @@ const connectDisabled = computed(() => !props.modelValue || props.loading || pro
.connectForm {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
gap: var(--spacing--2xs);
width: 100%;
}

View File

@ -1091,14 +1091,15 @@ defineExpose({
// Allow flex item to shrink below content size so reka-ui viewport scrolls
min-height: 0;
:global([data-orientation='vertical'][data-orientation='vertical']) {
// Only the scrollbar, a direct child: menus inside the chat are vertical too.
> :global([data-orientation='vertical'][data-orientation='vertical']) {
background: transparent;
padding: 0;
// Sit above the sticky input dock (z-index: 3) so its gradient doesn't cover the scrollbar
z-index: 4;
}
:global([data-orientation='vertical'][data-orientation='vertical'] > *) {
> :global([data-orientation='vertical'][data-orientation='vertical'] > *) {
background: light-dark(var(--color--neutral-400), var(--color--neutral-600));
&:hover {

View File

@ -24,9 +24,17 @@ const mocks = vi.hoisted(() => {
icon: 'linear',
credentialTypes: ['linearOAuth2Api'],
};
const teamsIntegration = {
type: 'teams',
label: 'Microsoft Teams',
icon: 'teams',
credentialTypes: ['microsoftEntraOAuth2Api'],
};
return {
slackIntegration,
linearIntegration,
teamsIntegration,
teamsKeepOpenAfterConnect: false,
ensureLoaded: vi.fn(),
reloadCatalog: vi.fn(),
setCatalog: vi.fn<(integrations: ChatIntegrationDescriptor[]) => void>(),
@ -183,6 +191,54 @@ vi.mock('@/features/agents/components/AgentChannelSlackManagedSetup.vue', () =>
},
}));
vi.mock('@/features/agents/channels/teams/api', async (importOriginal) => ({
...(await importOriginal()),
getTeamsManagedSetup: vi
.fn()
.mockResolvedValue({ managedSetupAvailable: true, managerCredentials: [] }),
}));
vi.mock('@/features/agents/channels/teams/AgentChannelTeamsSetupKindSelector.vue', () => ({
default: { template: '<div data-testid="mock-teams-setup-kind" />' },
}));
vi.mock('@/features/agents/channels/teams/AgentChannelTeamsSetupView.vue', () => ({
default: {
props: ['modelValue'],
emits: ['update:modelValue', 'persist', 'done', 'connect'],
setup(
_: unknown,
{
emit,
expose,
}: { emit: (...args: unknown[]) => void; expose: (exposed: Record<string, unknown>) => void },
) {
const canFinish = ref(false);
expose({
canFinish,
get keepOpenAfterConnect() {
return mocks.teamsKeepOpenAfterConnect;
},
});
function provision() {
emit('update:modelValue', 'teams-cred');
emit('persist');
canFinish.value = true;
}
function download() {
emit('update:modelValue', 'teams-cred');
emit('connect');
}
return { provision, download, finish: () => emit('done') };
},
template: `<div data-testid="mock-teams-setup">
<button data-testid="mock-teams-provision" @click="provision">Provision</button>
<button data-testid="mock-teams-download" @click="download">Download</button>
<button data-testid="mock-teams-done" @click="finish">Done</button>
</div>`,
},
}));
vi.mock('@/features/agents/channels/linear/AgentChannelLinearSetup.vue', () => ({
default: {
props: ['connectedDescription'],
@ -233,6 +289,7 @@ describe('ChannelSetupCard', () => {
mocks.connect.mockResolvedValue({ status: 'connected' });
mocks.disconnect.mockResolvedValue(undefined);
mocks.isConnected.mockReturnValue(false);
mocks.teamsKeepOpenAfterConnect = false;
mocks.isConfigured.mockReturnValue(false);
mocks.getAgent.mockResolvedValue({ name: 'Agent', id: 'agent-1' });
mocks.createSlackAgentApp.mockResolvedValue({ installUrl: 'https://slack.com/oauth/install' });
@ -295,6 +352,18 @@ describe('ChannelSetupCard', () => {
expect(wrapper.find('[data-testid="mock-slack-setup"]').exists()).toBe(true);
});
it('shows no setup choice for Slack', async () => {
mocks.getSlackManagedSetup.mockResolvedValue({
managedSetupAvailable: true,
managerCredentials: [],
});
const wrapper = mountCard();
await flushPromises();
expect(wrapper.find('[data-testid="mock-slack-managed-setup"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="slack-setup-kind-selector"]').exists()).toBe(false);
});
it('does not describe a configured draft integration as connected', async () => {
mocks.isConfigured.mockReturnValue(true);
const wrapper = mountCard({ integrationType: 'linear' });
@ -621,4 +690,68 @@ describe('ChannelSetupCard', () => {
expect(mocks.connect).not.toHaveBeenCalled();
expect(wrapper.emitted('resolve')).toBeUndefined();
});
describe('Microsoft Teams', () => {
beforeEach(() => {
const integrations = [mocks.slackIntegration, mocks.teamsIntegration];
mocks.setCatalog(integrations);
mocks.ensureLoaded.mockResolvedValue(integrations);
mocks.reloadCatalog.mockResolvedValue(integrations);
});
it('offers the setup choice in the header', async () => {
const wrapper = mountCard({ integrationType: 'teams' });
await flushPromises();
expect(wrapper.find('header [data-testid="mock-teams-setup-kind"]').exists()).toBe(true);
});
it('saves the provisioned channel without resolving, then resolves on Done', async () => {
const wrapper = mountCard({ integrationType: 'teams' });
await flushPromises();
await wrapper.find('[data-testid="mock-teams-provision"]').trigger('click');
await flushPromises();
expect(mocks.connect).toHaveBeenCalledWith('teams', 'teams-cred', undefined, {});
expect(wrapper.emitted('resolve')).toBeUndefined();
expect(wrapper.find('[data-testid="channel-setup-card-skip"]').exists()).toBe(false);
await wrapper.find('[data-testid="mock-teams-done"]').trigger('click');
await flushPromises();
expect(mocks.connect).toHaveBeenCalledTimes(2);
expect(wrapper.emitted('resolve')).toEqual([[{ approved: true }]]);
});
it('keeps Later while the provisioned channel failed to save', async () => {
mocks.connect.mockRejectedValueOnce(new Error('connect failed'));
const wrapper = mountCard({ integrationType: 'teams' });
await flushPromises();
await wrapper.find('[data-testid="mock-teams-provision"]').trigger('click');
await flushPromises();
expect(mocks.connect).toHaveBeenCalledTimes(1);
expect(wrapper.emitted('resolve')).toBeUndefined();
expect(wrapper.find('[data-testid="channel-setup-card-skip"]').exists()).toBe(true);
});
it('stays open after a connect when the setup has steps past it', async () => {
mocks.teamsKeepOpenAfterConnect = true;
const wrapper = mountCard({ integrationType: 'teams' });
await flushPromises();
await wrapper.find('[data-testid="mock-teams-download"]').trigger('click');
await flushPromises();
expect(mocks.connect).toHaveBeenCalledTimes(1);
expect(wrapper.emitted('resolve')).toBeUndefined();
// Skipping now would remove the channel this card just saved.
expect(wrapper.find('[data-testid="channel-setup-card-skip"]').exists()).toBe(false);
await wrapper.find('[data-testid="mock-teams-done"]').trigger('click');
await flushPromises();
expect(wrapper.emitted('resolve')).toEqual([[{ approved: true }]]);
});
});
});

View File

@ -139,6 +139,9 @@ const channelActionInFlight = computed(
channelViewRef.value?.loading === true,
);
const integrationLabel = computed(() => currentIntegration.value.label);
const canFinish = computed(() => channelViewRef.value?.canFinish === true);
/** Set once this card saved the channel, which skipping would then remove. */
const savedHere = ref(false);
const connectedDescription = computed(() => {
if (!isIntegrationConnected(props.integrationType)) return '';
@ -200,10 +203,14 @@ async function skipSetup() {
}
}
async function saveChannelConfig() {
if (isBlocked() || channelActionInFlight.value) return;
/**
* Saves without resolving: the recommended Teams flow binds its credential
* mid-flow and still has steps after it.
*/
async function writeChannelConfig(): Promise<boolean> {
if (isBlocked() || channelActionInFlight.value) return false;
const credentialId = currentChannelCredentialId.value;
if (!credentialId || channelViewRef.value?.validationError) return;
if (!credentialId || channelViewRef.value?.validationError) return false;
connectionInFlight.value = true;
try {
@ -214,14 +221,27 @@ async function saveChannelConfig() {
...(approval ? { approval } : {}),
});
notifyAgentUpdated();
finish(true);
savedHere.value = true;
return true;
} catch {
// useAgentIntegrationStatus exposes the configuration error to the setup component.
return false;
} finally {
connectionInFlight.value = false;
}
}
async function saveChannelConfig() {
// A view with steps past the connect resolves the card itself, through `done`.
if ((await writeChannelConfig()) && !channelViewRef.value?.keepOpenAfterConnect) finish(true);
}
/** Done is also the save for a setup that reopened past the step that binds the credential. */
async function finishSetup() {
if (!canFinish.value) return finish(true);
if (await writeChannelConfig()) finish(true);
}
function handlePlatformConnected() {
if (isBlocked()) return;
notifyAgentUpdated();
@ -288,6 +308,12 @@ watch(
<N8nText :class="$style.title" size="medium" color="text-dark" bold>
{{ cardTitle }}
</N8nText>
<component
:is="currentPlatform.headerContent?.setupCard"
v-if="currentPlatform.headerContent?.setupCard && !catalogLoading"
:runtime="currentRuntime"
:disabled="channelActionInFlight"
/>
</header>
<div :class="$style.bodyWrapper">
@ -344,10 +370,13 @@ watch(
@edit="editCredential"
@connect="saveChannelConfig"
@connected="handlePlatformConnected"
@persist="writeChannelConfig"
@done="finishSetup"
/>
</div>
<footer :class="$style.footer">
<!-- Skipping removes the channel, which would orphan the one this card saved. -->
<footer v-if="!savedHere" :class="$style.footer">
<N8nButton
variant="ghost"
size="medium"
@ -377,10 +406,10 @@ watch(
align-items: center;
gap: var(--spacing--2xs);
padding: 0 var(--spacing--sm);
text-transform: capitalize;
}
.title {
text-transform: capitalize;
flex: 1;
}