diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 98810df853a..3c5479772bc 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -3596,7 +3596,7 @@ "settings.serviceAccounts.actions.actAs": "Act as this account", "settings.serviceAccounts.actions.disable": "Disable", "settings.serviceAccounts.actions.enable": "Enable", - "settings.serviceAccounts.actions.createCredential": "Create credential", + "settings.serviceAccounts.actions.credentials": "Manage credentials", "settings.serviceAccounts.actions.delete": "Delete", "settings.serviceAccounts.createModal.title": "Create service account", "settings.serviceAccounts.createModal.name.label": "Name", @@ -3617,16 +3617,33 @@ "settings.serviceAccounts.enable.success": "Service account enabled", "settings.serviceAccounts.role.success": "Role updated", "settings.serviceAccounts.blockedWhileActingAs": "Return to your own account to manage service accounts.", - "settings.serviceAccounts.credentials.title": "Create credential for {name}", - "settings.serviceAccounts.credentials.description": "Create an OAuth client credential for {name}. The secret is shown once and cannot be retrieved later.", + "settings.serviceAccounts.credentials.title": "Credentials for {name}", "settings.serviceAccounts.credentials.warning": "Make sure to copy the client secret now — you will not be able to see it again.", "settings.serviceAccounts.credentials.clientId": "Client ID", "settings.serviceAccounts.credentials.clientSecret": "Client secret", - "settings.serviceAccounts.credentials.create": "Create", + "settings.serviceAccounts.credentials.column.created": "Created", + "settings.serviceAccounts.credentials.empty": "No credentials yet. Add one so this service account can request access tokens.", + "settings.serviceAccounts.credentials.add": "Add credential", "settings.serviceAccounts.credentials.done": "Done", "settings.serviceAccounts.credentials.success": "Credential created", "settings.serviceAccounts.credentials.error": "Could not create credential", + "settings.serviceAccounts.credentials.listError": "Could not load credentials", "settings.serviceAccounts.credentials.copied": "Copied to clipboard", + "settings.serviceAccounts.credentials.delete.title": "Delete this credential?", + "settings.serviceAccounts.credentials.delete.description": "Anything still using client ID {clientId} will no longer be able to request access tokens.", + "settings.serviceAccounts.credentials.delete.confirm": "Delete credential", + "settings.serviceAccounts.credentials.delete.action": "Delete credential {clientId}", + "settings.serviceAccounts.credentials.delete.success": "Credential deleted", + "settings.serviceAccounts.credentials.delete.error": "Could not delete credential", + "settings.serviceAccounts.credentials.tokensRemainValid": "Access tokens already issued for this credential keep working until they expire, up to one hour from now.", + "settings.serviceAccounts.credentials.usage.title": "How to use these credentials", + "settings.serviceAccounts.credentials.usage.tokenEndpoint": "Token endpoint", + "settings.serviceAccounts.credentials.usage.resource": "Resource (token audience)", + "settings.serviceAccounts.credentials.usage.resourceInstanceMcp": "Instance MCP server", + "settings.serviceAccounts.credentials.usage.resourcePublicApi": "Public API", + "settings.serviceAccounts.credentials.usage.resourceCustom": "Custom (MCP Trigger URL)", + "settings.serviceAccounts.credentials.usage.notice": "Resource is required and must exactly match the URL you call. A mismatch returns 401 invalid_token.", + "settings.serviceAccounts.credentials.usage.curlLabel": "Request an access token", "impersonation.banner.message": "You're acting as {serviceAccount}. Everything you do here belongs to this service account, not to {actor}.", "impersonation.banner.return": "Return to my account", "impersonation.badge": "Service account", diff --git a/packages/frontend/@n8n/rest-api-client/src/api/service-account-credentials.ts b/packages/frontend/@n8n/rest-api-client/src/api/service-account-credentials.ts index cd8a4e30558..0fa7cfa5cbb 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/service-account-credentials.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/service-account-credentials.ts @@ -9,8 +9,14 @@ import { makeRestApiRequest } from '../utils'; export async function getServiceAccountCredentials( context: IRestApiContext, + userId?: string, ): Promise { - return await makeRestApiRequest(context, 'GET', '/service-account-credentials'); + return await makeRestApiRequest( + context, + 'GET', + '/service-account-credentials', + userId ? { userId } : undefined, + ); } export async function createServiceAccountCredential( diff --git a/packages/frontend/editor-ui/src/features/settings/serviceAccounts/components/CodeSnippet.vue b/packages/frontend/editor-ui/src/features/settings/serviceAccounts/components/CodeSnippet.vue new file mode 100644 index 00000000000..c08185fc67c --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/serviceAccounts/components/CodeSnippet.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/settings/serviceAccounts/components/CreateServiceAccountCredentialModal.vue b/packages/frontend/editor-ui/src/features/settings/serviceAccounts/components/CreateServiceAccountCredentialModal.vue deleted file mode 100644 index dde2b2c8f2f..00000000000 --- a/packages/frontend/editor-ui/src/features/settings/serviceAccounts/components/CreateServiceAccountCredentialModal.vue +++ /dev/null @@ -1,151 +0,0 @@ - - - - - diff --git a/packages/frontend/editor-ui/src/features/settings/serviceAccounts/components/ServiceAccountCredentialUsage.vue b/packages/frontend/editor-ui/src/features/settings/serviceAccounts/components/ServiceAccountCredentialUsage.vue new file mode 100644 index 00000000000..93b90862bfa --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/serviceAccounts/components/ServiceAccountCredentialUsage.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/settings/serviceAccounts/components/ServiceAccountCredentialsModal.test.ts b/packages/frontend/editor-ui/src/features/settings/serviceAccounts/components/ServiceAccountCredentialsModal.test.ts new file mode 100644 index 00000000000..41d0e3e85ac --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/serviceAccounts/components/ServiceAccountCredentialsModal.test.ts @@ -0,0 +1,113 @@ +import type { ServiceAccount, ServiceAccountCredential } from '@n8n/api-types'; +import { createTestingPinia } from '@pinia/testing'; +import { screen, waitFor } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { setActivePinia } from 'pinia'; + +import { renderComponent } from '@/__tests__/render'; + +import ServiceAccountCredentialsModal from './ServiceAccountCredentialsModal.vue'; +import { useServiceAccountsStore } from '../serviceAccounts.store'; + +const serviceAccount: ServiceAccount = { + id: 'sa-1', + name: 'Deploy Bot', + email: 'deploy-bot-abcd@service-accounts.invalid', + role: 'global:member', + disabled: false, +}; + +const credential = ( + overrides: Partial = {}, +): ServiceAccountCredential => ({ + id: 'cred-1', + clientId: 'client-abc', + credentialType: 'client_secret', + userId: serviceAccount.id, + createdAt: '2026-08-01T10:00:00.000Z', + ...overrides, +}); + +const renderModal = async (credentials: ServiceAccountCredential[]) => { + const pinia = createTestingPinia({ stubActions: false }); + setActivePinia(pinia); + + const store = useServiceAccountsStore(); + store.listCredentials = vi.fn(async () => credentials); + store.createCredential = vi.fn(); + store.deleteCredential = vi.fn(async () => ({ success: true })); + + // The list is fetched on the `open` transition, so mount closed and flip. + const rendered = renderComponent(ServiceAccountCredentialsModal, { + pinia, + props: { serviceAccount, open: false }, + }); + await rendered.rerender({ serviceAccount, open: true }); + await screen.findByTestId('service-account-credentials-modal'); + + return { store, ...rendered }; +}; + +describe('ServiceAccountCredentialsModal', () => { + it('lists existing credentials by client ID and creation date', async () => { + const { store } = await renderModal([ + credential(), + credential({ id: 'cred-2', clientId: 'client-xyz', createdAt: '2026-08-03T10:00:00.000Z' }), + ]); + + expect(store.listCredentials).toHaveBeenCalledWith(serviceAccount.id); + + const rows = await screen.findAllByTestId('service-account-credential-row'); + expect(rows).toHaveLength(2); + // Newest first — creation date is the only thing distinguishing rows. + expect(rows.map((row) => row.querySelector('input')?.value)).toEqual([ + 'client-xyz', + 'client-abc', + ]); + }); + + it('explains an empty list instead of rendering an empty table', async () => { + await renderModal([]); + + expect(await screen.findByTestId('service-account-credentials-empty')).toBeInTheDocument(); + expect(screen.queryByTestId('service-account-credentials-list')).not.toBeInTheDocument(); + }); + + it('reveals the secret once after creating a credential', async () => { + const { store } = await renderModal([]); + store.createCredential = vi.fn(async () => ({ + ...credential({ id: 'cred-new', clientId: 'client-new' }), + clientSecret: 'super-secret', + })); + + await userEvent.click(screen.getByTestId('service-account-credential-add')); + + expect(await screen.findByDisplayValue('super-secret')).toBeInTheDocument(); + expect(store.createCredential).toHaveBeenCalledWith(serviceAccount.id); + }); + + it('deletes a credential once the confirmation is accepted', async () => { + const { store } = await renderModal([credential()]); + + await userEvent.click(await screen.findByTestId('service-account-credential-delete')); + await userEvent.click(await screen.findByRole('button', { name: 'Delete credential' })); + + await waitFor(() => expect(store.deleteCredential).toHaveBeenCalledWith('cred-1')); + }); + + it('drops the revealed secret when reopened', async () => { + const { store, rerender } = await renderModal([]); + store.createCredential = vi.fn(async () => ({ + ...credential({ id: 'cred-new', clientId: 'client-new' }), + clientSecret: 'super-secret', + })); + + await userEvent.click(screen.getByTestId('service-account-credential-add')); + await screen.findByDisplayValue('super-secret'); + + await rerender({ serviceAccount, open: false }); + await rerender({ serviceAccount, open: true }); + + await waitFor(() => expect(screen.queryByDisplayValue('super-secret')).not.toBeInTheDocument()); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/settings/serviceAccounts/components/ServiceAccountCredentialsModal.vue b/packages/frontend/editor-ui/src/features/settings/serviceAccounts/components/ServiceAccountCredentialsModal.vue new file mode 100644 index 00000000000..ddd2c90c2b5 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/serviceAccounts/components/ServiceAccountCredentialsModal.vue @@ -0,0 +1,336 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/settings/serviceAccounts/serviceAccounts.constants.ts b/packages/frontend/editor-ui/src/features/settings/serviceAccounts/serviceAccounts.constants.ts index d11dd64da8f..64ca0dfd7c3 100644 --- a/packages/frontend/editor-ui/src/features/settings/serviceAccounts/serviceAccounts.constants.ts +++ b/packages/frontend/editor-ui/src/features/settings/serviceAccounts/serviceAccounts.constants.ts @@ -8,7 +8,7 @@ export const SERVICE_ACCOUNT_ACTIONS = { DISABLE: 'disable', ENABLE: 'enable', DELETE: 'delete', - CREATE_CREDENTIAL: 'createCredential', + CREDENTIALS: 'credentials', } as const; export const CREATE_SERVICE_ACCOUNT_MODAL_KEY = 'createServiceAccount'; diff --git a/packages/frontend/editor-ui/src/features/settings/serviceAccounts/serviceAccounts.store.ts b/packages/frontend/editor-ui/src/features/settings/serviceAccounts/serviceAccounts.store.ts index 8d7f5a2fea4..67e76436170 100644 --- a/packages/frontend/editor-ui/src/features/settings/serviceAccounts/serviceAccounts.store.ts +++ b/packages/frontend/editor-ui/src/features/settings/serviceAccounts/serviceAccounts.store.ts @@ -36,17 +36,28 @@ export const useServiceAccountsStore = defineStore(STORES.SERVICE_ACCOUNTS, () = const remove = async (id: string) => await serviceAccountsApi.deleteServiceAccount(rootStore.restApiContext, id); + const listCredentials = async (userId: string) => + await serviceAccountCredentialsApi.getServiceAccountCredentials( + rootStore.restApiContext, + userId, + ); + const createCredential = async (userId: string) => await serviceAccountCredentialsApi.createServiceAccountCredential(rootStore.restApiContext, { userId, }); + const deleteCredential = async (id: string) => + await serviceAccountCredentialsApi.deleteServiceAccountCredential(rootStore.restApiContext, id); + return { serviceAccountsList, create, update, changeRole, remove, + listCredentials, createCredential, + deleteCredential, }; }); diff --git a/packages/frontend/editor-ui/src/features/settings/serviceAccounts/views/SettingsServiceAccountsView.test.ts b/packages/frontend/editor-ui/src/features/settings/serviceAccounts/views/SettingsServiceAccountsView.test.ts index 94cc2f79b89..6baf1e5b0df 100644 --- a/packages/frontend/editor-ui/src/features/settings/serviceAccounts/views/SettingsServiceAccountsView.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/serviceAccounts/views/SettingsServiceAccountsView.test.ts @@ -1,6 +1,7 @@ -import type { ServiceAccountsList } from '@n8n/api-types'; +import { ROLE, type ServiceAccountsList } from '@n8n/api-types'; import { createTestingPinia } from '@pinia/testing'; -import { screen, waitFor } from '@testing-library/vue'; +import { screen, waitFor, within } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; import { setActivePinia } from 'pinia'; import { renderComponent } from '@/__tests__/render'; @@ -9,6 +10,7 @@ import SettingsServiceAccountsView from './SettingsServiceAccountsView.vue'; import { useServiceAccountsStore } from '../serviceAccounts.store'; const grantedScopes = vi.hoisted(() => ({ value: [] as string[] })); +const grantedRoles = vi.hoisted(() => ({ value: [] as string[] })); vi.mock('@n8n/stores/roles.store', () => ({ useRolesStore: () => ({ @@ -20,7 +22,13 @@ vi.mock('@n8n/stores/roles.store', () => ({ })); vi.mock('@/app/utils/rbac/permissions', () => ({ - hasPermission: (_checks: string[], options?: { rbac?: { scope?: string | string[] } }) => { + hasPermission: ( + checks: string[], + options?: { rbac?: { scope?: string | string[] }; role?: string[] }, + ) => { + if (checks.includes('role')) { + return (options?.role ?? []).some((role) => grantedRoles.value.includes(role)); + } const scope = options?.rbac?.scope; const scopes = Array.isArray(scope) ? scope : [scope]; return scopes.every((s) => s !== undefined && grantedScopes.value.includes(s)); @@ -51,6 +59,7 @@ const renderView = async (list: ServiceAccountsList) => { return list; }) as never; Object.assign(store.serviceAccountsList.state, list); + store.listCredentials = vi.fn(async () => []); const rendered = renderComponent(SettingsServiceAccountsView, { pinia }); await waitFor(() => expect(screen.getByTestId('service-accounts-header')).toBeInTheDocument()); @@ -66,6 +75,7 @@ describe('SettingsServiceAccountsView', () => { 'serviceAccount:delete', 'serviceAccount:impersonate', ]; + grantedRoles.value = []; }); it('shows the empty state with a create action when there are none', async () => { @@ -102,4 +112,26 @@ describe('SettingsServiceAccountsView', () => { expect(screen.getByText('Disabled')).toBeInTheDocument(); }); + + it('opens the credentials modal from the row action', async () => { + grantedRoles.value = [ROLE.Owner]; + const { store } = await renderView(makeList([serviceAccount])); + const user = userEvent.setup(); + + await user.click(within(screen.getByTestId('action-toggle')).getByRole('button')); + await user.click(await screen.findByTestId('action-credentials')); + + expect(await screen.findByTestId('service-account-credentials-modal')).toBeInTheDocument(); + expect(store.listCredentials).toHaveBeenCalledWith(serviceAccount.id); + }); + + it('hides the credentials action for non-owner/admin roles', async () => { + await renderView(makeList([serviceAccount])); + const user = userEvent.setup(); + + await user.click(within(screen.getByTestId('action-toggle')).getByRole('button')); + + expect(await screen.findByTestId('action-impersonate')).toBeInTheDocument(); + expect(screen.queryByTestId('action-credentials')).not.toBeInTheDocument(); + }); }); diff --git a/packages/frontend/editor-ui/src/features/settings/serviceAccounts/views/SettingsServiceAccountsView.vue b/packages/frontend/editor-ui/src/features/settings/serviceAccounts/views/SettingsServiceAccountsView.vue index 640cf946528..0ab8c4425a0 100644 --- a/packages/frontend/editor-ui/src/features/settings/serviceAccounts/views/SettingsServiceAccountsView.vue +++ b/packages/frontend/editor-ui/src/features/settings/serviceAccounts/views/SettingsServiceAccountsView.vue @@ -19,9 +19,9 @@ import { useUIStore } from '@/app/stores/ui.store'; import { hasPermission } from '@/app/utils/rbac/permissions'; import ActAsServiceAccountDialog from '../components/ActAsServiceAccountDialog.vue'; -import CreateServiceAccountCredentialModal from '../components/CreateServiceAccountCredentialModal.vue'; import CreateServiceAccountModal from '../components/CreateServiceAccountModal.vue'; import DeleteServiceAccountDialog from '../components/DeleteServiceAccountDialog.vue'; +import ServiceAccountCredentialsModal from '../components/ServiceAccountCredentialsModal.vue'; import ServiceAccountsTable from '../components/ServiceAccountsTable.vue'; import { useImpersonationStore } from '../impersonation.store'; import { @@ -87,8 +87,8 @@ const actions = computed>>(() => [ guard: (row) => canUpdate.value && Boolean(row.disabled), }, { - label: i18n.baseText('settings.serviceAccounts.actions.createCredential'), - value: SERVICE_ACCOUNT_ACTIONS.CREATE_CREDENTIAL, + label: i18n.baseText('settings.serviceAccounts.actions.credentials'), + value: SERVICE_ACCOUNT_ACTIONS.CREDENTIALS, guard: () => canManageCredentials.value, }, { @@ -198,7 +198,7 @@ const onAction = async ({ action, userId }: { action: string; userId: string }) case SERVICE_ACCOUNT_ACTIONS.DELETE: deleteTarget.value = serviceAccount; break; - case SERVICE_ACCOUNT_ACTIONS.CREATE_CREDENTIAL: + case SERVICE_ACCOUNT_ACTIONS.CREDENTIALS: credentialTarget.value = serviceAccount; break; } @@ -303,7 +303,7 @@ const openCreateModal = () => uiStore.openModal(CREATE_SERVICE_ACCOUNT_MODAL_KEY @cancel="deleteTarget = null" @update:open="!$event && (deleteTarget = null)" /> -