mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-30 19:35:36 +08:00
feat(editor): Add data encryption keys settings page (#29068)
This commit is contained in:
@@ -4372,6 +4372,35 @@
|
||||
"settings.security.personalSpace.sharing.existingCount.value": "{workflowCount} workflows, {credentialCount} credentials",
|
||||
"settings.security.personalSpace.unlicensed_tooltip": "Upgrade to a plan that includes personal space policies to manage this setting. {action}",
|
||||
"settings.security.personalSpace.unlicensed_tooltip.link": "Upgrade now",
|
||||
"settings.encryptionKeys": "Data encryption keys",
|
||||
"settings.encryptionKeys.title": "Data encryption keys",
|
||||
"settings.encryptionKeys.description": "Data encryption keys protect credentials, variables, and other sensitive data at rest. Rotating generates a new active key. Past keys are archived and retained for audit.",
|
||||
"settings.encryptionKeys.description.docsLink": "Learn more in documentation",
|
||||
"settings.encryptionKeys.column.key": "Key",
|
||||
"settings.encryptionKeys.column.type": "Type",
|
||||
"settings.encryptionKeys.column.activated": "Activated",
|
||||
"settings.encryptionKeys.column.archived": "Archived",
|
||||
"settings.encryptionKeys.status.active": "Active",
|
||||
"settings.encryptionKeys.status.inactive": "Inactive",
|
||||
"settings.encryptionKeys.sortBy.label": "Sort by",
|
||||
"settings.encryptionKeys.sortBy.activated": "Activated",
|
||||
"settings.encryptionKeys.sortBy.archived": "Archived",
|
||||
"settings.encryptionKeys.sortBy.type": "Type",
|
||||
"settings.encryptionKeys.filter.title": "Filter",
|
||||
"settings.encryptionKeys.filter.dateRange": "Date range",
|
||||
"settings.encryptionKeys.filter.apply": "Apply",
|
||||
"settings.encryptionKeys.filter.clear": "Clear",
|
||||
"settings.encryptionKeys.rotate.button": "Rotate key",
|
||||
"settings.encryptionKeys.rotate.confirm.title": "Rotate encryption key?",
|
||||
"settings.encryptionKeys.rotate.confirm.body": "A new encryption key will be generated and used to encrypt new data. The previous key will be archived and kept to decrypt existing data.",
|
||||
"settings.encryptionKeys.rotate.confirm.action": "Rotate key",
|
||||
"settings.encryptionKeys.rotate.confirm.cancel": "Cancel",
|
||||
"settings.encryptionKeys.rotate.success": "Encryption key rotated",
|
||||
"settings.encryptionKeys.rotate.error": "Could not rotate encryption key",
|
||||
"settings.encryptionKeys.copyId.success": "Key ID copied to clipboard",
|
||||
"settings.encryptionKeys.empty.title": "No encryption keys yet",
|
||||
"settings.encryptionKeys.empty.description": "Rotate a key to generate your first active encryption key.",
|
||||
"settings.encryptionKeys.loadError": "Could not load encryption keys",
|
||||
"settings.sso": "SSO",
|
||||
"settings.sso.title": "Single Sign-On",
|
||||
"settings.sso.subtitle": "SAML 2.0 Configuration",
|
||||
|
||||
@@ -119,6 +119,16 @@ export function useSettingsItems() {
|
||||
available: canUserAccessRouteByName(VIEWS.SSO_SETTINGS),
|
||||
route: { to: { name: VIEWS.SSO_SETTINGS } },
|
||||
},
|
||||
{
|
||||
id: 'settings-encryption-keys',
|
||||
icon: 'key-round',
|
||||
label: i18n.baseText('settings.encryptionKeys'),
|
||||
position: 'top',
|
||||
available:
|
||||
envFeatureFlagCheck.value('ENCRYPTION_KEY_ROTATION') &&
|
||||
canUserAccessRouteByName(VIEWS.ENCRYPTION_KEYS_SETTINGS),
|
||||
route: { to: { name: VIEWS.ENCRYPTION_KEYS_SETTINGS } },
|
||||
},
|
||||
{
|
||||
id: 'settings-security',
|
||||
icon: 'shield',
|
||||
|
||||
@@ -37,6 +37,7 @@ export const enum VIEWS {
|
||||
USAGE = 'Usage',
|
||||
LOG_STREAMING_SETTINGS = 'LogStreamingSettingsView',
|
||||
SSO_SETTINGS = 'SSoSettings',
|
||||
ENCRYPTION_KEYS_SETTINGS = 'EncryptionKeysSettings',
|
||||
EXTERNAL_SECRETS_SETTINGS = 'ExternalSecretsSettings',
|
||||
SAML_ONBOARDING = 'SamlOnboarding',
|
||||
SOURCE_CONTROL = 'SourceControl',
|
||||
|
||||
@@ -71,6 +71,8 @@ const TemplatesSearchView = async () =>
|
||||
const SettingsUsageAndPlan = async () =>
|
||||
await import('@/features/settings/usage/views/SettingsUsageAndPlan.vue');
|
||||
const SettingsSso = async () => await import('@/features/settings/sso/views/SettingsSso.vue');
|
||||
const SettingsEncryptionKeys = async () =>
|
||||
await import('@/features/settings/encryption-keys/views/SettingsEncryptionKeys.vue');
|
||||
const SignoutView = async () => await import('@/features/core/auth/views/SignoutView.vue');
|
||||
const SamlOnboarding = async () => await import('@/features/settings/sso/views/SamlOnboarding.vue');
|
||||
const SettingsSourceControl = async () =>
|
||||
@@ -926,6 +928,31 @@ export const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'encryption-keys',
|
||||
name: VIEWS.ENCRYPTION_KEYS_SETTINGS,
|
||||
component: SettingsEncryptionKeys,
|
||||
meta: {
|
||||
middleware: ['authenticated', 'rbac', 'custom'],
|
||||
middlewareOptions: {
|
||||
rbac: {
|
||||
scope: 'encryptionKey:manage',
|
||||
},
|
||||
custom: () => {
|
||||
const { check } = useEnvFeatureFlag();
|
||||
return check.value('ENCRYPTION_KEY_ROTATION');
|
||||
},
|
||||
},
|
||||
telemetry: {
|
||||
pageCategory: 'settings',
|
||||
getProperties() {
|
||||
return {
|
||||
feature: 'encryption-keys',
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'log-streaming',
|
||||
name: VIEWS.LOG_STREAMING_SETTINGS,
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import type { CreateEncryptionKeyDto } from '@n8n/api-types';
|
||||
import { makeRestApiRequest, type IRestApiContext } from '@n8n/rest-api-client';
|
||||
|
||||
import type { EncryptionKey } from './encryption-keys.types';
|
||||
|
||||
const ENDPOINT = '/encryption/keys';
|
||||
|
||||
export const getEncryptionKeys = async (context: IRestApiContext): Promise<EncryptionKey[]> => {
|
||||
return await makeRestApiRequest<EncryptionKey[]>(context, 'GET', ENDPOINT, {
|
||||
type: 'data_encryption',
|
||||
});
|
||||
};
|
||||
|
||||
export const rotateEncryptionKey = async (context: IRestApiContext): Promise<EncryptionKey> => {
|
||||
const payload: CreateEncryptionKeyDto = { type: 'data_encryption' };
|
||||
return await makeRestApiRequest<EncryptionKey>(context, 'POST', ENDPOINT, { ...payload });
|
||||
};
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
|
||||
import * as encryptionKeysApi from './encryption-keys.api';
|
||||
import type {
|
||||
EncryptionKey,
|
||||
EncryptionKeyFilters,
|
||||
EncryptionKeySort,
|
||||
} from './encryption-keys.types';
|
||||
|
||||
const DEFAULT_SORT: EncryptionKeySort = { field: 'createdAt', direction: 'desc' };
|
||||
|
||||
const DEFAULT_FILTERS: EncryptionKeyFilters = {
|
||||
activatedFrom: null,
|
||||
activatedTo: null,
|
||||
};
|
||||
|
||||
export const useEncryptionKeysStore = defineStore('encryptionKeys', () => {
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const keys = ref<EncryptionKey[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const isRotating = ref(false);
|
||||
const sort = ref<EncryptionKeySort>({ ...DEFAULT_SORT });
|
||||
const filters = ref<EncryptionKeyFilters>({ ...DEFAULT_FILTERS });
|
||||
|
||||
const compareKeys = (a: EncryptionKey, b: EncryptionKey, field: EncryptionKeySort['field']) => {
|
||||
if (field === 'status') {
|
||||
return a.status.localeCompare(b.status);
|
||||
}
|
||||
|
||||
if (field === 'updatedAt') {
|
||||
const valueA = a.status === 'inactive' ? a.updatedAt : null;
|
||||
const valueB = b.status === 'inactive' ? b.updatedAt : null;
|
||||
if (valueA === null && valueB === null) return 0;
|
||||
if (valueA === null) return 1;
|
||||
if (valueB === null) return -1;
|
||||
return new Date(valueA).getTime() - new Date(valueB).getTime();
|
||||
}
|
||||
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
};
|
||||
|
||||
const matchesFilters = (key: EncryptionKey) => {
|
||||
const { activatedFrom, activatedTo } = filters.value;
|
||||
const activatedAt = new Date(key.createdAt).getTime();
|
||||
|
||||
if (activatedFrom && activatedAt < new Date(activatedFrom).getTime()) return false;
|
||||
if (activatedTo && activatedAt > new Date(activatedTo).getTime()) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const visibleKeys = computed<EncryptionKey[]>(() => {
|
||||
const filtered = keys.value.filter(matchesFilters);
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const diff = compareKeys(a, b, sort.value.field);
|
||||
return sort.value.direction === 'asc' ? diff : -diff;
|
||||
});
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const activeKey = computed(() => keys.value.find((key) => key.status === 'active') ?? null);
|
||||
|
||||
const isEmpty = computed(() => !isLoading.value && keys.value.length === 0);
|
||||
|
||||
const fetchKeys = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
keys.value = await encryptionKeysApi.getEncryptionKeys(rootStore.restApiContext);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const rotateKey = async () => {
|
||||
isRotating.value = true;
|
||||
try {
|
||||
await encryptionKeysApi.rotateEncryptionKey(rootStore.restApiContext);
|
||||
await fetchKeys();
|
||||
} finally {
|
||||
isRotating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setSort = (next: EncryptionKeySort) => {
|
||||
sort.value = { ...next };
|
||||
};
|
||||
|
||||
const setFilters = (next: Partial<EncryptionKeyFilters>) => {
|
||||
filters.value = { ...filters.value, ...next };
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.value = { ...DEFAULT_FILTERS };
|
||||
};
|
||||
|
||||
return {
|
||||
keys,
|
||||
visibleKeys,
|
||||
activeKey,
|
||||
isLoading,
|
||||
isRotating,
|
||||
isEmpty,
|
||||
sort,
|
||||
filters,
|
||||
fetchKeys,
|
||||
rotateKey,
|
||||
setSort,
|
||||
setFilters,
|
||||
resetFilters,
|
||||
};
|
||||
});
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import type { EncryptionKeyResponseDto } from '@n8n/api-types';
|
||||
|
||||
export type EncryptionKeyStatus = 'active' | 'inactive';
|
||||
|
||||
/**
|
||||
* For an inactive key, `updatedAt` is effectively the archive date: when a key
|
||||
* transitions from `active` to `inactive` its `updatedAt` is bumped, and that
|
||||
* transition cannot be undone.
|
||||
*/
|
||||
export type EncryptionKey = Omit<EncryptionKeyResponseDto, 'status'> & {
|
||||
status: EncryptionKeyStatus;
|
||||
};
|
||||
|
||||
export type EncryptionKeySortField = 'createdAt' | 'updatedAt' | 'status';
|
||||
|
||||
export type EncryptionKeySort = {
|
||||
field: EncryptionKeySortField;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export type EncryptionKeyFilters = {
|
||||
activatedFrom: string | null;
|
||||
activatedTo: string | null;
|
||||
};
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
import { renderComponent } from '@/__tests__/render';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
|
||||
import SettingsEncryptionKeys from './SettingsEncryptionKeys.vue';
|
||||
import { useEncryptionKeysStore } from '../encryption-keys.store';
|
||||
import type { EncryptionKey } from '../encryption-keys.types';
|
||||
|
||||
vi.mock('@/app/composables/useClipboard', () => ({
|
||||
useClipboard: () => ({ copy: vi.fn() }),
|
||||
}));
|
||||
|
||||
const showMessage = vi.fn();
|
||||
const showError = vi.fn();
|
||||
vi.mock('@/app/composables/useToast', () => ({
|
||||
useToast: () => ({ showMessage, showError }),
|
||||
}));
|
||||
|
||||
const makeKey = (overrides: Partial<EncryptionKey> = {}): EncryptionKey => ({
|
||||
id: '4d51a27b8f03a6d20863',
|
||||
type: 'data_encryption',
|
||||
algorithm: 'aes-256-gcm',
|
||||
status: 'active',
|
||||
createdAt: '2026-04-21T10:00:00.000Z',
|
||||
updatedAt: '2026-04-21T10:00:00.000Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('SettingsEncryptionKeys', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setActivePinia(createTestingPinia({ stubActions: false }));
|
||||
});
|
||||
|
||||
it('renders a row for each encryption key with a masked id', async () => {
|
||||
const store = mockedStore(useEncryptionKeysStore);
|
||||
store.keys = [
|
||||
makeKey(),
|
||||
makeKey({
|
||||
id: '74f6c1e9b4d8a2f51234',
|
||||
status: 'inactive',
|
||||
createdAt: '2026-03-15T10:00:00.000Z',
|
||||
}),
|
||||
];
|
||||
store.visibleKeys = store.keys;
|
||||
store.fetchKeys.mockResolvedValue(undefined);
|
||||
|
||||
renderComponent(SettingsEncryptionKeys);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/4d51.{0,20}0863/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/74f6.{0,20}1234/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows active and inactive status badges', async () => {
|
||||
const store = mockedStore(useEncryptionKeysStore);
|
||||
store.keys = [
|
||||
makeKey({ status: 'active' }),
|
||||
makeKey({
|
||||
id: '74f6c1e9b4d8a2f51234',
|
||||
status: 'inactive',
|
||||
createdAt: '2026-03-15T10:00:00.000Z',
|
||||
}),
|
||||
];
|
||||
store.visibleKeys = store.keys;
|
||||
store.fetchKeys.mockResolvedValue(undefined);
|
||||
|
||||
renderComponent(SettingsEncryptionKeys);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||
expect(screen.getByText('Inactive')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('rotates the key after confirmation and reports success', async () => {
|
||||
const store = mockedStore(useEncryptionKeysStore);
|
||||
store.keys = [makeKey()];
|
||||
store.visibleKeys = store.keys;
|
||||
store.fetchKeys.mockResolvedValue(undefined);
|
||||
store.rotateKey.mockResolvedValue(undefined);
|
||||
|
||||
renderComponent(SettingsEncryptionKeys);
|
||||
|
||||
const rotateButtons = await screen.findAllByRole('button', { name: /Rotate key/i });
|
||||
await fireEvent.click(rotateButtons[0]);
|
||||
|
||||
const confirmButtons = await screen.findAllByRole('button', { name: /Rotate key/i });
|
||||
const dialogConfirm = confirmButtons[confirmButtons.length - 1];
|
||||
await fireEvent.click(dialogConfirm);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.rotateKey).toHaveBeenCalledTimes(1);
|
||||
expect(showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces rotate errors via showError', async () => {
|
||||
const store = mockedStore(useEncryptionKeysStore);
|
||||
store.keys = [makeKey()];
|
||||
store.visibleKeys = store.keys;
|
||||
store.fetchKeys.mockResolvedValue(undefined);
|
||||
const error = new Error('rotate failed');
|
||||
store.rotateKey.mockRejectedValue(error);
|
||||
|
||||
renderComponent(SettingsEncryptionKeys);
|
||||
|
||||
const rotateButtons = await screen.findAllByRole('button', { name: /Rotate key/i });
|
||||
await fireEvent.click(rotateButtons[0]);
|
||||
|
||||
const confirmButtons = await screen.findAllByRole('button', { name: /Rotate key/i });
|
||||
const dialogConfirm = confirmButtons[confirmButtons.length - 1];
|
||||
await fireEvent.click(dialogConfirm);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showError).toHaveBeenCalledWith(error, expect.any(String));
|
||||
});
|
||||
});
|
||||
});
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import {
|
||||
N8nAlertDialog,
|
||||
N8nBadge,
|
||||
N8nButton,
|
||||
N8nDataTableServer,
|
||||
N8nHeading,
|
||||
N8nIcon,
|
||||
N8nIconButton,
|
||||
N8nLink,
|
||||
N8nOption,
|
||||
N8nPopover,
|
||||
N8nSelect,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import type { TableHeader } from '@n8n/design-system/components/N8nDataTableServer';
|
||||
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
import { useClipboard } from '@/app/composables/useClipboard';
|
||||
|
||||
import { useEncryptionKeysStore } from '../encryption-keys.store';
|
||||
import type { EncryptionKey, EncryptionKeySortField } from '../encryption-keys.types';
|
||||
|
||||
const i18n = useI18n();
|
||||
const documentTitle = useDocumentTitle();
|
||||
const { showMessage, showError } = useToast();
|
||||
const clipboard = useClipboard();
|
||||
const store = useEncryptionKeysStore();
|
||||
|
||||
const DOCS_URL = 'https://docs.n8n.io/hosting/configuration/encryption-keys/';
|
||||
|
||||
const isConfirmRotateOpen = ref(false);
|
||||
|
||||
const sortOptions = computed<Array<{ value: EncryptionKeySortField; label: string }>>(() => [
|
||||
{ value: 'createdAt', label: i18n.baseText('settings.encryptionKeys.sortBy.activated') },
|
||||
{ value: 'updatedAt', label: i18n.baseText('settings.encryptionKeys.sortBy.archived') },
|
||||
{ value: 'status', label: i18n.baseText('settings.encryptionKeys.sortBy.type') },
|
||||
]);
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const formatDate = (value: string | null) => {
|
||||
if (!value) return '—';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return '—';
|
||||
return dateFormatter.format(parsed);
|
||||
};
|
||||
|
||||
const maskId = (id: string) => {
|
||||
if (id.length <= 8) return id;
|
||||
return `${id.slice(0, 4)}••••••••${id.slice(-4)}`;
|
||||
};
|
||||
|
||||
const headers = computed<Array<TableHeader<EncryptionKey>>>(() => [
|
||||
{
|
||||
title: i18n.baseText('settings.encryptionKeys.column.key'),
|
||||
key: 'id',
|
||||
value: (row) => row.id,
|
||||
minWidth: 220,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.encryptionKeys.column.type'),
|
||||
key: 'status',
|
||||
value: (row) => row.status,
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.encryptionKeys.column.activated'),
|
||||
key: 'createdAt',
|
||||
value: (row) => row.createdAt,
|
||||
minWidth: 140,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.encryptionKeys.column.archived'),
|
||||
key: 'updatedAt',
|
||||
value: (row) => (row.status === 'inactive' ? row.updatedAt : ''),
|
||||
minWidth: 140,
|
||||
},
|
||||
]);
|
||||
|
||||
const archiveDate = (key: EncryptionKey): string | null =>
|
||||
key.status === 'inactive' ? key.updatedAt : null;
|
||||
|
||||
const visibleKeys = computed(() => store.visibleKeys);
|
||||
|
||||
const pageState = ref({ page: 1, itemsPerPage: 25 });
|
||||
|
||||
const sortByModel = computed<EncryptionKeySortField>({
|
||||
get: () => store.sort.field,
|
||||
set: (field: EncryptionKeySortField) => {
|
||||
store.setSort({ field, direction: store.sort.direction });
|
||||
},
|
||||
});
|
||||
|
||||
const isFilterOpen = ref(false);
|
||||
|
||||
const openRotateConfirm = () => {
|
||||
isConfirmRotateOpen.value = true;
|
||||
};
|
||||
|
||||
const closeRotateConfirm = () => {
|
||||
if (!store.isRotating) {
|
||||
isConfirmRotateOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirmRotate = async () => {
|
||||
try {
|
||||
await store.rotateKey();
|
||||
isConfirmRotateOpen.value = false;
|
||||
showMessage({
|
||||
type: 'success',
|
||||
title: i18n.baseText('settings.encryptionKeys.rotate.success'),
|
||||
});
|
||||
} catch (error) {
|
||||
showError(error, i18n.baseText('settings.encryptionKeys.rotate.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const copyKeyId = async (id: string) => {
|
||||
await clipboard.copy(id);
|
||||
showMessage({
|
||||
type: 'success',
|
||||
title: i18n.baseText('settings.encryptionKeys.copyId.success'),
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
documentTitle.set(i18n.baseText('settings.encryptionKeys.title'));
|
||||
try {
|
||||
await store.fetchKeys();
|
||||
} catch (error) {
|
||||
showError(error, i18n.baseText('settings.encryptionKeys.loadError'));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.page" data-testid="settings-encryption-keys">
|
||||
<header :class="$style.header">
|
||||
<N8nHeading tag="h1" size="2xlarge" bold>
|
||||
{{ i18n.baseText('settings.encryptionKeys.title') }}
|
||||
</N8nHeading>
|
||||
<N8nText color="text-base">
|
||||
{{ i18n.baseText('settings.encryptionKeys.description') }}
|
||||
<N8nLink :href="DOCS_URL" new-window>
|
||||
{{ i18n.baseText('settings.encryptionKeys.description.docsLink') }}
|
||||
</N8nLink>
|
||||
</N8nText>
|
||||
</header>
|
||||
|
||||
<div :class="$style.controls">
|
||||
<div :class="$style.sortControl">
|
||||
<N8nText tag="label" color="text-light" size="small" :class="$style.sortLabel">
|
||||
{{ i18n.baseText('settings.encryptionKeys.sortBy.label') }}
|
||||
</N8nText>
|
||||
<N8nSelect
|
||||
v-model="sortByModel"
|
||||
data-testid="encryption-keys-sort-select"
|
||||
size="medium"
|
||||
:class="$style.sortSelect"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="option in sortOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</div>
|
||||
|
||||
<N8nPopover v-model:open="isFilterOpen" side="bottom" align="end">
|
||||
<template #trigger>
|
||||
<N8nIconButton
|
||||
icon="funnel"
|
||||
type="tertiary"
|
||||
size="medium"
|
||||
data-testid="encryption-keys-filter-button"
|
||||
:title="i18n.baseText('settings.encryptionKeys.filter.title')"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<div :class="$style.filterPanel">
|
||||
<N8nText bold>
|
||||
{{ i18n.baseText('settings.encryptionKeys.filter.dateRange') }}
|
||||
</N8nText>
|
||||
<div :class="$style.filterDateRange">
|
||||
<input
|
||||
type="date"
|
||||
:value="store.filters.activatedFrom ?? ''"
|
||||
:class="$style.dateInput"
|
||||
@change="
|
||||
store.setFilters({
|
||||
activatedFrom: ($event.target as HTMLInputElement).value || null,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
:value="store.filters.activatedTo ?? ''"
|
||||
:class="$style.dateInput"
|
||||
@change="
|
||||
store.setFilters({
|
||||
activatedTo: ($event.target as HTMLInputElement).value || null,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div :class="$style.filterActions">
|
||||
<N8nButton
|
||||
type="tertiary"
|
||||
size="small"
|
||||
:label="i18n.baseText('settings.encryptionKeys.filter.clear')"
|
||||
@click="store.resetFilters()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</N8nPopover>
|
||||
|
||||
<N8nButton
|
||||
type="primary"
|
||||
size="medium"
|
||||
:loading="store.isRotating"
|
||||
data-testid="encryption-keys-rotate-button"
|
||||
@click="openRotateConfirm"
|
||||
>
|
||||
<N8nIcon
|
||||
icon="refresh-cw"
|
||||
:class="[$style.rotateIcon, { [$style.isRotating]: store.isRotating }]"
|
||||
/>
|
||||
{{ i18n.baseText('settings.encryptionKeys.rotate.button') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
|
||||
<div :class="$style.tableWrapper">
|
||||
<N8nDataTableServer
|
||||
v-model:page="pageState.page"
|
||||
v-model:items-per-page="pageState.itemsPerPage"
|
||||
:headers="headers"
|
||||
:items="visibleKeys"
|
||||
:items-length="visibleKeys.length"
|
||||
:loading="store.isLoading"
|
||||
:page-sizes="[10, 25, 50]"
|
||||
data-testid="encryption-keys-table"
|
||||
>
|
||||
<template #[`item.id`]="{ item }">
|
||||
<div :class="$style.keyCell">
|
||||
<code :class="$style.keyValue">{{ maskId(item.id) }}</code>
|
||||
<N8nIconButton
|
||||
icon="copy"
|
||||
type="tertiary"
|
||||
size="mini"
|
||||
data-testid="encryption-keys-copy-id-button"
|
||||
:title="i18n.baseText('settings.encryptionKeys.copyId.success')"
|
||||
@click="copyKeyId(item.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #[`item.status`]="{ item }">
|
||||
<N8nBadge :theme="item.status === 'active' ? 'success' : 'default'" size="small">
|
||||
<N8nIcon :icon="item.status === 'active' ? 'circle-check' : 'circle'" size="xsmall" />
|
||||
{{
|
||||
i18n.baseText(
|
||||
item.status === 'active'
|
||||
? 'settings.encryptionKeys.status.active'
|
||||
: 'settings.encryptionKeys.status.inactive',
|
||||
)
|
||||
}}
|
||||
</N8nBadge>
|
||||
</template>
|
||||
|
||||
<template #[`item.createdAt`]="{ item }">
|
||||
{{ formatDate(item.createdAt) }}
|
||||
</template>
|
||||
|
||||
<template #[`item.updatedAt`]="{ item }">
|
||||
{{ formatDate(archiveDate(item)) }}
|
||||
</template>
|
||||
</N8nDataTableServer>
|
||||
|
||||
<div v-if="store.isEmpty" :class="$style.emptyState" data-testid="encryption-keys-empty">
|
||||
<N8nHeading tag="h2" size="large">
|
||||
{{ i18n.baseText('settings.encryptionKeys.empty.title') }}
|
||||
</N8nHeading>
|
||||
<N8nText color="text-base">
|
||||
{{ i18n.baseText('settings.encryptionKeys.empty.description') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<N8nAlertDialog
|
||||
:open="isConfirmRotateOpen"
|
||||
:title="i18n.baseText('settings.encryptionKeys.rotate.confirm.title')"
|
||||
:description="i18n.baseText('settings.encryptionKeys.rotate.confirm.body')"
|
||||
:action-label="i18n.baseText('settings.encryptionKeys.rotate.confirm.action')"
|
||||
:cancel-label="i18n.baseText('settings.encryptionKeys.rotate.confirm.cancel')"
|
||||
action-variant="destructive"
|
||||
:loading="store.isRotating"
|
||||
data-testid="encryption-keys-rotate-confirm"
|
||||
@action="onConfirmRotate"
|
||||
@cancel="closeRotateConfirm"
|
||||
@update:open="isConfirmRotateOpen = $event"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--lg);
|
||||
padding-bottom: var(--spacing--2xl);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.sortControl {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.sortLabel {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sortSelect {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.keyCell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.keyValue {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: var(--font-size--sm);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.rotateIcon {
|
||||
margin-right: var(--spacing--3xs);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.isRotating {
|
||||
animation: settings-encryption-keys-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes settings-encryption-keys-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.filterPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.filterDateRange {
|
||||
display: flex;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.dateInput {
|
||||
flex: 1;
|
||||
padding: var(--spacing--3xs) var(--spacing--2xs);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--font-size--sm);
|
||||
color: var(--color--text);
|
||||
}
|
||||
|
||||
.filterActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border-top: var(--border);
|
||||
padding-top: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: var(--spacing--2xl) var(--spacing--md);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user