feat(editor): Add an upload step to the Microsoft Teams setup (no-changelog) (#40339)

This commit is contained in:
Bernhard Wittmann
2026-10-09 22:13:36 +00:00
committed by GitHub
parent a33ecf7481
commit 7424f0ca04
6 changed files with 377 additions and 36 deletions
@@ -9232,7 +9232,7 @@
"agents.channels.teams.setup.createBot.title": "Deploy Azure Bot",
"agents.channels.teams.setup.createBot.description": "Prefilled from your credential. You choose the subscription and resource group.",
"agents.channels.teams.setup.createBot.button": "Deploy to Azure",
"agents.channels.teams.setup.createBot.hint": "You need an Azure subscription.",
"agents.channels.teams.setup.createBot.hint": "Needs an Azure subscription. Opens Azure with the n8n bot template filled in (Azure may note it isn’t from Microsoft).",
"agents.channels.teams.setup.availability.title": "Choose where it works",
"agents.channels.teams.setup.availability.description": "Changing these later means downloading and uploading the app again.",
"agents.channels.teams.setup.availability.directChat": "Direct chat",
@@ -9261,7 +9261,13 @@
"agents.channels.teams.setup.install.connectFailed": "The package downloaded, but the channel is not connected: {error}",
"agents.channels.teams.setup.install.staleDownload": "The credential changed during the download, so that package is for the previous bot. Download it again.",
"agents.channels.teams.setup.install.title": "Connect Microsoft Teams",
"agents.channels.teams.setup.install.description": "Download the zip and upload it in Teams under Apps › Manage your apps › Upload an app.",
"agents.channels.teams.setup.download.title": "Download app package",
"agents.channels.teams.setup.download.description": "Built from the steps above.",
"agents.channels.teams.setup.install.description": "Upload the zip in Teams under Apps › Manage your apps › Upload an app.",
"agents.channels.teams.setup.install.addTo.teams": "Then choose Add to a team and pick the team it should join.",
"agents.channels.teams.setup.install.addTo.chats": "Then choose Add to a chat and pick the chat it should join.",
"agents.channels.teams.setup.install.addTo.both": "Then choose Add to a team or Add to a chat, and pick where it should work.",
"agents.channels.teams.setup.install.done": "Done",
"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.",
@@ -9567,8 +9573,9 @@
"agents.channels.teams.setup.createBot.existingBot": "Use an existing Azure Bot",
"agents.channels.teams.setup.createBot.existingBotHint": "Paste this into the bot’s Configuration page and turn on the Microsoft Teams channel.",
"agents.channels.teams.setup.availability.whereTitle": "Where can people use the agent?",
"agents.channels.teams.settings.updateNotice": "Changing these means downloading and uploading the app again.",
"agents.channels.teams.settings.updateNoticeChanged": "Saving downloads a new package. Upload it in Teams to apply the change.",
"agents.channels.teams.settings.updateNotice": "You’ll need to upload the app again to apply changes.",
"agents.channels.teams.settings.uploadHint": "To update, upload the new package in Teams under Apps › Manage your apps › Upload an app.",
"agents.channels.teams.settings.findBot": "Find your Azure Bot",
"agents.channels.teams.settings.availabilityLabel": "Where it works",
"agents.channels.teams.settings.identityLabel": "Appearance in Teams",
"agents.channels.teams.settings.saveAndDownload": "Save and download package",
@@ -6,6 +6,7 @@ import type { AgentApproval, ChatIntegrationDescriptor } from '@n8n/api-types';
import AgentChannelModal, { type ChannelView } from '../components/AgentChannelModal.vue';
const mocks = vi.hoisted(() => ({
keepOpenAfterConnect: false,
connect: vi.fn(),
disconnect: vi.fn(),
fetchStatus: vi.fn(),
@@ -71,7 +72,7 @@ vi.mock('../channels/registry', async () => {
const { ref, defineComponent } = await import('vue');
const platformView = {
props: ['modelValue', 'mode', 'isPublished', 'runtime', 'ensureAgentPersisted'],
emits: ['update:modelValue', 'connect', 'connected'],
emits: ['update:modelValue', 'connect', 'connected', 'done'],
setup: () => {
// Platforms that drive their own flow (Slack) report `connected` while
// still reporting `loading`, so the two are controlled together here.
@@ -81,6 +82,7 @@ vi.mock('../channels/registry', async () => {
validationError: null,
beforeSave: mocks.beforeSave,
afterSave: mocks.afterSave,
keepOpenAfterConnect: mocks.keepOpenAfterConnect,
saveLabel: platformSaveLabel,
loading,
startOwnFlow: () => {
@@ -97,6 +99,7 @@ vi.mock('../channels/registry', async () => {
>
<button data-testid="select-credential" @click="$emit('update:modelValue', 'credential-new')" />
<button data-testid="connect-channel" @click="$emit('connect')" />
<button data-testid="platform-done" @click="$emit('done')" />
<button data-testid="persist-agent" @click="ensureAgentPersisted?.()" />
<button data-testid="platform-own-flow" @click="startOwnFlow(); $emit('connected')" />
</div>
@@ -343,6 +346,7 @@ enableAutoUnmount(afterEach);
describe('AgentChannelModal', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.keepOpenAfterConnect = false;
catalog.value = [exampleIntegration];
statuses.value = {};
connectedCredentials.value = {};
@@ -455,6 +459,23 @@ describe('AgentChannelModal', () => {
expect(wrapper.emitted('agent-changed')).toHaveLength(1);
});
it('stays open after connecting a view that asks to, until the view is done', async () => {
mocks.keepOpenAfterConnect = true;
selectedCredentials.value.example = 'credential-new';
const wrapper = mountModal('example_setup');
await wrapper.get('[data-testid="connect-channel"]').trigger('click');
await flushPromises();
expect(mocks.connect).toHaveBeenCalledOnce();
expect(wrapper.emitted('channel-connected')).toEqual([['example']]);
expect(wrapper.emitted('update:open')).toBeUndefined();
await wrapper.get('[data-testid="platform-done"]').trigger('click');
expect(wrapper.emitted('update:open')).toEqual([[false]]);
});
it('lets the platform view save the agent before it needs agent-scoped data', async () => {
const wrapper = mountModal('example_setup');
@@ -240,6 +240,14 @@ describe('AgentChannelTeamsSetup', () => {
});
describe('step 3, deploy the bot', () => {
it('shows the Azure subscription hint', async () => {
const { getByText } = renderComponent({ props: props() });
await waitFor(() =>
expect(getByText('agents.channels.teams.setup.createBot.hint')).toBeVisible(),
);
});
it('keeps the endpoint URL out of the way, since the deployment sets it', async () => {
const { getByTestId, container } = renderComponent({ props: props() });
@@ -528,7 +536,7 @@ describe('AgentChannelTeamsSetup', () => {
expect(vi.mocked(fetchTeamsAppPackage).mock.calls[0]?.slice(1, 3)).toEqual(['p', 'a']);
});
it('connects once the package is saved, since the modal closes on connect', async () => {
it('connects once the package is saved', async () => {
withBot();
const { getByTestId, emitted } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
@@ -563,6 +571,15 @@ describe('AgentChannelTeamsSetup', () => {
expect(emitted().connect).toBeFalsy();
});
it('names the download step and what the package is built from', async () => {
const { getByText } = renderComponent({ props: props() });
await waitFor(() =>
expect(getByText('agents.channels.teams.setup.download.title')).toBeVisible(),
);
expect(getByText('agents.channels.teams.setup.download.description')).toBeVisible();
});
it('reports a failed download and does not connect', async () => {
withBot();
vi.mocked(fetchTeamsAppPackage).mockRejectedValue(new Error('401'));
@@ -576,6 +593,169 @@ describe('AgentChannelTeamsSetup', () => {
});
});
describe('step 6, upload in Teams', () => {
const UPLOAD = 'agents.channels.teams.setup.install.description';
const downloadAndConnect = async () => {
withBot();
const utils = renderComponent({ props: props({ modelValue: 'cred-1' }) });
await waitFor(() => expect(utils.getByTestId('teams-download-package')).toBeEnabled());
await fireEvent.click(utils.getByTestId('teams-download-package'));
await waitFor(() => expect(utils.emitted().connect).toBeTruthy());
return utils;
};
it('says where to upload the zip, and what to do without the option', async () => {
const { getByText, getByTestId } = renderComponent({ props: props() });
await waitFor(() =>
expect(getByText('agents.channels.teams.setup.install.title')).toBeVisible(),
);
expect(getByText(UPLOAD)).toBeVisible();
expect(getByTestId('teams-upload-hint')).toHaveTextContent(
'agents.channels.teams.setup.install.hint',
);
});
it.each([
[['teams-scope-channels'], 'agents.channels.teams.setup.install.addTo.teams'],
[['teams-scope-groups'], 'agents.channels.teams.setup.install.addTo.chats'],
[
['teams-scope-channels', 'teams-scope-groups'],
'agents.channels.teams.setup.install.addTo.both',
],
])('tells the user where to add the app for %j', async (scopes, addTo) => {
withBot();
const { getByTestId, getByText } = renderComponent({
props: props({ modelValue: 'cred-1' }),
});
await openAvailability(getByTestId);
for (const scope of scopes) await fireEvent.click(getByTestId(scope));
await waitFor(() => expect(getByText(`${UPLOAD} ${addTo}`)).toBeVisible());
});
it('adds nothing about teams or chats for direct chat only', async () => {
const { getByText } = renderComponent({ props: props() });
await waitFor(() => expect(getByText(UPLOAD)).toBeVisible());
});
it('asks the modal to stay open after connecting in setup, but not in settings', async () => {
const Host = defineComponent({
components: { AgentChannelTeamsSetup },
props: { viewProps: { type: Object, required: true } },
setup: () => ({ view: ref<InstanceType<typeof AgentChannelTeamsSetup>>() }),
template: `<div><AgentChannelTeamsSetup ref="view" v-bind="viewProps" /><span data-testid="keep-open">{{ String(view?.keepOpenAfterConnect) }}</span></div>`,
});
const renderHost = createComponentRenderer(Host);
const setup = renderHost({ props: { viewProps: props() } });
await waitFor(() => expect(setup.getByTestId('keep-open')).toHaveTextContent('true'));
setup.unmount();
const settings = renderHost({ props: { viewProps: props({ mode: 'edit' }) } });
await waitFor(() => expect(settings.getByTestId('keep-open')).toHaveTextContent('false'));
});
it('keeps Done disabled until the package is downloaded and the channel connected', async () => {
withBot();
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
expect(getByTestId('teams-done')).toBeDisabled();
await fireEvent.click(getByTestId('teams-download-package'));
await waitFor(() => expect(saveAs).toHaveBeenCalled());
expect(getByTestId('teams-done')).toBeDisabled();
});
it('keeps Done disabled on a connected channel until a package is downloaded', async () => {
withBot();
const { getByTestId } = renderComponent({
props: props({ modelValue: 'cred-1', connected: true }),
});
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
expect(getByTestId('teams-done')).toBeDisabled();
});
it('finishes on Done once connected, without another toast', async () => {
const { getByTestId, emitted, rerender } = await downloadAndConnect();
await rerender(props({ modelValue: 'cred-1', connected: true }));
await waitFor(() => expect(getByTestId('teams-done')).toBeEnabled());
showMessage.mockClear();
await fireEvent.click(getByTestId('teams-done'));
expect(emitted().done).toHaveLength(1);
expect(showMessage).not.toHaveBeenCalled();
});
it('shows a failed connect in this step, with Done still disabled', async () => {
const { getByTestId, rerender } = await downloadAndConnect();
await rerender(props({ modelValue: 'cred-1', errorMessage: 'Bot rejected' }));
await waitFor(() => expect(getByTestId('teams-connect-retry')).toBeVisible());
const step = getByTestId('teams-install-step');
expect(step).toContainElement(getByTestId('teams-connect-error'));
expect(step).toContainElement(getByTestId('teams-done'));
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 () => {
withBot();
let release: ((blob: Blob) => void) | undefined;
vi.mocked(fetchTeamsAppPackage).mockImplementation(
async () => await new Promise((resolve) => (release = resolve)),
);
const { getByTestId, emitted, rerender } = renderComponent({
props: props({ modelValue: 'cred-1' }),
});
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
expect(getByTestId('teams-availability-step')).not.toHaveAttribute('inert');
await fireEvent.click(getByTestId('teams-download-package'));
await waitFor(() => expect(getByTestId('teams-availability-step')).toHaveAttribute('inert'));
release?.(new Blob(['zip']));
await waitFor(() => expect(emitted().connect).toBeTruthy());
await rerender(props({ modelValue: 'cred-1', errorMessage: 'Bot rejected' }));
await waitFor(() => expect(getByTestId('teams-connect-retry')).toBeVisible());
expect(getByTestId('teams-availability-step')).toHaveAttribute('inert');
});
it('does not count a package for a credential that was swapped since', async () => {
const { getByTestId, rerender } = await downloadAndConnect();
await rerender(props({ modelValue: 'cred-2', connected: true }));
await waitFor(() => expect(getByTestId('teams-done')).toBeDisabled());
});
});
describe('settings', () => {
const settingsProps = (overrides: Record<string, unknown> = {}) =>
props({ mode: 'edit', connected: true, modelValue: 'cred-1', ...overrides });
@@ -586,6 +766,57 @@ describe('AgentChannelTeamsSetup', () => {
await waitFor(() => expect(getByTestId('teams-update-notice')).toBeVisible());
});
it('says where to upload a new package in Teams', async () => {
const { getByTestId } = renderComponent({ props: settingsProps() });
await waitFor(() =>
expect(getByTestId('teams-upload-instructions')).toHaveTextContent(
'agents.channels.teams.settings.uploadHint',
),
);
});
it('links to the Azure Bot resources, since the bot itself is not known here', async () => {
const { getByTestId } = renderComponent({ props: settingsProps() });
await waitFor(() =>
expect(getByTestId('teams-azure-bot-link')).toHaveAttribute(
'href',
'https://portal.azure.com/#browse/Microsoft.BotService%2FbotServices',
),
);
expect(getByTestId('teams-azure-bot-link')).toHaveAttribute('target', '_blank');
expect(getByTestId('teams-azure-bot-link')).toHaveTextContent(
'agents.channels.teams.settings.findBot',
);
});
it('keeps the Azure link out of the setup', async () => {
const { queryByTestId, getByText } = renderComponent({ props: props() });
await waitFor(() =>
expect(getByText('agents.channels.teams.setup.install.title')).toBeVisible(),
);
expect(queryByTestId('teams-azure-bot-link')).toBeNull();
});
it('adds the admin hint to the toast after a download from the card', async () => {
withBot();
const { getByTestId } = renderComponent({ props: settingsProps() });
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
await fireEvent.click(getByTestId('teams-download-package'));
await waitFor(() =>
expect(showMessage).toHaveBeenCalledWith(
expect.objectContaining({
title: 'agents.channels.teams.setup.install.downloaded',
message: 'agents.channels.teams.setup.install.hint',
}),
),
);
});
it('takes the app name and description from the agent, not from inputs here', async () => {
const { getByTestId, queryByTestId } = renderComponent({ props: settingsProps() });
@@ -672,7 +903,7 @@ describe('AgentChannelTeamsSetup', () => {
);
});
it('says a changed availability is saved with a new package', async () => {
it('stresses the upload notice once the availability changes', async () => {
const { getByTestId } = renderComponent({ props: settingsProps() });
await waitFor(() =>
@@ -680,12 +911,14 @@ describe('AgentChannelTeamsSetup', () => {
'agents.channels.teams.settings.updateNotice',
),
);
const quiet = getByTestId('teams-update-notice').className;
await openAvailability(getByTestId);
await fireEvent.click(getByTestId('teams-scope-groups'));
expect(getByTestId('teams-update-notice')).toHaveTextContent(
'agents.channels.teams.settings.updateNoticeChanged',
'agents.channels.teams.settings.updateNotice',
);
expect(getByTestId('teams-update-notice').className).not.toBe(quiet);
});
it('downloads the saved app from the card, not unsaved changes', async () => {
@@ -840,7 +1073,10 @@ describe('AgentChannelTeamsSetup', () => {
expect.objectContaining({ groupChats: true }),
);
expect(showMessage).toHaveBeenCalledWith(
expect.objectContaining({ title: 'agents.channels.teams.setup.install.downloaded' }),
expect.objectContaining({
title: 'agents.channels.teams.setup.install.downloaded',
message: 'agents.channels.teams.setup.install.hint',
}),
);
});
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { saveAs } from 'file-saver';
import { N8nButton, N8nCopyInput, N8nIcon, N8nStepper, N8nText } from '@n8n/design-system';
import { N8nButton, N8nCopyInput, N8nIcon, N8nLink, N8nStepper, N8nText } from '@n8n/design-system';
import type {
AgentJsonConfig,
AgentTeamsIntegrationSettings,
@@ -65,6 +65,7 @@ const emit = defineEmits<{
create: [];
edit: [];
connect: [];
done: [];
}>();
const i18n = useI18n();
@@ -72,6 +73,8 @@ const rootStore = useRootStore();
const toast = useToast();
const agentTelemetry = useAgentTelemetry();
// The bot's subscription and resource group are picked in Azure, so this lists every Azure Bot.
const AZURE_BOTS_URL = 'https://portal.azure.com/#browse/Microsoft.BotService%2FbotServices';
const ENTRA_APP_REGISTRATION_URL =
'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/CreateApplicationBlade';
@@ -265,8 +268,9 @@ onBeforeUnmount(() => (unmounted = true));
// Set when the credential changed during a download, so the saved zip is for the old bot.
const staleDownload = ref(false);
// The credential the last package was built for. Done waits for one of the current credential.
const downloadedFor = ref<string | null>(null);
// Connecting closes the modal, so it waits for the package to be saved.
async function downloadAndConnect() {
const id = credentialId.value;
staleDownload.value = false;
@@ -277,6 +281,7 @@ async function downloadAndConnect() {
staleDownload.value = true;
return;
}
downloadedFor.value = id;
toast.showMessage({
type: 'success',
title: i18n.baseText('agents.channels.teams.setup.install.downloaded'),
@@ -284,6 +289,32 @@ 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,
);
const canFinish = computed(() => downloadedFor.value === credentialId.value && props.connected);
function addToText(scopes?: { teamChannels?: boolean; groupChats?: boolean }) {
if (scopes?.teamChannels && scopes.groupChats)
return i18n.baseText('agents.channels.teams.setup.install.addTo.both');
if (scopes?.teamChannels) return i18n.baseText('agents.channels.teams.setup.install.addTo.teams');
if (scopes?.groupChats) return i18n.baseText('agents.channels.teams.setup.install.addTo.chats');
return null;
}
function uploadInstruction(scopes?: { teamChannels?: boolean; groupChats?: boolean }) {
const upload = i18n.baseText('agents.channels.teams.setup.install.description');
const addTo = addToText(scopes);
return addTo ? `${upload} ${addTo}` : upload;
}
// The modal clears the error when the credential changes. A conflict is about
// the credential itself, so it stays in step 2.
const showConnectError = computed(
@@ -371,10 +402,15 @@ const steps = computed(() => [
title: i18n.baseText('agents.channels.teams.setup.availability.title'),
description: i18n.baseText('agents.channels.teams.setup.availability.description'),
},
{
id: 'download',
title: i18n.baseText('agents.channels.teams.setup.download.title'),
description: i18n.baseText('agents.channels.teams.setup.download.description'),
},
{
id: 'install',
title: i18n.baseText('agents.channels.teams.setup.install.title'),
description: i18n.baseText('agents.channels.teams.setup.install.description'),
description: uploadInstruction(availability.value),
},
]);
@@ -389,10 +425,12 @@ const currentSettings = computed<AgentTeamsIntegrationSettings>(() => {
return { ...rest, ...availability.value };
});
// Saving closes the modal, so after "Save and download" the toast is the only place left for the hint.
function showDownloaded() {
toast.showMessage({
type: 'success',
title: i18n.baseText('agents.channels.teams.setup.install.downloaded'),
message: i18n.baseText('agents.channels.teams.setup.install.hint'),
});
}
@@ -442,6 +480,7 @@ defineExpose({
saveLabel,
beforeSave,
afterSave,
keepOpenAfterConnect: computed(() => props.mode === 'setup'),
});
</script>
@@ -613,8 +652,8 @@ defineExpose({
<div
v-else-if="step.id === 'availability'"
:class="[$style.stepStack, { [$style.locked]: !ready }]"
:inert="!ready || undefined"
:class="[$style.stepStack, { [$style.locked]: availabilityLocked }]"
:inert="availabilityLocked || undefined"
data-testid="teams-availability-step"
>
<AgentChannelTeamsAvailability
@@ -624,11 +663,7 @@ defineExpose({
/>
</div>
<div v-else-if="step.id === 'install'" :class="$style.stepStack">
<N8nText :class="$style.hint" size="small">
{{ i18n.baseText('agents.channels.teams.setup.install.hint') }}
</N8nText>
<div v-else-if="step.id === 'download'" :class="$style.stepStack">
<AgentChannelTeamsIdentityCard
:name="defaultDisplayName"
:description="defaultDescription"
@@ -656,7 +691,25 @@ defineExpose({
>
{{ i18n.baseText('agents.channels.teams.setup.install.needsReady') }}
</N8nText>
<!-- Connect errors otherwise land in step 2, far from this button. -->
<N8nText
v-if="downloadError"
size="small"
:class="$style.error"
data-testid="teams-download-error"
>
{{ downloadError }}
</N8nText>
</div>
<div
v-else-if="step.id === 'install'"
:class="$style.stepStack"
data-testid="teams-install-step"
>
<N8nText :class="$style.hint" size="small" data-testid="teams-upload-hint">
{{ i18n.baseText('agents.channels.teams.setup.install.hint') }}
</N8nText>
<!-- Connect errors otherwise land in step 2, far from this step. -->
<div v-if="showConnectError" :class="$style.actions">
<N8nText size="small" :class="$style.error" data-testid="teams-connect-error">
{{
@@ -676,14 +729,6 @@ defineExpose({
{{ i18n.baseText('generic.retry') }}
</N8nButton>
</div>
<N8nText
v-if="downloadError"
size="small"
:class="$style.error"
data-testid="teams-download-error"
>
{{ downloadError }}
</N8nText>
<N8nText
v-if="connected && !isPublished"
:class="$style.hint"
@@ -692,6 +737,15 @@ defineExpose({
>
{{ i18n.baseText('agents.channels.teams.setup.publishNotice') }}
</N8nText>
<N8nButton
variant="solid"
size="medium"
:disabled="!canFinish"
data-testid="teams-done"
@click="emit('done')"
>
{{ i18n.baseText('agents.channels.teams.setup.install.done') }}
</N8nButton>
</div>
</div>
</template>
@@ -712,13 +766,7 @@ defineExpose({
:class="manifestChanged ? undefined : $style.hint"
data-testid="teams-update-notice"
>
{{
i18n.baseText(
manifestChanged
? 'agents.channels.teams.settings.updateNoticeChanged'
: 'agents.channels.teams.settings.updateNotice',
)
}}
{{ i18n.baseText('agents.channels.teams.settings.updateNotice') }}
</N8nText>
</div>
@@ -739,6 +787,9 @@ defineExpose({
:loading="downloading"
@download="downloadSaved"
/>
<N8nText :class="$style.hint" size="small" data-testid="teams-upload-instructions">
{{ i18n.baseText('agents.channels.teams.settings.uploadHint') }}
</N8nText>
<N8nText
v-if="downloadError"
size="small"
@@ -748,6 +799,19 @@ defineExpose({
{{ downloadError }}
</N8nText>
</div>
<N8nLink
:href="AZURE_BOTS_URL"
target="_blank"
rel="noopener noreferrer"
size="small"
:class="$style.azureLink"
data-testid="teams-azure-bot-link"
>
<span :class="$style.linkContent">
{{ i18n.baseText('agents.channels.teams.settings.findBot') }}
<N8nIcon icon="external-link" size="xsmall" />
</span>
</N8nLink>
</div>
</div>
</template>
@@ -811,6 +875,16 @@ defineExpose({
pointer-events: none;
}
.azureLink {
align-self: flex-start;
}
.linkContent {
display: inline-flex;
align-items: center;
gap: var(--spacing--4xs);
}
.urlInput {
flex: 1;
min-width: 0;
@@ -29,6 +29,8 @@ export interface AgentChannelViewExpose {
saveLabel?: string;
/** Runs after a successful save, before the modal closes. Must not throw. */
afterSave?: () => Promise<void>;
/** Keeps the modal open after connecting. The view emits `done` to close it. */
keepOpenAfterConnect?: boolean;
}
export interface AgentChannelRuntimeContext {
@@ -481,7 +481,7 @@ function finishConnect(channelType: string) {
endSetupTracking(true);
emit('channel-connected', channelType);
emit('agent-changed');
completeAndClose();
if (!channelViewRef.value?.keepOpenAfterConnect) completeAndClose();
}
/** Same shape as `persistAgent`: reports its own failure and returns whether it saved. */
@@ -670,6 +670,7 @@ watch(
@edit="editCredential"
@connect="saveChannelConfig"
@connected="handlePlatformConnected"
@done="completeAndClose"
/>
<N8nText
v-if="saveAttempted && !currentChannelCredentialId"