Allow credential generation via the UI

This commit is contained in:
Andreas Fitzek 2026-08-04 14:01:04 +02:00
parent a08748d92c
commit df74d1fcfd
No known key found for this signature in database
6 changed files with 217 additions and 1 deletions

View File

@ -3596,6 +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.delete": "Delete",
"settings.serviceAccounts.createModal.title": "Create service account",
"settings.serviceAccounts.createModal.name.label": "Name",
@ -3616,6 +3617,16 @@
"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.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.done": "Done",
"settings.serviceAccounts.credentials.success": "Credential created",
"settings.serviceAccounts.credentials.error": "Could not create credential",
"settings.serviceAccounts.credentials.copied": "Copied to clipboard",
"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

@ -0,0 +1,28 @@
import type {
CreateServiceAccountCredentialRequestDto,
ServiceAccountCredential,
ServiceAccountCredentialWithSecret,
} from '@n8n/api-types';
import type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils';
export async function getServiceAccountCredentials(
context: IRestApiContext,
): Promise<ServiceAccountCredential[]> {
return await makeRestApiRequest(context, 'GET', '/service-account-credentials');
}
export async function createServiceAccountCredential(
context: IRestApiContext,
payload: CreateServiceAccountCredentialRequestDto,
): Promise<ServiceAccountCredentialWithSecret> {
return await makeRestApiRequest(context, 'POST', '/service-account-credentials', payload);
}
export async function deleteServiceAccountCredential(
context: IRestApiContext,
id: string,
): Promise<{ success: boolean }> {
return await makeRestApiRequest(context, 'DELETE', `/service-account-credentials/${id}`);
}

View File

@ -0,0 +1,151 @@
<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

@ -8,6 +8,7 @@ export const SERVICE_ACCOUNT_ACTIONS = {
DISABLE: 'disable',
ENABLE: 'enable',
DELETE: 'delete',
CREATE_CREDENTIAL: 'createCredential',
} as const;
export const CREATE_SERVICE_ACCOUNT_MODAL_KEY = 'createServiceAccount';

View File

@ -1,5 +1,6 @@
import type { ServiceAccountsList, UsersListFilterDto } from '@n8n/api-types';
import * as serviceAccountsApi from '@n8n/rest-api-client/api/service-accounts';
import * as serviceAccountCredentialsApi from '@n8n/rest-api-client/api/service-account-credentials';
import { STORES } from '@n8n/stores';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useAsyncState } from '@vueuse/core';
@ -35,11 +36,17 @@ export const useServiceAccountsStore = defineStore(STORES.SERVICE_ACCOUNTS, () =
const remove = async (id: string) =>
await serviceAccountsApi.deleteServiceAccount(rootStore.restApiContext, id);
const createCredential = async (userId: string) =>
await serviceAccountCredentialsApi.createServiceAccountCredential(rootStore.restApiContext, {
userId,
});
return {
serviceAccountsList,
create,
update,
changeRole,
remove,
createCredential,
};
});

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { ServiceAccount } from '@n8n/api-types';
import { ROLE, type ServiceAccount } from '@n8n/api-types';
import { useToast } from '@n8n/composables/useToast';
import {
N8nButton,
@ -19,6 +19,7 @@ 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 ServiceAccountsTable from '../components/ServiceAccountsTable.vue';
@ -43,6 +44,7 @@ const submitting = ref(false);
const updatingRoleId = ref<string | null>(null);
const actAsTarget = ref<ServiceAccount | null>(null);
const deleteTarget = ref<ServiceAccount | null>(null);
const credentialTarget = ref<ServiceAccount | null>(null);
// `state`/`isLoading` are already unwrapped — pinia's `reactive()` on the store's
// return value deep-unwraps the refs `useAsyncState` hands back.
@ -62,6 +64,9 @@ const canUpdate = computed(() =>
const canImpersonate = computed(() =>
hasPermission(['rbac'], { rbac: { scope: 'serviceAccount:impersonate' } }),
);
const canManageCredentials = computed(() =>
hasPermission(['role'], { role: [ROLE.Owner, ROLE.Admin] }),
);
const actions = computed<Array<UserAction<ServiceAccount>>>(() => [
{
@ -81,6 +86,11 @@ const actions = computed<Array<UserAction<ServiceAccount>>>(() => [
value: SERVICE_ACCOUNT_ACTIONS.ENABLE,
guard: (row) => canUpdate.value && Boolean(row.disabled),
},
{
label: i18n.baseText('settings.serviceAccounts.actions.createCredential'),
value: SERVICE_ACCOUNT_ACTIONS.CREATE_CREDENTIAL,
guard: () => canManageCredentials.value,
},
{
label: i18n.baseText('settings.serviceAccounts.actions.delete'),
value: SERVICE_ACCOUNT_ACTIONS.DELETE,
@ -188,6 +198,9 @@ const onAction = async ({ action, userId }: { action: string; userId: string })
case SERVICE_ACCOUNT_ACTIONS.DELETE:
deleteTarget.value = serviceAccount;
break;
case SERVICE_ACCOUNT_ACTIONS.CREATE_CREDENTIAL:
credentialTarget.value = serviceAccount;
break;
}
};
@ -290,5 +303,10 @@ const openCreateModal = () => uiStore.openModal(CREATE_SERVICE_ACCOUNT_MODAL_KEY
@cancel="deleteTarget = null"
@update:open="!$event && (deleteTarget = null)"
/>
<CreateServiceAccountCredentialModal
:service-account="credentialTarget"
:open="credentialTarget !== null"
@update:open="!$event && (credentialTarget = null)"
/>
</N8nSettingsLayout>
</template>