Added UI to create/manage credentials

This commit is contained in:
James Martin 2026-08-04 15:56:26 +01:00 committed by Andreas Fitzek
parent 060131bdf0
commit 91987eb7d8
No known key found for this signature in database
11 changed files with 779 additions and 165 deletions

View File

@ -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",

View File

@ -9,8 +9,14 @@ import { makeRestApiRequest } from '../utils';
export async function getServiceAccountCredentials(
context: IRestApiContext,
userId?: string,
): Promise<ServiceAccountCredential[]> {
return await makeRestApiRequest(context, 'GET', '/service-account-credentials');
return await makeRestApiRequest(
context,
'GET',
'/service-account-credentials',
userId ? { userId } : undefined,
);
}
export async function createServiceAccountCredential(

View File

@ -0,0 +1,80 @@
<script setup lang="ts">
import { useClipboard } from '@n8n/composables/useClipboard';
import { N8nButton, N8nMarkdown, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
const props = defineProps<{
/** Raw snippet; rendered as a copyable fenced code block. */
value: string;
/** Fence language for syntax highlighting. */
language?: string;
}>();
const i18n = useI18n();
const { copy, copied, isSupported } = useClipboard();
const code = computed(() => `\`\`\`${props.language ?? 'bash'}\n${props.value}\n\`\`\``);
const onCopy = async () => await copy(props.value);
</script>
<template>
<div :class="$style.container" data-test-id="code-snippet">
<N8nMarkdown :content="code" />
<div :class="$style.copyWrapper">
<N8nTooltip
placement="bottom-end"
:disabled="!isSupported"
:content="copied ? i18n.baseText('generic.copied') : i18n.baseText('generic.copy')"
>
<N8nButton
v-if="isSupported"
variant="ghost"
iconOnly
:icon="copied ? 'check' : 'copy'"
:class="$style.copyButton"
data-test-id="code-snippet-copy"
@click="onCopy"
/>
</N8nTooltip>
</div>
</div>
</template>
<style lang="scss" module>
.container {
position: relative;
:global(.n8n-markdown) {
width: 100%;
}
code {
display: block;
color: var(--color--text) !important;
font-size: var(--font-size--2xs);
padding: var(--spacing--2xs) !important;
padding-right: var(--spacing--xl) !important;
tab-size: 1;
background: none !important;
overflow-x: auto;
}
}
.copyWrapper {
position: absolute;
top: var(--spacing--2xs);
right: var(--spacing--2xs);
}
/* Icon-only buttons are forced to a square (width = button height) with the icon
centred, leaving dead space each side; shrink to the glyph so it sits flush. */
.copyButton {
width: auto !important;
> * {
width: auto !important;
}
}
</style>

View File

@ -1,151 +0,0 @@
<script lang="ts" setup>
import type { ServiceAccount, ServiceAccountCredentialWithSecret } from '@n8n/api-types';
import { useToast } from '@n8n/composables/useToast';
import {
N8nButton,
N8nCopyInput,
N8nDialog,
N8nDialogFooter,
N8nInputLabel,
N8nText,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed, ref, watch } from 'vue';
import { getServiceAccountDisplayName } from '../serviceAccounts.utils';
import { useServiceAccountsStore } from '../serviceAccounts.store';
const props = defineProps<{
serviceAccount: ServiceAccount | null;
open: boolean;
}>();
const emit = defineEmits<{
'update:open': [value: boolean];
}>();
const i18n = useI18n();
const toast = useToast();
const serviceAccountsStore = useServiceAccountsStore();
const created = ref<ServiceAccountCredentialWithSecret | null>(null);
const loading = ref(false);
const name = computed(() =>
props.serviceAccount ? getServiceAccountDisplayName(props.serviceAccount) : '',
);
// Reopening always starts from the form: the secret is shown once, so a stale
// `created` must never survive a close/reopen.
watch(
() => props.open,
(open) => {
if (!open) {
created.value = null;
loading.value = false;
}
},
);
const onCreate = async () => {
if (!props.serviceAccount) return;
loading.value = true;
try {
created.value = await serviceAccountsStore.createCredential(props.serviceAccount.id);
toast.showMessage({
title: i18n.baseText('settings.serviceAccounts.credentials.success'),
type: 'success',
});
} catch (error) {
toast.showError(error, i18n.baseText('settings.serviceAccounts.credentials.error'));
} finally {
loading.value = false;
}
};
const onCopied = () => {
toast.showMessage({
title: i18n.baseText('settings.serviceAccounts.credentials.copied'),
type: 'success',
});
};
const close = () => emit('update:open', false);
</script>
<template>
<N8nDialog
:open="open"
size="medium"
:header="i18n.baseText('settings.serviceAccounts.credentials.title', { interpolate: { name } })"
data-test-id="create-service-account-credential-modal"
@update:open="emit('update:open', $event)"
>
<div v-if="!created" :class="$style.form">
<N8nText size="medium" color="text-base">
{{
i18n.baseText('settings.serviceAccounts.credentials.description', {
interpolate: { name },
})
}}
</N8nText>
<N8nDialogFooter>
<N8nButton
:loading="loading"
:label="i18n.baseText('settings.serviceAccounts.credentials.create')"
data-test-id="create-service-account-credential-confirm"
@click="onCreate"
/>
</N8nDialogFooter>
</div>
<div v-else :class="$style.created">
<N8nText size="small" color="text-base">
{{ i18n.baseText('settings.serviceAccounts.credentials.warning') }}
</N8nText>
<N8nInputLabel
:label="i18n.baseText('settings.serviceAccounts.credentials.clientId')"
color="text-dark"
>
<N8nCopyInput
:value="created.clientId"
size="large"
:copy-label="i18n.baseText('generic.copy')"
:copied-label="i18n.baseText('generic.copiedToClipboard')"
data-test-id="service-account-credential-client-id"
@copy="onCopied"
/>
</N8nInputLabel>
<N8nInputLabel
:label="i18n.baseText('settings.serviceAccounts.credentials.clientSecret')"
color="text-dark"
>
<N8nCopyInput
:value="created.clientSecret"
size="large"
:copy-label="i18n.baseText('generic.copy')"
:copied-label="i18n.baseText('generic.copiedToClipboard')"
class="ph-no-capture"
data-test-id="service-account-credential-client-secret"
@copy="onCopied"
/>
</N8nInputLabel>
<N8nDialogFooter>
<N8nButton
:label="i18n.baseText('settings.serviceAccounts.credentials.done')"
data-test-id="create-service-account-credential-done"
@click="close"
/>
</N8nDialogFooter>
</div>
</N8nDialog>
</template>
<style module lang="scss">
.form,
.created {
display: flex;
flex-direction: column;
gap: var(--spacing--md);
}
</style>

View File

@ -0,0 +1,170 @@
<script lang="ts" setup>
import {
N8nCopyInput,
N8nInput,
N8nInputLabel,
N8nNotice,
N8nOption,
N8nSelect,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import { computed, ref } from 'vue';
import { MCP_ENDPOINT } from '@/features/ai/mcpAccess/mcp.constants';
import CodeSnippet from './CodeSnippet.vue';
const props = defineProps<{
clientId: string;
/**
* Only known in the moment right after creation; otherwise the snippet falls
* back to a shell-variable placeholder.
*/
clientSecret?: string;
}>();
const i18n = useI18n();
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
type ResourceKind = 'instanceMcp' | 'publicApi' | 'custom';
const resourceKind = ref<ResourceKind>('instanceMcp');
/** `urlBaseEditor` already carries a trailing slash. */
const tokenEndpoint = computed(() => `${rootStore.urlBaseEditor}oauth/token`);
// Same derivation as the MCP store: the backend-provided canonical URL reflects a
// configured dedicated MCP base URL, with the editor base as the fallback.
const instanceMcpResource = computed(
() => settingsStore.moduleSettings.mcp?.serverUrl ?? `${rootStore.urlBaseEditor}${MCP_ENDPOINT}`,
);
// Join with exactly one slash: `publicApiPath` may or may not start with one.
const publicApiResource = computed(() => {
const base = rootStore.urlBaseEditor.replace(/\/+$/, '');
const path = settingsStore.publicApiPath.replace(/^\/+/, '');
return `${base}/${path}/v${settingsStore.publicApiLatestVersion}`;
});
/** Prefilled with the MCP Trigger base so only the workflow's path is missing. */
const customResource = ref(`${rootStore.mcpUrl}/`);
const resource = computed(() => {
switch (resourceKind.value) {
case 'instanceMcp':
return instanceMcpResource.value;
case 'publicApi':
return publicApiResource.value;
case 'custom':
return customResource.value;
}
});
const resourceOptions = computed<Array<{ value: ResourceKind; label: string }>>(() => [
{
value: 'instanceMcp',
label: i18n.baseText('settings.serviceAccounts.credentials.usage.resourceInstanceMcp'),
},
{
value: 'publicApi',
label: i18n.baseText('settings.serviceAccounts.credentials.usage.resourcePublicApi'),
},
{
value: 'custom',
label: i18n.baseText('settings.serviceAccounts.credentials.usage.resourceCustom'),
},
]);
const secretPlaceholder = '$CLIENT_SECRET';
const curl = computed(
() => `curl -s -X POST ${tokenEndpoint.value} \\
-H 'Content-Type: application/x-www-form-urlencoded' \\
-d grant_type=client_credentials \\
-d client_id=${props.clientId} \\
-d client_secret=${props.clientSecret ?? secretPlaceholder} \\
--data-urlencode 'resource=${resource.value}'`,
);
</script>
<template>
<div :class="$style.container" data-test-id="service-account-credential-usage">
<N8nInputLabel
:label="i18n.baseText('settings.serviceAccounts.credentials.usage.tokenEndpoint')"
color="text-dark"
>
<N8nCopyInput
:value="tokenEndpoint"
size="medium"
:copy-label="i18n.baseText('generic.copy')"
:copied-label="i18n.baseText('generic.copiedToClipboard')"
data-test-id="service-account-token-endpoint"
/>
</N8nInputLabel>
<N8nInputLabel
:label="i18n.baseText('settings.serviceAccounts.credentials.usage.resource')"
color="text-dark"
>
<div :class="$style.resource">
<!-- Kept in the dialog's stacking context so the dropdown isn't clipped
behind the overlay. -->
<N8nSelect
v-model="resourceKind"
size="medium"
:teleported="false"
data-test-id="service-account-resource-select"
>
<N8nOption
v-for="option in resourceOptions"
:key="option.value"
:value="option.value"
:label="option.label"
/>
</N8nSelect>
<N8nInput
v-if="resourceKind === 'custom'"
v-model="customResource"
size="medium"
data-test-id="service-account-resource-custom"
/>
<N8nCopyInput
v-else
:value="resource"
size="medium"
:copy-label="i18n.baseText('generic.copy')"
:copied-label="i18n.baseText('generic.copiedToClipboard')"
data-test-id="service-account-resource-value"
/>
</div>
</N8nInputLabel>
<N8nNotice>
{{ i18n.baseText('settings.serviceAccounts.credentials.usage.notice') }}
</N8nNotice>
<N8nInputLabel
:label="i18n.baseText('settings.serviceAccounts.credentials.usage.curlLabel')"
color="text-dark"
>
<CodeSnippet :value="curl" language="bash" />
</N8nInputLabel>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
}
.resource {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
</style>

View File

@ -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> = {},
): 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());
});
});

View File

@ -0,0 +1,336 @@
<script lang="ts" setup>
import type {
ServiceAccount,
ServiceAccountCredential,
ServiceAccountCredentialWithSecret,
} from '@n8n/api-types';
import { useToast } from '@n8n/composables/useToast';
import {
N8nAlertDialog,
N8nButton,
N8nCollapsiblePanel,
N8nCopyInput,
N8nDialog,
N8nDialogFooter,
N8nInputLabel,
N8nLoading,
N8nText,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { DateTime } from 'luxon';
import { computed, ref, watch } from 'vue';
import { getServiceAccountDisplayName } from '../serviceAccounts.utils';
import { useServiceAccountsStore } from '../serviceAccounts.store';
import ServiceAccountCredentialUsage from './ServiceAccountCredentialUsage.vue';
const props = defineProps<{
serviceAccount: ServiceAccount | null;
open: boolean;
}>();
const emit = defineEmits<{
'update:open': [value: boolean];
}>();
const i18n = useI18n();
const toast = useToast();
const serviceAccountsStore = useServiceAccountsStore();
const credentials = ref<ServiceAccountCredential[]>([]);
const created = ref<ServiceAccountCredentialWithSecret | null>(null);
const loading = ref(false);
const creating = ref(false);
const deleting = ref(false);
const deleteTarget = ref<ServiceAccountCredential | null>(null);
const usageOpen = ref(false);
const name = computed(() =>
props.serviceAccount ? getServiceAccountDisplayName(props.serviceAccount) : '',
);
// The backend does not order the list, and `createdAt` is the only thing that
// distinguishes rows, so newest-first is imposed here.
const sorted = computed(() =>
[...credentials.value].sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
);
/** The usage panel documents the credential in hand: the fresh one, else the newest. */
const usageCredential = computed<ServiceAccountCredential | null>(
() => created.value ?? sorted.value[0] ?? null,
);
const deleteDescription = computed(() =>
deleteTarget.value
? i18n.baseText('settings.serviceAccounts.credentials.delete.description', {
interpolate: { clientId: deleteTarget.value.clientId },
})
: '',
);
const formatCreated = (createdAt: string) => DateTime.fromISO(createdAt).toRelative() ?? createdAt;
const fetchCredentials = async () => {
if (!props.serviceAccount) return;
loading.value = true;
try {
credentials.value = await serviceAccountsStore.listCredentials(props.serviceAccount.id);
} catch (error) {
toast.showError(error, i18n.baseText('settings.serviceAccounts.credentials.listError'));
} finally {
loading.value = false;
}
};
// Reopening always starts from the list: the secret is shown once, so a stale
// `created` must never survive a close/reopen.
watch(
() => props.open,
async (open) => {
created.value = null;
deleteTarget.value = null;
usageOpen.value = false;
creating.value = false;
if (open) {
await fetchCredentials();
} else {
credentials.value = [];
}
},
);
const onCreate = async () => {
if (!props.serviceAccount) return;
creating.value = true;
try {
created.value = await serviceAccountsStore.createCredential(props.serviceAccount.id);
// The one moment a copy-paste-ready command is possible, so lead with it open.
usageOpen.value = true;
toast.showMessage({
title: i18n.baseText('settings.serviceAccounts.credentials.success'),
type: 'success',
});
} catch (error) {
toast.showError(error, i18n.baseText('settings.serviceAccounts.credentials.error'));
} finally {
creating.value = false;
}
};
// Refresh before dropping `created`: the fresh credential is already in the list by
// the time the reveal state goes away, so the usage panel is never briefly credential-less.
const onDone = async () => {
await fetchCredentials();
created.value = null;
usageOpen.value = false;
};
const onConfirmDelete = async () => {
if (!deleteTarget.value) return;
deleting.value = true;
try {
await serviceAccountsStore.deleteCredential(deleteTarget.value.id);
toast.showMessage({
title: i18n.baseText('settings.serviceAccounts.credentials.delete.success'),
type: 'success',
});
deleteTarget.value = null;
await fetchCredentials();
} catch (error) {
toast.showError(error, i18n.baseText('settings.serviceAccounts.credentials.delete.error'));
} finally {
deleting.value = false;
}
};
const onCopied = () => {
toast.showMessage({
title: i18n.baseText('settings.serviceAccounts.credentials.copied'),
type: 'success',
});
};
</script>
<template>
<N8nDialog
:open="open"
size="large"
:header="i18n.baseText('settings.serviceAccounts.credentials.title', { interpolate: { name } })"
@update:open="emit('update:open', $event)"
>
<!-- reka-ui's DialogRoot sets `inheritAttrs: false`, so the test id has to sit
on an element that actually renders. -->
<div :class="$style.body" data-test-id="service-account-credentials-modal">
<template v-if="created">
<N8nText size="small" color="text-base">
{{ i18n.baseText('settings.serviceAccounts.credentials.warning') }}
</N8nText>
<N8nInputLabel
:label="i18n.baseText('settings.serviceAccounts.credentials.clientId')"
color="text-dark"
>
<N8nCopyInput
:value="created.clientId"
size="large"
:copy-label="i18n.baseText('generic.copy')"
:copied-label="i18n.baseText('generic.copiedToClipboard')"
data-test-id="service-account-credential-client-id"
@copy="onCopied"
/>
</N8nInputLabel>
<N8nInputLabel
:label="i18n.baseText('settings.serviceAccounts.credentials.clientSecret')"
color="text-dark"
>
<N8nCopyInput
:value="created.clientSecret"
size="large"
:copy-label="i18n.baseText('generic.copy')"
:copied-label="i18n.baseText('generic.copiedToClipboard')"
class="ph-no-capture"
data-test-id="service-account-credential-client-secret"
@copy="onCopied"
/>
</N8nInputLabel>
</template>
<template v-else>
<N8nLoading v-if="loading" variant="p" :rows="3" />
<N8nText
v-else-if="sorted.length === 0"
size="small"
color="text-base"
data-test-id="service-account-credentials-empty"
>
{{ i18n.baseText('settings.serviceAccounts.credentials.empty') }}
</N8nText>
<div v-else :class="$style.list" data-test-id="service-account-credentials-list">
<div :class="[$style.row, $style.headerRow]">
<N8nText size="small" color="text-light" bold>
{{ i18n.baseText('settings.serviceAccounts.credentials.clientId') }}
</N8nText>
<N8nText size="small" color="text-light" bold>
{{ i18n.baseText('settings.serviceAccounts.credentials.column.created') }}
</N8nText>
<span />
</div>
<div
v-for="credential in sorted"
:key="credential.id"
:class="$style.row"
data-test-id="service-account-credential-row"
>
<N8nCopyInput
:value="credential.clientId"
size="small"
:class="$style.clientId"
:copy-label="i18n.baseText('generic.copy')"
:copied-label="i18n.baseText('generic.copiedToClipboard')"
@copy="onCopied"
/>
<N8nText size="small" color="text-base">{{
formatCreated(credential.createdAt)
}}</N8nText>
<N8nButton
variant="ghost"
icon-only
icon="trash-2"
size="small"
:aria-label="
i18n.baseText('settings.serviceAccounts.credentials.delete.action', {
interpolate: { clientId: credential.clientId },
})
"
data-test-id="service-account-credential-delete"
@click="deleteTarget = credential"
/>
</div>
</div>
</template>
<N8nCollapsiblePanel
v-if="usageCredential"
v-model="usageOpen"
:title="i18n.baseText('settings.serviceAccounts.credentials.usage.title')"
>
<ServiceAccountCredentialUsage
:client-id="usageCredential.clientId"
:client-secret="created?.clientSecret"
/>
</N8nCollapsiblePanel>
</div>
<N8nDialogFooter>
<N8nButton
v-if="created"
:label="i18n.baseText('settings.serviceAccounts.credentials.done')"
data-test-id="service-account-credential-done"
@click="onDone"
/>
<N8nButton
v-else
:loading="creating"
:label="i18n.baseText('settings.serviceAccounts.credentials.add')"
data-test-id="service-account-credential-add"
@click="onCreate"
/>
</N8nDialogFooter>
<!--
Nested in the component tree only: its own portal puts it on the body after
this dialog's, so it stacks on top at the shared dialog z-index, and reka-ui's
layer stack keeps this dialog from dismissing underneath it.
-->
<N8nAlertDialog
:open="deleteTarget !== null"
:title="i18n.baseText('settings.serviceAccounts.credentials.delete.title')"
:description="deleteDescription"
:action-label="i18n.baseText('settings.serviceAccounts.credentials.delete.confirm')"
:cancel-label="i18n.baseText('generic.cancel')"
action-variant="destructive"
:loading="deleting"
size="medium"
@action="onConfirmDelete"
@cancel="deleteTarget = null"
@update:open="!$event && (deleteTarget = null)"
>
<N8nText size="small" color="text-light">
{{ i18n.baseText('settings.serviceAccounts.credentials.tokensRemainValid') }}
</N8nText>
</N8nAlertDialog>
</N8nDialog>
</template>
<style module lang="scss">
.body {
display: flex;
flex-direction: column;
gap: var(--spacing--md);
/* The usage panel makes the reveal state tall; keep the dialog inside the viewport. */
max-height: 60vh;
overflow-y: auto;
}
/* One grid for the whole list, rows as `display: contents`, so the column headers
line up with the cells below them — per-row grids would each size independently. */
.list {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
column-gap: var(--spacing--xs);
row-gap: var(--spacing--2xs);
}
.row {
display: contents;
}
/* No row dividers: `display: contents` rows can't carry a border, and per-cell
borders come out broken by the column gaps. The bordered client-ID field
already separates the rows. */
.clientId input {
font-family: var(--font-family--monospace);
}
</style>

View File

@ -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';

View File

@ -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,
};
});

View File

@ -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();
});
});

View File

@ -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<Array<UserAction<ServiceAccount>>>(() => [
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)"
/>
<CreateServiceAccountCredentialModal
<ServiceAccountCredentialsModal
:service-account="credentialTarget"
:open="credentialTarget !== null"
@update:open="!$event && (credentialTarget = null)"