mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-21 04:37:50 +08:00
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:
co-authored by
Cursor
Ricardo Espinoza
parent
e26c6f39a0
commit
eb4b20b80b
+21
@@ -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: {
|
||||
|
||||
+17
-1
@@ -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;
|
||||
|
||||
+20
-1
@@ -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;
|
||||
+5
-1
@@ -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);
|
||||
}
|
||||
|
||||
+132
-14
@@ -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];
|
||||
|
||||
+75
-65
@@ -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 {
|
||||
|
||||
+54
-6
@@ -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>
|
||||
|
||||
+17
-25
@@ -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);
|
||||
}
|
||||
|
||||
+5
-2
@@ -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 () => {
|
||||
|
||||
+4
-4
@@ -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">
|
||||
|
||||
+38
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+39
-22
@@ -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>
|
||||
|
||||
+3
-2
@@ -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 },
|
||||
});
|
||||
|
||||
+52
@@ -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', () => {
|
||||
|
||||
+143
-138
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user