feat(editor): Add data encryption keys settings page (#29068)

This commit is contained in:
Csaba Tuncsik
2026-04-30 08:02:40 +00:00
committed by GitHub
parent 5a56459129
commit 656f9c2d7f
9 changed files with 768 additions and 0 deletions
@@ -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,
@@ -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 });
};
@@ -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,
};
});
@@ -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;
};
@@ -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));
});
});
});
@@ -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>