mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
Added UI to create/manage credentials
This commit is contained in:
parent
060131bdf0
commit
91987eb7d8
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user