fix: Disable update button when the user is not allowed to update (#19223)

This commit is contained in:
Nikhil Kuriakose
2025-09-15 17:04:58 +02:00
committed by GitHub
parent 7902ad6785
commit dfb0c4e77a
8 changed files with 554 additions and 19 deletions

View File

@@ -3438,6 +3438,7 @@
"insights.upgradeModal.title": "Upgrade to Enterprise",
"whatsNew.versionsBehind": "{count} version behind | {count} versions behind",
"whatsNew.update": "Update",
"whatsNew.updateNudgeTooltip": "Only owners can perform updates",
"whatsNew.updateAvailable": "You're currently on version {currentVersion}. Update to {latestVersion} to get {count} versions worth of new features, improvements, and fixes. See what changed",
"whatsNew.updateAvailable.changelogLink": "in the full changelog",
"workflowDiff.changes": "Changes",

View File

@@ -7,6 +7,9 @@ import MainSidebar from '@/components/MainSidebar.vue';
import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useVersionsStore } from '@/stores/versions.store';
import { useUsersStore } from '@/stores/users.store';
import type { Version } from '@n8n/rest-api-client/api/versions';
vi.mock('vue-router', () => ({
useRouter: () => ({}),
@@ -18,6 +21,20 @@ let renderComponent: ReturnType<typeof createComponentRenderer>;
let settingsStore: MockedStore<typeof useSettingsStore>;
let uiStore: MockedStore<typeof useUIStore>;
let sourceControlStore: MockedStore<typeof useSourceControlStore>;
let versionsStore: MockedStore<typeof useVersionsStore>;
let usersStore: MockedStore<typeof useUsersStore>;
const mockVersion: Version = {
name: '1.2.0',
nodes: [],
createdAt: '2025-01-01T00:00:00Z',
description: 'Test version',
documentationUrl: 'https://docs.n8n.io',
hasBreakingChange: false,
hasSecurityFix: false,
hasSecurityIssue: false,
securityIssueFixVersion: '',
};
describe('MainSidebar', () => {
beforeEach(() => {
@@ -27,8 +44,15 @@ describe('MainSidebar', () => {
settingsStore = mockedStore(useSettingsStore);
uiStore = mockedStore(useUIStore);
sourceControlStore = mockedStore(useSourceControlStore);
versionsStore = mockedStore(useVersionsStore);
usersStore = mockedStore(useUsersStore);
settingsStore.settings = defaultSettings;
// Default store values
versionsStore.hasVersionUpdates = false;
versionsStore.nextVersions = [];
usersStore.canUserUpdateVersion = true;
});
it('renders the sidebar without error', () => {
@@ -51,4 +75,39 @@ describe('MainSidebar', () => {
expect(queryByTestId('read-only-env-icon') !== null).toBe(shouldRender);
},
);
describe('Version Update CTA', () => {
it('should not render version update CTA when hasVersionUpdates is false', () => {
versionsStore.hasVersionUpdates = false;
usersStore.canUserUpdateVersion = true;
const { queryByTestId } = renderComponent();
expect(queryByTestId('version-update-cta-button')).not.toBeInTheDocument();
});
it('should render version update CTA disabled when canUserUpdateVersion is false', () => {
versionsStore.hasVersionUpdates = true;
versionsStore.nextVersions = [mockVersion];
usersStore.canUserUpdateVersion = false;
const { getByTestId } = renderComponent();
const updateButton = getByTestId('version-update-cta-button');
expect(updateButton).toBeInTheDocument();
expect(updateButton).toBeDisabled();
});
it('should render version update CTA enabled when canUserUpdateVersion is true and hasVersionUpdates is true', () => {
versionsStore.hasVersionUpdates = true;
versionsStore.nextVersions = [mockVersion];
usersStore.canUserUpdateVersion = true;
const { getByTestId } = renderComponent();
const updateButton = getByTestId('version-update-cta-button');
expect(updateButton).toBeInTheDocument();
expect(updateButton).toBeEnabled();
});
});
});

View File

@@ -264,7 +264,12 @@ const mainMenuItems = computed<IMenuItem[]>(() => [
id: 'version-upgrade-cta',
component: VersionUpdateCTA,
available: versionsStore.hasVersionUpdates,
props: {},
props: {
disabled: !usersStore.canUserUpdateVersion,
tooltipText: !usersStore.canUserUpdateVersion
? i18n.baseText('whatsNew.updateNudgeTooltip')
: undefined,
},
},
],
},

View File

@@ -1,12 +1,22 @@
<script setup lang="ts">
import { useVersionsStore } from '@/stores/versions.store';
import { N8nButton, N8nLink } from '@n8n/design-system';
import { N8nButton, N8nLink, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { useUIStore } from '@/stores/ui.store';
import { useTelemetry } from '@/composables/useTelemetry';
import { VERSIONS_MODAL_KEY } from '@/constants';
interface Props {
disabled?: boolean;
tooltipText?: string;
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
tooltipText: undefined,
});
const i18n = useI18n();
const versionsStore = useVersionsStore();
const uiStore = useUIStore();
@@ -44,13 +54,16 @@ const onUpdateClick = async () => {
}}
</N8nLink>
<N8nButton
:class="$style.button"
:label="i18n.baseText('whatsNew.update')"
data-test-id="version-update-cta-button"
size="mini"
@click="onUpdateClick"
/>
<N8nTooltip :disabled="!props.tooltipText" :content="props.tooltipText">
<N8nButton
:class="$style.button"
:label="i18n.baseText('whatsNew.update')"
data-test-id="version-update-cta-button"
size="mini"
:disabled="props.disabled"
@click="onUpdateClick"
/>
</N8nTooltip>
</div>
</template>

View File

@@ -6,6 +6,7 @@ import { mockedStore, type MockedStore } from '@/__tests__/utils';
import { useUIStore } from '@/stores/ui.store';
import { WHATS_NEW_MODAL_KEY, VERSIONS_MODAL_KEY } from '@/constants';
import { useVersionsStore } from '@/stores/versions.store';
import { useUsersStore } from '@/stores/users.store';
import type { Version } from '@n8n/rest-api-client/api/versions';
import WhatsNewModal from './WhatsNewModal.vue';
@@ -40,6 +41,7 @@ const renderComponent = createComponentRenderer(WhatsNewModal, {
let uiStore: MockedStore<typeof useUIStore>;
let versionsStore: MockedStore<typeof useVersionsStore>;
let usersStore: MockedStore<typeof useUsersStore>;
const telemetry = useTelemetry();
const pageRedirectionHelper = usePageRedirectionHelper();
@@ -67,7 +69,9 @@ describe('WhatsNewModal', () => {
};
versionsStore = mockedStore(useVersionsStore);
usersStore = mockedStore(useUsersStore);
versionsStore.hasVersionUpdates = false;
usersStore.canUserUpdateVersion = true;
versionsStore.currentVersion = currentVersion;
versionsStore.latestVersion = currentVersion;
versionsStore.nextVersions = [];
@@ -129,7 +133,7 @@ describe('WhatsNewModal', () => {
vi.clearAllMocks();
});
it('should render with update button disabled', async () => {
it('should not render update button when no version updates available', async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
data: {
@@ -143,7 +147,7 @@ describe('WhatsNewModal', () => {
expect(screen.getByText("What's New in n8n 1.100.0")).toBeInTheDocument();
expect(getByTestId('whats-new-item-1')).toMatchSnapshot();
expect(getByTestId('whats-new-modal-update-button')).toBeDisabled();
expect(queryByTestId('whats-new-modal-update-button')).not.toBeInTheDocument();
expect(queryByTestId('whats-new-modal-next-versions-link')).not.toBeInTheDocument();
});
@@ -219,4 +223,59 @@ describe('WhatsNewModal', () => {
expect(uiStore.openModal).toHaveBeenCalledWith(VERSIONS_MODAL_KEY);
});
describe('Update button permission tests', () => {
it('should not render update button when hasVersionUpdates is false', async () => {
versionsStore.hasVersionUpdates = false;
usersStore.canUserUpdateVersion = true;
const { queryByTestId } = renderComponent({
props: {
data: {
articleId: 1,
},
},
});
await waitFor(() => expect(queryByTestId('whatsNew-modal')).toBeInTheDocument());
expect(queryByTestId('whats-new-modal-update-button')).not.toBeInTheDocument();
});
it('should render update button disabled when canUserUpdateVersion is false', async () => {
versionsStore.hasVersionUpdates = true;
usersStore.canUserUpdateVersion = false;
const { getByTestId } = renderComponent({
props: {
data: {
articleId: 1,
},
},
});
await waitFor(() => expect(getByTestId('whatsNew-modal')).toBeInTheDocument());
const updateButton = getByTestId('whats-new-modal-update-button');
expect(updateButton).toBeInTheDocument();
expect(updateButton).toBeDisabled();
});
it('should render update button enabled when canUserUpdateVersion is true and hasVersionUpdates is true', async () => {
versionsStore.hasVersionUpdates = true;
usersStore.canUserUpdateVersion = true;
const { getByTestId } = renderComponent({
props: {
data: {
articleId: 1,
},
},
});
await waitFor(() => expect(getByTestId('whatsNew-modal')).toBeInTheDocument());
const updateButton = getByTestId('whats-new-modal-update-button');
expect(updateButton).toBeInTheDocument();
expect(updateButton).toBeEnabled();
});
});
});

View File

@@ -2,13 +2,22 @@
import dateformat from 'dateformat';
import { useI18n } from '@n8n/i18n';
import { RELEASE_NOTES_URL, VERSIONS_MODAL_KEY, WHATS_NEW_MODAL_KEY } from '@/constants';
import { N8nCallout, N8nHeading, N8nIcon, N8nLink, N8nMarkdown, N8nText } from '@n8n/design-system';
import {
N8nCallout,
N8nHeading,
N8nIcon,
N8nLink,
N8nMarkdown,
N8nText,
N8nTooltip,
} from '@n8n/design-system';
import { createEventBus } from '@n8n/utils/event-bus';
import { useVersionsStore } from '@/stores/versions.store';
import { computed, nextTick, ref } from 'vue';
import { useTelemetry } from '@/composables/useTelemetry';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
const props = defineProps<{
modalName: string;
@@ -24,6 +33,7 @@ const i18n = useI18n();
const modalBus = createEventBus();
const versionsStore = useVersionsStore();
const uiStore = useUIStore();
const usersStore = useUsersStore();
const telemetry = useTelemetry();
const nextVersions = computed(() => versionsStore.nextVersions);
@@ -112,13 +122,20 @@ modalBus.on('opened', () => {
</div>
</div>
<n8n-button
:size="'large'"
:label="i18n.baseText('whatsNew.update')"
:disabled="!versionsStore.hasVersionUpdates"
data-test-id="whats-new-modal-update-button"
@click="onUpdateClick"
/>
<N8nTooltip
v-if="versionsStore.hasVersionUpdates"
:disabled="usersStore.canUserUpdateVersion"
:content="i18n.baseText('whatsNew.updateNudgeTooltip')"
placement="bottom"
>
<n8n-button
:size="'large'"
:label="i18n.baseText('whatsNew.update')"
:disabled="!usersStore.canUserUpdateVersion"
data-test-id="whats-new-modal-update-button"
@click="onUpdateClick"
/>
</N8nTooltip>
</div>
</template>
<template #content>

View File

@@ -1,5 +1,381 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`WhatsNewModal > should not render update button when no version updates available 1`] = `
<div
class="article"
data-test-id="whats-new-item-1"
>
<h2
class="n8n-heading size-xlarge bold"
>
Convert to sub-workflow
</h2>
<div
class="n8n-markdown markdown"
>
<!-- Needed to support YouTube player embeds. HTML rendered here is sanitized. -->
<!-- eslint-disable vue/no-v-html -->
<div
class="markdown"
>
<p>
Large, monolithic workflows can slow things down. Theyre harder to maintain, tougher to debug, and more difficult to scale. With sub-workflows, you can take a more modular approach, breaking up big workflows into smaller, manageable parts that are easier to reuse, test, understand, and explain.
</p>
<p>
Until now, creating sub-workflows required copying and pasting nodes manually, setting up a new workflow from scratch, and reconnecting everything by hand.
<strong>
Convert to sub-workflow
</strong>
allows you to simplify this process into a single action, so you can spend more time building and less time restructuring.
</p>
<h3>
How it works
</h3>
<ol>
<li>
Highlight the nodes you want to convert to a sub-workflow. These must:
<ul>
<li>
Be fully connected, meaning no missing steps in between them
</li>
<li>
Start from a single starting node
</li>
<li>
End with a single node
</li>
</ul>
</li>
<li>
Right-click to open the context menu and select
<strong>
Convert to sub-workflow
</strong>
<ul>
<li>
Or use the shortcut:
<code>
Alt + X
</code>
</li>
</ul>
</li>
<li>
n8n will:
<ul>
<li>
Open a new tab containing the selected nodes
</li>
<li>
Preserve all node parameters as-is
</li>
<li>
Replace the selected nodes in the original workflow with a
<strong>
Call My Sub-workflow
</strong>
node
</li>
</ul>
</li>
</ol>
<p>
<em>
Note:
</em>
You will need to manually adjust the field types in the Start and Return nodes in the new sub-workflow.
</p>
<p>
This makes it easier to keep workflows modular, performant, and easier to maintain.
</p>
<p>
Learn more about
<a
href="https://docs.n8n.io/flow-logic/subworkflows/"
target="_blank"
>
sub-workflows
</a>
.
</p>
<p>
This release contains performance improvements and bug fixes.
</p>
<p>
<iframe
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen=""
frameborder="0"
height="315"
referrerpolicy="strict-origin-when-cross-origin"
src="https://www.youtube-nocookie.com/embed/ZCuL2e4zC_4"
title="YouTube video player"
width="100%"
/>
</p>
<p>
Fusce malesuada diam eget tincidunt ultrices. Mauris quis mauris mollis, venenatis risus ut.
</p>
<h2>
Second level title
</h2>
<h3>
Third level title
</h3>
<p>
This
<strong>
is bold
</strong>
, this
<em>
in italics
</em>
.
<br />
<s>
Strikethrough is also something we support
</s>
.
</p>
<p>
Heres a peace of code:
</p>
<pre>
<code>
const props = defineProps&lt;{
modalName: string;
data: {
articleId: number;
};
}&gt;();
</code>
</pre>
<p>
Inline
<code>
code also works
</code>
withing text.
</p>
<p>
This is a list:
</p>
<ul>
<li>
first
</li>
<li>
second
</li>
<li>
third
</li>
</ul>
<p>
And this list is ordered
</p>
<ol>
<li>
foo
</li>
<li>
bar
</li>
<li>
qux
</li>
</ol>
<p>
Dividers:
</p>
<p>
Three or more…
</p>
<hr />
<p>
Hyphens
</p>
<hr />
<p>
Asterisks
</p>
<hr />
<p>
Underscores
</p>
<hr />
<details>
<summary>
Fixes (4)
</summary>
<ul>
<li>
<strong>
Credential Storage Issue
</strong>
Resolved an issue where credentials would occasionally become inaccessible after server restarts
</li>
<li>
<strong>
Webhook Timeout Handling
</strong>
Fixed timeout issues with long-running webhook requests
</li>
<li>
<strong>
Node Connection Validation
</strong>
Improved validation for node connections to prevent invalid workflow configurations
</li>
<li>
<strong>
Memory Leak in Execution Engine
</strong>
Fixed memory leak that could occur during long-running workflow executions
</li>
</ul>
</details>
</div>
</div>
</div>
`;
exports[`WhatsNewModal > should render with update button disabled 1`] = `
<div
class="article"

View File

@@ -79,6 +79,10 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
Boolean(currentUser.value?.settings?.easyAIWorkflowOnboarded),
);
const canUserUpdateVersion = computed(() => {
return isInstanceOwner.value;
});
const setEasyAIWorkflowOnboardingDone = () => {
if (currentUser.value?.settings) {
currentUser.value.settings.easyAIWorkflowOnboarded = true;
@@ -446,6 +450,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
personalizedNodeTypes,
userClaimedAiCredits,
isEasyAIWorkflowOnboardingDone,
canUserUpdateVersion,
usersLimitNotReached,
addUsers,
loginWithCookie,