diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.test.ts index d8593df1bee..8c6ca033a2a 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.test.ts @@ -106,6 +106,27 @@ describe('components', () => { // This ensures badge-click only emits for disabled items }); + it('should mark destructive items with the destructive class', async () => { + const wrapper = render(N8nActionDropdown, { + props: { + items: [ + { id: 'edit', label: 'Edit' }, + { id: 'delete', label: 'Delete', variant: 'destructive' as const }, + ], + }, + }); + + await userEvent.click(wrapper.container.querySelector('button')!); + + await waitFor(() => { + const deleteItem = document.querySelector('[data-test-id="action-delete"]'); + expect(deleteItem?.className).toContain('destructive'); + + const editItem = document.querySelector('[data-test-id="action-edit"]'); + expect(editItem?.className).not.toContain('destructive'); + }); + }); + it('should render footer content', async () => { const wrapper = render(N8nActionDropdown, { props: { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue b/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue index a89325b3c21..ba1d0afc609 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue @@ -99,6 +99,7 @@ const getItemClasses = (item: ActionDropdownItem): Record => return { [$style.itemContainer]: true, [$style.disabled]: !!item.disabled, + [$style.destructive]: item.variant === 'destructive', [$style.hasCustomStyling]: item.customClass !== undefined, ...(item.customClass !== undefined ? { [item.customClass]: true } : {}), }; @@ -200,7 +201,9 @@ const getItemClasses = (item: ActionDropdownItem): Record => .itemContainer { display: flex; align-items: center; - gap: var(--spacing--sm); + /* Matches the base N8nDropdownMenu item gap so icon-to-label spacing is + * consistent across every menu in the app. */ + gap: var(--spacing--2xs); justify-content: space-between; font-size: var(--font-size--2xs); line-height: 18px; @@ -217,6 +220,19 @@ const getItemClasses = (item: ActionDropdownItem): Record => } } +/* Destructive items (delete, revoke, ...) turn danger-red on hover or keyboard + * highlight: the icon and label both inherit the item color, so one rule + * covers them, and the token adapts to light/dark themes on its own. */ +.destructive { + &:not([data-disabled]) { + &:hover, + &[data-highlighted], + &[aria-selected='true'] { + color: var(--color--danger); + } + } +} + .icon { display: flex; text-align: center; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nAnimatedCollapsibleContent/AnimatedCollapsibleContent.vue b/packages/frontend/@n8n/design-system/src/components/N8nAnimatedCollapsibleContent/AnimatedCollapsibleContent.vue index 91728f89f9d..ca3545ce090 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nAnimatedCollapsibleContent/AnimatedCollapsibleContent.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nAnimatedCollapsibleContent/AnimatedCollapsibleContent.vue @@ -1,9 +1,15 @@ @@ -24,4 +30,17 @@ import { CollapsibleContent } from 'reka-ui'; @include motion.collapsible-slide-up; } } + +/* The blurred mixins carry their own reduced-motion overrides, and because + * they're included here — after .content's rules in the cascade — they win at + * equal specificity in both directions (motion on, motion off). */ +.blurred { + &[data-state='open'] { + @include motion.collapsible-slide-down-blurred; + } + + &[data-state='closed'] { + @include motion.collapsible-slide-up-blurred; + } +} diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCopyInput/CopyInput.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nCopyInput/CopyInput.stories.ts new file mode 100644 index 00000000000..e59098fd48b --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nCopyInput/CopyInput.stories.ts @@ -0,0 +1,51 @@ +import type { StoryFn } from '@storybook/vue3-vite'; + +import N8nCopyInput from './CopyInput.vue'; + +export default { + title: 'Core/CopyInput', + component: N8nCopyInput, + argTypes: { + size: { + control: 'select', + options: ['mini', 'small', 'medium', 'large', 'xlarge'], + }, + }, + parameters: { + docs: { + description: { + component: + 'A readonly input with an attached copy button, rendered as one continuous bordered field. ' + + 'Clicking the button writes the full value to the clipboard and morphs the copy icon into a ' + + 'check mark through the blur-swap motion. Use `displayValue` to show a truncated secret ' + + 'while still copying the full value.', + }, + }, + }, +}; + +const Template: StoryFn = (args, { argTypes }) => ({ + setup: () => ({ args }), + props: Object.keys(argTypes), + components: { + N8nCopyInput, + }, + template: '', +}); + +export const Default = Template.bind({}); +Default.args = { + value: 'n8n_api_3f9d2c1b8a7e6f5d4c3b2a1908f7e6d5c4b3a291', +}; + +export const TruncatedSecret = Template.bind({}); +TruncatedSecret.args = { + value: 'n8n_api_3f9d2c1b8a7e6f5d4c3b2a1908f7e6d5c4b3a291', + displayValue: 'n8n_api_3f9d2c1b8a7e...6d5c4b3a291', +}; + +export const Medium = Template.bind({}); +Medium.args = { + value: 'https://example.n8n.cloud/webhook/abcd-1234', + size: 'medium', +}; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCopyInput/CopyInput.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nCopyInput/CopyInput.test.ts new file mode 100644 index 00000000000..6b238f34fa7 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nCopyInput/CopyInput.test.ts @@ -0,0 +1,79 @@ +import { fireEvent, render } from '@testing-library/vue'; +import { nextTick } from 'vue'; + +import N8nCopyInput from './CopyInput.vue'; + +const clipboardCopy = vi.fn(); + +vi.mock('@vueuse/core', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + useClipboard: () => ({ copy: clipboardCopy }), + }; +}); + +describe('N8nCopyInput', () => { + beforeEach(() => { + clipboardCopy.mockClear(); + }); + + it('renders the value in a readonly input', () => { + const { getByDisplayValue } = render(N8nCopyInput, { + props: { value: 'secret-token' }, + }); + + const input = getByDisplayValue('secret-token'); + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute('readonly'); + }); + + it('shows the display value but copies the full value', async () => { + const { getByDisplayValue, getByTestId, queryByDisplayValue, emitted } = render(N8nCopyInput, { + props: { value: 'secret-token', displayValue: 'secret...oken' }, + }); + + expect(getByDisplayValue('secret...oken')).toBeInTheDocument(); + expect(queryByDisplayValue('secret-token')).not.toBeInTheDocument(); + + await fireEvent.click(getByTestId('copy-input-button')); + + expect(clipboardCopy).toHaveBeenCalledWith('secret-token'); + expect(emitted('copy')).toEqual([['secret-token']]); + }); + + it('flips the copy button to a check mark after copying, then back', async () => { + vi.useFakeTimers(); + try { + const { getByTestId } = render(N8nCopyInput, { + props: { value: 'secret-token', feedbackDurationMs: 1000 }, + }); + + const button = getByTestId('copy-input-button'); + expect(button).toHaveAccessibleName('Copy'); + + await fireEvent.click(button); + await nextTick(); + expect(button).toHaveAccessibleName('Copied to clipboard'); + + await vi.advanceTimersByTimeAsync(1000); + await nextTick(); + expect(button).toHaveAccessibleName('Copy'); + } finally { + vi.useRealTimers(); + } + }); + + it('uses custom button labels', async () => { + const { getByTestId } = render(N8nCopyInput, { + props: { value: 'secret-token', copyLabel: 'Kopieren', copiedLabel: 'Kopiert' }, + }); + + const button = getByTestId('copy-input-button'); + expect(button).toHaveAccessibleName('Kopieren'); + + await fireEvent.click(button); + await nextTick(); + expect(button).toHaveAccessibleName('Kopiert'); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCopyInput/CopyInput.vue b/packages/frontend/@n8n/design-system/src/components/N8nCopyInput/CopyInput.vue new file mode 100644 index 00000000000..2b71102d5c3 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nCopyInput/CopyInput.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCopyInput/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nCopyInput/index.ts new file mode 100644 index 00000000000..c09e42b8c4c --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nCopyInput/index.ts @@ -0,0 +1,3 @@ +import CopyInput from './CopyInput.vue'; + +export default CopyInput; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue b/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue index ce4d34b5357..590d5ff29f9 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue @@ -336,7 +336,11 @@ function handlePageSizeChange(newPageSize: number) { const columnHelper = createColumnHelper(); const table = useVueTable({ data, - columns: columnsDefinition.value, + // A getter keeps the column set reactive, so tables can add/remove columns + // after mount (e.g. contextual columns that depend on the active tab). + get columns() { + return columnsDefinition.value; + }, get rowCount() { return props.itemsLength; }, diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/SettingsRow.vue b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/SettingsRow.vue index 93ca0891f79..afe856e9fad 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/SettingsRow.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nSettingsRow/SettingsRow.vue @@ -306,11 +306,12 @@ function onKeydown(event: KeyboardEvent) { diff --git a/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyOwnerFilter.vue b/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyOwnerFilter.vue index 4fe6f93412f..848255ca6ff 100644 --- a/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyOwnerFilter.vue +++ b/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyOwnerFilter.vue @@ -3,7 +3,9 @@ import { computed, ref, watch } from 'vue'; import { useI18n } from '@n8n/i18n'; import type { IUser } from '@n8n/design-system'; -import { N8nAvatar, N8nCheckbox, N8nIcon, N8nPopover, N8nText } from '@n8n/design-system'; +import { N8nAvatar, N8nCheckbox, N8nIcon, N8nPopover, N8nTag, N8nText } from '@n8n/design-system'; + +import { getApiKeyOwnerDisplayName } from '../apiKeys.utils'; interface ApiKeyOwnerFilterProps { /** Selected owner ids. Empty means "all" (no narrowing). */ @@ -45,10 +47,7 @@ const someSelected = computed( // trigger and summary (and reverts to all when the panel closes). const effectiveAll = computed(() => allSelected.value || props.modelValue.length === 0); -const displayName = (user: IUser) => { - const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim(); - return name || user.email || ''; -}; +const displayName = (user: IUser) => getApiKeyOwnerDisplayName(user); const filteredUsers = computed(() => { const needle = filter.value.trim().toLowerCase(); @@ -78,8 +77,10 @@ const pillCount = computed(() => effectiveAll.value ? props.users.length : props.modelValue.length, ); +// Only a real narrowing shows the person; when the one selected owner is also +// the only owner (i.e. "all"), the trigger keeps the generic all-owners look. const singleSelectedUser = computed(() => - props.modelValue.length === 1 + !effectiveAll.value && props.modelValue.length === 1 ? props.users.find((user) => user.id === props.modelValue[0]) : undefined, ); @@ -157,11 +158,10 @@ watch(open, (isOpen, wasOpen) => { /> {{ triggerLabel }} + + - - {{ pillCount }} - - + @@ -268,7 +268,7 @@ watch(open, (isOpen, wasOpen) => { diff --git a/packages/frontend/editor-ui/src/features/settings/apiKeys/components/RevokeApiKeyConfirmModal.vue b/packages/frontend/editor-ui/src/features/settings/apiKeys/components/RevokeApiKeyConfirmModal.vue index c7c09a150ba..ea383238bd1 100644 --- a/packages/frontend/editor-ui/src/features/settings/apiKeys/components/RevokeApiKeyConfirmModal.vue +++ b/packages/frontend/editor-ui/src/features/settings/apiKeys/components/RevokeApiKeyConfirmModal.vue @@ -4,6 +4,8 @@ import { useI18n } from '@n8n/i18n'; import type { ApiKey } from '@n8n/api-types'; import { N8nAlertDialog } from '@n8n/design-system'; +import { getApiKeyOwnerDisplayName } from '../apiKeys.utils'; + const props = defineProps<{ apiKey: ApiKey | null; open: boolean; @@ -32,8 +34,7 @@ const description = computed(() => { if (!props.apiKey) return ''; if (props.revokingForOther) { const owner = props.apiKey.owner; - const ownerName = - [owner?.firstName, owner?.lastName].filter(Boolean).join(' ') || owner?.email || ''; + const ownerName = owner ? getApiKeyOwnerDisplayName(owner) : ''; return i18n.baseText('settings.api.revoke.description.other', { interpolate: { ownerName }, }); diff --git a/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.test.ts b/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.test.ts index 5abe6071fc0..b8ddd66488e 100644 --- a/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.test.ts @@ -279,6 +279,27 @@ describe('SettingsApiView', () => { expect(screen.getByText(/Revoke "test-key-1" API key/)).toBeInTheDocument(); }); + it('opens the scopes modal when the scopes count is clicked', async () => { + settingsStore.isPublicApiEnabled = true; + cloudStore.userIsTrialing = false; + apiKeysStore.apiKeys = [ + makeKey({ id: '1', label: 'test-key-1', scopes: ['user:create', 'workflow:read'] }), + ]; + apiKeysStore.allCount = 1; + apiKeysStore.mineCount = 1; + apiKeysStore.totalMineCount = 1; + apiKeysStore.totalAllCount = 1; + + renderComponent(SettingsApiView); + + await fireEvent.click(screen.getByTestId('api-key-scopes-cell')); + + // The dialog renders via a portal; its title interpolates the key label. + expect(await screen.findByText('test-key-1 scopes')).toBeInTheDocument(); + expect(screen.getByText('user:create')).toBeInTheDocument(); + expect(screen.getByText('workflow:read')).toBeInTheDocument(); + }); + describe('rotation', () => { const singleOwnedKey = (overrides: Partial = {}) => { settingsStore.isPublicApiEnabled = true; @@ -465,6 +486,37 @@ describe('SettingsApiView', () => { expect(track).not.toHaveBeenCalledWith('User viewed all API keys'); }); + + it('hides the Owner column on the Mine tab', () => { + settingsStore.isPublicApiEnabled = true; + apiKeysStore.apiKeys = [makeKey({ id: '1', label: 'admin-own', owner: ownerFixture })]; + apiKeysStore.mineCount = 1; + apiKeysStore.allCount = 2; + apiKeysStore.totalMineCount = apiKeysStore.mineCount; + apiKeysStore.totalAllCount = apiKeysStore.allCount || 1; + apiKeysStore.ownership = 'mine'; + + renderComponent(SettingsApiView); + + // Ownership is implied on "Mine": no Owner header, no owner cells. + expect(screen.queryByText('Owner')).toBeNull(); + expect(screen.queryAllByTestId('api-key-owner-cell')).toHaveLength(0); + }); + + it('shows the Owner column on the All tab', () => { + settingsStore.isPublicApiEnabled = true; + apiKeysStore.apiKeys = [makeKey({ id: '1', label: 'admin-own', owner: ownerFixture })]; + apiKeysStore.mineCount = 1; + apiKeysStore.allCount = 2; + apiKeysStore.totalMineCount = apiKeysStore.mineCount; + apiKeysStore.totalAllCount = apiKeysStore.allCount || 1; + apiKeysStore.ownership = 'all'; + + renderComponent(SettingsApiView); + + expect(screen.getByText('Owner')).toBeInTheDocument(); + expect(screen.getAllByTestId('api-key-owner-cell')).toHaveLength(1); + }); }); describe('telemetry', () => { diff --git a/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.vue b/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.vue index 405d6277a2f..93313e42de1 100644 --- a/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.vue +++ b/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.vue @@ -24,9 +24,10 @@ import type { IUser } from '@n8n/design-system'; import { N8nEmptyState, N8nButton, - N8nHeading, N8nIcon, N8nInput, + N8nSettingsLayout, + N8nSettingsPageHeader, N8nTabs, N8nText, } from '@n8n/design-system'; @@ -291,119 +292,123 @@ function onOpenScopes(apiKey: ApiKey) {