fix(editor): UX Improvements to RBAC feature set (#9683)

This commit is contained in:
Csaba Tuncsik
2024-07-18 14:17:27 +02:00
committed by GitHub
parent 5b440a7679
commit 028a8a2c75
32 changed files with 337 additions and 112 deletions

View File

@@ -23,6 +23,7 @@ describe('ProjectCardBadge', () => {
id: '1',
},
},
resourceType: 'workflow',
personalProject: {
id: '1',
},
@@ -49,6 +50,7 @@ describe('ProjectCardBadge', () => {
name,
},
},
resourceType: 'workflow',
personalProject: {
id: '2',
},

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import type { ResourceType } from '@/utils/projects.utils';
import { splitName } from '@/utils/projects.utils';
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
import type { Project } from '@/types/projects.types';
@@ -8,45 +9,111 @@ import { ProjectTypes } from '@/types/projects.types';
type Props = {
resource: IWorkflowDb | ICredentialsResponse;
resourceType: ResourceType;
resourceTypeLabel: string;
personalProject: Project | null;
};
const enum ProjectState {
SharedPersonal = 'shared-personal',
SharedOwned = 'shared-owned',
Owned = 'owned',
Personal = 'personal',
Team = 'team',
Unknown = 'unknown',
}
const props = defineProps<Props>();
const locale = useI18n();
const i18n = useI18n();
const badgeText = computed(() => {
const projectState = computed(() => {
if (
(props.resource.homeProject &&
props.personalProject &&
props.resource.homeProject.id === props.personalProject.id) ||
!props.resource.homeProject
) {
return locale.baseText('generic.ownedByMe');
if (props.resource.sharedWithProjects?.length) {
return ProjectState.SharedOwned;
}
return ProjectState.Owned;
} else if (props.resource.homeProject?.type !== ProjectTypes.Team) {
if (props.resource.sharedWithProjects?.length) {
return ProjectState.SharedPersonal;
}
return ProjectState.Personal;
} else if (props.resource.homeProject?.type === ProjectTypes.Team) {
return ProjectState.Team;
}
return ProjectState.Unknown;
});
const badgeText = computed(() => {
if (
projectState.value === ProjectState.Owned ||
projectState.value === ProjectState.SharedOwned
) {
return i18n.baseText('generic.ownedByMe');
} else {
const { firstName, lastName, email } = splitName(props.resource.homeProject?.name ?? '');
return !firstName ? email : `${firstName}${lastName ? ' ' + lastName : ''}`;
return (!firstName ? email : `${firstName}${lastName ? ' ' + lastName : ''}`) ?? '';
}
});
const badgeIcon = computed(() => {
if (
props.resource.sharedWithProjects?.length &&
props.resource.homeProject?.type !== ProjectTypes.Team
) {
return 'user-friends';
} else if (props.resource.homeProject?.type === ProjectTypes.Team) {
return 'archive';
} else {
return '';
switch (projectState.value) {
case ProjectState.SharedPersonal:
case ProjectState.SharedOwned:
return 'user-friends';
case ProjectState.Team:
return 'archive';
default:
return '';
}
});
const badgeTooltip = computed(() => {
switch (projectState.value) {
case ProjectState.SharedOwned:
return i18n.baseText('projects.badge.tooltip.sharedOwned', {
interpolate: {
resourceTypeLabel: props.resourceTypeLabel,
},
});
case ProjectState.SharedPersonal:
return i18n.baseText('projects.badge.tooltip.sharedPersonal', {
interpolate: {
resourceTypeLabel: props.resourceTypeLabel,
name: badgeText.value,
},
});
case ProjectState.Personal:
return i18n.baseText('projects.badge.tooltip.personal', {
interpolate: {
resourceTypeLabel: props.resourceTypeLabel,
name: badgeText.value,
},
});
case ProjectState.Team:
return i18n.baseText('projects.badge.tooltip.team', {
interpolate: {
resourceTypeLabel: props.resourceTypeLabel,
name: badgeText.value,
},
});
default:
return '';
}
});
</script>
<template>
<n8n-badge v-if="badgeText" class="mr-xs" theme="tertiary" bold data-test-id="card-badge">
{{ badgeText }}
<n8n-icon v-if="badgeIcon" :icon="badgeIcon" size="small" class="ml-5xs" />
</n8n-badge>
<N8nTooltip :disabled="!badgeTooltip" placement="top">
<N8nBadge v-if="badgeText" class="mr-xs" theme="tertiary" bold data-test-id="card-badge">
{{ badgeText }}
<N8nIcon v-if="badgeIcon" :icon="badgeIcon" size="small" class="ml-5xs" />
</N8nBadge>
<template #content>
{{ badgeTooltip }}
</template>
</N8nTooltip>
</template>
<style lang="scss" module></style>

View File

@@ -49,6 +49,7 @@ describe('ProjectMoveResourceConfirmModal', () => {
id: '1',
},
projectId: '1',
projectName: 'My Project',
},
};
const { getByRole, getAllByRole } = renderComponent({ props });

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { ref, computed, h } from 'vue';
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
import { useI18n } from '@/composables/useI18n';
import { useUIStore } from '@/stores/ui.store';
@@ -8,13 +8,18 @@ import Modal from '@/components/Modal.vue';
import { N8nCheckbox, N8nText } from 'n8n-design-system';
import { useToast } from '@/composables/useToast';
import { useTelemetry } from '@/composables/useTelemetry';
import { VIEWS } from '@/constants';
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
import { ResourceType } from '@/utils/projects.utils';
const props = defineProps<{
modalName: string;
data: {
resource: IWorkflowDb | ICredentialsResponse;
resourceType: 'workflow' | 'credential';
resourceType: ResourceType;
resourceTypeLabel: string;
projectId: string;
projectName: string;
};
}>();
@@ -28,7 +33,7 @@ const checks = ref([false, false]);
const allChecked = computed(() => checks.value.every(Boolean));
const moveResourceLabel = computed(() =>
props.data.resourceType === 'workflow'
props.data.resourceType === ResourceType.Workflow
? i18n.baseText('projects.move.workflow.confirm.modal.label')
: i18n.baseText('projects.move.credential.confirm.modal.label'),
);
@@ -49,12 +54,30 @@ const confirm = async () => {
[`${props.data.resourceType}_id`]: props.data.resource.id,
project_from_type: projectsStore.currentProject?.type ?? projectsStore.personalProject?.type,
});
toast.showToast({
title: i18n.baseText('projects.move.resource.success.title', {
interpolate: {
resourceTypeLabel: props.data.resourceTypeLabel,
},
}),
message: h(ProjectMoveSuccessToastMessage, {
routeName:
props.data.resourceType === ResourceType.Workflow
? VIEWS.PROJECTS_WORKFLOWS
: VIEWS.PROJECTS_CREDENTIALS,
resource: props.data.resource,
resourceTypeLabel: props.data.resourceTypeLabel,
projectId: props.data.projectId,
projectName: props.data.projectName,
}),
type: 'success',
});
} catch (error) {
toast.showError(
error.message,
i18n.baseText('projects.move.resource.error.title', {
interpolate: {
resourceType: props.data.resourceType,
resourceTypeLabel: props.data.resourceTypeLabel,
resourceName: props.data.resource.name,
},
}),
@@ -74,7 +97,7 @@ const confirm = async () => {
<N8nCheckbox v-model="checks[1]">
<N8nText>
<i18n-t keypath="projects.move.resource.confirm.modal.label">
<template #resourceType>{{ props.data.resourceType }}</template>
<template #resourceTypeLabel>{{ props.data.resourceTypeLabel }}</template>
<template #numberOfUsers>{{
i18n.baseText('projects.move.resource.confirm.modal.numberOfUsers', {
interpolate: {

View File

@@ -34,6 +34,7 @@ describe('ProjectMoveResourceModal', () => {
id: '1',
},
projectId: '1',
projectName: 'My Project',
},
};
renderComponent({ props });

View File

@@ -6,6 +6,7 @@ import { useUIStore } from '@/stores/ui.store';
import { useProjectsStore } from '@/stores/projects.store';
import Modal from '@/components/Modal.vue';
import { PROJECT_MOVE_RESOURCE_CONFIRM_MODAL } from '@/constants';
import type { ResourceType } from '@/utils/projects.utils';
import { splitName } from '@/utils/projects.utils';
import { useTelemetry } from '@/composables/useTelemetry';
@@ -13,7 +14,8 @@ const props = defineProps<{
modalName: string;
data: {
resource: IWorkflowDb | ICredentialsResponse;
resourceType: 'workflow' | 'credential';
resourceType: ResourceType;
resourceTypeLabel: string;
};
}>();
@@ -46,7 +48,9 @@ const next = () => {
data: {
resource: props.data.resource,
resourceType: props.data.resourceType,
resourceTypeLabel: props.data.resourceTypeLabel,
projectId: projectId.value,
projectName: availableProjects.value.find((p) => p.id === projectId.value)?.name ?? '',
},
});
};
@@ -64,7 +68,7 @@ onMounted(() => {
<N8nHeading tag="h2" size="xlarge" class="mb-m">
{{
i18n.baseText('projects.move.resource.modal.title', {
interpolate: { resourceType: props.data.resourceType },
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
})
}}
</N8nHeading>
@@ -74,7 +78,7 @@ onMounted(() => {
><strong>{{ props.data.resource.name }}</strong></template
>
<template #resourceHomeProjectName>{{ processedName }}</template>
<template #resourceType>{{ props.data.resourceType }}</template>
<template #resourceTypeLabel>{{ props.data.resourceTypeLabel }}</template>
</i18n-t>
</N8nText>
</template>

View File

@@ -0,0 +1,32 @@
<script lang="ts" setup>
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
const props = defineProps<{
routeName: string;
resource: IWorkflowDb | ICredentialsResponse;
resourceTypeLabel: string;
projectId: string;
projectName: string;
}>();
</script>
<template>
<i18n-t keypath="projects.move.resource.success.message">
<template #resourceTypeLabel>{{ props.resourceTypeLabel }}</template>
<template #resourceName>{{ props.resource.name }}</template>
<template #targetProjectName>{{ props.projectName }}</template>
<template #link>
<router-link
:to="{
name: props.routeName,
params: { projectId: props.projectId },
}"
>
<p class="pt-s">
<i18n-t keypath="projects.move.resource.success.link">
<template #targetProjectName>{{ props.projectName }}</template>
</i18n-t>
</p>
</router-link>
</template>
</i18n-t>
</template>

View File

@@ -124,6 +124,9 @@ onMounted(async () => {
<N8nMenuItem
v-for="project in displayProjects"
:key="project.id"
:class="{
[$style.collapsed]: props.collapsed,
}"
:item="getProjectMenuItem(project)"
:compact="props.collapsed"
:handle-select="projectClicked"
@@ -194,6 +197,10 @@ onMounted(async () => {
color: var(--color-primary);
cursor: pointer;
}
.collapsed {
text-transform: uppercase;
}
</style>
<style lang="scss" scoped>

View File

@@ -1,126 +0,0 @@
import { within } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import { getDropdownItems } from '@/__tests__/utils';
import { useRouter } from 'vue-router';
import ProjectSettings from '@/components/Projects/ProjectSettings.vue';
import { useProjectsStore } from '@/stores/projects.store';
import { VIEWS } from '@/constants';
import { useUsersStore } from '@/stores/users.store';
import { createProjectListItem } from '@/__tests__/data/projects';
import { useSettingsStore } from '@/stores/settings.store';
import type { IN8nUISettings } from 'n8n-workflow';
vi.mock('vue-router', () => {
const params = {};
const push = vi.fn();
return {
useRoute: () => ({
params,
}),
useRouter: () => ({
push,
}),
RouterLink: vi.fn(),
};
});
const renderComponent = createComponentRenderer(ProjectSettings);
const teamProjects = Array.from({ length: 3 }, () => createProjectListItem('team'));
let router: ReturnType<typeof useRouter>;
let projectsStore: ReturnType<typeof useProjectsStore>;
let usersStore: ReturnType<typeof useUsersStore>;
let settingsStore: ReturnType<typeof useSettingsStore>;
describe('ProjectSettings', () => {
beforeEach(() => {
const pinia = createPinia();
setActivePinia(pinia);
router = useRouter();
projectsStore = useProjectsStore();
usersStore = useUsersStore();
settingsStore = useSettingsStore();
vi.spyOn(usersStore, 'fetchUsers').mockImplementation(async () => await Promise.resolve());
vi.spyOn(projectsStore, 'getAllProjects').mockImplementation(
async () => await Promise.resolve(),
);
vi.spyOn(projectsStore, 'teamProjects', 'get').mockReturnValue(teamProjects);
vi.spyOn(settingsStore, 'settings', 'get').mockReturnValue({
enterprise: {
projects: {
team: {
limit: -1,
},
},
},
} as IN8nUISettings);
projectsStore.setCurrentProject({
id: '123',
type: 'team',
name: 'Test Project',
relations: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scopes: [],
});
});
it('should show confirmation modal before deleting project and delete with transfer', async () => {
const deleteProjectSpy = vi
.spyOn(projectsStore, 'deleteProject')
.mockImplementation(async () => {});
const { getByTestId, getByRole } = renderComponent();
const deleteButton = getByTestId('project-settings-delete-button');
await userEvent.click(deleteButton);
expect(deleteProjectSpy).not.toHaveBeenCalled();
const modal = getByRole('dialog');
expect(modal).toBeVisible();
const confirmButton = getByTestId('project-settings-delete-confirm-button');
expect(confirmButton).toBeDisabled();
await userEvent.click(within(modal).getAllByRole('radio')[0]);
const projectSelect = getByTestId('project-sharing-select');
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
await userEvent.click(projectSelectDropdownItems[0]);
expect(confirmButton).toBeEnabled();
await userEvent.click(confirmButton);
expect(deleteProjectSpy).toHaveBeenCalledWith('123', expect.any(String));
expect(router.push).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
});
it('should show confirmation modal before deleting project and deleting without transfer', async () => {
const deleteProjectSpy = vi
.spyOn(projectsStore, 'deleteProject')
.mockImplementation(async () => {});
const { getByTestId, getByRole } = renderComponent();
const deleteButton = getByTestId('project-settings-delete-button');
await userEvent.click(deleteButton);
expect(deleteProjectSpy).not.toHaveBeenCalled();
const modal = getByRole('dialog');
expect(modal).toBeVisible();
const confirmButton = getByTestId('project-settings-delete-confirm-button');
expect(confirmButton).toBeDisabled();
await userEvent.click(within(modal).getAllByRole('radio')[1]);
const input = within(modal).getByRole('textbox');
await userEvent.type(input, 'delete all ');
expect(confirmButton).toBeDisabled();
await userEvent.type(input, 'data');
expect(confirmButton).toBeEnabled();
await userEvent.click(confirmButton);
expect(deleteProjectSpy).toHaveBeenCalledWith('123', undefined);
expect(router.push).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
});
});

View File

@@ -1,420 +0,0 @@
<script lang="ts" setup>
import { computed, ref, watch, onBeforeMount, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { deepCopy } from 'n8n-workflow';
import { useUsersStore } from '@/stores/users.store';
import type { IUser } from '@/Interface';
import { useI18n } from '@/composables/useI18n';
import { useProjectsStore } from '@/stores/projects.store';
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
import type { Project, ProjectRelation } from '@/types/projects.types';
import { useToast } from '@/composables/useToast';
import { VIEWS } from '@/constants';
import ProjectDeleteDialog from '@/components/Projects/ProjectDeleteDialog.vue';
import ProjectRoleUpgradeDialog from '@/components/Projects/ProjectRoleUpgradeDialog.vue';
import { useRolesStore } from '@/stores/roles.store';
import type { ProjectRole } from '@/types/roles.types';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { useTelemetry } from '@/composables/useTelemetry';
type FormDataDiff = {
name?: string;
role?: ProjectRelation[];
memberAdded?: ProjectRelation[];
memberRemoved?: ProjectRelation[];
};
const usersStore = useUsersStore();
const locale = useI18n();
const projectsStore = useProjectsStore();
const rolesStore = useRolesStore();
const cloudPlanStore = useCloudPlanStore();
const toast = useToast();
const router = useRouter();
const telemetry = useTelemetry();
const dialogVisible = ref(false);
const upgradeDialogVisible = ref(false);
const isDirty = ref(false);
const formData = ref<Pick<Project, 'name' | 'relations'>>({
name: '',
relations: [],
});
const projectRoleTranslations = ref<{ [key: string]: string }>({
'project:editor': locale.baseText('projects.settings.role.editor'),
'project:admin': locale.baseText('projects.settings.role.admin'),
});
const nameInput = ref<HTMLInputElement | null>(null);
const usersList = computed(() =>
usersStore.allUsers.filter((user: IUser) => {
const isAlreadySharedWithUser = (formData.value.relations || []).find(
(r: ProjectRelation) => r.id === user.id,
);
return !isAlreadySharedWithUser;
}),
);
const projects = computed(() =>
projectsStore.teamProjects.filter((project) => project.id !== projectsStore.currentProjectId),
);
const projectRoles = computed(() =>
rolesStore.processedProjectRoles.map((role) => ({
...role,
name: projectRoleTranslations.value[role.role],
})),
);
const firstLicensedRole = computed(() => projectRoles.value.find((role) => role.licensed)?.role);
const onAddMember = (userId: string) => {
isDirty.value = true;
const user = usersStore.usersById[userId];
if (!user) return;
const { id, firstName, lastName, email } = user;
const relation = { id, firstName, lastName, email } as ProjectRelation;
if (firstLicensedRole.value) {
relation.role = firstLicensedRole.value;
}
formData.value.relations.push(relation);
};
const onRoleAction = (user: Partial<IUser>, role: string) => {
isDirty.value = true;
const index = formData.value.relations.findIndex((r: ProjectRelation) => r.id === user.id);
if (role === 'remove') {
formData.value.relations.splice(index, 1);
} else {
formData.value.relations[index].role = role as ProjectRole;
}
};
const onNameInput = () => {
isDirty.value = true;
};
const onCancel = () => {
formData.value.relations = projectsStore.currentProject?.relations
? deepCopy(projectsStore.currentProject.relations)
: [];
formData.value.name = projectsStore.currentProject?.name ?? '';
isDirty.value = false;
};
const makeFormDataDiff = (): FormDataDiff => {
const diff: FormDataDiff = {};
if (!projectsStore.currentProject) {
return diff;
}
if (formData.value.name !== projectsStore.currentProject.name) {
diff.name = formData.value.name ?? '';
}
if (formData.value.relations.length !== projectsStore.currentProject.relations.length) {
diff.memberAdded = formData.value.relations.filter(
(r: ProjectRelation) => !projectsStore.currentProject?.relations.find((cr) => cr.id === r.id),
);
diff.memberRemoved = projectsStore.currentProject.relations.filter(
(cr: ProjectRelation) => !formData.value.relations.find((r) => r.id === cr.id),
);
}
diff.role = formData.value.relations.filter((r: ProjectRelation) => {
const currentRelation = projectsStore.currentProject?.relations.find((cr) => cr.id === r.id);
return currentRelation?.role !== r.role && !diff.memberAdded?.find((ar) => ar.id === r.id);
});
return diff;
};
const sendTelemetry = (diff: FormDataDiff) => {
if (diff.name) {
telemetry.track('User changed project name', {
project_id: projectsStore.currentProject?.id,
name: diff.name,
});
}
if (diff.memberAdded) {
diff.memberAdded.forEach((r) => {
telemetry.track('User added member to project', {
project_id: projectsStore.currentProject?.id,
target_user_id: r.id,
role: r.role,
});
});
}
if (diff.memberRemoved) {
diff.memberRemoved.forEach((r) => {
telemetry.track('User removed member from project', {
project_id: projectsStore.currentProject?.id,
target_user_id: r.id,
});
});
}
if (diff.role) {
diff.role.forEach((r) => {
telemetry.track('User changed member role on project', {
project_id: projectsStore.currentProject?.id,
target_user_id: r.id,
role: r.role,
});
});
}
};
const onSubmit = async () => {
try {
if (isDirty.value && projectsStore.currentProject) {
const diff = makeFormDataDiff();
await projectsStore.updateProject({
id: projectsStore.currentProject.id,
name: formData.value.name,
relations: formData.value.relations.map((r: ProjectRelation) => ({
userId: r.id,
role: r.role,
})),
});
sendTelemetry(diff);
isDirty.value = false;
toast.showMessage({
title: locale.baseText('projects.settings.save.successful.title', {
interpolate: { projectName: formData.value.name ?? '' },
}),
type: 'success',
});
}
} catch (error) {
toast.showError(error, locale.baseText('projects.settings.save.error.title'));
}
};
const onDelete = async () => {
await projectsStore.getAllProjects();
dialogVisible.value = true;
};
const onConfirmDelete = async (transferId?: string) => {
try {
if (projectsStore.currentProject) {
const projectName = projectsStore.currentProject?.name ?? '';
await projectsStore.deleteProject(projectsStore.currentProject.id, transferId);
await router.push({ name: VIEWS.HOMEPAGE });
toast.showMessage({
title: locale.baseText('projects.settings.delete.successful.title', {
interpolate: { projectName },
}),
type: 'success',
});
dialogVisible.value = true;
}
} catch (error) {
toast.showError(error, locale.baseText('projects.settings.delete.error.title'));
}
};
const selectProjectNameIfMatchesDefault = () => {
if (
nameInput.value &&
formData.value.name === locale.baseText('projects.settings.newProjectName')
) {
nameInput.value.focus();
nameInput.value.select();
}
};
watch(
() => projectsStore.currentProject,
async () => {
formData.value.name = projectsStore.currentProject?.name ?? '';
formData.value.relations = projectsStore.currentProject?.relations
? deepCopy(projectsStore.currentProject.relations)
: [];
await nextTick();
selectProjectNameIfMatchesDefault();
},
{ immediate: true },
);
onBeforeMount(async () => {
await usersStore.fetchUsers();
});
</script>
<template>
<div :class="$style.projectSettings">
<div :class="$style.header">
<ProjectTabs />
</div>
<form @submit.prevent="onSubmit">
<fieldset>
<label for="projectName">{{ locale.baseText('projects.settings.name') }}</label>
<N8nInput
id="projectName"
ref="nameInput"
v-model="formData.name"
type="text"
name="name"
data-test-id="project-settings-name-input"
@input="onNameInput"
/>
</fieldset>
<fieldset>
<label for="projectMembers">{{
locale.baseText('projects.settings.projectMembers')
}}</label>
<N8nUserSelect
id="projectMembers"
class="mb-s"
size="large"
:users="usersList"
:current-user-id="usersStore.currentUser?.id"
:placeholder="$locale.baseText('workflows.shareModal.select.placeholder')"
data-test-id="project-members-select"
@update:model-value="onAddMember"
>
<template #prefix>
<N8nIcon icon="search" />
</template>
</N8nUserSelect>
<N8nUsersList
:actions="[]"
:users="formData.relations"
:current-user-id="usersStore.currentUser?.id"
:delete-label="$locale.baseText('workflows.shareModal.list.delete')"
>
<template #actions="{ user }">
<div :class="$style.buttons">
<N8nSelect
class="mr-2xs"
:model-value="user?.role || projectRoles[0].role"
size="small"
@update:model-value="onRoleAction(user, $event)"
>
<N8nOption
v-for="role in projectRoles"
:key="role.role"
:value="role.role"
:label="role.name"
:disabled="!role.licensed"
>
{{ role.name
}}<span
v-if="!role.licensed"
:class="$style.upgrade"
@click="upgradeDialogVisible = true"
>
&nbsp;-&nbsp;{{ locale.baseText('generic.upgrade') }}
</span>
</N8nOption>
</N8nSelect>
<N8nButton
type="tertiary"
native-type="button"
square
icon="trash"
data-test-id="project-user-remove"
@click="onRoleAction(user, 'remove')"
/>
</div>
</template>
</N8nUsersList>
</fieldset>
<fieldset :class="$style.buttons">
<div>
<small v-if="isDirty" class="mr-2xs">{{
locale.baseText('projects.settings.message.unsavedChanges')
}}</small>
<N8nButton
:disabled="!isDirty"
type="secondary"
native-type="button"
class="mr-2xs"
data-test-id="project-settings-cancel-button"
@click.stop.prevent="onCancel"
>{{ locale.baseText('projects.settings.button.cancel') }}</N8nButton
>
</div>
<N8nButton
:disabled="!isDirty"
type="primary"
data-test-id="project-settings-save-button"
>{{ locale.baseText('projects.settings.button.save') }}</N8nButton
>
</fieldset>
<fieldset>
<hr class="mb-2xl" />
<h3 class="mb-xs">{{ locale.baseText('projects.settings.danger.title') }}</h3>
<small>{{ locale.baseText('projects.settings.danger.message') }}</small>
<br />
<N8nButton
type="tertiary"
native-type="button"
class="mt-s"
data-test-id="project-settings-delete-button"
@click.stop.prevent="onDelete"
>{{ locale.baseText('projects.settings.danger.deleteProject') }}</N8nButton
>
</fieldset>
</form>
<ProjectDeleteDialog
v-model="dialogVisible"
:current-project="projectsStore.currentProject"
:projects="projects"
@confirm-delete="onConfirmDelete"
/>
<ProjectRoleUpgradeDialog
v-model="upgradeDialogVisible"
:limit="projectsStore.teamProjectsLimit"
:plan-name="cloudPlanStore.currentPlanData?.displayName"
/>
</div>
</template>
<style lang="scss" module>
.projectSettings {
display: grid;
width: 100%;
justify-items: center;
grid-auto-rows: max-content;
form {
width: 100%;
max-width: 1280px;
padding: 0 var(--spacing-2xl);
fieldset {
padding-bottom: var(--spacing-2xl);
label {
display: block;
margin-bottom: var(--spacing-xs);
font-size: var(--font-size-xl);
}
}
}
}
.header {
width: 100%;
max-width: 1280px;
padding: var(--spacing-l) var(--spacing-2xl) 0;
}
.upgrade {
cursor: pointer;
}
.buttons {
display: flex;
justify-content: flex-end;
align-items: center;
}
</style>