diff --git a/packages/cli/src/controllers/api-keys.controller.ts b/packages/cli/src/controllers/api-keys.controller.ts index 7e6ede7a3a1..d3611a4cfc9 100644 --- a/packages/cli/src/controllers/api-keys.controller.ts +++ b/packages/cli/src/controllers/api-keys.controller.ts @@ -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); diff --git a/packages/cli/test/integration/api-keys.api.test.ts b/packages/cli/test/integration/api-keys.api.test.ts index 1f5e9c0b8b0..58d563caea9 100644 --- a/packages/cli/test/integration/api-keys.api.test.ts +++ b/packages/cli/test/integration/api-keys.api.test.ts @@ -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); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nTabs/Tabs.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nTabs/Tabs.test.ts new file mode 100644 index 00000000000..7b7fca1b5f5 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nTabs/Tabs.test.ts @@ -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(); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nTabs/Tabs.vue b/packages/frontend/@n8n/design-system/src/components/N8nTabs/Tabs.vue index 1fe777368b7..7b230a4c4f8 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nTabs/Tabs.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nTabs/Tabs.vue @@ -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) => { + 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)" >
{{ 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)" > 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)); diff --git a/packages/frontend/@n8n/design-system/src/types/tabs.ts b/packages/frontend/@n8n/design-system/src/types/tabs.ts index bab8c8e57fc..40b7e3666c3 100644 --- a/packages/frontend/@n8n/design-system/src/types/tabs.ts +++ b/packages/frontend/@n8n/design-system/src/types/tabs.ts @@ -9,6 +9,8 @@ export interface TabOptions { 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; diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 58c5c2ec23b..53ddc478562 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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 n8n API", "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", diff --git a/packages/frontend/editor-ui/src/app/router.ts b/packages/frontend/editor-ui/src/app/router.ts index 96d1c31066d..4212ccf24d6 100644 --- a/packages/frontend/editor-ui/src/app/router.ts +++ b/packages/frontend/editor-ui/src/app/router.ts @@ -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() { diff --git a/packages/frontend/editor-ui/src/features/roles/RolesView.test.ts b/packages/frontend/editor-ui/src/features/roles/RolesView.test.ts index 1a4b85fa7a6..383be45203d 100644 --- a/packages/frontend/editor-ui/src/features/roles/RolesView.test.ts +++ b/packages/frontend/editor-ui/src/features/roles/RolesView.test.ts @@ -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', () => { diff --git a/packages/frontend/editor-ui/src/features/roles/RolesView.vue b/packages/frontend/editor-ui/src/features/roles/RolesView.vue index 6e64ce4b9d7..39b9bd05867 100644 --- a/packages/frontend/editor-ui/src/features/roles/RolesView.vue +++ b/packages/frontend/editor-ui/src/features/roles/RolesView.vue @@ -45,14 +45,17 @@ const addRoleLabel = computed(() => i18n.baseText(activeTab.value === 'project' ? 'roles.addRole.project' : 'roles.addRole.instance'), ); -const tabOptions = computed>>(() => - 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>>(() => [ + { + 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) => { diff --git a/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyCreateOrEditModal.test.ts b/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyCreateOrEditModal.test.ts index e03389374c2..7116b8f1080 100644 --- a/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyCreateOrEditModal.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyCreateOrEditModal.test.ts @@ -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); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyCreateOrEditModal.vue b/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyCreateOrEditModal.vue index d70be4eca2d..ffbddf03b57 100644 --- a/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyCreateOrEditModal.vue +++ b/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyCreateOrEditModal.vue @@ -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(() => 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; }); diff --git a/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyTable.vue b/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyTable.vue index c73c99faedb..0acba9236ae 100644 --- a/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyTable.vue +++ b/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyTable.vue @@ -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> { const actions: Array> = []; - 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> }); } } 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'), diff --git a/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.test.ts b/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.test.ts index 5abe6071fc0..fe574f7823a 100644 --- a/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.test.ts @@ -101,6 +101,14 @@ vi.mock('@n8n/design-system', async (importOriginal) => {
`, }, + // 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: + '
', + }, }; }); @@ -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 = []; diff --git a/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.vue b/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.vue index 405d6277a2f..c60ad817a5c 100644 --- a/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.vue +++ b/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.vue @@ -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(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) { /> - - {{ i18n.baseText('settings.api.create.button') }} - + + + {{ i18n.baseText('settings.api.create.button') }} + + + > + +