mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
fix(editor): Consistent protected environment styling and messaging (#12374)
This commit is contained in:
parent
983e87a9b0
commit
6891cefa6d
|
@ -13,6 +13,7 @@ interface ActionBoxProps {
|
|||
buttonText: string;
|
||||
buttonType: ButtonType;
|
||||
buttonDisabled?: boolean;
|
||||
buttonIcon?: string;
|
||||
description: string;
|
||||
calloutText?: string;
|
||||
calloutTheme?: CalloutTheme;
|
||||
|
@ -22,6 +23,7 @@ interface ActionBoxProps {
|
|||
defineOptions({ name: 'N8nActionBox' });
|
||||
withDefaults(defineProps<ActionBoxProps>(), {
|
||||
calloutTheme: 'info',
|
||||
buttonIcon: undefined,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -51,6 +53,7 @@ withDefaults(defineProps<ActionBoxProps>(), {
|
|||
:label="buttonText"
|
||||
:type="buttonType"
|
||||
:disabled="buttonDisabled"
|
||||
:icon="buttonIcon"
|
||||
size="large"
|
||||
@click="$emit('click:button', $event)"
|
||||
/>
|
||||
|
|
|
@ -68,6 +68,7 @@ onMounted(() => {
|
|||
<div v-if="showReleaseChannelTag" size="small" round :class="$style.releaseChannelTag">
|
||||
{{ releaseChannel }}
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ import { useUIStore } from '@/stores/ui.store';
|
|||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useVersionsStore } from '@/stores/versions.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
|
||||
import { hasPermission } from '@/utils/rbac/permissions';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
|
@ -23,7 +24,7 @@ import { useBugReporting } from '@/composables/useBugReporting';
|
|||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
|
||||
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
|
||||
import { N8nNavigationDropdown } from 'n8n-design-system';
|
||||
import { N8nNavigationDropdown, N8nTooltip, N8nLink, N8nIconButton } from 'n8n-design-system';
|
||||
import { onClickOutside, type VueInstance } from '@vueuse/core';
|
||||
import Logo from './Logo/Logo.vue';
|
||||
|
||||
|
@ -36,6 +37,7 @@ const uiStore = useUIStore();
|
|||
const usersStore = useUsersStore();
|
||||
const versionsStore = useVersionsStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
|
||||
const { callDebounced } = useDebounce();
|
||||
const externalHooks = useExternalHooks();
|
||||
|
@ -292,6 +294,8 @@ const {
|
|||
menu,
|
||||
handleSelect: handleMenuSelect,
|
||||
createProjectAppendSlotName,
|
||||
createWorkflowsAppendSlotName,
|
||||
createCredentialsAppendSlotName,
|
||||
projectsLimitReachedMessage,
|
||||
upgradeLabel,
|
||||
} = useGlobalEntityCreation();
|
||||
|
@ -322,7 +326,26 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
|||
location="sidebar"
|
||||
:collapsed="isCollapsed"
|
||||
:release-channel="settingsStore.settings.releaseChannel"
|
||||
/>
|
||||
>
|
||||
<N8nTooltip
|
||||
v-if="sourceControlStore.preferences.branchReadOnly && !isCollapsed"
|
||||
placement="bottom"
|
||||
>
|
||||
<template #content>
|
||||
<i18n-t keypath="readOnlyEnv.tooltip">
|
||||
<template #link>
|
||||
<N8nLink
|
||||
to="https://docs.n8n.io/source-control-environments/setup/#step-4-connect-n8n-and-configure-your-instance"
|
||||
size="small"
|
||||
>
|
||||
{{ i18n.baseText('readOnlyEnv.tooltip.link') }}
|
||||
</N8nLink>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
<N8nIcon icon="lock" size="xsmall" :class="$style.readOnlyEnvironmentIcon" />
|
||||
</N8nTooltip>
|
||||
</Logo>
|
||||
<N8nNavigationDropdown
|
||||
ref="createBtn"
|
||||
data-test-id="universal-add"
|
||||
|
@ -330,6 +353,24 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
|||
@select="handleMenuSelect"
|
||||
>
|
||||
<N8nIconButton icon="plus" type="secondary" outline />
|
||||
<template #[createWorkflowsAppendSlotName]>
|
||||
<N8nTooltip
|
||||
v-if="sourceControlStore.preferences.branchReadOnly"
|
||||
placement="right"
|
||||
:content="i18n.baseText('readOnlyEnv.cantAdd.workflow')"
|
||||
>
|
||||
<N8nIcon style="margin-left: auto; margin-right: 5px" icon="lock" size="xsmall" />
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
<template #[createCredentialsAppendSlotName]>
|
||||
<N8nTooltip
|
||||
v-if="sourceControlStore.preferences.branchReadOnly"
|
||||
placement="right"
|
||||
:content="i18n.baseText('readOnlyEnv.cantAdd.credential')"
|
||||
>
|
||||
<N8nIcon style="margin-left: auto; margin-right: 5px" icon="lock" size="xsmall" />
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
<template #[createProjectAppendSlotName]="{ item }">
|
||||
<N8nTooltip v-if="item.disabled" placement="right" :content="projectsLimitReachedMessage">
|
||||
<N8nButton
|
||||
|
@ -544,4 +585,14 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.readOnlyEnvironmentIcon {
|
||||
display: inline-block;
|
||||
color: white;
|
||||
background-color: var(--color-warning);
|
||||
align-self: center;
|
||||
padding: 2px;
|
||||
border-radius: var(--border-radius-small);
|
||||
margin: 5px 5px 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -8,6 +8,7 @@ type Action = {
|
|||
};
|
||||
defineProps<{
|
||||
actions: Action[];
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
@ -25,7 +26,7 @@ const emit = defineEmits<{
|
|||
:teleported="false"
|
||||
@action="emit('action', $event)"
|
||||
>
|
||||
<N8nIconButton :class="[$style.buttonGroupDropdown]" icon="angle-down" />
|
||||
<N8nIconButton :disabled="disabled" :class="[$style.buttonGroupDropdown]" icon="angle-down" />
|
||||
</N8nActionToggle>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { N8nButton } from 'n8n-design-system';
|
||||
import { N8nButton, N8nTooltip } from 'n8n-design-system';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
|
@ -59,6 +59,8 @@ type ActionTypes = (typeof ACTION_TYPES)[keyof typeof ACTION_TYPES];
|
|||
const createWorkflowButton = computed(() => ({
|
||||
value: ACTION_TYPES.WORKFLOW,
|
||||
label: 'Create Workflow',
|
||||
icon: sourceControlStore.preferences.branchReadOnly ? 'lock' : undefined,
|
||||
size: 'mini' as const,
|
||||
disabled:
|
||||
sourceControlStore.preferences.branchReadOnly ||
|
||||
!getResourcePermissions(homeProject.value?.scopes).workflow.create,
|
||||
|
@ -119,17 +121,23 @@ const onSelect = (action: string) => {
|
|||
</N8nText>
|
||||
</div>
|
||||
<div v-if="route.name !== VIEWS.PROJECT_SETTINGS" :class="[$style.headerActions]">
|
||||
<ProjectCreateResource
|
||||
data-test-id="add-resource-buttons"
|
||||
:actions="menu"
|
||||
@action="onSelect"
|
||||
<N8nTooltip
|
||||
:disabled="!sourceControlStore.preferences.branchReadOnly"
|
||||
:content="i18n.baseText('readOnlyEnv.cantAdd.any')"
|
||||
>
|
||||
<N8nButton
|
||||
data-test-id="add-resource-workflow"
|
||||
v-bind="createWorkflowButton"
|
||||
@click="onSelect(ACTION_TYPES.WORKFLOW)"
|
||||
/>
|
||||
</ProjectCreateResource>
|
||||
<ProjectCreateResource
|
||||
data-test-id="add-resource-buttons"
|
||||
:actions="menu"
|
||||
:disabled="sourceControlStore.preferences.branchReadOnly"
|
||||
@action="onSelect"
|
||||
>
|
||||
<N8nButton
|
||||
data-test-id="add-resource-workflow"
|
||||
v-bind="createWorkflowButton"
|
||||
@click="onSelect(ACTION_TYPES.WORKFLOW)"
|
||||
/>
|
||||
</ProjectCreateResource>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.actions">
|
||||
|
|
|
@ -8,6 +8,7 @@ import { useToast } from '@/composables/useToast';
|
|||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import type { CloudPlanState } from '@/Interface';
|
||||
|
||||
import { VIEWS } from '@/constants';
|
||||
|
@ -179,4 +180,28 @@ describe('useGlobalEntityCreation', () => {
|
|||
);
|
||||
expect(upgradeLabel.value).toBe('Enterprise');
|
||||
});
|
||||
|
||||
it('should display properly for readOnlyEnvironment', () => {
|
||||
const sourceControlStore = mockedStore(useSourceControlStore);
|
||||
sourceControlStore.preferences.branchReadOnly = true;
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
|
||||
const personalProjectId = 'personal-project';
|
||||
projectsStore.isTeamProjectFeatureEnabled = true;
|
||||
projectsStore.personalProject = { id: personalProjectId } as Project;
|
||||
projectsStore.myProjects = [
|
||||
{ id: '1', name: '1', type: 'team' },
|
||||
{ id: '2', name: '2', type: 'public' },
|
||||
{ id: '3', name: '3', type: 'team' },
|
||||
] as ProjectListItem[];
|
||||
|
||||
const { menu } = useGlobalEntityCreation();
|
||||
|
||||
expect(menu.value[0].disabled).toBe(true);
|
||||
expect(menu.value[1].disabled).toBe(true);
|
||||
|
||||
expect(menu.value[0].submenu).toBe(undefined);
|
||||
expect(menu.value[1].submenu).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -27,6 +27,8 @@ type Item = BaseItem & {
|
|||
|
||||
export const useGlobalEntityCreation = () => {
|
||||
const CREATE_PROJECT_ID = 'create-project';
|
||||
const WORKFLOWS_MENU_ID = 'workflow';
|
||||
const CREDENTIALS_MENU_ID = 'credential';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const cloudPlanStore = useCloudPlanStore();
|
||||
|
@ -89,66 +91,73 @@ export const useGlobalEntityCreation = () => {
|
|||
// global
|
||||
return [
|
||||
{
|
||||
id: 'workflow',
|
||||
id: WORKFLOWS_MENU_ID,
|
||||
title: 'Workflow',
|
||||
submenu: [
|
||||
{
|
||||
id: 'workflow-title',
|
||||
title: 'Create in',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'workflow-personal',
|
||||
title: i18n.baseText('projects.menu.personal'),
|
||||
icon: 'user',
|
||||
disabled: disabledWorkflow(projectsStore.personalProject?.scopes),
|
||||
route: {
|
||||
name: VIEWS.NEW_WORKFLOW,
|
||||
query: { projectId: projectsStore.personalProject?.id },
|
||||
disabled: sourceControlStore.preferences.branchReadOnly,
|
||||
|
||||
...(!sourceControlStore.preferences.branchReadOnly && {
|
||||
submenu: [
|
||||
{
|
||||
id: 'workflow-title',
|
||||
title: 'Create in',
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
...displayProjects.value.map((project) => ({
|
||||
id: `workflow-${project.id}`,
|
||||
title: project.name as string,
|
||||
icon: 'layer-group',
|
||||
disabled: disabledWorkflow(project.scopes),
|
||||
route: {
|
||||
name: VIEWS.NEW_WORKFLOW,
|
||||
query: { projectId: project.id },
|
||||
{
|
||||
id: 'workflow-personal',
|
||||
title: i18n.baseText('projects.menu.personal'),
|
||||
icon: 'user',
|
||||
disabled: disabledWorkflow(projectsStore.personalProject?.scopes),
|
||||
route: {
|
||||
name: VIEWS.NEW_WORKFLOW,
|
||||
query: { projectId: projectsStore.personalProject?.id },
|
||||
},
|
||||
},
|
||||
})),
|
||||
],
|
||||
...displayProjects.value.map((project) => ({
|
||||
id: `workflow-${project.id}`,
|
||||
title: project.name as string,
|
||||
icon: 'layer-group',
|
||||
disabled: disabledWorkflow(project.scopes),
|
||||
route: {
|
||||
name: VIEWS.NEW_WORKFLOW,
|
||||
query: { projectId: project.id },
|
||||
},
|
||||
})),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'credential',
|
||||
id: CREDENTIALS_MENU_ID,
|
||||
title: 'Credential',
|
||||
submenu: [
|
||||
{
|
||||
id: 'credential-title',
|
||||
title: 'Create in',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'credential-personal',
|
||||
title: i18n.baseText('projects.menu.personal'),
|
||||
icon: 'user',
|
||||
disabled: disabledCredential(projectsStore.personalProject?.scopes),
|
||||
route: {
|
||||
name: VIEWS.PROJECTS_CREDENTIALS,
|
||||
params: { projectId: projectsStore.personalProject?.id, credentialId: 'create' },
|
||||
disabled: sourceControlStore.preferences.branchReadOnly,
|
||||
...(!sourceControlStore.preferences.branchReadOnly && {
|
||||
submenu: [
|
||||
{
|
||||
id: 'credential-title',
|
||||
title: 'Create in',
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
...displayProjects.value.map((project) => ({
|
||||
id: `credential-${project.id}`,
|
||||
title: project.name as string,
|
||||
icon: 'layer-group',
|
||||
disabled: disabledCredential(project.scopes),
|
||||
route: {
|
||||
name: VIEWS.PROJECTS_CREDENTIALS,
|
||||
params: { projectId: project.id, credentialId: 'create' },
|
||||
{
|
||||
id: 'credential-personal',
|
||||
title: i18n.baseText('projects.menu.personal'),
|
||||
icon: 'user',
|
||||
disabled: disabledCredential(projectsStore.personalProject?.scopes),
|
||||
route: {
|
||||
name: VIEWS.PROJECTS_CREDENTIALS,
|
||||
params: { projectId: projectsStore.personalProject?.id, credentialId: 'create' },
|
||||
},
|
||||
},
|
||||
})),
|
||||
],
|
||||
...displayProjects.value.map((project) => ({
|
||||
id: `credential-${project.id}`,
|
||||
title: project.name as string,
|
||||
icon: 'layer-group',
|
||||
disabled: disabledCredential(project.scopes),
|
||||
route: {
|
||||
name: VIEWS.PROJECTS_CREDENTIALS,
|
||||
params: { projectId: project.id, credentialId: 'create' },
|
||||
},
|
||||
})),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: CREATE_PROJECT_ID,
|
||||
|
@ -214,6 +223,8 @@ export const useGlobalEntityCreation = () => {
|
|||
});
|
||||
|
||||
const createProjectAppendSlotName = computed(() => `item.append.${CREATE_PROJECT_ID}`);
|
||||
const createWorkflowsAppendSlotName = computed(() => `item.append.${WORKFLOWS_MENU_ID}`);
|
||||
const createCredentialsAppendSlotName = computed(() => `item.append.${CREDENTIALS_MENU_ID}`);
|
||||
|
||||
const upgradeLabel = computed(() => {
|
||||
if (settingsStore.isCloudDeployment) {
|
||||
|
@ -231,6 +242,8 @@ export const useGlobalEntityCreation = () => {
|
|||
menu,
|
||||
handleSelect,
|
||||
createProjectAppendSlotName,
|
||||
createWorkflowsAppendSlotName,
|
||||
createCredentialsAppendSlotName,
|
||||
projectsLimitReachedMessage,
|
||||
upgradeLabel,
|
||||
createProject,
|
||||
|
|
|
@ -888,6 +888,12 @@
|
|||
"readOnlyEnv.showMessage.executions.title": "Cannot edit execution",
|
||||
"readOnlyEnv.showMessage.workflows.message": "Workflows are read-only in protected instances.",
|
||||
"readOnlyEnv.showMessage.workflows.title": "Cannot edit workflow",
|
||||
"readOnlyEnv.tooltip": "This is a protected instance where modifications are restricted. {link}",
|
||||
"readOnlyEnv.tooltip.link": "More info.",
|
||||
"readOnlyEnv.cantAdd.workflow": "You can't add new workflows to a protected n8n instance",
|
||||
"readOnlyEnv.cantAdd.credential": "You can't add new credentials to a protected n8n instance",
|
||||
"readOnlyEnv.cantAdd.any": "You can't create new workflows or credentials on a protected n8n instance",
|
||||
"readOnlyEnv.cantEditOrRun": "This workflow can't be edited or run manually because it's on a protected instance",
|
||||
"mainSidebar.aboutN8n": "About n8n",
|
||||
"mainSidebar.confirmMessage.workflowDelete.cancelButtonText": "",
|
||||
"mainSidebar.confirmMessage.workflowDelete.confirmButtonText": "Yes, delete",
|
||||
|
|
|
@ -21,6 +21,7 @@ import { useSourceControlStore } from '@/stores/sourceControl.store';
|
|||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import useEnvironmentsStore from '@/stores/environments.ee.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
|
@ -37,6 +38,7 @@ const uiStore = useUIStore();
|
|||
const sourceControlStore = useSourceControlStore();
|
||||
const externalSecretsStore = useExternalSecretsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const usersStore = useUsersStore();
|
||||
|
||||
const documentTitle = useDocumentTitle();
|
||||
const route = useRoute();
|
||||
|
@ -179,7 +181,6 @@ onMounted(() => {
|
|||
:type-props="{ itemSize: 77 }"
|
||||
:loading="loading"
|
||||
:disabled="readOnlyEnv || !projectPermissions.credential.create"
|
||||
@click:add="addCredential"
|
||||
@update:filters="filters = $event"
|
||||
>
|
||||
<template #header>
|
||||
|
@ -221,6 +222,36 @@ onMounted(() => {
|
|||
</N8nSelect>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<n8n-action-box
|
||||
data-test-id="empty-resources-list"
|
||||
emoji="👋"
|
||||
:heading="
|
||||
i18n.baseText(
|
||||
usersStore.currentUser?.firstName
|
||||
? 'credentials.empty.heading'
|
||||
: 'credentials.empty.heading.userNotSetup',
|
||||
{
|
||||
interpolate: { name: usersStore.currentUser?.firstName ?? '' },
|
||||
},
|
||||
)
|
||||
"
|
||||
:description="i18n.baseText('credentials.empty.description')"
|
||||
:button-text="i18n.baseText('credentials.empty.button')"
|
||||
button-type="secondary"
|
||||
:button-disabled="readOnlyEnv || !projectPermissions.credential.create"
|
||||
:button-icon="readOnlyEnv ? 'lock' : undefined"
|
||||
@click:button="addCredential"
|
||||
>
|
||||
<template #disabledButtonTooltip>
|
||||
{{
|
||||
readOnlyEnv
|
||||
? i18n.baseText('readOnlyEnv.cantAdd.credential')
|
||||
: i18n.baseText('credentials.empty.button.disabled.tooltip')
|
||||
}}
|
||||
</template>
|
||||
</n8n-action-box>
|
||||
</template>
|
||||
</ResourcesListLayout>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -99,7 +99,7 @@ import { nodeViewEventBus } from '@/event-bus';
|
|||
import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
||||
import { tryToParseNumber } from '@/utils/typesUtils';
|
||||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
import { createEventBus } from 'n8n-design-system';
|
||||
import { createEventBus, N8nCallout } from 'n8n-design-system';
|
||||
import type { PinDataSource } from '@/composables/usePinnedData';
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
import { useBeforeUnload } from '@/composables/useBeforeUnload';
|
||||
|
@ -1682,6 +1682,16 @@ onBeforeUnmount(() => {
|
|||
@click="onClearExecutionData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<N8nCallout
|
||||
v-if="isReadOnlyEnvironment"
|
||||
theme="warning"
|
||||
icon="lock"
|
||||
:class="$style.readOnlyEnvironmentNotification"
|
||||
>
|
||||
{{ i18n.baseText('readOnlyEnv.cantEditOrRun') }}
|
||||
</N8nCallout>
|
||||
|
||||
<Suspense>
|
||||
<LazyNodeCreation
|
||||
v-if="!isCanvasReadOnly"
|
||||
|
@ -1736,4 +1746,11 @@ onBeforeUnmount(() => {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.readOnlyEnvironmentNotification {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
</style>
|
||||
|
|
Loading…
Reference in a new issue