mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-27 11:05:14 +02:00
Merge 44d1306dcb into 90f1430c5e
This commit is contained in:
commit
de229fb0e5
|
|
@ -61,8 +61,9 @@ export class ApiKeysController {
|
|||
};
|
||||
}
|
||||
|
||||
// `apiKey:manage` callers see every key by default; `ownership=mine` narrows to own.
|
||||
@GlobalScope('apiKey:list')
|
||||
// Every authenticated user may list their own keys. The service only
|
||||
// includes other users' keys for `apiKey:manage` callers; `ownership=mine`
|
||||
// narrows back to own.
|
||||
@Get('/', { middlewares: [isApiEnabledMiddleware] })
|
||||
async getApiKeys(req: AuthenticatedRequest, _res: Response, @Query query: ListApiKeysQueryDto) {
|
||||
return await this.publicApiKeyService.getRedactedApiKeys(req.user, {
|
||||
|
|
@ -75,8 +76,8 @@ export class ApiKeysController {
|
|||
});
|
||||
}
|
||||
|
||||
// Members can delete their own keys; `apiKey:manage` holders can revoke anyone's.
|
||||
@GlobalScope('apiKey:delete')
|
||||
// No role scope required: own keys are always revocable. The service
|
||||
// restricts deleting other users' keys to `apiKey:manage` holders.
|
||||
@Delete('/:id', { middlewares: [isApiEnabledMiddleware] })
|
||||
async deleteApiKey(req: AuthenticatedRequest, _res: Response, @Param('id') apiKeyId: string) {
|
||||
const { isOwn } = await this.publicApiKeyService.deleteApiKey(req.user, apiKeyId);
|
||||
|
|
@ -124,7 +125,8 @@ export class ApiKeysController {
|
|||
};
|
||||
}
|
||||
|
||||
@GlobalScope('apiKey:list')
|
||||
// No role scope required: returns the scopes the caller's role can assign
|
||||
// to a key — empty-ish for roles without apiKey grants.
|
||||
@Get('/scopes', { middlewares: [isApiEnabledMiddleware] })
|
||||
async getApiKeyScopes(req: AuthenticatedRequest, _res: Response) {
|
||||
const scopes = getApiKeyScopesForRole(req.user);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
createUser,
|
||||
createUserShell,
|
||||
} from './shared/db/users';
|
||||
import { createCustomRoleWithScopeSlugs } from './shared/db/roles';
|
||||
import type { SuperAgentTest } from './shared/types';
|
||||
import * as utils from './shared/utils/';
|
||||
|
||||
|
|
@ -843,3 +844,89 @@ describe('Cross-user behavior (admin scope)', () => {
|
|||
expect(response.body.data.totals.all).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User with a role that grants no apiKey scopes', () => {
|
||||
let restrictedUser: User;
|
||||
let authAgent: SuperAgentTest;
|
||||
|
||||
beforeEach(async () => {
|
||||
const restrictedRole = await createCustomRoleWithScopeSlugs(['role:manageProject'], {
|
||||
roleType: 'global',
|
||||
});
|
||||
restrictedUser = await createUser({
|
||||
password: randomValidPassword(),
|
||||
role: restrictedRole,
|
||||
});
|
||||
authAgent = testServer.authAgentFor(restrictedUser);
|
||||
});
|
||||
|
||||
test('GET /api-keys should return own keys', async () => {
|
||||
const ownKey = await addApiKey(restrictedUser, { scopes: ['workflow:create'] });
|
||||
|
||||
const response = await authAgent.get('/api-keys').expect(200);
|
||||
|
||||
expect(response.body.data.counts.all).toBe(1);
|
||||
expect(response.body.data.items).toHaveLength(1);
|
||||
expect(response.body.data.items[0].id).toBe(ownKey.id);
|
||||
});
|
||||
|
||||
test("GET /api-keys should never include other users' keys", async () => {
|
||||
await createOwnerWithApiKey();
|
||||
await addApiKey(restrictedUser, { scopes: ['workflow:create'] });
|
||||
|
||||
const response = await authAgent.get('/api-keys?ownership=all').expect(200);
|
||||
|
||||
expect(response.body.data.items).toHaveLength(1);
|
||||
expect(
|
||||
response.body.data.items.every(
|
||||
(apiKey: { userId: string }) => apiKey.userId === restrictedUser.id,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(response.body.data.owners).toEqual([]);
|
||||
});
|
||||
|
||||
test('DELETE /api-keys/:id should revoke an own key', async () => {
|
||||
const ownKey = await addApiKey(restrictedUser, { scopes: ['workflow:create'] });
|
||||
|
||||
const response = await authAgent.delete(`/api-keys/${ownKey.id}`).expect(200);
|
||||
|
||||
expect(response.body.data.success).toBe(true);
|
||||
|
||||
const remaining = await authAgent.get('/api-keys').expect(200);
|
||||
expect(remaining.body.data.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("DELETE /api-keys/:id should 404 for another user's key", async () => {
|
||||
const otherUser = await createOwnerWithApiKey();
|
||||
|
||||
await authAgent.delete(`/api-keys/${otherUser.apiKeys[0].id}`).expect(404);
|
||||
});
|
||||
|
||||
test('POST /api-keys should be forbidden', async () => {
|
||||
await authAgent
|
||||
.post('/api-keys')
|
||||
.send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
test('PATCH /api-keys/:id should be forbidden even for an own key', async () => {
|
||||
const ownKey = await addApiKey(restrictedUser, { scopes: ['workflow:create'] });
|
||||
|
||||
await authAgent
|
||||
.patch(`/api-keys/${ownKey.id}`)
|
||||
.send({ label: 'Updated label', scopes: ['workflow:create'] })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
test('POST /api-keys/:id/rotate should be forbidden', async () => {
|
||||
const ownKey = await addApiKey(restrictedUser, { scopes: ['workflow:create'] });
|
||||
|
||||
await authAgent.post(`/api-keys/${ownKey.id}/rotate`).expect(403);
|
||||
});
|
||||
|
||||
test('GET /api-keys/scopes should return the scopes assignable by the role', async () => {
|
||||
const response = await authAgent.get('/api-keys/scopes').expect(200);
|
||||
|
||||
expect(Array.isArray(response.body.data)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '@testing-library/vue';
|
||||
|
||||
import N8nTabs from './Tabs.vue';
|
||||
|
||||
const options = [
|
||||
{ value: 'first', label: 'First' },
|
||||
{ value: 'second', label: 'Second', disabled: true, tooltip: 'No access' },
|
||||
];
|
||||
|
||||
describe('N8nTabs', () => {
|
||||
it('emits update:modelValue when an enabled tab is clicked', async () => {
|
||||
const { getByText, emitted } = render(N8nTabs, {
|
||||
props: { modelValue: 'second', options },
|
||||
});
|
||||
|
||||
await userEvent.click(getByText('First'));
|
||||
|
||||
expect(emitted('update:modelValue')).toEqual([['first']]);
|
||||
});
|
||||
|
||||
it('does not emit for a disabled tab and marks it aria-disabled', async () => {
|
||||
const { getByText, emitted } = render(N8nTabs, {
|
||||
props: { modelValue: 'first', options },
|
||||
});
|
||||
|
||||
const tab = getByText('Second').closest('[aria-disabled]');
|
||||
expect(tab).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
await userEvent.click(getByText('Second'));
|
||||
|
||||
expect(emitted('update:modelValue')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -60,7 +60,10 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const handleTooltipClick = (tab: Value, event: MouseEvent) => emit('tooltipClick', tab, event);
|
||||
const handleTabClick = (tab: Value) => emit('update:modelValue', tab);
|
||||
const handleTabClick = (option: TabOptions<Value>) => {
|
||||
if (option.disabled) return;
|
||||
emit('update:modelValue', option.value);
|
||||
};
|
||||
|
||||
const scroll = (left: number) => {
|
||||
const container = tabs.value;
|
||||
|
|
@ -105,7 +108,7 @@ const scrollRight = () => scroll(50);
|
|||
:href="option.href"
|
||||
rel="noopener noreferrer"
|
||||
:class="[$style.link, $style.tab, option.label ? '' : $style.noText]"
|
||||
@click="() => handleTabClick(option.value)"
|
||||
@click="() => handleTabClick(option)"
|
||||
>
|
||||
<div :class="$style.externalLinkContent">
|
||||
{{ option.label }}
|
||||
|
|
@ -141,10 +144,12 @@ const scrollRight = () => scroll(50);
|
|||
[$style.activeTab]: modelValue === option.value,
|
||||
[$style.noText]: !option.label,
|
||||
[$style.dangerTab]: option.variant === 'danger',
|
||||
[$style.disabledTab]: option.disabled === true,
|
||||
}"
|
||||
@click="() => handleTabClick(option.value)"
|
||||
@keydown.enter.prevent="() => handleTabClick(option.value)"
|
||||
@keydown.space.prevent="() => handleTabClick(option.value)"
|
||||
:aria-disabled="option.disabled || undefined"
|
||||
@click="() => handleTabClick(option)"
|
||||
@keydown.enter.prevent="() => handleTabClick(option)"
|
||||
@keydown.space.prevent="() => handleTabClick(option)"
|
||||
>
|
||||
<N8nIcon
|
||||
v-if="option.icon && option.iconPosition !== 'right'"
|
||||
|
|
@ -304,6 +309,15 @@ const scrollRight = () => scroll(50);
|
|||
}
|
||||
}
|
||||
|
||||
.disabledTab {
|
||||
color: var(--color--text--tint-1);
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
position: absolute;
|
||||
background-color: var(--tabs--arrow-buttons--color, var(--color--foreground--tint-2));
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ export interface TabOptions<Value extends string | number> {
|
|||
iconPosition?: 'left' | 'right';
|
||||
variant?: 'default' | 'danger';
|
||||
href?: string;
|
||||
/** Prevents selecting the tab (greyed out); combine with `tooltip` to explain why. */
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
align?: 'left' | 'right';
|
||||
to?: RouteLocationRaw;
|
||||
|
|
|
|||
|
|
@ -2885,6 +2885,7 @@
|
|||
"projectRoles.tab.assignments": "Assignments",
|
||||
"projectRoles.sourceControl.table.projectsAssigned": "Projects assigned",
|
||||
"roles.tab.instance": "Instance roles",
|
||||
"roles.tab.instance.disabledTooltip": "Your role does not have the permission to create instance roles",
|
||||
"roles.tab.project": "Project roles",
|
||||
"instanceRoles.table.membersAssigned": "Members assigned",
|
||||
"instanceRoles.resource.settings": "Instance settings",
|
||||
|
|
@ -3631,6 +3632,7 @@
|
|||
"settings.api.create.description": "Control n8n programmatically using the <a href=\"https://docs.n8n.io/api\" target=\"_blank\">n8n API</a>",
|
||||
"settings.api.create.button": "Create API key",
|
||||
"settings.api.create.button.loading": "Creating API key...",
|
||||
"settings.api.create.disabledTooltip": "Your role does not have the permission to create API keys",
|
||||
"settings.api.search.placeholder": "Search",
|
||||
"settings.api.search.noResults": "No API keys match your search.",
|
||||
"settings.api.owners.all": "All owners",
|
||||
|
|
|
|||
|
|
@ -944,12 +944,7 @@ export const routes: RouteRecordRaw[] = [
|
|||
name: VIEWS.API_SETTINGS,
|
||||
component: SettingsApiView,
|
||||
meta: {
|
||||
middleware: ['authenticated', 'rbac'],
|
||||
middlewareOptions: {
|
||||
rbac: {
|
||||
scope: ['apiKey:list'],
|
||||
},
|
||||
},
|
||||
middleware: ['authenticated'],
|
||||
telemetry: {
|
||||
pageCategory: 'settings',
|
||||
getProperties() {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { mockedStore, type MockedStore } from '@/__tests__/utils';
|
|||
import { useRBACStore } from '@n8n/stores/rbac.store';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen } from '@testing-library/vue';
|
||||
import RolesView from './RolesView.vue';
|
||||
|
||||
const mockReplace = vi.fn();
|
||||
|
|
@ -73,15 +74,32 @@ describe('RolesView', () => {
|
|||
expect(getByText('Project roles')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the instance tab from a role:manageProject-only user', () => {
|
||||
it('shows the instance tab disabled with a tooltip to a role:manageProject-only user', async () => {
|
||||
rbacStore.hasScope = vi.fn().mockReturnValue(false);
|
||||
|
||||
const { getByText, queryByText, getByTestId, queryByTestId } = renderComponent();
|
||||
const { getByText, getByTestId, queryByTestId } = renderComponent();
|
||||
|
||||
expect(queryByText('Instance roles')).not.toBeInTheDocument();
|
||||
const instanceTab = getByText('Instance roles').closest('[aria-disabled]');
|
||||
expect(instanceTab).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(getByText('Project roles')).toBeInTheDocument();
|
||||
expect(getByTestId('project-roles-view')).toBeInTheDocument();
|
||||
expect(queryByTestId('instance-roles-view')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.hover(getByText('Instance roles'));
|
||||
expect(
|
||||
await screen.findByText('Your role does not have the permission to create instance roles'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the project tab active when the disabled instance tab is clicked', async () => {
|
||||
rbacStore.hasScope = vi.fn().mockReturnValue(false);
|
||||
|
||||
const { getByText, getByTestId, queryByTestId } = renderComponent();
|
||||
|
||||
await userEvent.click(getByText('Instance roles'));
|
||||
|
||||
expect(getByTestId('project-roles-view')).toBeInTheDocument();
|
||||
expect(queryByTestId('instance-roles-view')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('coerces ?tab=instance to the project tab for a role:manageProject-only user', () => {
|
||||
|
|
|
|||
|
|
@ -45,14 +45,17 @@ const addRoleLabel = computed(() =>
|
|||
i18n.baseText(activeTab.value === 'project' ? 'roles.addRole.project' : 'roles.addRole.instance'),
|
||||
);
|
||||
|
||||
const tabOptions = computed<Array<TabOptions<RolesTab>>>(() =>
|
||||
canManageInstanceRoles.value
|
||||
? [
|
||||
{ label: i18n.baseText('roles.tab.instance'), value: 'instance' },
|
||||
{ label: i18n.baseText('roles.tab.project'), value: 'project' },
|
||||
]
|
||||
: [{ label: i18n.baseText('roles.tab.project'), value: 'project' }],
|
||||
);
|
||||
const tabOptions = computed<Array<TabOptions<RolesTab>>>(() => [
|
||||
{
|
||||
label: i18n.baseText('roles.tab.instance'),
|
||||
value: 'instance',
|
||||
disabled: !canManageInstanceRoles.value,
|
||||
tooltip: canManageInstanceRoles.value
|
||||
? undefined
|
||||
: i18n.baseText('roles.tab.instance.disabledTooltip'),
|
||||
},
|
||||
{ label: i18n.baseText('roles.tab.project'), value: 'project' },
|
||||
]);
|
||||
|
||||
// Reflect tab selection in the URL (replace keeps history clean / back-button safe).
|
||||
watch(activeTab, (tab) => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import userEvent from '@testing-library/user-event';
|
|||
import { useApiKeysStore } from '../apiKeys.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import { useRBACStore } from '@n8n/stores/rbac.store';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { ApiKeyWithRawValue } from '@n8n/api-types';
|
||||
|
|
@ -53,14 +54,17 @@ const testApiKey: ApiKeyWithRawValue = {
|
|||
const apiKeysStore = mockedStore(useApiKeysStore);
|
||||
const usersStore = mockedStore(useUsersStore);
|
||||
const uiStore = mockedStore(useUIStore);
|
||||
const rbacStore = mockedStore(useRBACStore);
|
||||
|
||||
describe('ApiKeyCreateOrEditModal', () => {
|
||||
beforeEach(() => {
|
||||
apiKeysStore.availableScopes = ['user:create', 'user:list'];
|
||||
// Default: current user IS the owner of the test key (no read-only).
|
||||
// Default: current user IS the owner of the test key (no read-only)
|
||||
// and their role allows editing keys.
|
||||
usersStore.currentUserId = 'u1';
|
||||
// @ts-expect-error: replacing a computed for the test
|
||||
usersStore.currentUser = { id: 'u1' };
|
||||
rbacStore.hasScope.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -372,4 +376,23 @@ describe('ApiKeyCreateOrEditModal', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('read-only mode (role without apiKey:update)', () => {
|
||||
test('renders an own key read-only, keeping Close + Revoke', async () => {
|
||||
rbacStore.hasScope.mockReturnValue(false);
|
||||
apiKeysStore.apiKeys = [testApiKey];
|
||||
|
||||
const { getByTestId, queryByText } = renderComponent({
|
||||
props: { mode: 'edit', activeId: '123' },
|
||||
});
|
||||
|
||||
await retry(() => expect(getByTestId('api-key-label')).toBeInTheDocument());
|
||||
|
||||
expect(queryByText('Save')).not.toBeInTheDocument();
|
||||
expect(getByTestId('api-key-readonly-close')).toBeInTheDocument();
|
||||
// Revoking own keys stays available regardless of role scopes.
|
||||
expect(getByTestId('api-key-readonly-revoke')).toBeInTheDocument();
|
||||
expect((getByTestId('api-key-label') as unknown as HTMLInputElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ 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 { useRBACStore } from '@n8n/stores/rbac.store';
|
||||
import { useClipboard } from '@n8n/composables/useClipboard';
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import { useApiKeysStore } from '../apiKeys.store';
|
||||
|
|
@ -49,6 +50,7 @@ const clipboard = useClipboard();
|
|||
const apiKeysStore = useApiKeysStore();
|
||||
const { createApiKey, updateApiKey, deleteApiKey, apiKeysById, availableScopes } = apiKeysStore;
|
||||
const { currentUser } = storeToRefs(useUsersStore());
|
||||
const rbacStore = useRBACStore();
|
||||
const documentTitle = useDocumentTitle();
|
||||
|
||||
const label = ref('');
|
||||
|
|
@ -128,7 +130,10 @@ const currentApiKey = computed<ApiKey | null>(() =>
|
|||
|
||||
const isReadOnly = computed(() => {
|
||||
const apiKey = currentApiKey.value;
|
||||
if (!apiKey?.owner || !currentUser.value) return false;
|
||||
if (!apiKey) return false;
|
||||
// Even own keys are view-only when the role doesn't allow editing them.
|
||||
if (!rbacStore.hasScope('apiKey:update')) return true;
|
||||
if (!apiKey.owner || !currentUser.value) return false;
|
||||
return apiKey.owner.id !== currentUser.value.id;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,13 +11,18 @@ import ApiKeyLabelCell from './ApiKeyLabelCell.vue';
|
|||
import ApiKeyOwnerCell from './ApiKeyOwnerCell.vue';
|
||||
import ApiKeyScopesCell from './ApiKeyScopesCell.vue';
|
||||
|
||||
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;
|
||||
/** Whether the current user's role allows editing/rotating their own keys. */
|
||||
canUpdate?: boolean;
|
||||
}>(),
|
||||
{ currentUserId: undefined, canUpdate: true },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [apiKey: ApiKey];
|
||||
|
|
@ -61,7 +66,7 @@ type ApiKeyAction = 'edit' | 'view' | 'revoke' | 'rotate';
|
|||
|
||||
function getRowActions(apiKey: ApiKey): Array<ActionDropdownItem<ApiKeyAction>> {
|
||||
const actions: Array<ActionDropdownItem<ApiKeyAction>> = [];
|
||||
if (isOwn(apiKey)) {
|
||||
if (isOwn(apiKey) && props.canUpdate) {
|
||||
actions.push({
|
||||
id: 'edit',
|
||||
label: i18n.baseText('settings.api.actions.edit'),
|
||||
|
|
@ -77,7 +82,8 @@ function getRowActions(apiKey: ApiKey): Array<ActionDropdownItem<ApiKeyAction>>
|
|||
});
|
||||
}
|
||||
} else {
|
||||
// Non-owners open the same modal, which renders read-only based on ownership.
|
||||
// Non-owners and roles without apiKey:update open the same modal,
|
||||
// which renders read-only based on ownership and scopes.
|
||||
actions.push({
|
||||
id: 'view',
|
||||
label: i18n.baseText('settings.api.actions.view'),
|
||||
|
|
|
|||
|
|
@ -101,6 +101,14 @@ vi.mock('@n8n/design-system', async (importOriginal) => {
|
|||
</div>
|
||||
`,
|
||||
},
|
||||
// Reka UI tooltips don't open in jsdom either; expose the props the view
|
||||
// passes so tests can assert the tooltip wiring without driving the popup.
|
||||
N8nTooltip: {
|
||||
name: 'N8nTooltip',
|
||||
props: ['disabled', 'content'],
|
||||
template:
|
||||
'<div :data-tooltip-disabled="disabled" :data-tooltip-content="content"><slot /></div>',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -290,6 +298,14 @@ describe('SettingsApiView', () => {
|
|||
apiKeysStore.totalAllCount = 1;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Rotation requires apiKey:update; these tests exercise the expiry and
|
||||
// ownership gates, not the scope gate.
|
||||
rbacStore.hasScope.mockImplementation(
|
||||
(scope: string | string[]) => scope === 'apiKey:update',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers Rotate for an owned, non-expired key', () => {
|
||||
singleOwnedKey({ expiresAt: null });
|
||||
|
||||
|
|
@ -342,6 +358,89 @@ describe('SettingsApiView', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('restricted role (no apiKey scopes)', () => {
|
||||
const setupKeys = () => {
|
||||
settingsStore.isPublicApiEnabled = true;
|
||||
cloudStore.userIsTrialing = false;
|
||||
apiKeysStore.apiKeys = [makeKey({ id: '1', label: 'test-key-1' })];
|
||||
apiKeysStore.mineCount = 1;
|
||||
apiKeysStore.allCount = 1;
|
||||
apiKeysStore.totalMineCount = 1;
|
||||
apiKeysStore.totalAllCount = 1;
|
||||
};
|
||||
|
||||
it('disables the create button and explains why for a role without apiKey:create', () => {
|
||||
setupKeys();
|
||||
|
||||
renderComponent(SettingsApiView);
|
||||
|
||||
const createButton = screen.getByTestId('api-key-create-button');
|
||||
expect(createButton).toBeDisabled();
|
||||
|
||||
const tooltip = createButton.closest('[data-tooltip-content]');
|
||||
expect(tooltip?.getAttribute('data-tooltip-disabled')).toBe('false');
|
||||
expect(tooltip?.getAttribute('data-tooltip-content')).toBe(
|
||||
'Your role does not have the permission to create API keys',
|
||||
);
|
||||
});
|
||||
|
||||
it('enables the create button when the role has apiKey:create', () => {
|
||||
rbacStore.hasScope.mockImplementation(
|
||||
(scope: string | string[]) => scope === 'apiKey:create',
|
||||
);
|
||||
setupKeys();
|
||||
|
||||
renderComponent(SettingsApiView);
|
||||
|
||||
const createButton = screen.getByTestId('api-key-create-button');
|
||||
expect(createButton).toBeEnabled();
|
||||
// The explanatory tooltip is off while creation is allowed.
|
||||
expect(
|
||||
createButton.closest('[data-tooltip-content]')?.getAttribute('data-tooltip-disabled'),
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
it('offers View instead of Edit/Rotate on own keys for a role without apiKey:update', () => {
|
||||
setupKeys();
|
||||
|
||||
renderComponent(SettingsApiView);
|
||||
|
||||
expect(screen.getByTestId('api-key-view-action')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('api-key-edit-action')).toBeNull();
|
||||
expect(screen.queryByTestId('api-key-rotate-action')).toBeNull();
|
||||
// Revoking own keys stays available regardless of role scopes.
|
||||
expect(screen.getByTestId('api-key-revoke-action')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps Edit and Rotate on own keys when the role has apiKey:update', () => {
|
||||
rbacStore.hasScope.mockImplementation(
|
||||
(scope: string | string[]) => scope === 'apiKey:update',
|
||||
);
|
||||
setupKeys();
|
||||
|
||||
renderComponent(SettingsApiView);
|
||||
|
||||
expect(screen.getByTestId('api-key-edit-action')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('api-key-rotate-action')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('api-key-view-action')).toBeNull();
|
||||
});
|
||||
|
||||
it('disables the empty-state CTA for a role without apiKey:create', () => {
|
||||
settingsStore.isPublicApiEnabled = true;
|
||||
cloudStore.userIsTrialing = false;
|
||||
apiKeysStore.apiKeys = [];
|
||||
apiKeysStore.mineCount = 0;
|
||||
apiKeysStore.allCount = 0;
|
||||
apiKeysStore.totalMineCount = 0;
|
||||
apiKeysStore.totalAllCount = 0;
|
||||
apiKeysStore.labelFilter = '';
|
||||
|
||||
renderComponent(SettingsApiView);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Create API key' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the search input visible when a filter zeroes the results', () => {
|
||||
settingsStore.isPublicApiEnabled = true;
|
||||
apiKeysStore.apiKeys = [];
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
N8nInput,
|
||||
N8nTabs,
|
||||
N8nText,
|
||||
N8nTooltip,
|
||||
} from '@n8n/design-system';
|
||||
import { I18nT } from 'vue-i18n';
|
||||
import ApiKeyOwnerFilter from '../components/ApiKeyOwnerFilter.vue';
|
||||
|
|
@ -147,6 +148,10 @@ const rotateConfirmApiKey = ref<ApiKey | null>(null);
|
|||
const rotating = ref(false);
|
||||
|
||||
const canManageAllKeys = computed(() => rbacStore.hasScope('apiKey:manage'));
|
||||
// Viewing and revoking own keys is available to everyone; creating and editing
|
||||
// stay behind role scopes, so the section explains the restriction instead of hiding.
|
||||
const canCreateKeys = computed(() => rbacStore.hasScope('apiKey:create'));
|
||||
const canUpdateKeys = computed(() => rbacStore.hasScope('apiKey:update'));
|
||||
|
||||
// Badges show the unfiltered totals so a search-narrowed "Mine (0)" doesn't read
|
||||
// as "I have no keys" when the user really has keys that just don't match.
|
||||
|
|
@ -357,9 +362,19 @@ function onOpenScopes(apiKey: ApiKey) {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<N8nButton size="medium" @click="onCreateApiKey">
|
||||
{{ i18n.baseText('settings.api.create.button') }}
|
||||
</N8nButton>
|
||||
<N8nTooltip
|
||||
:disabled="canCreateKeys"
|
||||
:content="i18n.baseText('settings.api.create.disabledTooltip')"
|
||||
>
|
||||
<N8nButton
|
||||
size="medium"
|
||||
:disabled="!canCreateKeys"
|
||||
data-test-id="api-key-create-button"
|
||||
@click="onCreateApiKey"
|
||||
>
|
||||
{{ i18n.baseText('settings.api.create.button') }}
|
||||
</N8nButton>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
|
||||
<N8nTabs
|
||||
|
|
@ -378,6 +393,7 @@ function onOpenScopes(apiKey: ApiKey) {
|
|||
:items-length="apiKeysCount"
|
||||
:loading="loading"
|
||||
:current-user-id="usersStore.currentUser?.id"
|
||||
:can-update="canUpdateKeys"
|
||||
:class="$style.table"
|
||||
@edit="onEdit"
|
||||
@revoke="onRevokeRequest"
|
||||
|
|
@ -418,9 +434,14 @@ function onOpenScopes(apiKey: ApiKey) {
|
|||
:button-text="
|
||||
i18n.baseText(loading ? 'settings.api.create.button.loading' : 'settings.api.create.button')
|
||||
"
|
||||
:button-disabled="!canCreateKeys"
|
||||
:description="i18n.baseText('settings.api.create.description')"
|
||||
@click:button="onCreateApiKey"
|
||||
/>
|
||||
>
|
||||
<template #disabledButtonTooltip>
|
||||
{{ i18n.baseText('settings.api.create.disabledTooltip') }}
|
||||
</template>
|
||||
</N8nEmptyState>
|
||||
|
||||
<ApiKeyScopesModal
|
||||
:api-key="scopesModalApiKey"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user