fix(editor): Consistent protected environment styling and messaging (#12374)

This commit is contained in:
Raúl Gómez Morales 2024-12-27 14:18:30 +01:00 committed by GitHub
parent 983e87a9b0
commit 6891cefa6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 224 additions and 68 deletions

View file

@ -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)"
/>

View file

@ -68,6 +68,7 @@ onMounted(() => {
<div v-if="showReleaseChannelTag" size="small" round :class="$style.releaseChannelTag">
{{ releaseChannel }}
</div>
<slot />
</div>
</template>

View file

@ -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>

View file

@ -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>

View file

@ -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">

View file

@ -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);
});
});

View file

@ -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,

View file

@ -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",

View file

@ -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>

View file

@ -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>