mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-20 03:12:15 +00:00
feat(core): Add scopes to API Keys (#14176)
Co-authored-by: Charlie Kolb <charlie@n8n.io> Co-authored-by: Danny Martini <danny@n8n.io>
This commit is contained in:
@@ -1544,7 +1544,8 @@ export type EnterpriseEditionFeatureKey =
|
||||
| 'DebugInEditor'
|
||||
| 'WorkflowHistory'
|
||||
| 'WorkerView'
|
||||
| 'AdvancedPermissions';
|
||||
| 'AdvancedPermissions'
|
||||
| 'ApiKeyScopes';
|
||||
|
||||
export type EnterpriseEditionFeatureValue = keyof Omit<FrontendSettings['enterprise'], 'projects'>;
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export const defaultSettings: FrontendSettings = {
|
||||
externalSecrets: false,
|
||||
workerView: false,
|
||||
advancedPermissions: false,
|
||||
apiKeyScopes: false,
|
||||
projects: {
|
||||
team: {
|
||||
limit: 1,
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '@/constants';
|
||||
import type { INodeUi, IWorkflowDb } from '@/Interface';
|
||||
import { CanvasNodeRenderType } from '@/types';
|
||||
import type { FrontendSettings } from '@n8n/api-types';
|
||||
|
||||
export const mockNode = ({
|
||||
id = uuid(),
|
||||
@@ -205,6 +206,35 @@ export function createTestNode(node: Partial<INode> = {}): INode {
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockEnterpriseSettings(
|
||||
overrides: Partial<FrontendSettings['enterprise']> = {},
|
||||
): FrontendSettings['enterprise'] {
|
||||
return {
|
||||
sharing: false,
|
||||
ldap: false,
|
||||
saml: false,
|
||||
logStreaming: false,
|
||||
advancedExecutionFilters: false,
|
||||
variables: false,
|
||||
sourceControl: false,
|
||||
auditLogs: false,
|
||||
externalSecrets: false,
|
||||
showNonProdBanner: false,
|
||||
debugInEditor: false,
|
||||
binaryDataS3: false,
|
||||
workflowHistory: false,
|
||||
workerView: false,
|
||||
advancedPermissions: false,
|
||||
apiKeyScopes: false,
|
||||
projects: {
|
||||
team: {
|
||||
limit: 0,
|
||||
},
|
||||
},
|
||||
...overrides, // Override with any passed properties
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestTaskData(partialData: Partial<ITaskData>): ITaskData {
|
||||
return {
|
||||
startTime: 0,
|
||||
|
||||
@@ -6,11 +6,16 @@ import type {
|
||||
ApiKey,
|
||||
ApiKeyWithRawValue,
|
||||
} from '@n8n/api-types';
|
||||
import type { ApiKeyScope } from '@n8n/permissions';
|
||||
|
||||
export async function getApiKeys(context: IRestApiContext): Promise<ApiKey[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/api-keys');
|
||||
}
|
||||
|
||||
export async function getApiKeyScopes(context: IRestApiContext): Promise<ApiKeyScope[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/api-keys/scopes');
|
||||
}
|
||||
|
||||
export async function createApiKey(
|
||||
context: IRestApiContext,
|
||||
payload: CreateApiKeyRequestDto,
|
||||
|
||||
@@ -8,6 +8,8 @@ import { fireEvent } from '@testing-library/vue';
|
||||
import { useApiKeysStore } from '@/stores/apiKeys.store';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { ApiKeyWithRawValue } from '@n8n/api-types';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { createMockEnterpriseSettings } from '@/__tests__/mocks';
|
||||
|
||||
const renderComponent = createComponentRenderer(ApiKeyEditModal, {
|
||||
pinia: createTestingPinia({
|
||||
@@ -29,13 +31,17 @@ const testApiKey: ApiKeyWithRawValue = {
|
||||
updatedAt: new Date().toString(),
|
||||
rawApiKey: '123456',
|
||||
expiresAt: 0,
|
||||
scopes: ['user:create', 'user:list'],
|
||||
};
|
||||
|
||||
const apiKeysStore = mockedStore(useApiKeysStore);
|
||||
const settingsStore = mockedStore(useSettingsStore);
|
||||
|
||||
describe('ApiKeyCreateOrEditModal', () => {
|
||||
beforeEach(() => {
|
||||
createAppModals();
|
||||
apiKeysStore.availableScopes = ['user:create', 'user:list'];
|
||||
settingsStore.settings.enterprise = createMockEnterpriseSettings({ apiKeyScopes: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -87,6 +93,7 @@ describe('ApiKeyCreateOrEditModal', () => {
|
||||
updatedAt: new Date().toString(),
|
||||
rawApiKey: '***456',
|
||||
expiresAt: 0,
|
||||
scopes: ['user:create', 'user:list'],
|
||||
});
|
||||
|
||||
const { getByText, getByPlaceholderText, getByTestId } = renderComponent({
|
||||
@@ -184,6 +191,116 @@ describe('ApiKeyCreateOrEditModal', () => {
|
||||
expect(getByText('new api key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should allow creating API key with scopes when feat:apiKeyScopes is enabled', async () => {
|
||||
settingsStore.settings.enterprise = createMockEnterpriseSettings({ apiKeyScopes: true });
|
||||
|
||||
apiKeysStore.createApiKey.mockResolvedValue(testApiKey);
|
||||
|
||||
const { getByText, getByPlaceholderText, getByTestId, getAllByText } = renderComponent({
|
||||
props: {
|
||||
mode: 'new',
|
||||
},
|
||||
});
|
||||
|
||||
await retry(() => expect(getByText('Create API Key')).toBeInTheDocument());
|
||||
expect(getByText('Label')).toBeInTheDocument();
|
||||
|
||||
const inputLabel = getByPlaceholderText('e.g Internal Project');
|
||||
const saveButton = getByText('Save');
|
||||
|
||||
const scopesSelect = getByTestId('scopes-select');
|
||||
|
||||
expect(inputLabel).toBeInTheDocument();
|
||||
expect(scopesSelect).toBeInTheDocument();
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
|
||||
await fireEvent.update(inputLabel, 'new label');
|
||||
|
||||
await fireEvent.click(scopesSelect);
|
||||
|
||||
const userCreateScope = getByText('user:create');
|
||||
|
||||
expect(userCreateScope).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(userCreateScope);
|
||||
|
||||
const [userCreateTag, userCreateSelectOption] = getAllByText('user:create');
|
||||
|
||||
expect(userCreateTag).toBeInTheDocument();
|
||||
expect(userCreateSelectOption).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(saveButton);
|
||||
|
||||
expect(getByText('API Key Created')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('Done')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
getByText('Make sure to copy your API key now as you will not be able to see this again.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(getByText('Click to copy')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('new api key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not let the user select scopes and show upgrade banner when feat:apiKeyScopes is disabled', async () => {
|
||||
settingsStore.settings.enterprise = createMockEnterpriseSettings({ apiKeyScopes: false });
|
||||
|
||||
apiKeysStore.createApiKey.mockResolvedValue(testApiKey);
|
||||
|
||||
const { getByText, getByPlaceholderText, getByTestId, getAllByText } = renderComponent({
|
||||
props: {
|
||||
mode: 'new',
|
||||
},
|
||||
});
|
||||
|
||||
await retry(() => expect(getByText('Create API Key')).toBeInTheDocument());
|
||||
expect(getByText('Label')).toBeInTheDocument();
|
||||
|
||||
const inputLabel = getByPlaceholderText('e.g Internal Project');
|
||||
const saveButton = getByText('Save');
|
||||
|
||||
expect(getByText('Upgrade')).toBeInTheDocument();
|
||||
expect(getByText('to unlock the ability to modify API key scopes')).toBeInTheDocument();
|
||||
|
||||
const scopesSelect = getByTestId('scopes-select');
|
||||
|
||||
expect(inputLabel).toBeInTheDocument();
|
||||
expect(scopesSelect).toBeInTheDocument();
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
|
||||
await fireEvent.update(inputLabel, 'new label');
|
||||
|
||||
await fireEvent.click(scopesSelect);
|
||||
|
||||
const userCreateScope = getAllByText('user:create');
|
||||
|
||||
const [userCreateTag, userCreateSelectOption] = userCreateScope;
|
||||
expect(userCreateTag).toBeInTheDocument();
|
||||
expect(userCreateSelectOption).toBeInTheDocument();
|
||||
|
||||
expect(userCreateSelectOption).toBeInTheDocument();
|
||||
|
||||
expect(userCreateSelectOption.parentNode).toHaveClass('is-disabled');
|
||||
|
||||
await fireEvent.click(userCreateSelectOption);
|
||||
|
||||
await fireEvent.click(saveButton);
|
||||
|
||||
expect(getByText('API Key Created')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('Done')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
getByText('Make sure to copy your API key now as you will not be able to see this again.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(getByText('Click to copy')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('new api key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should allow editing API key label', async () => {
|
||||
apiKeysStore.apiKeys = [testApiKey];
|
||||
|
||||
@@ -218,6 +335,9 @@ describe('ApiKeyCreateOrEditModal', () => {
|
||||
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
expect(apiKeysStore.updateApiKey).toHaveBeenCalledWith('123', { label: 'updated api key' });
|
||||
expect(apiKeysStore.updateApiKey).toHaveBeenCalledWith('123', {
|
||||
label: 'updated api key',
|
||||
scopes: ['user:create', 'user:list'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import { API_KEY_CREATE_OR_EDIT_MODAL_KEY } from '@/constants';
|
||||
import { API_KEY_CREATE_OR_EDIT_MODAL_KEY, EnterpriseEditionFeature } from '@/constants';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
@@ -13,6 +13,9 @@ import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import { N8nText } from '@n8n/design-system';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { ApiKey, ApiKeyWithRawValue, CreateApiKeyRequestDto } from '@n8n/api-types';
|
||||
import ApiKeyScopes from '@/components/ApiKeyScopes.vue';
|
||||
import type { ApiKeyScope } from '@n8n/permissions';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
||||
const EXPIRATION_OPTIONS = {
|
||||
'7_DAYS': 7,
|
||||
@@ -28,7 +31,7 @@ const { showError, showMessage } = useToast();
|
||||
|
||||
const uiStore = useUIStore();
|
||||
const rootStore = useRootStore();
|
||||
const { createApiKey, updateApiKey, apiKeysById } = useApiKeysStore();
|
||||
const { createApiKey, updateApiKey, apiKeysById, availableScopes } = useApiKeysStore();
|
||||
const documentTitle = useDocumentTitle();
|
||||
|
||||
const label = ref('');
|
||||
@@ -40,6 +43,14 @@ const rawApiKey = ref('');
|
||||
const customExpirationDate = ref('');
|
||||
const showExpirationDateSelector = ref(false);
|
||||
const apiKeyCreationDate = ref('');
|
||||
const selectedScopes = ref<ApiKeyScope[]>([]);
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const apiKeyStore = useApiKeysStore();
|
||||
|
||||
const apiKeyScopesEnabled = computed(
|
||||
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.ApiKeyScopes],
|
||||
);
|
||||
|
||||
const calculateExpirationDate = (daysFromNow: number) => {
|
||||
const date = DateTime.now()
|
||||
@@ -88,7 +99,11 @@ const allFormFieldsAreSet = computed(() => {
|
||||
(expirationDaysFromNow.value === EXPIRATION_OPTIONS.CUSTOM && customExpirationDate.value) ||
|
||||
expirationDate.value;
|
||||
|
||||
return label.value && (props.mode === 'edit' ? true : isExpirationDateSet);
|
||||
return (
|
||||
label.value &&
|
||||
(!apiKeyScopesEnabled.value ? true : selectedScopes.value.length) &&
|
||||
(props.mode === 'edit' ? true : isExpirationDateSet)
|
||||
);
|
||||
});
|
||||
|
||||
const isCustomDateInThePast = (date: Date) => Date.now() > date.getTime();
|
||||
@@ -104,6 +119,11 @@ onMounted(() => {
|
||||
const apiKey = apiKeysById[props.activeId];
|
||||
label.value = apiKey.label ?? '';
|
||||
apiKeyCreationDate.value = getApiKeyCreationTime(apiKey);
|
||||
selectedScopes.value = !apiKeyScopesEnabled.value ? apiKeyStore.availableScopes : apiKey.scopes;
|
||||
}
|
||||
|
||||
if (props.mode === 'new' && !apiKeyScopesEnabled.value) {
|
||||
selectedScopes.value = availableScopes;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -111,6 +131,10 @@ function onInput(value: string): void {
|
||||
label.value = value;
|
||||
}
|
||||
|
||||
function onScopeSelectionChanged(scopes: ApiKeyScope[]) {
|
||||
selectedScopes.value = scopes;
|
||||
}
|
||||
|
||||
const getApiKeyCreationTime = (apiKey: ApiKey): string => {
|
||||
const time = DateTime.fromMillis(Date.parse(apiKey.createdAt)).toFormat('ccc, MMM d yyyy');
|
||||
return i18n.baseText('settings.api.creationTime', { interpolate: { time } });
|
||||
@@ -119,7 +143,7 @@ const getApiKeyCreationTime = (apiKey: ApiKey): string => {
|
||||
async function onEdit() {
|
||||
try {
|
||||
loading.value = true;
|
||||
await updateApiKey(props.activeId, { label: label.value });
|
||||
await updateApiKey(props.activeId, { label: label.value, scopes: selectedScopes.value });
|
||||
showMessage({
|
||||
type: 'success',
|
||||
title: i18n.baseText('settings.api.update.toast'),
|
||||
@@ -152,6 +176,7 @@ const onSave = async () => {
|
||||
const payload: CreateApiKeyRequestDto = {
|
||||
label: label.value,
|
||||
expiresAt: expirationUnixTimestamp,
|
||||
scopes: selectedScopes.value,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -218,7 +243,7 @@ async function handleEnterKey(event: KeyboardEvent) {
|
||||
width="600px"
|
||||
:lock-scroll="false"
|
||||
:close-on-esc="true"
|
||||
:close-on-click-outside="true"
|
||||
:close-on-click-modal="false"
|
||||
:show-close="true"
|
||||
>
|
||||
<template #content>
|
||||
@@ -260,6 +285,7 @@ async function handleEnterKey(event: KeyboardEvent) {
|
||||
v-model="expirationDaysFromNow"
|
||||
size="large"
|
||||
filterable
|
||||
readonly
|
||||
data-test-id="expiration-select"
|
||||
@update:model-value="onSelect"
|
||||
>
|
||||
@@ -291,6 +317,12 @@ async function handleEnterKey(event: KeyboardEvent) {
|
||||
:disabled-date="isCustomDateInThePast"
|
||||
/>
|
||||
</div>
|
||||
<ApiKeyScopes
|
||||
v-model="selectedScopes"
|
||||
:available-scopes="availableScopes"
|
||||
:enabled="apiKeyScopesEnabled"
|
||||
@update:model-value="onScopeSelectionChanged"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
186
packages/frontend/editor-ui/src/components/ApiKeyScopes.vue
Normal file
186
packages/frontend/editor-ui/src/components/ApiKeyScopes.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { ElSelect, ElOption, ElOptionGroup } from 'element-plus';
|
||||
import { capitalCase } from 'change-case';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
|
||||
// Define props
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
availableScopes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
enabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const selectedScopes = ref(props.modelValue);
|
||||
|
||||
const i18n = useI18n();
|
||||
const { goToUpgrade } = usePageRedirectionHelper();
|
||||
|
||||
const checkAll = ref(false);
|
||||
const indeterminate = ref(false);
|
||||
|
||||
const groupedScopes = computed(() => {
|
||||
const groups = {};
|
||||
|
||||
props.availableScopes.forEach((scope) => {
|
||||
const [resource, action] = scope.split(':');
|
||||
|
||||
if (!groups[resource]) {
|
||||
groups[resource] = [];
|
||||
}
|
||||
|
||||
if (action) {
|
||||
groups[resource].push(action);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
watch(selectedScopes, (newValue) => {
|
||||
if (newValue.length === props.availableScopes.length) {
|
||||
indeterminate.value = false;
|
||||
checkAll.value = true;
|
||||
} else if (newValue.length > 0) {
|
||||
indeterminate.value = true;
|
||||
} else if (newValue.length === 0) {
|
||||
indeterminate.value = false;
|
||||
checkAll.value = false;
|
||||
}
|
||||
emit('update:modelValue', newValue);
|
||||
});
|
||||
|
||||
watch(checkAll, (newValue) => {
|
||||
if (newValue) {
|
||||
selectedScopes.value = props.availableScopes;
|
||||
} else {
|
||||
selectedScopes.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
function goToUpgradeApiKeyScopes() {
|
||||
void goToUpgrade('api-key-scopes', 'upgrade-api-key-scopes');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style['api-key-scopes']">
|
||||
<div ref="popperContainer"></div>
|
||||
<N8nInputLabel :label="i18n.baseText('settings.api.scopes.label')" color="text-dark">
|
||||
<ElSelect
|
||||
v-model="selectedScopes"
|
||||
data-test-id="scopes-select"
|
||||
:popper-class="$style['scopes-dropdown-container']"
|
||||
:teleported="true"
|
||||
multiple
|
||||
collapse-tags
|
||||
:max-collapse-tags="10"
|
||||
placement="top"
|
||||
:reserve-keyword="false"
|
||||
:placeholder="i18n.baseText('settings.api.scopes.placeholder')"
|
||||
:append-to="popperContainer"
|
||||
>
|
||||
<template #header>
|
||||
<el-checkbox
|
||||
v-model="checkAll"
|
||||
:disabled="!enabled"
|
||||
:class="$style['scopes-checkbox']"
|
||||
:indeterminate="indeterminate"
|
||||
>
|
||||
{{ i18n.baseText('settings.api.scopes.selectAll') }}
|
||||
</el-checkbox>
|
||||
</template>
|
||||
|
||||
<template v-for="(actions, resource) in groupedScopes" :key="resource">
|
||||
<ElOptionGroup :disabled="!enabled" :label="capitalCase(resource).toUpperCase()">
|
||||
<ElOption
|
||||
v-for="action in actions"
|
||||
:key="`${resource}:${action}`"
|
||||
:label="`${resource}:${action}`"
|
||||
:value="`${resource}:${action}`"
|
||||
/>
|
||||
</ElOptionGroup>
|
||||
</template>
|
||||
</ElSelect>
|
||||
</N8nInputLabel>
|
||||
<N8nNotice v-if="!enabled">
|
||||
<i18n-t keypath="settings.api.scopes.upgrade">
|
||||
<template #link>
|
||||
<n8n-link size="small" @click="goToUpgradeApiKeyScopes">
|
||||
{{ i18n.baseText('settings.api.scopes.upgrade.link') }}
|
||||
</n8n-link>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</N8nNotice>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.api-key-scopes :global(.el-tag) {
|
||||
padding: var(--spacing-3xs);
|
||||
}
|
||||
|
||||
.api-key-scopes :global(.el-tag__close) {
|
||||
color: white;
|
||||
margin-left: var(--spacing-3xs);
|
||||
background-color: var(--color-text-base);
|
||||
}
|
||||
|
||||
.api-key-scopes :global(.el-checkbox) {
|
||||
margin-left: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.scopes-dropdown-container :global(.el-select-group__title) {
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-dark);
|
||||
font-weight: var(--font-weight-bold);
|
||||
border-bottom: var(--spacing-5xs) solid var(--color-text-lighter);
|
||||
padding-left: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.scopes-dropdown-container :global(.el-select-dropdown__item) {
|
||||
color: var(--color-text-base);
|
||||
font-weight: var(--font-weight-regular);
|
||||
padding-left: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.scopes-dropdown-container
|
||||
:global(.el-select-dropdown.is-multiple .el-select-dropdown__item.selected) {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.scopes-dropdown-container :global(.el-select-group__wrap:not(:last-of-type)) {
|
||||
padding: 0px;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.scopes-dropdown-container :global(.el-checkbox) {
|
||||
margin-left: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.scopes-dropdown-container :global(.el-select-dropdown__header) {
|
||||
margin-top: var(--spacing-xs);
|
||||
padding-bottom: var(--spacing-xs);
|
||||
border-bottom: var(--spacing-5xs) solid var(--color-text-lighter);
|
||||
}
|
||||
|
||||
.scopes-checkbox {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.scopes-dropdown-container :global(.el-select-group__wrap::after) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -634,6 +634,7 @@ export const EnterpriseEditionFeature: Record<
|
||||
WorkflowHistory: 'workflowHistory',
|
||||
WorkerView: 'workerView',
|
||||
AdvancedPermissions: 'advancedPermissions',
|
||||
ApiKeyScopes: 'apiKeyScopes',
|
||||
};
|
||||
|
||||
export const MAIN_NODE_PANEL_WIDTH = 390;
|
||||
|
||||
@@ -1902,6 +1902,8 @@
|
||||
"settings.users.userRoleUpdated.message": "{user} has been successfully updated to a {role}",
|
||||
"settings.users.userRoleUpdatedError": "Unable to updated role",
|
||||
"settings.api": "API",
|
||||
"settings.api.scopes.upgrade": "{link} to unlock the ability to modify API key scopes",
|
||||
"settings.api.scopes.upgrade.link": "Upgrade",
|
||||
"settings.n8napi": "n8n API",
|
||||
"settings.log-streaming": "Log Streaming",
|
||||
"settings.log-streaming.heading": "Log Streaming",
|
||||
@@ -1984,6 +1986,9 @@
|
||||
"settings.api.view.modal.done.button": "Done",
|
||||
"settings.api.view.modal.edit.button": "Edit",
|
||||
"settings.api.view.modal.save.button": "Save",
|
||||
"settings.api.scopes.placeholder": "Select",
|
||||
"settings.api.scopes.selectAll": "Select All",
|
||||
"settings.api.scopes.label": "Scopes",
|
||||
"settings.version": "Version",
|
||||
"settings.usageAndPlan.title": "Usage and plan",
|
||||
"settings.usageAndPlan.description": "You’re on the {name} {type}",
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useRootStore } from '@/stores/root.store';
|
||||
import * as publicApiApi from '@/api/api-keys';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { ApiKey, CreateApiKeyRequestDto, UpdateApiKeyRequestDto } from '@n8n/api-types';
|
||||
import type { ApiKeyScope } from '@n8n/permissions';
|
||||
|
||||
export const useApiKeysStore = defineStore(STORES.API_KEYS, () => {
|
||||
const apiKeys = ref<ApiKey[]>([]);
|
||||
const availableScopes = ref<ApiKeyScope[]>([]);
|
||||
|
||||
const rootStore = useRootStore();
|
||||
|
||||
@@ -25,6 +27,11 @@ export const useApiKeysStore = defineStore(STORES.API_KEYS, () => {
|
||||
);
|
||||
});
|
||||
|
||||
const getApiKeyAvailableScopes = async () => {
|
||||
availableScopes.value = await publicApiApi.getApiKeyScopes(rootStore.restApiContext);
|
||||
return availableScopes.value;
|
||||
};
|
||||
|
||||
const getAndCacheApiKeys = async () => {
|
||||
if (apiKeys.value.length) return apiKeys.value;
|
||||
apiKeys.value = await publicApiApi.getApiKeys(rootStore.restApiContext);
|
||||
@@ -46,6 +53,7 @@ export const useApiKeysStore = defineStore(STORES.API_KEYS, () => {
|
||||
const updateApiKey = async (id: string, payload: UpdateApiKeyRequestDto) => {
|
||||
await publicApiApi.updateApiKey(rootStore.restApiContext, id, payload);
|
||||
apiKeysById.value[id].label = payload.label;
|
||||
apiKeysById.value[id].scopes = payload.scopes;
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -53,8 +61,10 @@ export const useApiKeysStore = defineStore(STORES.API_KEYS, () => {
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
updateApiKey,
|
||||
getApiKeyAvailableScopes,
|
||||
apiKeysSortByCreationDate,
|
||||
apiKeysById,
|
||||
apiKeys,
|
||||
availableScopes,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@ describe('SettingsApiView', () => {
|
||||
updatedAt: new Date().toString(),
|
||||
apiKey: '****Atcr',
|
||||
expiresAt: null,
|
||||
scopes: ['user:create'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
@@ -115,6 +116,7 @@ describe('SettingsApiView', () => {
|
||||
updatedAt: new Date().toString(),
|
||||
apiKey: '****Bdcr',
|
||||
expiresAt: dateInTheFuture.toSeconds(),
|
||||
scopes: ['user:create'],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
@@ -123,6 +125,7 @@ describe('SettingsApiView', () => {
|
||||
updatedAt: new Date().toString(),
|
||||
apiKey: '****Wtcr',
|
||||
expiresAt: dateInThePast.toSeconds(),
|
||||
scopes: ['user:create'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -163,6 +166,7 @@ describe('SettingsApiView', () => {
|
||||
updatedAt: new Date().toString(),
|
||||
apiKey: '****Atcr',
|
||||
expiresAt: null,
|
||||
scopes: ['user:create'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
@@ -171,6 +175,7 @@ describe('SettingsApiView', () => {
|
||||
updatedAt: new Date().toString(),
|
||||
apiKey: '****Bdcr',
|
||||
expiresAt: dateInTheFuture.toSeconds(),
|
||||
scopes: ['user:create'],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
@@ -179,6 +184,7 @@ describe('SettingsApiView', () => {
|
||||
updatedAt: new Date().toString(),
|
||||
apiKey: '****Wtcr',
|
||||
expiresAt: dateInThePast.toSeconds(),
|
||||
scopes: ['user:create'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -212,6 +218,7 @@ describe('SettingsApiView', () => {
|
||||
updatedAt: new Date().toString(),
|
||||
apiKey: '****Atcr',
|
||||
expiresAt: null,
|
||||
scopes: ['user:create'],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ const telemetry = useTelemetry();
|
||||
|
||||
const loading = ref(false);
|
||||
const apiKeysStore = useApiKeysStore();
|
||||
const { getAndCacheApiKeys, deleteApiKey } = apiKeysStore;
|
||||
const { getAndCacheApiKeys, deleteApiKey, getApiKeyAvailableScopes } = apiKeysStore;
|
||||
const { apiKeysSortByCreationDate } = storeToRefs(apiKeysStore);
|
||||
const { isSwaggerUIEnabled, publicApiPath, publicApiLatestVersion } = settingsStore;
|
||||
const { baseUrl } = useRootStore();
|
||||
@@ -55,17 +55,17 @@ onMounted(async () => {
|
||||
|
||||
if (!isPublicApiEnabled) return;
|
||||
|
||||
await getApiKeys();
|
||||
await getApiKeysAndScopes();
|
||||
});
|
||||
|
||||
function onUpgrade() {
|
||||
void goToUpgrade('settings-n8n-api', 'upgrade-api', 'redirect');
|
||||
}
|
||||
|
||||
async function getApiKeys() {
|
||||
async function getApiKeysAndScopes() {
|
||||
try {
|
||||
loading.value = true;
|
||||
await getAndCacheApiKeys();
|
||||
await Promise.all([getAndCacheApiKeys(), getApiKeyAvailableScopes()]);
|
||||
} catch (error) {
|
||||
showError(error, i18n.baseText('settings.api.view.error'));
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user