mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -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 { SettingsUpdateRequestDto } from './user/settings-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;
|
||||
};
|
||||
|
||||
'license-community-plus-registered': {
|
||||
email: string;
|
||||
licenseKey: string;
|
||||
};
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Variable
|
||||
|
|
|
@ -54,6 +54,7 @@ export class TelemetryEventRelay extends EventRelay {
|
|||
'source-control-user-finished-push-ui': (event) =>
|
||||
this.sourceControlUserFinishedPushUi(event),
|
||||
'license-renewal-attempted': (event) => this.licenseRenewalAttempted(event),
|
||||
'license-community-plus-registered': (event) => this.licenseCommunityPlusRegistered(event),
|
||||
'variable-created': () => this.variableCreated(),
|
||||
'external-secrets-provider-settings-saved': (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
|
||||
|
||||
// #region Variable
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import type { TEntitlement } from '@n8n_io/license-sdk';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
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 { LicenseErrors, LicenseService } from '@/license/license.service';
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
describe('LicenseService', () => {
|
||||
const license = mock<License>();
|
||||
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 { 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 { AuthenticatedRequest, LicenseRequest } from '@/requests';
|
||||
import { AuthenticatedRequest, AuthlessRequest, LicenseRequest } from '@/requests';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
|
||||
import { LicenseService } from './license.service';
|
||||
|
||||
@RestController('/license')
|
||||
export class LicenseController {
|
||||
constructor(private readonly licenseService: LicenseService) {}
|
||||
constructor(
|
||||
private readonly licenseService: LicenseService,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly urlService: UrlService,
|
||||
) {}
|
||||
|
||||
@Get('/')
|
||||
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')
|
||||
@GlobalScope('license:manage')
|
||||
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 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 {
|
||||
return this.license.getManagementJwt();
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import type { CommunityRegisteredRequestDto } from '@n8n/api-types';
|
||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||
import type { IRestApiContext, UsageState } from '@/Interface';
|
||||
|
||||
|
@ -21,3 +22,15 @@ export const requestLicenseTrial = async (
|
|||
): Promise<UsageState['data']> => {
|
||||
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,
|
||||
PROJECT_MOVE_RESOURCE_MODAL,
|
||||
PROMPT_MFA_CODE_MODAL_KEY,
|
||||
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||
} from '@/constants';
|
||||
|
||||
import AboutModal from '@/components/AboutModal.vue';
|
||||
|
@ -67,6 +68,7 @@ import SetupWorkflowCredentialsModal from '@/components/SetupWorkflowCredentials
|
|||
import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceModal.vue';
|
||||
import NewAssistantSessionModal from '@/components/AskAssistant/NewAssistantSessionModal.vue';
|
||||
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
||||
import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -252,5 +254,11 @@ import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
|||
<NewAssistantSessionModal :name="modalName" :data="data" />
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="COMMUNITY_PLUS_ENROLLMENT_MODAL">
|
||||
<template #default="{ modalName, data }">
|
||||
<CommunityPlusEnrollmentModal :modal-name="modalName" :data="data" />
|
||||
</template>
|
||||
</ModalRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -15,6 +15,16 @@ import {
|
|||
DEVOPS_AUTOMATION_GOAL_KEY,
|
||||
} from '@/constants';
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
replace: vi.fn(),
|
||||
}),
|
||||
useRoute: () => ({
|
||||
location: {},
|
||||
}),
|
||||
RouterLink: vi.fn(),
|
||||
}));
|
||||
|
||||
const renderModal = createComponentRenderer(PersonalizationModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
|
|
|
@ -80,6 +80,7 @@ import {
|
|||
REPORTED_SOURCE_OTHER_KEY,
|
||||
VIEWS,
|
||||
MORE_ONBOARDING_OPTIONS_EXPERIMENT,
|
||||
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||
} from '@/constants';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import Modal from '@/components/Modal.vue';
|
||||
|
@ -91,6 +92,7 @@ import { usePostHog } from '@/stores/posthog.store';
|
|||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
|
||||
const SURVEY_VERSION = 'v4';
|
||||
|
||||
|
@ -104,6 +106,7 @@ const usersStore = useUsersStore();
|
|||
const posthogStore = usePostHog();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const formValues = ref<Record<string, string>>({});
|
||||
const isSaving = ref(false);
|
||||
|
@ -547,6 +550,10 @@ const onSave = () => {
|
|||
|
||||
const closeDialog = () => {
|
||||
modalBus.emit('close');
|
||||
uiStore.openModalWithData({
|
||||
name: COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||
data: {
|
||||
closeCallback: () => {
|
||||
const isPartOfOnboardingExperiment =
|
||||
posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) ===
|
||||
MORE_ONBOARDING_OPTIONS_EXPERIMENT.control;
|
||||
|
@ -555,6 +562,9 @@ const closeDialog = () => {
|
|||
if (route.name !== VIEWS.HOMEPAGE && !isPartOfOnboardingExperiment) {
|
||||
void router.replace({ name: VIEWS.HOMEPAGE });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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 NEW_ASSISTANT_SESSION_MODAL = 'newAssistantSession';
|
||||
export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';
|
||||
export const COMMUNITY_PLUS_ENROLLMENT_MODAL = 'communityPlusEnrollment';
|
||||
|
||||
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
||||
UNINSTALL: 'uninstall',
|
||||
|
|
|
@ -2658,5 +2658,20 @@
|
|||
"becomeCreator.closeButtonTitle": "Close",
|
||||
"feedback.title": "Was 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,
|
||||
NEW_ASSISTANT_SESSION_MODAL,
|
||||
PROMPT_MFA_CODE_MODAL_KEY,
|
||||
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||
} from '@/constants';
|
||||
import type {
|
||||
CloudUpdateLinkSourceType,
|
||||
|
@ -126,6 +127,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||
SETUP_CREDENTIALS_MODAL_KEY,
|
||||
PROJECT_MOVE_RESOURCE_MODAL,
|
||||
NEW_ASSISTANT_SESSION_MODAL,
|
||||
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||
].map((modalKey) => [modalKey, { open: false }]),
|
||||
),
|
||||
[DELETE_USER_MODAL_KEY]: {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { computed, reactive } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
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 { useSettingsStore } from '@/stores/settings.store';
|
||||
|
||||
|
@ -63,19 +63,19 @@ export const useUsageStore = defineStore('usage', () => {
|
|||
};
|
||||
|
||||
const getLicenseInfo = async () => {
|
||||
const data = await getLicense(rootStore.restApiContext);
|
||||
const data = await usageApi.getLicense(rootStore.restApiContext);
|
||||
setData(data);
|
||||
};
|
||||
|
||||
const activateLicense = async (activationKey: string) => {
|
||||
const data = await activateLicenseKey(rootStore.restApiContext, { activationKey });
|
||||
const data = await usageApi.activateLicenseKey(rootStore.restApiContext, { activationKey });
|
||||
setData(data);
|
||||
await settingsStore.getSettings();
|
||||
};
|
||||
|
||||
const refreshLicenseManagementToken = async () => {
|
||||
try {
|
||||
const data = await renewLicense(rootStore.restApiContext);
|
||||
const data = await usageApi.renewLicense(rootStore.restApiContext);
|
||||
setData(data);
|
||||
} catch (error) {
|
||||
await getLicenseInfo();
|
||||
|
@ -83,9 +83,12 @@ export const useUsageStore = defineStore('usage', () => {
|
|||
};
|
||||
|
||||
const requestEnterpriseLicenseTrial = async () => {
|
||||
await requestLicenseTrial(rootStore.restApiContext);
|
||||
await usageApi.requestLicenseTrial(rootStore.restApiContext);
|
||||
};
|
||||
|
||||
const registerCommunityEdition = async (email: string) =>
|
||||
await usageApi.registerCommunityEdition(rootStore.restApiContext, { email });
|
||||
|
||||
return {
|
||||
setLoading,
|
||||
getLicenseInfo,
|
||||
|
@ -93,6 +96,7 @@ export const useUsageStore = defineStore('usage', () => {
|
|||
activateLicense,
|
||||
refreshLicenseManagementToken,
|
||||
requestEnterpriseLicenseTrial,
|
||||
registerCommunityEdition,
|
||||
planName,
|
||||
planId,
|
||||
executionLimit,
|
||||
|
|
|
@ -42,10 +42,10 @@ describe('SettingsUsageAndPlan', () => {
|
|||
usageStore.isLoading = false;
|
||||
usageStore.viewPlansUrl = 'https://subscription.n8n.io';
|
||||
usageStore.managePlanUrl = 'https://subscription.n8n.io';
|
||||
usageStore.planName = 'Community registered';
|
||||
usageStore.planName = 'Registered Community';
|
||||
const { getByRole, container } = renderComponent();
|
||||
expect(getByRole('heading', { level: 3 })).toHaveTextContent('Community Edition');
|
||||
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 [name, badge] = usageStore.planName.split(' ');
|
||||
const [badge, name] = usageStore.planName.split(' ');
|
||||
return {
|
||||
name,
|
||||
badge,
|
||||
|
@ -41,7 +41,7 @@ const badgedPlanName = computed(() => {
|
|||
});
|
||||
|
||||
const isCommunityEditionRegistered = computed(
|
||||
() => usageStore.planName.toLowerCase() === 'community registered',
|
||||
() => usageStore.planName.toLowerCase() === 'registered community',
|
||||
);
|
||||
|
||||
const showActivationSuccess = () => {
|
||||
|
|
Loading…
Reference in a new issue