mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
Allow credential generation via the UI
This commit is contained in:
parent
a08748d92c
commit
df74d1fcfd
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user