feat(editor): Show locked state and permission notice on data redaction workflow settings (#30022)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Stephen Wright 2026-05-11 13:02:59 +01:00 committed by GitHub
parent 0d571c05e4
commit 7635131bd3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 244 additions and 53 deletions

View File

@ -3816,6 +3816,10 @@
"workflowSettings.redactionOptions.redact": "Redact",
"workflowSettings.helpTexts.redactProductionData": "Controls whether execution data from production (non-manually triggered) executions is redacted.",
"workflowSettings.helpTexts.redactManualData": "Controls whether execution data from manually triggered executions is redacted.",
"workflowSettings.redactionPermissionNotice": "You don't have permission to change data redaction settings.",
"workflowSettings.redactionPermissionNotice.viewUsers": "View users with access",
"workflowSettings.redactionMembersModal.title": "Users who can edit data redaction settings",
"workflowSettings.redactionMembersModal.description": "These users can change data redaction settings. Contact one of them to request a change.",
"workflowSettings.saveManualExecutions": "Save manual executions",
"workflowSettings.saveManualOptions.defaultSave": "Default - {defaultValue}",
"workflowSettings.saveManualOptions.doNotSave": "Do not save",

View File

@ -0,0 +1,114 @@
<script setup lang="ts">
import { useRolesStore } from '@/app/stores/roles.store';
import { N8nDialog, N8nIconButton, N8nLoading, N8nText, N8nUserInfo } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { ref, watch } from 'vue';
import type { RoleProjectMembersResponse } from '@n8n/api-types';
const props = defineProps<{
open: boolean;
projectId: string;
}>();
const emit = defineEmits<{
'update:open': [value: boolean];
}>();
const rolesStore = useRolesStore();
const i18n = useI18n();
const membersData = ref<RoleProjectMembersResponse>({ members: [] });
const isLoading = ref(false);
watch(
() => props.open,
async (isOpen) => {
if (!isOpen) return;
isLoading.value = true;
try {
membersData.value = await rolesStore.fetchRoleProjectMembers(
'project:admin',
props.projectId,
);
} finally {
isLoading.value = false;
}
},
{ immediate: true },
);
</script>
<template>
<N8nDialog
:open="open"
size="medium"
:show-close-button="false"
@update:open="emit('update:open', $event)"
>
<div :class="$style.header">
<N8nIconButton
icon="chevron-left"
variant="ghost"
size="small"
:aria-label="i18n.baseText('generic.back')"
@click="emit('update:open', false)"
/>
<N8nText tag="h2" size="large" bold>
{{ i18n.baseText('workflowSettings.redactionMembersModal.title') }}
</N8nText>
</div>
<div :class="$style.content">
<N8nText color="text-base" size="small" :class="$style.description">
{{ i18n.baseText('workflowSettings.redactionMembersModal.description') }}
</N8nText>
<N8nLoading v-if="isLoading" :rows="3" />
<div v-else :class="$style.memberList">
<div v-for="member in membersData.members" :key="member.userId" :class="$style.memberRow">
<N8nUserInfo
:first-name="member.firstName"
:last-name="member.lastName"
:email="member.email"
/>
</div>
</div>
</div>
</N8nDialog>
</template>
<style lang="css" module>
.header {
display: flex;
align-items: center;
gap: var(--spacing--xs);
padding-bottom: var(--spacing--xs);
}
.content {
max-height: 400px;
overflow-y: auto;
}
.description {
display: block;
margin-bottom: var(--spacing--sm);
}
.memberList {
display: flex;
flex-direction: column;
}
.memberRow {
display: flex;
align-items: center;
padding: var(--spacing--xs) 0;
border-bottom: var(--border);
}
.memberRow:last-child {
border-bottom: none;
}
</style>

View File

@ -968,11 +968,11 @@ describe('WorkflowSettingsVue', () => {
});
describe('Redaction Policy', () => {
it('should not render redaction policy when env feature flag is missing', async () => {
const { queryByTestId } = createComponent({ pinia });
it('should show locked redaction policy when licensed but user lacks updateRedactionSetting scope', async () => {
const { getByTestId } = createComponent({ pinia });
await flushPromises();
expect(queryByTestId('workflow-settings-redaction-policy')).not.toBeInTheDocument();
expect(getByTestId('workflow-settings-redaction-policy')).toBeInTheDocument();
});
it('should not render redaction policy when redaction module is inactive', async () => {
@ -995,13 +995,20 @@ describe('WorkflowSettingsVue', () => {
expect(queryByTestId('workflow-settings-redaction-policy')).not.toBeInTheDocument();
});
it('should not render redaction policy when user lacks updateRedactionSetting scope', async () => {
it('should disable redaction dropdowns when user lacks updateRedactionSetting scope', async () => {
vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true);
const { queryByTestId } = createComponent({ pinia });
const { getByTestId } = createComponent({ pinia });
await flushPromises();
expect(queryByTestId('workflow-settings-redaction-policy')).not.toBeInTheDocument();
const productionCombobox = within(
getByTestId('workflow-settings-redact-production-select'),
).getByRole('combobox');
const manualCombobox = within(
getByTestId('workflow-settings-redact-manual-select'),
).getByRole('combobox');
expect(productionCombobox).toBeDisabled();
expect(manualCombobox).toBeDisabled();
});
it('should render redaction policy when module is active and user has scope', async () => {

View File

@ -45,6 +45,7 @@ import { useTelemetry } from '@/app/composables/useTelemetry';
import { useDebounce } from '@/app/composables/useDebounce';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp';
import RedactionMembersModal from '@/app/components/RedactionMembersModal.vue';
import { useGlobalLinkActions } from '@/app/composables/useGlobalLinkActions';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.store';
@ -202,12 +203,14 @@ const isDataRedactionLicensed = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.DataRedaction],
);
const isRedactionSettingVisible = computed(
() =>
settingsStore.isModuleActive('redaction') &&
(isDataRedactionLicensed.value ? workflowPermissions.value.updateRedactionSetting : true),
const isRedactionSettingVisible = computed(() => settingsStore.isModuleActive('redaction'));
const isRedactionSettingLocked = computed(
() => isDataRedactionLicensed.value && !workflowPermissions.value.updateRedactionSetting,
);
const redactionMembersModalOpen = ref(false);
function goToDataRedactionUpgrade() {
void pageRedirectionHelper.goToUpgrade('workflow-settings', 'upgrade-data-redaction');
}
@ -1171,10 +1174,19 @@ onBeforeUnmount(() => {
:span="10"
:class="[
$style['setting-name'],
{ [$style['setting-name--disabled']]: !isDataRedactionLicensed },
{
[$style['setting-name--disabled']]:
!isDataRedactionLicensed || isRedactionSettingLocked,
},
]"
>
{{ i18n.baseText('workflowSettings.redactProductionData') }}
<N8nIcon
v-if="isRedactionSettingLocked"
icon="lock"
size="xsmall"
style="opacity: 1"
/>
<N8nBadge
v-if="!isDataRedactionLicensed"
:class="[$style['upgrade-badge'], 'ml-4xs']"
@ -1189,13 +1201,31 @@ onBeforeUnmount(() => {
<N8nIcon icon="circle-help" />
</N8nTooltip>
</ElCol>
<ElCol :span="14" class="ignore-key-press-canvas">
<ElCol
:span="14"
class="ignore-key-press-canvas"
:class="{ [$style['setting-name--disabled']]: isRedactionSettingLocked }"
>
<N8nTooltip :disabled="!isRedactionSettingLocked" :enterable="true" placement="top">
<template #content>
<span
>{{ i18n.baseText('workflowSettings.redactionPermissionNotice') }}
<span
:class="$style['permission-notice-link']"
@click="redactionMembersModalOpen = true"
>{{
i18n.baseText('workflowSettings.redactionPermissionNotice.viewUsers')
}}</span
></span
>
</template>
<N8nSelect
v-model="redactProductionData"
:disabled="
!isDataRedactionLicensed ||
readOnlyEnv ||
!workflowPermissions.updateRedactionSetting ||
isRedactionSettingLocked ||
workflowHasDynamicCredentials
"
:placeholder="i18n.baseText('workflowSettings.selectOption')"
@ -1211,6 +1241,7 @@ onBeforeUnmount(() => {
>
</N8nOption>
</N8nSelect>
</N8nTooltip>
</ElCol>
</ElRow>
<ElRow v-if="workflowHasDynamicCredentials" :class="$style['dynamic-credentials-hint']">
@ -1226,10 +1257,14 @@ onBeforeUnmount(() => {
:span="10"
:class="[
$style['setting-name'],
{ [$style['setting-name--disabled']]: !isDataRedactionLicensed },
{
[$style['setting-name--disabled']]:
!isDataRedactionLicensed || isRedactionSettingLocked,
},
]"
>
{{ i18n.baseText('workflowSettings.redactManualData') }}
<N8nIcon v-if="isRedactionSettingLocked" icon="lock" size="xsmall" />
<N8nBadge
v-if="!isDataRedactionLicensed"
:class="[$style['upgrade-badge'], 'ml-4xs']"
@ -1244,13 +1279,31 @@ onBeforeUnmount(() => {
<N8nIcon icon="circle-help" />
</N8nTooltip>
</ElCol>
<ElCol :span="14" class="ignore-key-press-canvas">
<ElCol
:span="14"
class="ignore-key-press-canvas"
:class="{ [$style['setting-name--disabled']]: isRedactionSettingLocked }"
>
<N8nTooltip :disabled="!isRedactionSettingLocked" :enterable="true" placement="top">
<template #content>
<span
>{{ i18n.baseText('workflowSettings.redactionPermissionNotice') }}
<span
:class="$style['permission-notice-link']"
@click="redactionMembersModalOpen = true"
>{{
i18n.baseText('workflowSettings.redactionPermissionNotice.viewUsers')
}}</span
></span
>
</template>
<N8nSelect
v-model="redactManualData"
:disabled="
!isDataRedactionLicensed ||
readOnlyEnv ||
!workflowPermissions.updateRedactionSetting
!workflowPermissions.updateRedactionSetting ||
isRedactionSettingLocked
"
:placeholder="i18n.baseText('workflowSettings.selectOption')"
filterable
@ -1265,6 +1318,7 @@ onBeforeUnmount(() => {
>
</N8nOption>
</N8nSelect>
</N8nTooltip>
</ElCol>
</ElRow>
</template>
@ -1494,6 +1548,12 @@ onBeforeUnmount(() => {
@click="saveSettings"
/>
</div>
<RedactionMembersModal
v-if="workflowDocumentStore?.homeProject?.id"
:open="redactionMembersModalOpen"
:project-id="workflowDocumentStore?.homeProject?.id"
@update:open="redactionMembersModalOpen = $event"
/>
</template>
</Modal>
</template>
@ -1545,6 +1605,12 @@ onBeforeUnmount(() => {
opacity: 0.5;
}
.permission-notice-link {
color: var(--color-foreground-xlight);
text-decoration: underline;
cursor: pointer;
}
.upgrade-badge {
cursor: pointer;
}