feat(editor): Add user activation survey (#5677)
* ⚡ Add user activation survey
* Fix typo
* Avoid showing the modal when there is a modal view
* Allow to redirect to specific execution
* Improve structure
* Handle errors when sharing feedback
* update withFeatureFlag function
* Fix linting issue
* Set user activation flag on workflowExecutionCompleted event
* Revert update user settings functionality
* Remove unnecessary changes
* fix linting issue
* account for new functionality in tests
* Small improvements
* keep once instace of the model open between tabs
* Add sorting to GET /executions
* type parameters for GET /executions
a
* Add constant for local store key
* Add execution mode filtering
* fix linting issue
* Do not override settings when setting isOnboarded true
* Add update user settings endpoint
* improvements
* revert changes to /GET executions
* Fix typo
* Add userActivated flag to user store
* Add E2E test
* Fix linting issue
* Update pnpm-lock
* Revert unnecessary change
* Centralize user's settings update
* Remove unused ref in userActivationSurvey modal
* Use aliased imports
* Use createEventBus function in component
* Fix tests
This commit is contained in:
@@ -489,6 +489,7 @@ export interface IN8nUISettings {
|
||||
debug: boolean;
|
||||
};
|
||||
personalizationSurveyEnabled: boolean;
|
||||
userActivationSurveyEnabled: boolean;
|
||||
defaultLocale: string;
|
||||
userManagement: IUserManagementSettings;
|
||||
sso: {
|
||||
@@ -547,6 +548,9 @@ export interface IPersonalizationSurveyAnswers {
|
||||
|
||||
export interface IUserSettings {
|
||||
isOnboarded?: boolean;
|
||||
showUserActivationSurvey?: boolean;
|
||||
firstSuccessfulWorkflowId?: string;
|
||||
userActivated?: boolean;
|
||||
}
|
||||
|
||||
export interface IUserManagementSettings {
|
||||
@@ -855,6 +859,7 @@ export interface PublicUser {
|
||||
globalRole?: Role;
|
||||
signInType: AuthProviderType;
|
||||
disabled: boolean;
|
||||
settings?: IUserSettings | null;
|
||||
inviteAcceptUrl?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -262,6 +262,8 @@ class Server extends AbstractServer {
|
||||
},
|
||||
personalizationSurveyEnabled:
|
||||
config.getEnv('personalization.enabled') && config.getEnv('diagnostics.enabled'),
|
||||
userActivationSurveyEnabled:
|
||||
config.getEnv('userActivationSurvey.enabled') && config.getEnv('diagnostics.enabled'),
|
||||
defaultLocale: config.getEnv('defaultLocale'),
|
||||
userManagement: {
|
||||
enabled: isUserManagementEnabled(),
|
||||
@@ -364,7 +366,6 @@ class Server extends AbstractServer {
|
||||
if (config.get('nodes.packagesMissing').length > 0) {
|
||||
this.frontendSettings.missingPackages = true;
|
||||
}
|
||||
|
||||
return this.frontendSettings;
|
||||
}
|
||||
|
||||
|
||||
@@ -179,7 +179,6 @@ export async function withFeatureFlags(
|
||||
|
||||
const fetchPromise = new Promise<CurrentUser>(async (resolve) => {
|
||||
user.featureFlags = await postHog.getFeatureFlags(user);
|
||||
|
||||
resolve(user);
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import { whereClause } from '@/UserManagement/UserManagementHelper';
|
||||
import omit from 'lodash.omit';
|
||||
import { PermissionChecker } from './UserManagement/PermissionChecker';
|
||||
import { isWorkflowIdValid } from './utils';
|
||||
import { UserService } from './user/user.service';
|
||||
|
||||
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
|
||||
|
||||
@@ -429,7 +430,7 @@ export async function isBelowOnboardingThreshold(user: User): Promise<boolean> {
|
||||
|
||||
// user is above threshold --> set flag in settings
|
||||
if (!belowThreshold) {
|
||||
void Db.collections.User.update(user.id, { settings: { isOnboarded: true } });
|
||||
void UserService.updateUserSettings(user.id, { isOnboarded: true });
|
||||
}
|
||||
|
||||
return belowThreshold;
|
||||
|
||||
@@ -1042,6 +1042,15 @@ export const schema = {
|
||||
},
|
||||
},
|
||||
|
||||
userActivationSurvey: {
|
||||
enabled: {
|
||||
doc: 'Whether user activation survey is enabled.',
|
||||
format: Boolean,
|
||||
default: true,
|
||||
env: 'N8N_USER_ACTIVATION_SURVEY_ENABLED',
|
||||
},
|
||||
},
|
||||
|
||||
diagnostics: {
|
||||
enabled: {
|
||||
doc: 'Whether diagnostic mode is enabled.',
|
||||
|
||||
@@ -14,7 +14,12 @@ import { issueCookie } from '@/auth/jwt';
|
||||
import { Response } from 'express';
|
||||
import type { Repository } from 'typeorm';
|
||||
import type { ILogger } from 'n8n-workflow';
|
||||
import { AuthenticatedRequest, MeRequest, UserUpdatePayload } from '@/requests';
|
||||
import {
|
||||
AuthenticatedRequest,
|
||||
MeRequest,
|
||||
UserSettingsUpdatePayload,
|
||||
UserUpdatePayload,
|
||||
} from '@/requests';
|
||||
import type {
|
||||
PublicUser,
|
||||
IDatabaseCollections,
|
||||
@@ -23,6 +28,7 @@ import type {
|
||||
} from '@/Interfaces';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { isSamlLicensedAndEnabled } from '../sso/saml/samlHelpers';
|
||||
import { UserService } from '@/user/user.service';
|
||||
|
||||
@RestController('/me')
|
||||
export class MeController {
|
||||
@@ -52,7 +58,7 @@ export class MeController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the logged-in user's settings, except password.
|
||||
* Update the logged-in user's properties, except password.
|
||||
*/
|
||||
@Patch('/')
|
||||
async updateCurrentUser(req: MeRequest.UserUpdate, res: Response): Promise<PublicUser> {
|
||||
@@ -234,4 +240,22 @@ export class MeController {
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the logged-in user's settings.
|
||||
*/
|
||||
@Patch('/settings')
|
||||
async updateCurrentUserSettings(req: MeRequest.UserSettingsUpdate): Promise<User['settings']> {
|
||||
const payload = plainToInstance(UserSettingsUpdatePayload, req.body);
|
||||
const { id } = req.user;
|
||||
|
||||
await UserService.updateUserSettings(id, payload);
|
||||
|
||||
const user = await this.userRepository.findOneOrFail({
|
||||
select: ['settings'],
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return user.settings;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { QueryFailedError } from 'typeorm';
|
||||
import { Container } from 'typedi';
|
||||
import { InternalHooks } from '@/InternalHooks';
|
||||
import config from '@/config';
|
||||
import { UserService } from '@/user/user.service';
|
||||
|
||||
enum StatisticsUpsertResult {
|
||||
insert = 'insert',
|
||||
@@ -112,6 +113,15 @@ export async function workflowExecutionCompleted(
|
||||
user_id: owner.id,
|
||||
workflow_id: workflowId,
|
||||
};
|
||||
|
||||
if (!owner.settings?.firstSuccessfulWorkflowId) {
|
||||
await UserService.updateUserSettings(owner.id, {
|
||||
firstSuccessfulWorkflowId: workflowId,
|
||||
userActivated: true,
|
||||
showUserActivationSurvey: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Send the metrics
|
||||
await Container.get(InternalHooks).onFirstProductionWorkflowSuccess(metrics);
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@ export class ExecutionsService {
|
||||
});
|
||||
}
|
||||
|
||||
// Omit `data` from the Execution since it is the largest and not necesary for the list.
|
||||
// Omit `data` from the Execution since it is the largest and not necessary for the list.
|
||||
let query = Db.collections.Execution.createQueryBuilder('execution')
|
||||
.select([
|
||||
'execution.id',
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
IWorkflowSettings,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { IsEmail, IsString, Length } from 'class-validator';
|
||||
import { IsBoolean, IsEmail, IsOptional, IsString, Length } from 'class-validator';
|
||||
import { NoXss } from '@db/utils/customValidators';
|
||||
import type { PublicUser, IExecutionDeleteFilter, IWorkflowDb } from '@/Interfaces';
|
||||
import type { Role } from '@db/entities/Role';
|
||||
@@ -31,6 +31,15 @@ export class UserUpdatePayload implements Pick<User, 'email' | 'firstName' | 'la
|
||||
@Length(1, 32, { message: 'Last name must be $constraint1 to $constraint2 characters long.' })
|
||||
lastName: string;
|
||||
}
|
||||
export class UserSettingsUpdatePayload {
|
||||
@IsBoolean({ message: 'showUserActivationSurvey should be a boolean' })
|
||||
@IsOptional()
|
||||
showUserActivationSurvey: boolean;
|
||||
|
||||
@IsBoolean({ message: 'userActivated should be a boolean' })
|
||||
@IsOptional()
|
||||
userActivated: boolean;
|
||||
}
|
||||
|
||||
export type AuthlessRequest<
|
||||
RouteParams = {},
|
||||
@@ -161,6 +170,7 @@ export declare namespace ExecutionRequest {
|
||||
// ----------------------------------
|
||||
|
||||
export declare namespace MeRequest {
|
||||
export type UserSettingsUpdate = AuthenticatedRequest<{}, {}, UserSettingsUpdatePayload>;
|
||||
export type UserUpdate = AuthenticatedRequest<{}, {}, UserUpdatePayload>;
|
||||
export type Password = AuthenticatedRequest<
|
||||
{},
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { EntityManager, FindOptionsWhere } from 'typeorm';
|
||||
import { In } from 'typeorm';
|
||||
import * as Db from '@/Db';
|
||||
import { User } from '@db/entities/User';
|
||||
import type { IUserSettings } from '@/Interfaces';
|
||||
|
||||
export class UserService {
|
||||
static async get(where: FindOptionsWhere<User>): Promise<User | null> {
|
||||
@@ -14,4 +15,11 @@ export class UserService {
|
||||
static async getByIds(transaction: EntityManager, ids: string[]) {
|
||||
return transaction.find(User, { where: { id: In(ids) } });
|
||||
}
|
||||
|
||||
static async updateUserSettings(id: string, userSettings: Partial<IUserSettings>) {
|
||||
const { settings: currentSettings } = await Db.collections.User.findOneOrFail({
|
||||
where: { id },
|
||||
});
|
||||
return Db.collections.User.update(id, { settings: { ...currentSettings, ...userSettings } });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user