mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 12:57:29 -08:00
feat(editor): Community+ enrollment (#10776)
This commit is contained in:
parent
42c0733990
commit
92cf860f9f
|
@ -2,3 +2,4 @@ export { PasswordUpdateRequestDto } from './user/password-update-request.dto';
|
||||||
export { RoleChangeRequestDto } from './user/role-change-request.dto';
|
export { RoleChangeRequestDto } from './user/role-change-request.dto';
|
||||||
export { SettingsUpdateRequestDto } from './user/settings-update-request.dto';
|
export { SettingsUpdateRequestDto } from './user/settings-update-request.dto';
|
||||||
export { UserUpdateRequestDto } from './user/user-update-request.dto';
|
export { UserUpdateRequestDto } from './user/user-update-request.dto';
|
||||||
|
export { CommunityRegisteredRequestDto } from './license/community-registered-request.dto';
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { CommunityRegisteredRequestDto } from '../community-registered-request.dto';
|
||||||
|
|
||||||
|
describe('CommunityRegisteredRequestDto', () => {
|
||||||
|
it('should fail validation for missing email', () => {
|
||||||
|
const invalidRequest = {};
|
||||||
|
|
||||||
|
const result = CommunityRegisteredRequestDto.safeParse(invalidRequest);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error?.issues[0]).toEqual(
|
||||||
|
expect.objectContaining({ message: 'Required', path: ['email'] }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail validation for an invalid email', () => {
|
||||||
|
const invalidRequest = {
|
||||||
|
email: 'invalid-email',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = CommunityRegisteredRequestDto.safeParse(invalidRequest);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error?.issues[0]).toEqual(
|
||||||
|
expect.objectContaining({ message: 'Invalid email', path: ['email'] }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Z } from 'zod-class';
|
||||||
|
|
||||||
|
export class CommunityRegisteredRequestDto extends Z.class({ email: z.string().email() }) {}
|
|
@ -420,6 +420,11 @@ export type RelayEventMap = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
'license-community-plus-registered': {
|
||||||
|
email: string;
|
||||||
|
licenseKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
// #region Variable
|
// #region Variable
|
||||||
|
|
|
@ -54,6 +54,7 @@ export class TelemetryEventRelay extends EventRelay {
|
||||||
'source-control-user-finished-push-ui': (event) =>
|
'source-control-user-finished-push-ui': (event) =>
|
||||||
this.sourceControlUserFinishedPushUi(event),
|
this.sourceControlUserFinishedPushUi(event),
|
||||||
'license-renewal-attempted': (event) => this.licenseRenewalAttempted(event),
|
'license-renewal-attempted': (event) => this.licenseRenewalAttempted(event),
|
||||||
|
'license-community-plus-registered': (event) => this.licenseCommunityPlusRegistered(event),
|
||||||
'variable-created': () => this.variableCreated(),
|
'variable-created': () => this.variableCreated(),
|
||||||
'external-secrets-provider-settings-saved': (event) =>
|
'external-secrets-provider-settings-saved': (event) =>
|
||||||
this.externalSecretsProviderSettingsSaved(event),
|
this.externalSecretsProviderSettingsSaved(event),
|
||||||
|
@ -234,6 +235,16 @@ export class TelemetryEventRelay extends EventRelay {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private licenseCommunityPlusRegistered({
|
||||||
|
email,
|
||||||
|
licenseKey,
|
||||||
|
}: RelayEventMap['license-community-plus-registered']) {
|
||||||
|
this.telemetry.track('User registered for license community plus', {
|
||||||
|
email,
|
||||||
|
licenseKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
// #region Variable
|
// #region Variable
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { TEntitlement } from '@n8n_io/license-sdk';
|
import type { TEntitlement } from '@n8n_io/license-sdk';
|
||||||
|
import axios, { AxiosError } from 'axios';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
|
|
||||||
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
|
@ -7,6 +8,8 @@ import type { EventService } from '@/events/event.service';
|
||||||
import type { License } from '@/license';
|
import type { License } from '@/license';
|
||||||
import { LicenseErrors, LicenseService } from '@/license/license.service';
|
import { LicenseErrors, LicenseService } from '@/license/license.service';
|
||||||
|
|
||||||
|
jest.mock('axios');
|
||||||
|
|
||||||
describe('LicenseService', () => {
|
describe('LicenseService', () => {
|
||||||
const license = mock<License>();
|
const license = mock<License>();
|
||||||
const workflowRepository = mock<WorkflowRepository>();
|
const workflowRepository = mock<WorkflowRepository>();
|
||||||
|
@ -84,4 +87,37 @@ describe('LicenseService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('registerCommunityEdition', () => {
|
||||||
|
test('on success', async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(axios, 'post')
|
||||||
|
.mockResolvedValueOnce({ data: { title: 'Title', text: 'Text', licenseKey: 'abc-123' } });
|
||||||
|
const data = await licenseService.registerCommunityEdition({
|
||||||
|
email: 'test@ema.il',
|
||||||
|
instanceId: '123',
|
||||||
|
instanceUrl: 'http://localhost',
|
||||||
|
licenseType: 'community-registered',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(data).toEqual({ title: 'Title', text: 'Text' });
|
||||||
|
expect(eventService.emit).toHaveBeenCalledWith('license-community-plus-registered', {
|
||||||
|
email: 'test@ema.il',
|
||||||
|
licenseKey: 'abc-123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on failure', async () => {
|
||||||
|
jest.spyOn(axios, 'post').mockRejectedValueOnce(new AxiosError('Failed'));
|
||||||
|
await expect(
|
||||||
|
licenseService.registerCommunityEdition({
|
||||||
|
email: 'test@ema.il',
|
||||||
|
instanceId: '123',
|
||||||
|
instanceUrl: 'http://localhost',
|
||||||
|
licenseType: 'community-registered',
|
||||||
|
}),
|
||||||
|
).rejects.toThrowError('Failed');
|
||||||
|
expect(eventService.emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,14 +1,21 @@
|
||||||
|
import { CommunityRegisteredRequestDto } from '@n8n/api-types';
|
||||||
import type { AxiosError } from 'axios';
|
import type { AxiosError } from 'axios';
|
||||||
|
import { InstanceSettings } from 'n8n-core';
|
||||||
|
|
||||||
import { Get, Post, RestController, GlobalScope } from '@/decorators';
|
import { Get, Post, RestController, GlobalScope, Body } from '@/decorators';
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
import { AuthenticatedRequest, LicenseRequest } from '@/requests';
|
import { AuthenticatedRequest, AuthlessRequest, LicenseRequest } from '@/requests';
|
||||||
|
import { UrlService } from '@/services/url.service';
|
||||||
|
|
||||||
import { LicenseService } from './license.service';
|
import { LicenseService } from './license.service';
|
||||||
|
|
||||||
@RestController('/license')
|
@RestController('/license')
|
||||||
export class LicenseController {
|
export class LicenseController {
|
||||||
constructor(private readonly licenseService: LicenseService) {}
|
constructor(
|
||||||
|
private readonly licenseService: LicenseService,
|
||||||
|
private readonly instanceSettings: InstanceSettings,
|
||||||
|
private readonly urlService: UrlService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('/')
|
@Get('/')
|
||||||
async getLicenseData() {
|
async getLicenseData() {
|
||||||
|
@ -32,6 +39,20 @@ export class LicenseController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('/enterprise/community-registered')
|
||||||
|
async registerCommunityEdition(
|
||||||
|
_req: AuthlessRequest,
|
||||||
|
_res: Response,
|
||||||
|
@Body payload: CommunityRegisteredRequestDto,
|
||||||
|
) {
|
||||||
|
return await this.licenseService.registerCommunityEdition({
|
||||||
|
email: payload.email,
|
||||||
|
instanceId: this.instanceSettings.instanceId,
|
||||||
|
instanceUrl: this.urlService.getInstanceBaseUrl(),
|
||||||
|
licenseType: 'community-registered',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Post('/activate')
|
@Post('/activate')
|
||||||
@GlobalScope('license:manage')
|
@GlobalScope('license:manage')
|
||||||
async activateLicense(req: LicenseRequest.Activate) {
|
async activateLicense(req: LicenseRequest.Activate) {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import axios from 'axios';
|
import axios, { AxiosError } from 'axios';
|
||||||
|
import { ensureError } from 'n8n-workflow';
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
|
@ -60,6 +61,43 @@ export class LicenseService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async registerCommunityEdition({
|
||||||
|
email,
|
||||||
|
instanceId,
|
||||||
|
instanceUrl,
|
||||||
|
licenseType,
|
||||||
|
}: {
|
||||||
|
email: string;
|
||||||
|
instanceId: string;
|
||||||
|
instanceUrl: string;
|
||||||
|
licenseType: string;
|
||||||
|
}): Promise<{ title: string; text: string }> {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { licenseKey, ...rest },
|
||||||
|
} = await axios.post<{ title: string; text: string; licenseKey: string }>(
|
||||||
|
'https://enterprise.n8n.io/community-registered',
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
instanceId,
|
||||||
|
instanceUrl,
|
||||||
|
licenseType,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.eventService.emit('license-community-plus-registered', { email, licenseKey });
|
||||||
|
return rest;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof AxiosError) {
|
||||||
|
const error = e as AxiosError<{ message: string }>;
|
||||||
|
const errorMsg = error.response?.data?.message ?? e.message;
|
||||||
|
throw new BadRequestError('Failed to register community edition: ' + errorMsg);
|
||||||
|
} else {
|
||||||
|
this.logger.error('Failed to register community edition', { error: ensureError(e) });
|
||||||
|
throw new BadRequestError('Failed to register community edition');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getManagementJwt(): string {
|
getManagementJwt(): string {
|
||||||
return this.license.getManagementJwt();
|
return this.license.getManagementJwt();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { CommunityRegisteredRequestDto } from '@n8n/api-types';
|
||||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||||
import type { IRestApiContext, UsageState } from '@/Interface';
|
import type { IRestApiContext, UsageState } from '@/Interface';
|
||||||
|
|
||||||
|
@ -21,3 +22,15 @@ export const requestLicenseTrial = async (
|
||||||
): Promise<UsageState['data']> => {
|
): Promise<UsageState['data']> => {
|
||||||
return await makeRestApiRequest(context, 'POST', '/license/enterprise/request_trial');
|
return await makeRestApiRequest(context, 'POST', '/license/enterprise/request_trial');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const registerCommunityEdition = async (
|
||||||
|
context: IRestApiContext,
|
||||||
|
params: CommunityRegisteredRequestDto,
|
||||||
|
): Promise<{ title: string; text: string }> => {
|
||||||
|
return await makeRestApiRequest(
|
||||||
|
context,
|
||||||
|
'POST',
|
||||||
|
'/license/enterprise/community-registered',
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue';
|
||||||
|
import { COMMUNITY_PLUS_ENROLLMENT_MODAL } from '@/constants';
|
||||||
|
import { useUsageStore } from '@/stores/usage.store';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
|
|
||||||
|
vi.mock('@/composables/useToast', () => {
|
||||||
|
const showMessage = vi.fn();
|
||||||
|
const showError = vi.fn();
|
||||||
|
return {
|
||||||
|
useToast: () => {
|
||||||
|
return {
|
||||||
|
showMessage,
|
||||||
|
showError,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/composables/useTelemetry', () => {
|
||||||
|
const track = vi.fn();
|
||||||
|
return {
|
||||||
|
useTelemetry: () => {
|
||||||
|
return {
|
||||||
|
track,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderComponent = createComponentRenderer(CommunityPlusEnrollmentModal, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Modal: {
|
||||||
|
template:
|
||||||
|
'<div role="dialog"><slot name="header" /><slot name="content" /><slot name="footer" /></div>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CommunityPlusEnrollmentModal', () => {
|
||||||
|
const buttonLabel = 'Send me a free license key';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
createTestingPinia();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should test enrolling', async () => {
|
||||||
|
const closeCallbackSpy = vi.fn();
|
||||||
|
const usageStore = mockedStore(useUsageStore);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
usageStore.registerCommunityEdition.mockResolvedValue({
|
||||||
|
title: 'Title',
|
||||||
|
text: 'Text',
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
modalName: COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
|
data: {
|
||||||
|
closeCallback: closeCallbackSpy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getByRole } = renderComponent({ props });
|
||||||
|
const emailInput = getByRole('textbox');
|
||||||
|
expect(emailInput).toBeVisible();
|
||||||
|
|
||||||
|
await userEvent.type(emailInput, 'not-an-email');
|
||||||
|
expect(emailInput).toHaveValue('not-an-email');
|
||||||
|
expect(getByRole('button', { name: buttonLabel })).toBeDisabled();
|
||||||
|
|
||||||
|
await userEvent.clear(emailInput);
|
||||||
|
await userEvent.type(emailInput, 'test@ema.il');
|
||||||
|
expect(emailInput).toHaveValue('test@ema.il');
|
||||||
|
expect(getByRole('button', { name: buttonLabel })).toBeEnabled();
|
||||||
|
|
||||||
|
await userEvent.click(getByRole('button', { name: buttonLabel }));
|
||||||
|
expect(usageStore.registerCommunityEdition).toHaveBeenCalledWith('test@ema.il');
|
||||||
|
expect(toast.showMessage).toHaveBeenCalledWith({
|
||||||
|
title: 'Title',
|
||||||
|
message: 'Text',
|
||||||
|
type: 'success',
|
||||||
|
duration: 0,
|
||||||
|
});
|
||||||
|
expect(closeCallbackSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should test enrolling error', async () => {
|
||||||
|
const closeCallbackSpy = vi.fn();
|
||||||
|
const usageStore = mockedStore(useUsageStore);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
usageStore.registerCommunityEdition.mockRejectedValue(
|
||||||
|
new Error('Failed to register community edition'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
modalName: COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
|
data: {
|
||||||
|
closeCallback: closeCallbackSpy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getByRole } = renderComponent({ props });
|
||||||
|
const emailInput = getByRole('textbox');
|
||||||
|
expect(emailInput).toBeVisible();
|
||||||
|
|
||||||
|
await userEvent.type(emailInput, 'test@ema.il');
|
||||||
|
expect(emailInput).toHaveValue('test@ema.il');
|
||||||
|
expect(getByRole('button', { name: buttonLabel })).toBeEnabled();
|
||||||
|
|
||||||
|
await userEvent.click(getByRole('button', { name: buttonLabel }));
|
||||||
|
expect(usageStore.registerCommunityEdition).toHaveBeenCalledWith('test@ema.il');
|
||||||
|
expect(toast.showError).toHaveBeenCalledWith(
|
||||||
|
new Error('Failed to register community edition'),
|
||||||
|
'License request failed',
|
||||||
|
);
|
||||||
|
expect(closeCallbackSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track skipping', async () => {
|
||||||
|
const closeCallbackSpy = vi.fn();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
modalName: COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
|
data: {
|
||||||
|
closeCallback: closeCallbackSpy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getByRole } = renderComponent({ props });
|
||||||
|
const skipButton = getByRole('button', { name: 'Skip' });
|
||||||
|
expect(skipButton).toBeVisible();
|
||||||
|
|
||||||
|
await userEvent.click(skipButton);
|
||||||
|
expect(telemetry.track).toHaveBeenCalledWith('User skipped community plus');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use user email if possible', async () => {
|
||||||
|
const closeCallbackSpy = vi.fn();
|
||||||
|
const usersStore = mockedStore(useUsersStore);
|
||||||
|
|
||||||
|
usersStore.currentUser = {
|
||||||
|
id: '1',
|
||||||
|
email: 'test@n8n.io',
|
||||||
|
isDefaultUser: false,
|
||||||
|
isPending: false,
|
||||||
|
mfaEnabled: false,
|
||||||
|
isPendingUser: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
modalName: COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
|
data: {
|
||||||
|
closeCallback: closeCallbackSpy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getByRole } = renderComponent({ props });
|
||||||
|
const emailInput = getByRole('textbox');
|
||||||
|
expect(emailInput).toHaveValue('test@n8n.io');
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,186 @@
|
||||||
|
<script lang="ts" setup="">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { createEventBus } from 'n8n-design-system/utils';
|
||||||
|
import type { Validatable, IValidator } from 'n8n-design-system';
|
||||||
|
import { N8nFormInput } from 'n8n-design-system';
|
||||||
|
import { VALID_EMAIL_REGEX } from '@/constants';
|
||||||
|
import Modal from '@/components/Modal.vue';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { useUsageStore } from '@/stores/usage.store';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modalName: string;
|
||||||
|
data: {
|
||||||
|
closeCallback: () => void;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const toast = useToast();
|
||||||
|
const usageStore = useUsageStore();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
const usersStore = useUsersStore();
|
||||||
|
|
||||||
|
const valid = ref(false);
|
||||||
|
const email = ref(usersStore.currentUser?.email ?? '');
|
||||||
|
const validationRules = ref([{ name: 'email' }]);
|
||||||
|
const validators = ref<{ [key: string]: IValidator }>({
|
||||||
|
email: {
|
||||||
|
validate: (value: Validatable) => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_EMAIL_REGEX.test(value)) {
|
||||||
|
return {
|
||||||
|
messageKey: 'settings.users.invalidEmailError',
|
||||||
|
options: { interpolate: { email: value } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const modalBus = createEventBus();
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
telemetry.track('User skipped community plus');
|
||||||
|
modalBus.emit('close');
|
||||||
|
props.data.closeCallback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirm = async () => {
|
||||||
|
try {
|
||||||
|
const { title, text } = await usageStore.registerCommunityEdition(email.value);
|
||||||
|
closeModal();
|
||||||
|
toast.showMessage({
|
||||||
|
title: title ?? i18n.baseText('communityPlusModal.success.title'),
|
||||||
|
message:
|
||||||
|
text ??
|
||||||
|
i18n.baseText('communityPlusModal.success.message', {
|
||||||
|
interpolate: { email: email.value },
|
||||||
|
}),
|
||||||
|
type: 'success',
|
||||||
|
duration: 0,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError(error, i18n.baseText('communityPlusModal.error.title'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
width="500px"
|
||||||
|
:name="props.modalName"
|
||||||
|
:event-bus="modalBus"
|
||||||
|
:show-close="false"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="false"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<div>
|
||||||
|
<p :class="$style.top">
|
||||||
|
<N8nBadge>{{ i18n.baseText('communityPlusModal.badge') }}</N8nBadge>
|
||||||
|
</p>
|
||||||
|
<N8nText tag="h1" align="center" size="xlarge" class="mb-m">{{
|
||||||
|
i18n.baseText('communityPlusModal.title')
|
||||||
|
}}</N8nText>
|
||||||
|
<N8nText tag="p">{{ i18n.baseText('communityPlusModal.description') }}</N8nText>
|
||||||
|
<ul :class="$style.features">
|
||||||
|
<li>
|
||||||
|
<i>🕰️</i>
|
||||||
|
<N8nText>
|
||||||
|
<strong>{{ i18n.baseText('communityPlusModal.features.first.title') }}</strong>
|
||||||
|
{{ i18n.baseText('communityPlusModal.features.first.description') }}
|
||||||
|
</N8nText>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i>🐞</i>
|
||||||
|
<N8nText>
|
||||||
|
<strong>{{ i18n.baseText('communityPlusModal.features.second.title') }}</strong>
|
||||||
|
{{ i18n.baseText('communityPlusModal.features.second.description') }}
|
||||||
|
</N8nText>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i>🔎</i>
|
||||||
|
<N8nText>
|
||||||
|
<strong>{{ i18n.baseText('communityPlusModal.features.third.title') }}</strong>
|
||||||
|
{{ i18n.baseText('communityPlusModal.features.third.description') }}
|
||||||
|
</N8nText>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<N8nFormInput
|
||||||
|
id="email"
|
||||||
|
v-model="email"
|
||||||
|
:label="i18n.baseText('communityPlusModal.input.email.label')"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
label-size="small"
|
||||||
|
tag-size="small"
|
||||||
|
required
|
||||||
|
:show-required-asterisk="true"
|
||||||
|
:validate-on-blur="false"
|
||||||
|
:validation-rules="validationRules"
|
||||||
|
:validators="validators"
|
||||||
|
@validate="valid = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div :class="$style.buttons">
|
||||||
|
<N8nButton :class="$style.skip" type="secondary" text @click="closeModal">{{
|
||||||
|
i18n.baseText('communityPlusModal.button.skip')
|
||||||
|
}}</N8nButton>
|
||||||
|
<N8nButton :disabled="!valid" type="primary" @click="confirm">
|
||||||
|
{{ i18n.baseText('communityPlusModal.button.confirm') }}
|
||||||
|
</N8nButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 0 var(--spacing-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.features {
|
||||||
|
padding: var(--spacing-s) var(--spacing-l) 0;
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
padding: 0 var(--spacing-s) var(--spacing-m) 0;
|
||||||
|
|
||||||
|
i {
|
||||||
|
display: inline-block;
|
||||||
|
margin: var(--spacing-5xs) var(--spacing-xs) 0 0;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--spacing-4xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -32,6 +32,7 @@ import {
|
||||||
SETUP_CREDENTIALS_MODAL_KEY,
|
SETUP_CREDENTIALS_MODAL_KEY,
|
||||||
PROJECT_MOVE_RESOURCE_MODAL,
|
PROJECT_MOVE_RESOURCE_MODAL,
|
||||||
PROMPT_MFA_CODE_MODAL_KEY,
|
PROMPT_MFA_CODE_MODAL_KEY,
|
||||||
|
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
|
||||||
import AboutModal from '@/components/AboutModal.vue';
|
import AboutModal from '@/components/AboutModal.vue';
|
||||||
|
@ -67,6 +68,7 @@ import SetupWorkflowCredentialsModal from '@/components/SetupWorkflowCredentials
|
||||||
import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceModal.vue';
|
import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceModal.vue';
|
||||||
import NewAssistantSessionModal from '@/components/AskAssistant/NewAssistantSessionModal.vue';
|
import NewAssistantSessionModal from '@/components/AskAssistant/NewAssistantSessionModal.vue';
|
||||||
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
||||||
|
import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -252,5 +254,11 @@ import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
||||||
<NewAssistantSessionModal :name="modalName" :data="data" />
|
<NewAssistantSessionModal :name="modalName" :data="data" />
|
||||||
</template>
|
</template>
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
||||||
|
<ModalRoot :name="COMMUNITY_PLUS_ENROLLMENT_MODAL">
|
||||||
|
<template #default="{ modalName, data }">
|
||||||
|
<CommunityPlusEnrollmentModal :modal-name="modalName" :data="data" />
|
||||||
|
</template>
|
||||||
|
</ModalRoot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -15,6 +15,16 @@ import {
|
||||||
DEVOPS_AUTOMATION_GOAL_KEY,
|
DEVOPS_AUTOMATION_GOAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
replace: vi.fn(),
|
||||||
|
}),
|
||||||
|
useRoute: () => ({
|
||||||
|
location: {},
|
||||||
|
}),
|
||||||
|
RouterLink: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const renderModal = createComponentRenderer(PersonalizationModal, {
|
const renderModal = createComponentRenderer(PersonalizationModal, {
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
|
|
|
@ -80,6 +80,7 @@ import {
|
||||||
REPORTED_SOURCE_OTHER_KEY,
|
REPORTED_SOURCE_OTHER_KEY,
|
||||||
VIEWS,
|
VIEWS,
|
||||||
MORE_ONBOARDING_OPTIONS_EXPERIMENT,
|
MORE_ONBOARDING_OPTIONS_EXPERIMENT,
|
||||||
|
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import Modal from '@/components/Modal.vue';
|
import Modal from '@/components/Modal.vue';
|
||||||
|
@ -91,6 +92,7 @@ import { usePostHog } from '@/stores/posthog.store';
|
||||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
|
||||||
const SURVEY_VERSION = 'v4';
|
const SURVEY_VERSION = 'v4';
|
||||||
|
|
||||||
|
@ -104,6 +106,7 @@ const usersStore = useUsersStore();
|
||||||
const posthogStore = usePostHog();
|
const posthogStore = usePostHog();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
|
||||||
const formValues = ref<Record<string, string>>({});
|
const formValues = ref<Record<string, string>>({});
|
||||||
const isSaving = ref(false);
|
const isSaving = ref(false);
|
||||||
|
@ -547,6 +550,10 @@ const onSave = () => {
|
||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
modalBus.emit('close');
|
modalBus.emit('close');
|
||||||
|
uiStore.openModalWithData({
|
||||||
|
name: COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
|
data: {
|
||||||
|
closeCallback: () => {
|
||||||
const isPartOfOnboardingExperiment =
|
const isPartOfOnboardingExperiment =
|
||||||
posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) ===
|
posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) ===
|
||||||
MORE_ONBOARDING_OPTIONS_EXPERIMENT.control;
|
MORE_ONBOARDING_OPTIONS_EXPERIMENT.control;
|
||||||
|
@ -555,6 +562,9 @@ const closeDialog = () => {
|
||||||
if (route.name !== VIEWS.HOMEPAGE && !isPartOfOnboardingExperiment) {
|
if (route.name !== VIEWS.HOMEPAGE && !isPartOfOnboardingExperiment) {
|
||||||
void router.replace({ name: VIEWS.HOMEPAGE });
|
void router.replace({ name: VIEWS.HOMEPAGE });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (values: IPersonalizationLatestVersion) => {
|
const onSubmit = async (values: IPersonalizationLatestVersion) => {
|
||||||
|
|
|
@ -71,6 +71,7 @@ export const SETUP_CREDENTIALS_MODAL_KEY = 'setupCredentials';
|
||||||
export const PROJECT_MOVE_RESOURCE_MODAL = 'projectMoveResourceModal';
|
export const PROJECT_MOVE_RESOURCE_MODAL = 'projectMoveResourceModal';
|
||||||
export const NEW_ASSISTANT_SESSION_MODAL = 'newAssistantSession';
|
export const NEW_ASSISTANT_SESSION_MODAL = 'newAssistantSession';
|
||||||
export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';
|
export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';
|
||||||
|
export const COMMUNITY_PLUS_ENROLLMENT_MODAL = 'communityPlusEnrollment';
|
||||||
|
|
||||||
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
||||||
UNINSTALL: 'uninstall',
|
UNINSTALL: 'uninstall',
|
||||||
|
|
|
@ -2658,5 +2658,20 @@
|
||||||
"becomeCreator.closeButtonTitle": "Close",
|
"becomeCreator.closeButtonTitle": "Close",
|
||||||
"feedback.title": "Was this helpful?",
|
"feedback.title": "Was this helpful?",
|
||||||
"feedback.positive": "I found this helpful",
|
"feedback.positive": "I found this helpful",
|
||||||
"feedback.negative": "I didn't find this helpful"
|
"feedback.negative": "I didn't find this helpful",
|
||||||
|
"communityPlusModal.badge": "Time limited offer",
|
||||||
|
"communityPlusModal.title": "Unlock select paid features for free (forever)",
|
||||||
|
"communityPlusModal.error.title": "License request failed",
|
||||||
|
"communityPlusModal.success.title": "Request sent",
|
||||||
|
"communityPlusModal.success.message": "License key will be sent to {email}",
|
||||||
|
"communityPlusModal.description": "Receive a free activation key for the advanced features below - lifetime access.",
|
||||||
|
"communityPlusModal.features.first.title": "Workflow history",
|
||||||
|
"communityPlusModal.features.first.description": "Review and restore any workflow version from the last 24 hours",
|
||||||
|
"communityPlusModal.features.second.title": "Advanced debugging",
|
||||||
|
"communityPlusModal.features.second.description": "Easily fix any workflow execution that’s errored, then re-run it",
|
||||||
|
"communityPlusModal.features.third.title": "Execution search and tagging",
|
||||||
|
"communityPlusModal.features.third.description": "Search and organize past workflow executions for easier review",
|
||||||
|
"communityPlusModal.input.email.label": "Enter email to receive your license key",
|
||||||
|
"communityPlusModal.button.skip": "Skip",
|
||||||
|
"communityPlusModal.button.confirm": "Send me a free license key"
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,6 +37,7 @@ import {
|
||||||
PROJECT_MOVE_RESOURCE_MODAL,
|
PROJECT_MOVE_RESOURCE_MODAL,
|
||||||
NEW_ASSISTANT_SESSION_MODAL,
|
NEW_ASSISTANT_SESSION_MODAL,
|
||||||
PROMPT_MFA_CODE_MODAL_KEY,
|
PROMPT_MFA_CODE_MODAL_KEY,
|
||||||
|
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import type {
|
import type {
|
||||||
CloudUpdateLinkSourceType,
|
CloudUpdateLinkSourceType,
|
||||||
|
@ -126,6 +127,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
||||||
SETUP_CREDENTIALS_MODAL_KEY,
|
SETUP_CREDENTIALS_MODAL_KEY,
|
||||||
PROJECT_MOVE_RESOURCE_MODAL,
|
PROJECT_MOVE_RESOURCE_MODAL,
|
||||||
NEW_ASSISTANT_SESSION_MODAL,
|
NEW_ASSISTANT_SESSION_MODAL,
|
||||||
|
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
].map((modalKey) => [modalKey, { open: false }]),
|
].map((modalKey) => [modalKey, { open: false }]),
|
||||||
),
|
),
|
||||||
[DELETE_USER_MODAL_KEY]: {
|
[DELETE_USER_MODAL_KEY]: {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { computed, reactive } from 'vue';
|
import { computed, reactive } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import type { UsageState } from '@/Interface';
|
import type { UsageState } from '@/Interface';
|
||||||
import { activateLicenseKey, getLicense, renewLicense, requestLicenseTrial } from '@/api/usage';
|
import * as usageApi from '@/api/usage';
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
|
||||||
|
@ -63,19 +63,19 @@ export const useUsageStore = defineStore('usage', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLicenseInfo = async () => {
|
const getLicenseInfo = async () => {
|
||||||
const data = await getLicense(rootStore.restApiContext);
|
const data = await usageApi.getLicense(rootStore.restApiContext);
|
||||||
setData(data);
|
setData(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const activateLicense = async (activationKey: string) => {
|
const activateLicense = async (activationKey: string) => {
|
||||||
const data = await activateLicenseKey(rootStore.restApiContext, { activationKey });
|
const data = await usageApi.activateLicenseKey(rootStore.restApiContext, { activationKey });
|
||||||
setData(data);
|
setData(data);
|
||||||
await settingsStore.getSettings();
|
await settingsStore.getSettings();
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshLicenseManagementToken = async () => {
|
const refreshLicenseManagementToken = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await renewLicense(rootStore.restApiContext);
|
const data = await usageApi.renewLicense(rootStore.restApiContext);
|
||||||
setData(data);
|
setData(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await getLicenseInfo();
|
await getLicenseInfo();
|
||||||
|
@ -83,9 +83,12 @@ export const useUsageStore = defineStore('usage', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestEnterpriseLicenseTrial = async () => {
|
const requestEnterpriseLicenseTrial = async () => {
|
||||||
await requestLicenseTrial(rootStore.restApiContext);
|
await usageApi.requestLicenseTrial(rootStore.restApiContext);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const registerCommunityEdition = async (email: string) =>
|
||||||
|
await usageApi.registerCommunityEdition(rootStore.restApiContext, { email });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setLoading,
|
setLoading,
|
||||||
getLicenseInfo,
|
getLicenseInfo,
|
||||||
|
@ -93,6 +96,7 @@ export const useUsageStore = defineStore('usage', () => {
|
||||||
activateLicense,
|
activateLicense,
|
||||||
refreshLicenseManagementToken,
|
refreshLicenseManagementToken,
|
||||||
requestEnterpriseLicenseTrial,
|
requestEnterpriseLicenseTrial,
|
||||||
|
registerCommunityEdition,
|
||||||
planName,
|
planName,
|
||||||
planId,
|
planId,
|
||||||
executionLimit,
|
executionLimit,
|
||||||
|
|
|
@ -42,10 +42,10 @@ describe('SettingsUsageAndPlan', () => {
|
||||||
usageStore.isLoading = false;
|
usageStore.isLoading = false;
|
||||||
usageStore.viewPlansUrl = 'https://subscription.n8n.io';
|
usageStore.viewPlansUrl = 'https://subscription.n8n.io';
|
||||||
usageStore.managePlanUrl = 'https://subscription.n8n.io';
|
usageStore.managePlanUrl = 'https://subscription.n8n.io';
|
||||||
usageStore.planName = 'Community registered';
|
usageStore.planName = 'Registered Community';
|
||||||
const { getByRole, container } = renderComponent();
|
const { getByRole, container } = renderComponent();
|
||||||
expect(getByRole('heading', { level: 3 })).toHaveTextContent('Community Edition');
|
expect(getByRole('heading', { level: 3 })).toHaveTextContent('Community Edition');
|
||||||
expect(getByRole('heading', { level: 3 })).toContain(container.querySelector('.n8n-badge'));
|
expect(getByRole('heading', { level: 3 })).toContain(container.querySelector('.n8n-badge'));
|
||||||
expect(container.querySelector('.n8n-badge')).toHaveTextContent('registered');
|
expect(container.querySelector('.n8n-badge')).toHaveTextContent('Registered');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -33,7 +33,7 @@ const canUserActivateLicense = computed(() =>
|
||||||
);
|
);
|
||||||
|
|
||||||
const badgedPlanName = computed(() => {
|
const badgedPlanName = computed(() => {
|
||||||
const [name, badge] = usageStore.planName.split(' ');
|
const [badge, name] = usageStore.planName.split(' ');
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
badge,
|
badge,
|
||||||
|
@ -41,7 +41,7 @@ const badgedPlanName = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const isCommunityEditionRegistered = computed(
|
const isCommunityEditionRegistered = computed(
|
||||||
() => usageStore.planName.toLowerCase() === 'community registered',
|
() => usageStore.planName.toLowerCase() === 'registered community',
|
||||||
);
|
);
|
||||||
|
|
||||||
const showActivationSuccess = () => {
|
const showActivationSuccess = () => {
|
||||||
|
|
Loading…
Reference in a new issue