feat(editor): Migrate API keys settings page to new instance settings UI (#34925)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Ricardo Espinoza <ricardo@n8n.io>
This commit is contained in:
Jan
2026-07-28 12:33:30 +00:00
committed by GitHub
co-authored by Cursor Ricardo Espinoza
parent e26c6f39a0
commit eb4b20b80b
27 changed files with 1186 additions and 402 deletions
@@ -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: {
@@ -99,6 +99,7 @@ const getItemClasses = (item: ActionDropdownItem<T>): Record<string, boolean> =>
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<T>): Record<string, boolean> =>
.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<T>): Record<string, boolean> =>
}
}
/* 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;
@@ -1,9 +1,15 @@
<script lang="ts" setup>
import { CollapsibleContent } from 'reka-ui';
/**
* When set, the height slide is paired with an opacity fade and a subtle blur
* that mimics motion blur — the same motion as N8nSettingsRow's expand region.
*/
withDefaults(defineProps<{ blur?: boolean }>(), { blur: false });
</script>
<template>
<CollapsibleContent :class="$style.content">
<CollapsibleContent :class="[$style.content, blur && $style.blurred]">
<slot />
</CollapsibleContent>
</template>
@@ -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;
}
}
</style>
@@ -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: '<n8n-copy-input v-bind="args" />',
});
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',
};
@@ -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<typeof import('@vueuse/core')>();
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');
});
});
@@ -0,0 +1,157 @@
<script lang="ts" setup>
import { useClipboard } from '@vueuse/core';
import { computed, onBeforeUnmount, ref } from 'vue';
import type { InputSize } from '../../types/input';
import N8nButton from '../N8nButton';
import N8nIcon from '../N8nIcon';
import N8nInput from '../N8nInput';
interface CopyInputProps {
/** Full value written to the clipboard. */
value: string;
/**
* Optional display override, e.g. a middle-truncated secret. The copy button
* always copies the full `value`.
*/
displayValue?: string;
size?: InputSize;
/** Accessible label of the copy button in its resting state. */
copyLabel?: string;
/** Accessible label of the copy button while the copied feedback shows. */
copiedLabel?: string;
/** How long the check-mark feedback lingers, in milliseconds. */
feedbackDurationMs?: number;
}
defineOptions({ name: 'N8nCopyInput' });
const props = withDefaults(defineProps<CopyInputProps>(), {
displayValue: undefined,
size: 'large',
copyLabel: 'Copy',
copiedLabel: 'Copied to clipboard',
feedbackDurationMs: 2000,
});
const emit = defineEmits<{
/** Emitted after the value has been written to the clipboard. */
copy: [value: string];
}>();
// legacy: falls back to document.execCommand on insecure origins (plain-http
// self-hosted instances), where navigator.clipboard is unavailable.
const clipboard = useClipboard({ legacy: true });
const showCopiedFeedback = ref(false);
let feedbackTimer: ReturnType<typeof setTimeout> | undefined;
onBeforeUnmount(() => clearTimeout(feedbackTimer));
async function onCopyClick() {
await clipboard.copy(props.value);
emit('copy', props.value);
showCopiedFeedback.value = true;
clearTimeout(feedbackTimer);
feedbackTimer = setTimeout(() => {
showCopiedFeedback.value = false;
}, props.feedbackDurationMs);
}
const buttonSize = computed(() => {
switch (props.size) {
case 'mini':
case 'small':
return 'small';
case 'large':
case 'xlarge':
return 'large';
default:
return 'medium';
}
});
</script>
<template>
<N8nInput :model-value="displayValue ?? value" :size="size" readonly :class="$style.copyInput">
<template #append>
<N8nButton
variant="ghost"
:size="buttonSize"
icon-only
:aria-label="showCopiedFeedback ? copiedLabel : copyLabel"
data-test-id="copy-input-button"
@click="onCopyClick"
>
<template #icon>
<span :class="$style.iconSwap">
<Transition
:enter-active-class="$style.swapEnterActive"
:leave-active-class="$style.swapLeaveActive"
>
<N8nIcon v-if="showCopiedFeedback" key="check" icon="check" :size="buttonSize" />
<N8nIcon v-else key="copy" icon="copy" :size="buttonSize" />
</Transition>
</span>
</template>
</N8nButton>
</template>
</N8nInput>
</template>
<style lang="scss" module>
@use '../../css/mixins/motion';
/*
* One continuous bordered field (the instance-settings copy-field pattern):
* border, radius and background move onto the input CONTAINER (with overflow
* hidden so the append segment is clipped by the outer radius), the wrapper
* drops its own border so it doesn't double up, and the append becomes a
* transparent, full-height button segment separated from the value by a single
* border-left divider. Focus indication is unaffected — the design system
* draws it with outline, not box-shadow.
*/
.copyInput {
gap: 0;
border-radius: var(--input--radius);
background-color: var(--input--color--background);
box-shadow: inset var(--input--border--shadow);
overflow: hidden;
:global(.n8n-input__wrapper) {
box-shadow: none;
background-color: transparent;
}
:global(.n8n-input__wrapper) + span {
background-color: transparent;
border-left: var(--border);
margin: 0;
padding: 0;
}
}
/*
* Copy -> check swap: both icons overlap in the same spot (the leaving one is
* absolutely positioned) and crossfade through the blur-swap motion. The blur
* is tightened for icon-sized glyphs — the surface-level 4px default dissolves
* a glyph this small instead of morphing it.
*/
.iconSwap {
--animation--blur-swap--blur: 2px;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.swapEnterActive {
@include motion.blur-swap-in;
}
.swapLeaveActive {
position: absolute;
@include motion.blur-swap-out;
}
</style>
@@ -0,0 +1,3 @@
import CopyInput from './CopyInput.vue';
export default CopyInput;
@@ -336,7 +336,11 @@ function handlePageSizeChange(newPageSize: number) {
const columnHelper = createColumnHelper<T>();
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;
},
@@ -306,11 +306,12 @@ function onKeydown(event: KeyboardEvent) {
<style lang="scss" module>
@use '../../css/mixins/utils';
@use '../../css/mixins/motion';
// The expand/collapse motion. No DS duration token equals 350ms (snappy=200, base=400) and the
// curve has no token either, so both live here as local constants per the motion spec.
$expand-duration: 350ms;
$expand-easing: cubic-bezier(0.32, 0.72, 0, 1);
// The expand/collapse motion: the shared settings-surface blur motion, whose
// canonical duration/easing live in the motion mixins.
$expand-duration: motion.$blur-motion-duration;
$expand-easing: motion.$blur-motion-easing;
.row {
position: relative;
@@ -38,6 +38,7 @@ export { default as N8nCard } from './N8nCard';
export { default as N8nCircleLoader } from './N8nCircleLoader';
export { default as N8nCollapsiblePanel } from './N8nCollapsiblePanel';
export { default as N8nColorPicker } from './N8nColorPicker';
export { default as N8nCopyInput } from './N8nCopyInput';
export { default as N8nDatatable } from './N8nDatatable';
export { default as N8nEmptyState } from './N8nEmptyState';
export type { EmptyStateCardIcon, EmptyStateIconCards } from './N8nEmptyState';
@@ -294,12 +294,20 @@
--duration--slow: 1000ms;
--duration--slowest: 1500ms;
/* The three easings below are the "cubic" curves from Benjamin De Cock's
* easing set (https://gist.github.com/bendc/ac03faac0bf2aee25b49e5fd260a1fba). */
--easing--ease-out: cubic-bezier(0.215, 0.61, 0.355, 1);
--easing--ease-in: cubic-bezier(0.55, 0.055, 0.675, 0.19);
--easing--ease-in-out: cubic-bezier(0.645, 0.045, 0.355, 1);
/* Single-overshoot spring (~10%): for small positional settles, not color/opacity fades. */
--easing--spring: cubic-bezier(0.34, 1.56, 0.64, 1);
/* Stronger "out" siblings from the same set, for motion that should land
* fast and settle softly (icon swaps, small reveals). */
--easing--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
--easing--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
--easing--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
--shadow-color: var(--color--black-alpha-100);
/* box-shadow: var(--shadow--xs), inset var(--shadow--outline); */
@@ -74,6 +74,63 @@
}
}
// The settings-surface blur motion (originating in N8nSettingsRow's expand
// region): a height/visibility change paired with an opacity fade and a subtle
// blur that reads as motion blur. No global duration token equals 350ms
// (snappy=200, base=400) and the curve has no token either, so the canonical
// values live here for every blurred mixin (and N8nSettingsRow) to share.
$blur-motion-duration: 350ms;
$blur-motion-easing: cubic-bezier(0.32, 0.72, 0, 1);
// Blurred variants of the collapsible slide: height + opacity + blur together.
@mixin collapsible-slide-down-blurred {
animation: collapsibleSlideDownBlurred
var(--animation--collapsible-slide-blurred--duration, #{$blur-motion-duration})
var(--animation--collapsible-slide-blurred--easing, #{$blur-motion-easing});
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
@mixin collapsible-slide-up-blurred {
animation: collapsibleSlideUpBlurred
var(--animation--collapsible-slide-blurred--duration, #{$blur-motion-duration})
var(--animation--collapsible-slide-blurred--easing, #{$blur-motion-easing});
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
// Blur swap - paired enter/leave for swapping an element in place (e.g. a copy
// button's icon becoming a check mark): the outgoing element blurs and fades
// away while the incoming one sharpens into position, reading as one
// continuous morph. Pair with an absolutely-positioned leave element so both
// occupy the same spot during the swap. Override --animation--blur-swap--blur
// for small glyphs (icons dissolve at the surface-level 4px default).
//
// Unlike the collapsible motion above, the swap has no height change to carry
// it, so it leans on a stronger deceleration (ease-out-quint) rather than a
// longer run: the new state lands fast, then settles softly, without dragging.
@mixin blur-swap-in {
animation: blurSwapIn var(--animation--blur-swap--duration, 250ms)
var(--animation--blur-swap--easing, var(--easing--ease-out-quint));
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
@mixin blur-swap-out {
animation: blurSwapOut var(--animation--blur-swap--duration, 250ms)
var(--animation--blur-swap--easing, var(--easing--ease-out-quint));
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
// Fade in - simple opacity fade for entrance animations
@mixin fade-in {
animation: fadeIn var(--animation--fade-in--duration, var(--duration--snappy))
@@ -170,6 +227,57 @@
}
}
/* The filter only exists inside the keyframes: once the animation finishes the
* element returns to its unfiltered base styles, so no stacking context (or
* compositing surface) stays active on settled content. */
@keyframes collapsibleSlideDownBlurred {
from {
height: 0;
opacity: 0;
filter: blur(var(--animation--collapsible-slide-blurred--blur, 4px));
}
to {
height: var(--reka-collapsible-content-height);
opacity: 1;
filter: blur(0);
}
}
@keyframes collapsibleSlideUpBlurred {
from {
height: var(--reka-collapsible-content-height);
opacity: 1;
filter: blur(0);
}
to {
height: 0;
opacity: 0;
filter: blur(var(--animation--collapsible-slide-blurred--blur, 4px));
}
}
@keyframes blurSwapIn {
from {
opacity: 0;
filter: blur(var(--animation--blur-swap--blur, 4px));
}
to {
opacity: 1;
filter: blur(0);
}
}
@keyframes blurSwapOut {
from {
opacity: 1;
filter: blur(0);
}
to {
opacity: 0;
filter: blur(var(--animation--blur-swap--blur, 4px));
}
}
// Pulse glow - expanding glow effect for loading states (used by N8nPulse)
@mixin pulse-glow {
animation: pulseGlow var(--animation--pulse-glow--duration, 6s) infinite
@@ -15,4 +15,6 @@ export interface ActionDropdownItem<T extends string> {
shortcut?: KeyboardShortcut;
customClass?: string;
checked?: boolean;
/** Destructive items (delete, revoke, ...) turn danger-red on hover. */
variant?: 'default' | 'destructive';
}
@@ -3667,6 +3667,8 @@
"settings.api.view.modal.form.label": "Label",
"settings.api.view.modal.form.expiration": "Expiration",
"settings.api.view.modal.form.expirationText": "The API key will expire on {expirationDate}",
"settings.api.view.modal.form.expirationText.never": "The API key will never expire. It will remain active until it is revoked manually.",
"settings.api.view.modal.form.expirationText.expired": "The API key expired on {expirationDate}",
"settings.api.view.modal.form.label.placeholder": "e.g Internal Project",
"settings.api.view.modal.form.expiration.custom": "Custom",
"settings.api.view.modal.form.expiration.days": "{numberOfDays} days",
@@ -2,10 +2,12 @@
import { computed, ref, watch } from 'vue';
import { capitalCase } from 'change-case';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import { useI18n } from '@n8n/i18n';
import type { BaseTextKey } from '@n8n/i18n';
import {
N8nAnimatedCollapsibleContent,
N8nBadge,
N8nCheckbox,
N8nIcon,
@@ -292,132 +294,136 @@ function toggleScope(scope: S, checked: boolean) {
</N8nRadioGroup>
</N8nInputLabel>
<div :class="$style.customSection">
<button
type="button"
:class="$style.treeHeader"
:aria-expanded="treeExpanded"
data-test-id="scopes-tree-toggle"
@click="treeExpanded = !treeExpanded"
>
<N8nIcon :icon="treeExpanded ? 'chevron-down' : 'chevron-right'" size="small" />
<span data-test-id="scopes-count">
{{
baseText('count', {
selected: modelValue.length,
total: availableScopes.length,
})
}}
</span>
</button>
<CollapsibleRoot v-model:open="treeExpanded" :class="$style.customSection">
<CollapsibleTrigger as-child>
<button type="button" :class="$style.treeHeader" data-test-id="scopes-tree-toggle">
<N8nIcon :icon="treeExpanded ? 'chevron-down' : 'chevron-right'" size="small" />
<span data-test-id="scopes-count">
{{
baseText('count', {
selected: modelValue.length,
total: availableScopes.length,
})
}}
</span>
</button>
</CollapsibleTrigger>
<template v-if="treeExpanded">
<N8nInput
v-model="searchTerm"
size="small"
clearable
:placeholder="baseText('search.placeholder')"
:aria-label="baseText('search.placeholder')"
data-test-id="scopes-search"
>
<template #prefix>
<N8nIcon icon="search" />
</template>
</N8nInput>
<N8nAnimatedCollapsibleContent blur>
<div :class="$style.treeBody">
<N8nInput
v-model="searchTerm"
size="small"
clearable
:placeholder="baseText('search.placeholder')"
:aria-label="baseText('search.placeholder')"
data-test-id="scopes-search"
>
<template #prefix>
<N8nIcon icon="search" />
</template>
</N8nInput>
<div :class="$style.groups">
<div v-for="{ group, visibleScopes } in filteredGroups" :key="group.key">
<div :class="$style.groupHeader">
<N8nIconButton
v-if="!isSearching"
:icon="isGroupExpanded(group) ? 'chevron-down' : 'chevron-right'"
variant="ghost"
size="small"
:aria-expanded="isGroupExpanded(group)"
:aria-label="baseText('toggleGroup', { group: getGroupLabel(group) })"
:data-test-id="`scope-group-toggle-${group.key}`"
@click="toggleGroupExpanded(group)"
/>
<N8nCheckbox
:model-value="isGroupChecked(group)"
:indeterminate="isGroupIndeterminate(group)"
:label="getGroupLabel(group)"
:disabled="disabled"
:data-test-id="`scope-group-${group.key}`"
@update:model-value="(checked: boolean) => toggleGroup(group, checked)"
/>
<N8nTooltip
v-if="groupTools(group).length > 0"
placement="right"
:show-after="150"
:content-class="$style['tools-tooltip']"
>
<template #content>
<div
:class="$style['tools-popover']"
:data-test-id="`scope-group-tools-popover-${group.key}`"
>
<div :class="$style['tools-popover-header']">
{{
baseText('tools.enabledOf', {
enabled: groupEnabledTools(group).size,
total: groupTools(group).length,
})
}}
</div>
<div :class="$style.groups">
<div v-for="{ group, visibleScopes } in filteredGroups" :key="group.key">
<div :class="$style.groupHeader">
<N8nIconButton
v-if="!isSearching"
:icon="isGroupExpanded(group) ? 'chevron-down' : 'chevron-right'"
variant="ghost"
size="small"
:aria-expanded="isGroupExpanded(group)"
:aria-label="baseText('toggleGroup', { group: getGroupLabel(group) })"
:data-test-id="`scope-group-toggle-${group.key}`"
@click="toggleGroupExpanded(group)"
/>
<N8nCheckbox
:model-value="isGroupChecked(group)"
:indeterminate="isGroupIndeterminate(group)"
:label="getGroupLabel(group)"
:disabled="disabled"
:data-test-id="`scope-group-${group.key}`"
@update:model-value="(checked: boolean) => toggleGroup(group, checked)"
/>
<N8nTooltip
v-if="groupTools(group).length > 0"
placement="right"
:show-after="150"
:content-class="$style['tools-tooltip']"
>
<template #content>
<div
v-for="tool in groupTools(group)"
:key="tool"
:class="[
$style['tool-row'],
{ [$style['tool-row-disabled']]: !groupEnabledTools(group).has(tool) },
]"
:class="$style['tools-popover']"
:data-test-id="`scope-group-tools-popover-${group.key}`"
>
<N8nIcon
:icon="groupEnabledTools(group).has(tool) ? 'check' : 'circle'"
size="xsmall"
:class="$style['tool-icon']"
<div :class="$style['tools-popover-header']">
{{
baseText('tools.enabledOf', {
enabled: groupEnabledTools(group).size,
total: groupTools(group).length,
})
}}
</div>
<div
v-for="tool in groupTools(group)"
:key="tool"
:class="[
$style['tool-row'],
{ [$style['tool-row-disabled']]: !groupEnabledTools(group).has(tool) },
]"
>
<N8nIcon
:icon="groupEnabledTools(group).has(tool) ? 'check' : 'circle'"
size="xsmall"
:class="$style['tool-icon']"
/>
<span :class="$style['tool-name']">{{ tool }}</span>
</div>
</div>
</template>
<span
:class="$style['tools-tag']"
tabindex="0"
:data-test-id="`scope-group-tools-${group.key}`"
>
<N8nIcon icon="wrench" size="xsmall" />
{{
baseText(
'tools.count',
{ count: groupTools(group).length },
groupTools(group).length,
)
}}
</span>
</N8nTooltip>
</div>
<CollapsibleRoot :open="isGroupExpanded(group)">
<N8nAnimatedCollapsibleContent blur>
<div :class="$style.scopeList">
<div v-for="scope in visibleScopes" :key="scope" :class="$style.scopeRow">
<N8nCheckbox
:model-value="selectedSet.has(scope)"
:label="scope"
:disabled="disabled"
:data-test-id="`scope-checkbox-${scope}`"
@update:model-value="(checked: boolean) => toggleScope(scope, checked)"
/>
<span :class="$style['tool-name']">{{ tool }}</span>
<N8nBadge
:theme="
classifyScope(scope, readActions) === 'read' ? 'default' : 'success'
"
>
{{ getBadgeLabel(scope) }}
</N8nBadge>
</div>
</div>
</template>
<span
:class="$style['tools-tag']"
tabindex="0"
:data-test-id="`scope-group-tools-${group.key}`"
>
<N8nIcon icon="wrench" size="xsmall" />
{{
baseText(
'tools.count',
{ count: groupTools(group).length },
groupTools(group).length,
)
}}
</span>
</N8nTooltip>
</div>
<div v-if="isGroupExpanded(group)" :class="$style.scopeList">
<div v-for="scope in visibleScopes" :key="scope" :class="$style.scopeRow">
<N8nCheckbox
:model-value="selectedSet.has(scope)"
:label="scope"
:disabled="disabled"
:data-test-id="`scope-checkbox-${scope}`"
@update:model-value="(checked: boolean) => toggleScope(scope, checked)"
/>
<N8nBadge
:theme="classifyScope(scope, readActions) === 'read' ? 'default' : 'success'"
>
{{ getBadgeLabel(scope) }}
</N8nBadge>
</div>
</N8nAnimatedCollapsibleContent>
</CollapsibleRoot>
</div>
</div>
</div>
</template>
</div>
</N8nAnimatedCollapsibleContent>
</CollapsibleRoot>
</div>
</template>
@@ -450,10 +456,18 @@ function toggleScope(scope: S, checked: boolean) {
.customSection {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
margin-top: var(--spacing--xs);
}
/* Spacing lives inside the animated wrapper (as padding, not flex gap on the
parent), so the collapse animates all the way to zero height with no jump. */
.treeBody {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
padding-top: var(--spacing--2xs);
}
.treeHeader {
display: flex;
align-items: center;
@@ -38,3 +38,17 @@ export function inferSelectionMode(
): ApiKeyScopeSelectionMode {
return inferSelectionModeGeneric(selectedScopes, availableScopes, READ_SCOPE_ACTIONS);
}
/** Full name when any part is set, otherwise the email. */
export function getApiKeyOwnerDisplayName(owner: {
firstName?: string | null;
lastName?: string | null;
email?: string | null;
}): string {
const name = [owner.firstName, owner.lastName].filter(Boolean).join(' ').trim();
return name || owner.email || '';
}
export function isApiKeyExpired(apiKey: { expiresAt: number | null }): boolean {
return apiKey.expiresAt !== null && apiKey.expiresAt <= Math.floor(Date.now() / 1000);
}
@@ -5,6 +5,8 @@ import { STORES } from '@n8n/stores';
import { mockedStore, retry } from '@/__tests__/utils';
import ApiKeyEditModal from './ApiKeyCreateOrEditModal.vue';
import userEvent from '@testing-library/user-event';
import { fireEvent } from '@testing-library/vue';
import { nextTick } from 'vue';
import { useApiKeysStore } from '../apiKeys.store';
import { useUIStore } from '@/app/stores/ui.store';
@@ -12,6 +14,7 @@ import { useUsersStore } from '@/features/settings/users/users.store';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { DateTime } from 'luxon';
import type { ApiKeyWithRawValue } from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore';
vi.mock('@/app/composables/useTelemetry', () => {
const track = vi.fn();
@@ -20,6 +23,15 @@ vi.mock('@/app/composables/useTelemetry', () => {
};
});
const clipboardCopy = vi.fn();
vi.mock('@vueuse/core', async (importOriginal) => {
const original = await importOriginal<typeof import('@vueuse/core')>();
return {
...original,
useClipboard: () => ({ copy: clipboardCopy }),
};
});
const renderComponent = createComponentRenderer(ApiKeyEditModal, {
pinia: createTestingPinia({
initialState: {
@@ -51,6 +63,7 @@ const testApiKey: ApiKeyWithRawValue = {
};
const apiKeysStore = mockedStore(useApiKeysStore);
const rootStore = mockedStore(useRootStore);
const usersStore = mockedStore(useUsersStore);
const uiStore = mockedStore(useUIStore);
@@ -70,7 +83,7 @@ describe('ApiKeyCreateOrEditModal', () => {
test('should allow creating API key with default expiration (30 days)', async () => {
apiKeysStore.createApiKey.mockResolvedValue(testApiKey);
const { getByText, getByPlaceholderText } = renderComponent({
const { getByText, getByPlaceholderText, getByDisplayValue } = renderComponent({
props: {
mode: 'new',
},
@@ -97,7 +110,8 @@ describe('ApiKeyCreateOrEditModal', () => {
getByText('Make sure to copy your API key now as you will not be able to see this again.'),
).toBeInTheDocument();
expect(getByText('123456')).toBeInTheDocument();
// The key is shown inside a readonly input, so query by display value.
expect(getByDisplayValue('123456')).toBeInTheDocument();
});
test('should allow creating API key with custom expiration', async () => {
@@ -119,11 +133,12 @@ describe('ApiKeyCreateOrEditModal', () => {
},
});
const { getByText, getAllByText, getByPlaceholderText, getByTestId } = renderComponent({
props: {
mode: 'new',
},
});
const { getByText, getAllByText, getByPlaceholderText, getByTestId, getByDisplayValue } =
renderComponent({
props: {
mode: 'new',
},
});
await retry(() => expect(getByText('Create API Key')).toBeInTheDocument());
expect(getByText('Label')).toBeInTheDocument();
@@ -157,7 +172,7 @@ describe('ApiKeyCreateOrEditModal', () => {
await userEvent.click(saveButton);
expect(getByText('***456')).toBeInTheDocument();
expect(getByDisplayValue('***456')).toBeInTheDocument();
expect(getByText('API key created successfully')).toBeInTheDocument();
@@ -171,7 +186,7 @@ describe('ApiKeyCreateOrEditModal', () => {
test('should allow creating API key with no expiration', async () => {
apiKeysStore.createApiKey.mockResolvedValue(testApiKey);
const { getByText, getByPlaceholderText, getByTestId } = renderComponent({
const { getByText, getByPlaceholderText, getByTestId, getByDisplayValue } = renderComponent({
props: {
mode: 'new',
},
@@ -208,13 +223,41 @@ describe('ApiKeyCreateOrEditModal', () => {
getByText('Make sure to copy your API key now as you will not be able to see this again.'),
).toBeInTheDocument();
expect(getByText('123456')).toBeInTheDocument();
expect(getByDisplayValue('123456')).toBeInTheDocument();
});
test('shows the expiration hint for the default option and the "never" copy when Never is selected', async () => {
const { getByText, getByTestId } = renderComponent({
props: {
mode: 'new',
},
});
await retry(() => expect(getByText('Create API Key')).toBeInTheDocument());
// Default is 30 days → the hint spells out the concrete date.
const expectedDate = DateTime.now()
.setZone(rootStore.timezone)
.startOf('day')
.plus({ days: 30 })
.toFormat('ccc, MMM d yyyy');
expect(getByTestId('api-key-expiration-hint')).toHaveTextContent(
`The API key will expire on ${expectedDate}`,
);
await userEvent.click(getByTestId('expiration-select'));
await userEvent.click(getByText('Never'));
// "Never" must explain itself instead of clearing the hint.
expect(getByTestId('api-key-expiration-hint')).toHaveTextContent(
'The API key will never expire. It will remain active until it is revoked manually.',
);
});
test('should allow creating API key with scopes pre-selected', async () => {
apiKeysStore.createApiKey.mockResolvedValue(testApiKey);
const { getByText, getByPlaceholderText, getByTestId } = renderComponent({
const { getByText, getByPlaceholderText, getByTestId, getByDisplayValue } = renderComponent({
props: {
mode: 'new',
},
@@ -245,11 +288,11 @@ describe('ApiKeyCreateOrEditModal', () => {
getByText('Make sure to copy your API key now as you will not be able to see this again.'),
).toBeInTheDocument();
expect(getByText('123456')).toBeInTheDocument();
expect(getByDisplayValue('123456')).toBeInTheDocument();
});
test('shows a rotated key in the same created view, with the rotation title', async () => {
const { getByText } = renderComponent({
const { getByText, getByDisplayValue } = renderComponent({
props: {
mode: 'new',
rotatedApiKey: testApiKey,
@@ -263,7 +306,36 @@ describe('ApiKeyCreateOrEditModal', () => {
expect(
getByText('Make sure to copy your API key now as you will not be able to see this again.'),
).toBeInTheDocument();
expect(getByText('123456')).toBeInTheDocument();
expect(getByDisplayValue('123456')).toBeInTheDocument();
});
test('flips the copy button to a check mark after copying, then back', async () => {
vi.useFakeTimers();
try {
const { getByTestId } = renderComponent({
props: {
mode: 'new',
rotatedApiKey: testApiKey,
},
});
await nextTick();
const copyButton = getByTestId('copy-input-button');
expect(copyButton).toHaveAccessibleName('Copy');
await fireEvent.click(copyButton);
await nextTick();
expect(clipboardCopy).toHaveBeenCalledWith('123456');
expect(copyButton).toHaveAccessibleName('Copied to clipboard');
// The feedback reverts on its own once the timer elapses.
await vi.advanceTimersByTimeAsync(2000);
await nextTick();
expect(copyButton).toHaveAccessibleName('Copy');
} finally {
vi.useRealTimers();
}
});
test('should allow editing API key label', async () => {
@@ -307,6 +379,52 @@ describe('ApiKeyCreateOrEditModal', () => {
});
});
describe('expiration in edit mode', () => {
test('shows the "never" copy read-only for a key without expiry', async () => {
apiKeysStore.apiKeys = [testApiKey]; // expiresAt: 0 → no expiry
const { getByText, getByTestId } = renderComponent({
props: { mode: 'edit', activeId: '123' },
});
await retry(() => expect(getByText('Edit API Key')).toBeInTheDocument());
expect(getByTestId('api-key-expiration-readonly')).toHaveTextContent(
'The API key will never expire. It will remain active until it is revoked manually.',
);
});
test('shows the expiry date read-only for a key that expires in the future', async () => {
const expiresAt = Math.floor(DateTime.now().plus({ days: 10 }).toSeconds());
apiKeysStore.apiKeys = [{ ...testApiKey, expiresAt }];
const { getByText, getByTestId } = renderComponent({
props: { mode: 'edit', activeId: '123' },
});
await retry(() => expect(getByText('Edit API Key')).toBeInTheDocument());
expect(getByTestId('api-key-expiration-readonly')).toHaveTextContent(
`The API key will expire on ${DateTime.fromSeconds(expiresAt).toFormat('ccc, MMM d yyyy')}`,
);
});
test('uses past-tense copy for an already-expired key', async () => {
const expiresAt = Math.floor(DateTime.now().minus({ days: 3 }).toSeconds());
apiKeysStore.apiKeys = [{ ...testApiKey, expiresAt }];
const { getByText, getByTestId } = renderComponent({
props: { mode: 'edit', activeId: '123' },
});
await retry(() => expect(getByText('Edit API Key')).toBeInTheDocument());
expect(getByTestId('api-key-expiration-readonly')).toHaveTextContent(
`The API key expired on ${DateTime.fromSeconds(expiresAt).toFormat('ccc, MMM d yyyy')}`,
);
});
});
describe('read-only mode (key not owned by current user)', () => {
const setupNonOwnerView = () => {
apiKeysStore.apiKeys = [testApiKey];
@@ -3,6 +3,7 @@ import ApiKeyScopes from './ApiKeyScopes.vue';
import RevokeApiKeyConfirmModal from './RevokeApiKeyConfirmModal.vue';
import Modal from '@/app/components/Modal.vue';
import { API_KEY_CREATE_OR_EDIT_MODAL_KEY } from '../apiKeys.constants';
import { isApiKeyExpired } from '../apiKeys.utils';
import { computed, onMounted, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useUIStore } from '@/app/stores/ui.store';
@@ -10,7 +11,6 @@ import { useUsersStore } from '@/features/settings/users/users.store';
import { createEventBus } from '@n8n/utils/event-bus';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useClipboard } from '@n8n/composables/useClipboard';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useApiKeysStore } from '../apiKeys.store';
import { useTelemetry } from '@/app/composables/useTelemetry';
@@ -23,7 +23,7 @@ import type { ApiKeyScope } from '@n8n/permissions';
import { ElDatePicker } from 'element-plus';
import {
N8nButton,
N8nIconButton,
N8nCopyInput,
N8nInput,
N8nInputLabel,
N8nOption,
@@ -39,13 +39,14 @@ const EXPIRATION_OPTIONS = {
NO_EXPIRATION: 0,
};
const API_KEY_DATE_FORMAT = 'ccc, MMM d yyyy';
const i18n = useI18n();
const telemetry = useTelemetry();
const { showError, showMessage } = useToast();
const uiStore = useUIStore();
const rootStore = useRootStore();
const clipboard = useClipboard();
const apiKeysStore = useApiKeysStore();
const { createApiKey, updateApiKey, deleteApiKey, apiKeysById, availableScopes } = apiKeysStore;
const { currentUser } = storeToRefs(useUsersStore());
@@ -90,7 +91,7 @@ const getExpirationOptionLabel = (value: number) => {
};
const expirationDate = ref(
calculateExpirationDate(expirationDaysFromNow.value).toFormat('ccc, MMM d yyyy'),
calculateExpirationDate(expirationDaysFromNow.value).toFormat(API_KEY_DATE_FORMAT),
);
const inputRef = ref<HTMLTextAreaElement | null>(null);
@@ -132,6 +133,36 @@ const isReadOnly = computed(() => {
return apiKey.owner.id !== currentUser.value.id;
});
// Copy for "expires on X" / "expired on X" / "never expires", shared by the
// create-form hint and the read-only edit view so the two can't drift.
const expirationCopy = (expirationDate: string, expired = false) =>
i18n.baseText(
expired
? 'settings.api.view.modal.form.expirationText.expired'
: 'settings.api.view.modal.form.expirationText',
{ interpolate: { expirationDate } },
);
const neverExpiresCopy = () => i18n.baseText('settings.api.view.modal.form.expirationText.never');
// Helper copy under the expiration select. Always present so "Never" explains
// itself (the key stays active until revoked) instead of silently clearing.
const expirationHint = computed(() => {
if (expirationDaysFromNow.value === EXPIRATION_OPTIONS.NO_EXPIRATION) return neverExpiresCopy();
return expirationDate.value ? expirationCopy(expirationDate.value) : '';
});
// Expiration can't be changed after creation, so edit/view modes surface it as
// read-only text using the same copy as the create form.
const editExpirationText = computed(() => {
const apiKey = currentApiKey.value;
if (!apiKey) return '';
if (!apiKey.expiresAt) return neverExpiresCopy();
return expirationCopy(
DateTime.fromSeconds(apiKey.expiresAt).toFormat(API_KEY_DATE_FORMAT),
isApiKeyExpired(apiKey),
);
});
const isCustomDateInThePast = (date: Date) => Date.now() > date.getTime();
onMounted(() => {
@@ -166,7 +197,7 @@ function onScopeSelectionChanged(scopes: ApiKeyScope[]) {
}
const getApiKeyCreationTime = (apiKey: ApiKey): string => {
const time = DateTime.fromMillis(Date.parse(apiKey.createdAt)).toFormat('ccc, MMM d yyyy');
const time = DateTime.fromMillis(Date.parse(apiKey.createdAt)).toFormat(API_KEY_DATE_FORMAT);
return i18n.baseText('settings.api.creationTime', { interpolate: { time } });
};
@@ -227,21 +258,15 @@ const onSave = async () => {
const API_KEY_VISIBLE_CHARS_PER_SIDE = 30;
const isApiKeyTruncated = computed(
() => rawApiKey.value.length > API_KEY_VISIBLE_CHARS_PER_SIDE * 2,
);
const apiKeyStart = computed(() =>
isApiKeyTruncated.value
? rawApiKey.value.slice(0, API_KEY_VISIBLE_CHARS_PER_SIDE)
: rawApiKey.value,
);
const apiKeyEnd = computed(() =>
isApiKeyTruncated.value ? rawApiKey.value.slice(-API_KEY_VISIBLE_CHARS_PER_SIDE) : '',
);
// Middle-truncated display value: the key's start and end stay visible so the
// user can eyeball what they copied, while the input never holds the full key.
const apiKeyDisplay = computed(() => {
const raw = rawApiKey.value;
const visible = API_KEY_VISIBLE_CHARS_PER_SIDE;
return raw.length > visible * 2 ? `${raw.slice(0, visible)}...${raw.slice(-visible)}` : raw;
});
async function copyApiKey() {
if (!rawApiKey.value) return;
await clipboard.copy(rawApiKey.value);
function onApiKeyCopied() {
showMessage({
title: i18n.baseText('settings.api.view.copy.toast'),
type: 'success',
@@ -292,7 +317,7 @@ const onSelect = (value: number) => {
}
if (value !== EXPIRATION_OPTIONS.NO_EXPIRATION) {
expirationDate.value = calculateExpirationDate(value).toFormat('ccc, MMM d yyyy');
expirationDate.value = calculateExpirationDate(value).toFormat(API_KEY_DATE_FORMAT);
showExpirationDateSelector.value = false;
return;
}
@@ -328,22 +353,16 @@ async function handleEnterKey(event: KeyboardEvent) {
<div @keyup.enter="handleEnterKey">
<div v-if="newApiKey" :class="$style.createdView">
<N8nText size="small">{{ i18n.baseText('settings.api.view.copy') }}</N8nText>
<div :class="$style.apiKeyField" data-test-id="copy-input">
<div :class="[$style.apiKeyValue, 'ph-no-capture']">
<span>{{ apiKeyStart }}</span>
<template v-if="isApiKeyTruncated">
<span>...</span>
<span>{{ apiKeyEnd }}</span>
</template>
</div>
<N8nIconButton
icon="copy"
variant="ghost"
size="small"
:aria-label="i18n.baseText('generic.copy')"
@click="copyApiKey"
/>
</div>
<N8nCopyInput
:value="rawApiKey"
:display-value="apiKeyDisplay"
size="large"
:copy-label="i18n.baseText('generic.copy')"
:copied-label="i18n.baseText('generic.copiedToClipboard')"
class="ph-no-capture"
data-test-id="copy-input"
@copy="onApiKeyCopied"
/>
</div>
<div v-else :class="$style.form">
@@ -390,11 +409,6 @@ async function handleEnterKey(event: KeyboardEvent) {
</N8nOption>
</N8nSelect>
</N8nInputLabel>
<N8nText v-if="expirationDate" class="mb-xs">{{
i18n.baseText('settings.api.view.modal.form.expirationText', {
interpolate: { expirationDate },
})
}}</N8nText>
<ElDatePicker
v-if="showExpirationDateSelector"
v-model="customExpirationDate"
@@ -404,7 +418,24 @@ async function handleEnterKey(event: KeyboardEvent) {
value-format="X"
:disabled-date="isCustomDateInThePast"
/>
<N8nText
v-if="expirationHint"
size="small"
color="text-light"
data-test-id="api-key-expiration-hint"
>
{{ expirationHint }}
</N8nText>
</div>
<N8nInputLabel
v-else
:label="i18n.baseText('settings.api.view.modal.form.expiration')"
color="text-dark"
>
<N8nText size="small" color="text-light" data-test-id="api-key-expiration-readonly">
{{ editExpirationText }}
</N8nText>
</N8nInputLabel>
<ApiKeyScopes
v-model="selectedScopes"
:available-scopes="availableScopes"
@@ -480,27 +511,6 @@ async function handleEnterKey(event: KeyboardEvent) {
gap: var(--spacing--2xs);
}
.apiKeyField {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--3xs) var(--spacing--3xs) var(--spacing--3xs) var(--spacing--xs);
background-color: var(--color--background--xlight);
border: var(--border);
border-radius: var(--radius);
}
.apiKeyValue {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-align: center;
font-family: Monaco, Consolas, monospace;
font-size: var(--font-size--xs);
color: var(--color--text);
}
.form {
display: flex;
flex-direction: column;
@@ -509,9 +519,9 @@ async function handleEnterKey(event: KeyboardEvent) {
.expirationSection {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: var(--spacing--xs);
flex-direction: column;
align-items: stretch;
gap: var(--spacing--3xs);
}
.footer {
@@ -1,20 +1,68 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useI18n } from '@n8n/i18n';
import type { ApiKeyOwner } from '@n8n/api-types';
import { N8nUserInfo } from '@n8n/design-system';
import { N8nAvatar, N8nText } from '@n8n/design-system';
defineProps<{
import { getApiKeyOwnerDisplayName } from '../apiKeys.utils';
const props = defineProps<{
owner: ApiKeyOwner;
isCurrentUser?: boolean;
}>();
const i18n = useI18n();
const displayName = computed(() => getApiKeyOwnerDisplayName(props.owner));
</script>
<template>
<div data-test-id="api-key-owner-cell">
<N8nUserInfo
<div :class="$style.cell" data-test-id="api-key-owner-cell">
<N8nAvatar
:first-name="owner.firstName ?? ''"
:last-name="owner.lastName ?? ''"
:email="owner.email"
:is-current-user="isCurrentUser"
size="xsmall"
:class="$style.avatar"
/>
<div :class="$style.info">
<N8nText size="small" color="text-dark" :class="$style.name">
{{ displayName }}
<!-- text-base: subtler than the name but the lightest DS text color
that still passes WCAG AA contrast on the row background. -->
<N8nText v-if="isCurrentUser" size="small" color="text-base">
{{ i18n.baseText('settings.api.owners.you') }}
</N8nText>
</N8nText>
<N8nText size="xsmall" color="text-light" :class="$style.email" data-test-id="user-email">
{{ owner.email }}
</N8nText>
</div>
</div>
</template>
<style lang="scss" module>
.cell {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
min-width: 0;
}
.avatar {
flex-shrink: 0;
}
.info {
display: flex;
flex-direction: column;
min-width: 0;
}
.name,
.email {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -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) => {
/>
<N8nIcon v-else icon="users" :class="$style.triggerIcon" />
<span :class="$style.triggerText">{{ triggerLabel }}</span>
<!-- Same tag component the tabs use for their counts. -->
<N8nTag :text="String(pillCount)" :clickable="false" :class="$style.triggerTag" />
</span>
<span :class="$style.triggerRight">
<span :class="$style.pill">{{ pillCount }}</span>
<N8nIcon icon="chevron-down" :class="$style.chevron" />
</span>
<N8nIcon icon="chevron-down" :class="$style.chevron" />
</button>
</template>
@@ -268,7 +268,7 @@ watch(open, (isOpen, wasOpen) => {
</template>
<style lang="scss" module>
// A subtle coral wash for selected rows / the count pill. Mixed into whatever
// A subtle coral wash for selected rows. Mixed into whatever
// surface sits behind it, so it stays light on the light panel and becomes a
// muted dark coral on the dark panel — unlike --color--primary--tint-3, which
// the design system never re-themes for dark mode. Declared on both .trigger
@@ -285,7 +285,9 @@ watch(open, (isOpen, wasOpen) => {
justify-content: space-between;
gap: var(--spacing--2xs);
width: 100%;
height: 36px;
// Same height token as N8nInput size="medium" resolves to, so the trigger
// lines up exactly with the search input and button beside it.
height: var(--height--md);
padding: 0 var(--spacing--xs);
// Share N8nInput's resting surface, border and radius so the trigger and the
// search box are visually identical at rest; coral only appears on open.
@@ -325,23 +327,13 @@ watch(open, (isOpen, wasOpen) => {
text-overflow: ellipsis;
}
.triggerRight {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
/* Long owner names truncate; the count tag never shrinks away. */
.triggerTag {
flex-shrink: 0;
}
.pill {
font-size: var(--font-size--3xs);
font-weight: var(--font-weight--bold);
color: var(--color--primary);
background-color: var(--owner-filter--accent-fill);
padding: 1px 7px;
border-radius: var(--radius--xlarge, 999px);
}
.chevron {
flex-shrink: 0;
color: var(--color--text--tint-2);
font-size: var(--font-size--sm);
}
@@ -1,4 +1,5 @@
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue';
import type { ApiKeyScope } from '@n8n/permissions';
import { createComponentRenderer } from '@/__tests__/render';
@@ -225,8 +226,10 @@ describe('ApiKeyScopes', () => {
expect(getByTestId('scopes-mode-all')).toBeChecked();
expect(getByTestId('scopes-mode-custom')).not.toBeChecked();
// The programmatic mode flip must also collapse the tree, not just move the radio.
expect(queryByTestId('scopes-search')).not.toBeInTheDocument();
// The programmatic mode flip must also collapse the tree, not just move the
// radio. The collapse cascades through two watchers plus the collapsible's
// unmount, which settles a couple of ticks after the rerender.
await waitFor(() => expect(queryByTestId('scopes-search')).not.toBeInTheDocument());
});
it('toggling a group while searching only affects scopes in that group, not the visible subset', async () => {
@@ -28,11 +28,11 @@ const scopes = computed(() => props.apiKey?.scopes ?? []);
<template>
<N8nDialog
:model-value="open"
:title="title"
width="480px"
:open="open"
:header="title"
size="medium"
data-test-id="api-key-scopes-modal"
@update:model-value="emit('update:open', $event)"
@update:open="emit('update:open', $event)"
>
<div :class="$style.body">
<N8nText v-if="!scopes.length" size="small" color="text-light">
@@ -72,4 +72,42 @@ describe('ApiKeyTable', () => {
expect(emitted('edit')).toEqual([[own]]);
expect(emitted('revoke')).toBeUndefined();
});
it('emits open-scopes, not edit, when the scopes count is clicked', async () => {
const key = makeKey();
const { emitted } = renderComponent(ApiKeyTable, {
props: {
apiKeys: [key],
itemsLength: 1,
currentUserId: 'u1',
},
});
await fireEvent.click(screen.getByTestId('api-key-scopes-cell'));
expect(emitted('open-scopes')).toEqual([[key]]);
// @click.stop on the cell: the row's edit handler must not also fire.
expect(emitted('edit')).toBeUndefined();
});
it('toggles the Owner column when showOwner changes after mount', async () => {
// Tab switches flip showOwner at runtime, so the column set must be reactive
// (regression test for N8nDataTableServer receiving columns as a static array).
const { rerender } = renderComponent(ApiKeyTable, {
props: {
apiKeys: [makeKey()],
itemsLength: 1,
currentUserId: 'u1',
},
});
expect(screen.getByText('Owner')).toBeInTheDocument();
expect(screen.getAllByTestId('api-key-owner-cell')).toHaveLength(1);
await rerender({ showOwner: false });
expect(screen.queryByText('Owner')).toBeNull();
expect(screen.queryAllByTestId('api-key-owner-cell')).toHaveLength(0);
});
});
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { computed } from 'vue';
import { useI18n } from '@n8n/i18n';
import { DateTime } from 'luxon';
import type { ApiKey } from '@n8n/api-types';
@@ -10,14 +10,23 @@ import type { ActionDropdownItem } from '@n8n/design-system';
import ApiKeyLabelCell from './ApiKeyLabelCell.vue';
import ApiKeyOwnerCell from './ApiKeyOwnerCell.vue';
import ApiKeyScopesCell from './ApiKeyScopesCell.vue';
import { isApiKeyExpired } from '../apiKeys.utils';
const props = defineProps<{
apiKeys: ApiKey[];
itemsLength: number;
loading?: boolean;
/** When set, Edit is only offered for keys owned by this user. */
currentUserId?: string;
}>();
const props = withDefaults(
defineProps<{
apiKeys: ApiKey[];
itemsLength: number;
loading?: boolean;
/** When set, Edit is only offered for keys owned by this user. */
currentUserId?: string;
/** Hide the Owner column where ownership is implied (e.g. the "Mine" tab). */
showOwner?: boolean;
}>(),
{
currentUserId: undefined,
showOwner: true,
},
);
const emit = defineEmits<{
edit: [apiKey: ApiKey];
@@ -48,11 +57,6 @@ function isOwn(apiKey: ApiKey): boolean {
return apiKey.owner?.id === props.currentUserId;
}
// Rotation preserves the original expiry, so an already-expired key can't be rotated.
function isExpired(apiKey: ApiKey): boolean {
return apiKey.expiresAt !== null && apiKey.expiresAt <= Math.floor(Date.now() / 1000);
}
function onRowClick(_event: MouseEvent, payload: { item: ApiKey }) {
emit('edit', payload.item);
}
@@ -68,7 +72,8 @@ function getRowActions(apiKey: ApiKey): Array<ActionDropdownItem<ApiKeyAction>>
icon: 'square-pen',
testId: 'api-key-edit-action',
});
if (!isExpired(apiKey)) {
// Rotation preserves the original expiry, so an already-expired key can't be rotated.
if (!isApiKeyExpired(apiKey)) {
actions.push({
id: 'rotate',
label: i18n.baseText('settings.api.actions.rotate'),
@@ -91,6 +96,7 @@ function getRowActions(apiKey: ApiKey): Array<ActionDropdownItem<ApiKeyAction>>
icon: 'trash-2',
testId: 'api-key-revoke-action',
divided: true,
variant: 'destructive',
});
return actions;
}
@@ -105,15 +111,19 @@ const rows = computed(() => props.apiKeys);
// `resize: false` everywhere — these columns are fixed-shape and the resizer
// handle otherwise highlights on every header hover.
const headers = ref<Array<TableHeader<ApiKey>>>([
const headers = computed<Array<TableHeader<ApiKey>>>(() => [
{ title: i18n.baseText('settings.api.columns.name'), key: 'label', width: 280, resize: false },
{
title: i18n.baseText('settings.api.columns.owner'),
key: 'owner',
width: 280,
disableSort: true,
resize: false,
},
...(props.showOwner
? [
{
title: i18n.baseText('settings.api.columns.owner'),
key: 'owner',
width: 240,
disableSort: true,
resize: false,
} satisfies TableHeader<ApiKey>,
]
: []),
{ title: i18n.baseText('settings.api.columns.scopes'), key: 'scopes', resize: false },
// expiresAt lives in the JWT, not a column — can't ORDER BY without a migration.
{
@@ -146,6 +156,7 @@ const headers = ref<Array<TableHeader<ApiKey>>>([
:items-length="itemsLength"
:loading="loading"
:page-sizes="[10, 25, 50]"
:row-props="{ class: $style.clickableRow }"
@update:options="emit('update:options', $event)"
@click:row="onRowClick"
>
@@ -174,6 +185,7 @@ const headers = ref<Array<TableHeader<ApiKey>>>([
:items="getRowActions(item)"
placement="bottom-end"
activator-size="small"
activator-icon="ellipsis-vertical"
data-test-id="api-key-actions-toggle"
@select="(action) => onAction(action, item)"
/>
@@ -188,4 +200,9 @@ const headers = ref<Array<TableHeader<ApiKey>>>([
display: flex;
justify-content: flex-end;
}
/* Rows open the edit/view modal on click; the cursor should say so. */
.clickableRow {
cursor: pointer;
}
</style>
@@ -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 },
});
@@ -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<ApiKey> = {}) => {
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', () => {
@@ -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) {
</script>
<template>
<div :class="$style.container">
<div :class="$style.heading">
<N8nHeading size="2xlarge">
{{ i18n.baseText('settings.api') }}
</N8nHeading>
</div>
<N8nSettingsLayout full-width :class="$style.layout">
<N8nSettingsPageHeader
:title="i18n.baseText('settings.api')"
:show-docs-link="false"
data-test-id="api-keys-header"
>
<template #description>
<N8nText size="medium" color="text-base">
<I18nT keypath="settings.api.view.info" tag="span" scope="global">
<template #apiPlayground>
<a
:class="$style.docLink"
data-test-id="api-playground-link"
:href="apiDocsURL"
target="_blank"
v-text="i18n.baseText('settings.api.view.info.apiPlayground')"
/>
</template>
<template #webhook>
<a
:class="$style.docLink"
data-test-id="webhook-docs-link"
href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/"
target="_blank"
v-text="i18n.baseText('settings.api.view.info.webhook')"
/>
</template>
<template #documentation>
<a
:class="$style.docLink"
data-test-id="api-docs-link"
href="https://docs.n8n.io/api"
target="_blank"
v-text="i18n.baseText('settings.api.view.info.documentation')"
/>
</template>
</I18nT>
</N8nText>
</template>
</N8nSettingsPageHeader>
<p v-if="isPublicApiEnabled && hasAnyKeys" :class="$style.description">
<I18nT keypath="settings.api.view.info" tag="span" scope="global">
<template #apiPlayground>
<a
:class="$style.docLink"
data-test-id="api-playground-link"
:href="apiDocsURL"
target="_blank"
v-text="i18n.baseText('settings.api.view.info.apiPlayground')"
/>
</template>
<template #webhook>
<a
:class="$style.docLink"
data-test-id="webhook-docs-link"
href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/"
target="_blank"
v-text="i18n.baseText('settings.api.view.info.webhook')"
/>
</template>
<template #documentation>
<a
:class="$style.docLink"
data-test-id="api-docs-link"
href="https://docs.n8n.io/api"
target="_blank"
v-text="i18n.baseText('settings.api.view.info.documentation')"
/>
</template>
</I18nT>
</p>
<div v-if="isPublicApiEnabled && hasAnyKeys" :class="$style.toolbar">
<div :class="$style.filters">
<N8nInput
:model-value="searchQuery"
:placeholder="i18n.baseText('settings.api.search.placeholder')"
:class="$style.search"
size="medium"
clearable
data-test-id="api-keys-search"
@update:model-value="onSearchInput"
>
<template #prefix>
<N8nIcon icon="search" />
</template>
</N8nInput>
<div v-if="canManageAllKeys && ownership === 'all'" :class="$style.ownerFilter">
<ApiKeyOwnerFilter
:model-value="selectedOwnerIds"
:users="ownerOptions"
:counts="ownerKeyCounts"
:total-count="totalAllCount"
:current-user-id="usersStore.currentUser?.id"
data-test-id="api-keys-owner-filter"
@update:model-value="onOwnerFilterChange"
/>
<div v-if="isPublicApiEnabled && hasAnyKeys" :class="$style.tableArea">
<div :class="$style.toolbar">
<N8nTabs
v-if="canManageAllKeys"
:model-value="ownership"
:options="tabOptions"
data-test-id="api-keys-tabs"
:class="$style.tabs"
@update:model-value="onTabChange"
/>
<div :class="$style.controls">
<N8nInput
:model-value="searchQuery"
:placeholder="i18n.baseText('settings.api.search.placeholder')"
:class="$style.search"
size="medium"
clearable
data-test-id="api-keys-search"
@update:model-value="onSearchInput"
>
<template #prefix>
<N8nIcon icon="search" />
</template>
</N8nInput>
<div v-if="canManageAllKeys && ownership === 'all'" :class="$style.ownerFilter">
<ApiKeyOwnerFilter
:model-value="selectedOwnerIds"
:users="ownerOptions"
:counts="ownerKeyCounts"
:total-count="totalAllCount"
:current-user-id="usersStore.currentUser?.id"
data-test-id="api-keys-owner-filter"
@update:model-value="onOwnerFilterChange"
/>
</div>
<N8nButton size="medium" @click="onCreateApiKey">
{{ i18n.baseText('settings.api.create.button') }}
</N8nButton>
</div>
</div>
<N8nButton size="medium" @click="onCreateApiKey">
{{ i18n.baseText('settings.api.create.button') }}
</N8nButton>
<ApiKeyTable
v-if="totalCountForOwnership > 0 && apiKeysCount > 0"
v-model:table-options="tableOptions"
:api-keys="apiKeys"
:items-length="apiKeysCount"
:loading="loading"
:current-user-id="usersStore.currentUser?.id"
:show-owner="canManageAllKeys && ownership === 'all'"
:class="$style.table"
@edit="onEdit"
@revoke="onRevokeRequest"
@rotate="onRotateRequest"
@open-scopes="onOpenScopes"
@update:options="onTableUpdate"
/>
<N8nText
v-else-if="labelFilter.trim()"
color="text-light"
:class="$style.noResults"
data-test-id="api-keys-no-results"
>
{{ i18n.baseText('settings.api.search.noResults') }}
</N8nText>
<N8nText
v-else-if="ownership === 'mine'"
color="text-light"
:class="$style.noResults"
data-test-id="api-keys-empty-mine"
>
{{ i18n.baseText('settings.api.empty.mine') }}
</N8nText>
</div>
<N8nTabs
v-if="isPublicApiEnabled && canManageAllKeys && hasAnyKeys"
:model-value="ownership"
:options="tabOptions"
data-test-id="api-keys-tabs"
:class="$style.tabs"
@update:model-value="onTabChange"
/>
<ApiKeyTable
v-if="isPublicApiEnabled && hasAnyKeys && totalCountForOwnership > 0 && apiKeysCount > 0"
v-model:table-options="tableOptions"
:api-keys="apiKeys"
:items-length="apiKeysCount"
:loading="loading"
:current-user-id="usersStore.currentUser?.id"
:class="$style.table"
@edit="onEdit"
@revoke="onRevokeRequest"
@rotate="onRotateRequest"
@open-scopes="onOpenScopes"
@update:options="onTableUpdate"
/>
<N8nText
v-else-if="isPublicApiEnabled && hasAnyKeys && labelFilter.trim()"
color="text-light"
:class="$style.noResults"
data-test-id="api-keys-no-results"
>
{{ i18n.baseText('settings.api.search.noResults') }}
</N8nText>
<N8nText
v-else-if="isPublicApiEnabled && hasAnyKeys && ownership === 'mine'"
color="text-light"
:class="$style.noResults"
data-test-id="api-keys-empty-mine"
>
{{ i18n.baseText('settings.api.empty.mine') }}
</N8nText>
<N8nEmptyState
v-if="!isPublicApiEnabled && cloudPlanStore.userIsTrialing"
data-test-id="public-api-upgrade-cta"
@@ -448,50 +453,59 @@ function onOpenScopes(apiKey: ApiKey) {
@cancel="rotateConfirmApiKey = null"
@update:open="rotateConfirmApiKey = null"
/>
</div>
</N8nSettingsLayout>
</template>
<style lang="scss" module>
.heading {
margin-bottom: var(--spacing--2xs);
}
.description {
font-size: var(--font-size--sm);
color: var(--color--text--tint-1);
line-height: var(--line-height--xl);
margin: 0 0 var(--spacing--lg);
/* Collapse the layout's own top inset; the settings shell already pads the page top. */
.layout {
padding-top: 0;
}
.docLink {
color: var(--color--text);
color: var(--text-color--subtle);
text-decoration: underline;
&::after {
content: '↗';
margin-left: 2px;
text-decoration: none;
display: inline-block;
}
}
.tableArea {
display: flex;
flex-direction: column;
width: 100%;
}
/*
* Tabs sit flush-left against the table with the filter/create controls on the
* right; the underline of the active tab aligns with the bottom of the controls.
*/
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
align-items: flex-end;
gap: var(--spacing--sm);
margin-bottom: var(--spacing--sm);
}
.filters {
display: flex;
align-items: center;
gap: var(--spacing--sm);
.tabs {
flex: 1 1 auto;
min-width: 0;
}
.controls {
display: flex;
align-items: center;
gap: var(--spacing--sm);
flex: 0 0 auto;
margin-left: auto;
}
.search {
max-width: 320px;
flex: 1 1 auto;
width: 260px;
}
.ownerFilter {
@@ -499,13 +513,8 @@ function onOpenScopes(apiKey: ApiKey) {
flex: 0 0 auto;
}
.container {
display: flex;
flex-direction: column;
}
.table {
margin-bottom: var(--spacing--lg);
width: 100%;
}
.noResults {
@@ -513,8 +522,4 @@ function onOpenScopes(apiKey: ApiKey) {
padding: var(--spacing--lg) 0;
text-align: center;
}
.tabs {
margin-bottom: var(--spacing--sm);
}
</style>