feat(API): Implement users account quota guards (#6434)

* feat(cli): Implement users account quota guards

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* Remove comment

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* Address PR comments

- Getting `usersQuota` from `Settings` repo
- Revert `isUserManagementEnabled` helper
- Fix FE listing of users

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* Refactor isWithinUserQuota getter and fix tests

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* Revert testDb.ts changes

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* Cleanup & improve types

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* Fix duplicated method

* Fix failing test

* Remove `isUserManagementEnabled` completely

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* Check for globalRole.name to determine if user is owner

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* Fix unit tests

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* Set isInstanceOwnerSetUp in specs

* Fix SettingsUserView UM

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* refactor: License typings suggestions for users quota guards (#6636)

refactor: License typings suggestions

* Update packages/cli/src/Ldap/helpers.ts

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>

* Update packages/cli/test/integration/shared/utils.ts

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>

* Address PR comments

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* Use 403 for all user quota related errors

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

---------

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
This commit is contained in:
OlegIvaniv 2023-07-12 14:11:46 +02:00 committed by GitHub
parent 26046f6fe8
commit e5620ab1e4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 271 additions and 94 deletions

View file

@ -61,6 +61,7 @@ import type {
WorkflowStatisticsRepository, WorkflowStatisticsRepository,
WorkflowTagMappingRepository, WorkflowTagMappingRepository,
} from '@db/repositories'; } from '@db/repositories';
import type { LICENSE_FEATURES, LICENSE_QUOTAS } from './constants';
export interface IActivationError { export interface IActivationError {
time: number; time: number;
@ -716,6 +717,11 @@ export interface IExecutionTrackProperties extends ITelemetryTrackProperties {
// license // license
// ---------------------------------- // ----------------------------------
type ValuesOf<T> = T[keyof T];
export type BooleanLicenseFeature = ValuesOf<typeof LICENSE_FEATURES>;
export type NumericLicenseFeature = ValuesOf<typeof LICENSE_QUOTAS>;
export interface ILicenseReadResponse { export interface ILicenseReadResponse {
usage: { usage: {
executions: { executions: {

View file

@ -36,7 +36,9 @@ import { InternalServerError } from '../ResponseHelper';
/** /**
* Check whether the LDAP feature is disabled in the instance * Check whether the LDAP feature is disabled in the instance
*/ */
export const isLdapEnabled = () => Container.get(License).isLdapEnabled(); export const isLdapEnabled = () => {
return Container.get(License).isLdapEnabled();
};
/** /**
* Check whether the LDAP feature is enabled in the instance * Check whether the LDAP feature is enabled in the instance

View file

@ -9,8 +9,16 @@ import {
LICENSE_QUOTAS, LICENSE_QUOTAS,
N8N_VERSION, N8N_VERSION,
SETTINGS_LICENSE_CERT_KEY, SETTINGS_LICENSE_CERT_KEY,
UNLIMITED_LICENSE_QUOTA,
} from './constants'; } from './constants';
import { Service } from 'typedi'; import { Service } from 'typedi';
import type { BooleanLicenseFeature, NumericLicenseFeature } from './Interfaces';
type FeatureReturnType = Partial<
{
planName: string;
} & { [K in NumericLicenseFeature]: number } & { [K in BooleanLicenseFeature]: boolean }
>;
@Service() @Service()
export class License { export class License {
@ -96,12 +104,8 @@ export class License {
await this.manager.renew(); await this.manager.renew();
} }
isFeatureEnabled(feature: LICENSE_FEATURES): boolean { isFeatureEnabled(feature: BooleanLicenseFeature) {
if (!this.manager) { return this.manager?.hasFeatureEnabled(feature) ?? false;
return false;
}
return this.manager.hasFeatureEnabled(feature);
} }
isSharingEnabled() { isSharingEnabled() {
@ -140,15 +144,8 @@ export class License {
return this.manager?.getCurrentEntitlements() ?? []; return this.manager?.getCurrentEntitlements() ?? [];
} }
getFeatureValue( getFeatureValue<T extends keyof FeatureReturnType>(feature: T): FeatureReturnType[T] {
feature: string, return this.manager?.getFeatureValue(feature) as FeatureReturnType[T];
requireValidCert?: boolean,
): undefined | boolean | number | string {
if (!this.manager) {
return undefined;
}
return this.manager.getFeatureValue(feature, requireValidCert);
} }
getManagementJwt(): string { getManagementJwt(): string {
@ -177,20 +174,20 @@ export class License {
} }
// Helper functions for computed data // Helper functions for computed data
getTriggerLimit(): number { getUsersLimit() {
return (this.getFeatureValue(LICENSE_QUOTAS.TRIGGER_LIMIT) ?? -1) as number; return this.getFeatureValue(LICENSE_QUOTAS.USERS_LIMIT) ?? UNLIMITED_LICENSE_QUOTA;
} }
getVariablesLimit(): number { getTriggerLimit() {
return (this.getFeatureValue(LICENSE_QUOTAS.VARIABLES_LIMIT) ?? -1) as number; return this.getFeatureValue(LICENSE_QUOTAS.TRIGGER_LIMIT) ?? UNLIMITED_LICENSE_QUOTA;
} }
getUsersLimit(): number { getVariablesLimit() {
return this.getFeatureValue(LICENSE_QUOTAS.USERS_LIMIT) as number; return this.getFeatureValue(LICENSE_QUOTAS.VARIABLES_LIMIT) ?? UNLIMITED_LICENSE_QUOTA;
} }
getPlanName(): string { getPlanName(): string {
return (this.getFeatureValue('planName') ?? 'Community') as string; return this.getFeatureValue('planName') ?? 'Community';
} }
getInfo(): string { getInfo(): string {
@ -200,4 +197,8 @@ export class License {
return this.manager.toString(); return this.manager.toString();
} }
isWithinUsersLimit() {
return this.getUsersLimit() === UNLIMITED_LICENSE_QUOTA;
}
} }

View file

@ -1,18 +1,9 @@
import { Container } from 'typedi'; import { Container } from 'typedi';
import { RoleRepository, UserRepository } from '@db/repositories'; import { UserRepository } from '@db/repositories';
import type { Role } from '@db/entities/Role';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import pick from 'lodash/pick'; import pick from 'lodash/pick';
import { validate as uuidValidate } from 'uuid'; import { validate as uuidValidate } from 'uuid';
export function isInstanceOwner(user: User): boolean {
return user.globalRole.name === 'owner';
}
export async function getWorkflowOwnerRole(): Promise<Role> {
return Container.get(RoleRepository).findWorkflowOwnerRoleOrFail();
}
export const getSelectableProperties = (table: 'user' | 'role'): string[] => { export const getSelectableProperties = (table: 'user' | 'role'): string[] => {
return { return {
user: ['id', 'email', 'firstName', 'lastName', 'createdAt', 'updatedAt', 'isPending'], user: ['id', 'email', 'firstName', 'lastName', 'createdAt', 'updatedAt', 'isPending'],

View file

@ -0,0 +1,7 @@
import { Container } from 'typedi';
import { RoleRepository } from '@db/repositories';
import type { Role } from '@db/entities/Role';
export async function getWorkflowOwnerRole(): Promise<Role> {
return Container.get(RoleRepository).findWorkflowOwnerRoleOrFail();
}

View file

@ -11,7 +11,7 @@ import { addNodeIds, replaceInvalidCredentials } from '@/WorkflowHelpers';
import type { WorkflowRequest } from '../../../types'; import type { WorkflowRequest } from '../../../types';
import { authorize, validCursor } from '../../shared/middlewares/global.middleware'; import { authorize, validCursor } from '../../shared/middlewares/global.middleware';
import { encodeNextCursor } from '../../shared/services/pagination.service'; import { encodeNextCursor } from '../../shared/services/pagination.service';
import { getWorkflowOwnerRole, isInstanceOwner } from '../users/users.service.ee'; import { getWorkflowOwnerRole } from '../users/users.service';
import { import {
getWorkflowById, getWorkflowById,
getSharedWorkflow, getSharedWorkflow,
@ -101,7 +101,7 @@ export = {
...(active !== undefined && { active }), ...(active !== undefined && { active }),
}; };
if (isInstanceOwner(req.user)) { if (req.user.isOwner) {
if (tags) { if (tags) {
const workflowIds = await getWorkflowIdsViaTags(parseTagNames(tags)); const workflowIds = await getWorkflowIdsViaTags(parseTagNames(tags));
where.id = In(workflowIds); where.id = In(workflowIds);

View file

@ -8,7 +8,6 @@ import * as Db from '@/Db';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { isInstanceOwner } from '../users/users.service.ee';
import type { Role } from '@db/entities/Role'; import type { Role } from '@db/entities/Role';
import config from '@/config'; import config from '@/config';
import { START_NODES } from '@/constants'; import { START_NODES } from '@/constants';
@ -32,7 +31,7 @@ export async function getSharedWorkflow(
): Promise<SharedWorkflow | null> { ): Promise<SharedWorkflow | null> {
return Db.collections.SharedWorkflow.findOne({ return Db.collections.SharedWorkflow.findOne({
where: { where: {
...(!isInstanceOwner(user) && { userId: user.id }), ...(!user.isOwner && { userId: user.id }),
...(workflowId && { workflowId }), ...(workflowId && { workflowId }),
}, },
relations: [...insertIf(!config.getEnv('workflowTagsDisabled'), ['workflow.tags']), 'workflow'], relations: [...insertIf(!config.getEnv('workflowTagsDisabled'), ['workflow.tags']), 'workflow'],
@ -48,7 +47,7 @@ export async function getSharedWorkflows(
): Promise<SharedWorkflow[]> { ): Promise<SharedWorkflow[]> {
return Db.collections.SharedWorkflow.find({ return Db.collections.SharedWorkflow.find({
where: { where: {
...(!isInstanceOwner(user) && { userId: user.id }), ...(!user.isOwner && { userId: user.id }),
...(options.workflowIds && { workflowId: In(options.workflowIds) }), ...(options.workflowIds && { workflowId: In(options.workflowIds) }),
}, },
...(options.relations && { relations: options.relations }), ...(options.relations && { relations: options.relations }),

View file

@ -149,6 +149,7 @@ import { PostHogClient } from './posthog';
import { eventBus } from './eventbus'; import { eventBus } from './eventbus';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { InternalHooks } from './InternalHooks'; import { InternalHooks } from './InternalHooks';
import { License } from './License';
import { import {
getStatusUsingPreviousExecutionStatusMethod, getStatusUsingPreviousExecutionStatusMethod,
isAdvancedExecutionFiltersEnabled, isAdvancedExecutionFiltersEnabled,
@ -259,6 +260,7 @@ export class Server extends AbstractServer {
config.getEnv('personalization.enabled') && config.getEnv('diagnostics.enabled'), config.getEnv('personalization.enabled') && config.getEnv('diagnostics.enabled'),
defaultLocale: config.getEnv('defaultLocale'), defaultLocale: config.getEnv('defaultLocale'),
userManagement: { userManagement: {
quota: Container.get(License).getUsersLimit(),
showSetupOnFirstLoad: config.getEnv('userManagement.isInstanceOwnerSetUp') === false, showSetupOnFirstLoad: config.getEnv('userManagement.isInstanceOwnerSetUp') === false,
smtpSetup: isEmailSetUp(), smtpSetup: isEmailSetUp(),
authenticationMethod: getCurrentAuthenticationMethod(), authenticationMethod: getCurrentAuthenticationMethod(),
@ -407,6 +409,7 @@ export class Server extends AbstractServer {
getSettingsForFrontend(): IN8nUISettings { getSettingsForFrontend(): IN8nUISettings {
// refresh user management status // refresh user management status
Object.assign(this.frontendSettings.userManagement, { Object.assign(this.frontendSettings.userManagement, {
quota: Container.get(License).getUsersLimit(),
authenticationMethod: getCurrentAuthenticationMethod(), authenticationMethod: getCurrentAuthenticationMethod(),
showSetupOnFirstLoad: showSetupOnFirstLoad:
config.getEnv('userManagement.isInstanceOwnerSetUp') === false && config.getEnv('userManagement.isInstanceOwnerSetUp') === false &&

View file

@ -12,8 +12,8 @@ import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '@db/entities/User';
import type { Role } from '@db/entities/Role'; import type { Role } from '@db/entities/Role';
import { RoleRepository } from '@db/repositories'; import { RoleRepository } from '@db/repositories';
import config from '@/config'; import config from '@/config';
import { getWebhookBaseUrl } from '@/WebhookHelpers';
import { License } from '@/License'; import { License } from '@/License';
import { getWebhookBaseUrl } from '@/WebhookHelpers';
import type { PostHogClient } from '@/posthog'; import type { PostHogClient } from '@/posthog';
export async function getWorkflowOwner(workflowId: string): Promise<User> { export async function getWorkflowOwner(workflowId: string): Promise<User> {

View file

@ -4,15 +4,18 @@ import jwt from 'jsonwebtoken';
import type { Response } from 'express'; import type { Response } from 'express';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import * as Db from '@/Db'; import * as Db from '@/Db';
import { AUTH_COOKIE_NAME } from '@/constants'; import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from '@/constants';
import type { JwtPayload, JwtToken } from '@/Interfaces'; import type { JwtPayload, JwtToken } from '@/Interfaces';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import config from '@/config'; import config from '@/config';
import * as ResponseHelper from '@/ResponseHelper'; import * as ResponseHelper from '@/ResponseHelper';
import { License } from '@/License';
import { Container } from 'typedi';
export function issueJWT(user: User): JwtToken { export function issueJWT(user: User): JwtToken {
const { id, email, password } = user; const { id, email, password } = user;
const expiresIn = 7 * 86400000; // 7 days const expiresIn = 7 * 86400000; // 7 days
const isWithinUsersLimit = Container.get(License).isWithinUsersLimit();
const payload: JwtPayload = { const payload: JwtPayload = {
id, id,
@ -20,6 +23,13 @@ export function issueJWT(user: User): JwtToken {
password: password ?? null, password: password ?? null,
}; };
if (
config.getEnv('userManagement.isInstanceOwnerSetUp') &&
!user.isOwner &&
!isWithinUsersLimit
) {
throw new ResponseHelper.UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
if (password) { if (password) {
payload.password = createHash('sha256') payload.password = createHash('sha256')
.update(password.slice(password.length / 2)) .update(password.slice(password.length / 2))

View file

@ -47,6 +47,7 @@ export const RESPONSE_ERROR_MESSAGES = {
PACKAGE_DOES_NOT_CONTAIN_NODES: 'The specified package does not contain any nodes', PACKAGE_DOES_NOT_CONTAIN_NODES: 'The specified package does not contain any nodes',
PACKAGE_LOADING_FAILED: 'The specified package could not be loaded', PACKAGE_LOADING_FAILED: 'The specified package could not be loaded',
DISK_IS_FULL: 'There appears to be insufficient disk space', DISK_IS_FULL: 'There appears to be insufficient disk space',
USERS_QUOTA_REACHED: 'Maximum number of users reached',
}; };
export const AUTH_COOKIE_NAME = 'n8n-auth'; export const AUTH_COOKIE_NAME = 'n8n-auth';
@ -68,21 +69,22 @@ export const WORKFLOW_REACTIVATE_MAX_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
export const SETTINGS_LICENSE_CERT_KEY = 'license.cert'; export const SETTINGS_LICENSE_CERT_KEY = 'license.cert';
export const enum LICENSE_FEATURES { export const LICENSE_FEATURES = {
SHARING = 'feat:sharing', SHARING: 'feat:sharing',
LDAP = 'feat:ldap', LDAP: 'feat:ldap',
SAML = 'feat:saml', SAML: 'feat:saml',
LOG_STREAMING = 'feat:logStreaming', LOG_STREAMING: 'feat:logStreaming',
ADVANCED_EXECUTION_FILTERS = 'feat:advancedExecutionFilters', ADVANCED_EXECUTION_FILTERS: 'feat:advancedExecutionFilters',
VARIABLES = 'feat:variables', VARIABLES: 'feat:variables',
SOURCE_CONTROL = 'feat:sourceControl', SOURCE_CONTROL: 'feat:sourceControl',
API_DISABLED = 'feat:apiDisabled', API_DISABLED: 'feat:apiDisabled',
} } as const;
export const enum LICENSE_QUOTAS { export const LICENSE_QUOTAS = {
TRIGGER_LIMIT = 'quota:activeWorkflows', TRIGGER_LIMIT: 'quota:activeWorkflows',
VARIABLES_LIMIT = 'quota:maxVariables', VARIABLES_LIMIT: 'quota:maxVariables',
USERS_LIMIT = 'quota:users', USERS_LIMIT: 'quota:users',
} } as const;
export const UNLIMITED_LICENSE_QUOTA = -1;
export const CREDENTIAL_BLANKING_VALUE = '__n8n_BLANK_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6'; export const CREDENTIAL_BLANKING_VALUE = '__n8n_BLANK_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6';

View file

@ -1,9 +1,14 @@
import validator from 'validator'; import validator from 'validator';
import { Authorized, Get, Post, RestController } from '@/decorators'; import { Authorized, Get, Post, RestController } from '@/decorators';
import { AuthError, BadRequestError, InternalServerError } from '@/ResponseHelper'; import {
AuthError,
BadRequestError,
InternalServerError,
UnauthorizedError,
} from '@/ResponseHelper';
import { sanitizeUser, withFeatureFlags } from '@/UserManagement/UserManagementHelper'; import { sanitizeUser, withFeatureFlags } from '@/UserManagement/UserManagementHelper';
import { issueCookie, resolveJwt } from '@/auth/jwt'; import { issueCookie, resolveJwt } from '@/auth/jwt';
import { AUTH_COOKIE_NAME } from '@/constants'; import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from '@/constants';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import type { ILogger } from 'n8n-workflow'; import type { ILogger } from 'n8n-workflow';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
@ -26,6 +31,7 @@ import {
import type { UserRepository } from '@db/repositories'; import type { UserRepository } from '@db/repositories';
import { InternalHooks } from '../InternalHooks'; import { InternalHooks } from '../InternalHooks';
import Container from 'typedi'; import Container from 'typedi';
import { License } from '@/License';
@RestController() @RestController()
export class AuthController { export class AuthController {
@ -71,7 +77,6 @@ export class AuthController {
let user: User | undefined; let user: User | undefined;
let usedAuthenticationMethod = getCurrentAuthenticationMethod(); let usedAuthenticationMethod = getCurrentAuthenticationMethod();
if (isSamlCurrentAuthenticationMethod()) { if (isSamlCurrentAuthenticationMethod()) {
// attempt to fetch user data with the credentials, but don't log in yet // attempt to fetch user data with the credentials, but don't log in yet
const preliminaryUser = await handleEmailLogin(email, password); const preliminaryUser = await handleEmailLogin(email, password);
@ -120,6 +125,7 @@ export class AuthController {
// If logged in, return user // If logged in, return user
try { try {
user = await resolveJwt(cookieContents); user = await resolveJwt(cookieContents);
return await withFeatureFlags(this.postHog, sanitizeUser(user)); return await withFeatureFlags(this.postHog, sanitizeUser(user));
} catch (error) { } catch (error) {
res.clearCookie(AUTH_COOKIE_NAME); res.clearCookie(AUTH_COOKIE_NAME);
@ -155,6 +161,15 @@ export class AuthController {
@Get('/resolve-signup-token') @Get('/resolve-signup-token')
async resolveSignupToken(req: UserRequest.ResolveSignUp) { async resolveSignupToken(req: UserRequest.ResolveSignUp) {
const { inviterId, inviteeId } = req.query; const { inviterId, inviteeId } = req.query;
const isWithinUsersLimit = Container.get(License).isWithinUsersLimit();
if (!isWithinUsersLimit) {
this.logger.debug('Request to resolve signup token failed because of users quota reached', {
inviterId,
inviteeId,
});
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
if (!inviterId || !inviteeId) { if (!inviterId || !inviteeId) {
this.logger.debug( this.logger.debug(

View file

@ -11,6 +11,7 @@ import { License } from '@/License';
import { LICENSE_FEATURES, inE2ETests } from '@/constants'; import { LICENSE_FEATURES, inE2ETests } from '@/constants';
import { NoAuthRequired, Patch, Post, RestController } from '@/decorators'; import { NoAuthRequired, Patch, Post, RestController } from '@/decorators';
import type { UserSetupPayload } from '@/requests'; import type { UserSetupPayload } from '@/requests';
import type { BooleanLicenseFeature } from '@/Interfaces';
if (!inE2ETests) { if (!inE2ETests) {
console.error('E2E endpoints only allowed during E2E tests'); console.error('E2E endpoints only allowed during E2E tests');
@ -51,7 +52,7 @@ type ResetRequest = Request<
@NoAuthRequired() @NoAuthRequired()
@RestController('/e2e') @RestController('/e2e')
export class E2EController { export class E2EController {
private enabledFeatures: Record<LICENSE_FEATURES, boolean> = { private enabledFeatures: Record<BooleanLicenseFeature, boolean> = {
[LICENSE_FEATURES.SHARING]: false, [LICENSE_FEATURES.SHARING]: false,
[LICENSE_FEATURES.LDAP]: false, [LICENSE_FEATURES.LDAP]: false,
[LICENSE_FEATURES.SAML]: false, [LICENSE_FEATURES.SAML]: false,
@ -69,7 +70,7 @@ export class E2EController {
private userRepo: UserRepository, private userRepo: UserRepository,
private workflowRunner: ActiveWorkflowRunner, private workflowRunner: ActiveWorkflowRunner,
) { ) {
license.isFeatureEnabled = (feature: LICENSE_FEATURES) => license.isFeatureEnabled = (feature: BooleanLicenseFeature) =>
this.enabledFeatures[feature] ?? false; this.enabledFeatures[feature] ?? false;
} }
@ -84,14 +85,14 @@ export class E2EController {
} }
@Patch('/feature') @Patch('/feature')
setFeature(req: Request<{}, {}, { feature: LICENSE_FEATURES; enabled: boolean }>) { setFeature(req: Request<{}, {}, { feature: BooleanLicenseFeature; enabled: boolean }>) {
const { enabled, feature } = req.body; const { enabled, feature } = req.body;
this.enabledFeatures[feature] = enabled; this.enabledFeatures[feature] = enabled;
} }
private resetFeatures() { private resetFeatures() {
for (const feature of Object.keys(this.enabledFeatures)) { for (const feature of Object.keys(this.enabledFeatures)) {
this.enabledFeatures[feature as LICENSE_FEATURES] = false; this.enabledFeatures[feature as BooleanLicenseFeature] = false;
} }
} }

View file

@ -23,8 +23,11 @@ import { PasswordResetRequest } from '@/requests';
import type { IDatabaseCollections, IExternalHooksClass, IInternalHooksClass } from '@/Interfaces'; import type { IDatabaseCollections, IExternalHooksClass, IInternalHooksClass } from '@/Interfaces';
import { issueCookie } from '@/auth/jwt'; import { issueCookie } from '@/auth/jwt';
import { isLdapEnabled } from '@/Ldap/helpers'; import { isLdapEnabled } from '@/Ldap/helpers';
import { isSamlCurrentAuthenticationMethod } from '../sso/ssoHelpers'; import { isSamlCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
import { UserService } from '../user/user.service'; import { UserService } from '@/user/user.service';
import { License } from '@/License';
import { Container } from 'typedi';
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
@RestController() @RestController()
export class PasswordResetController { export class PasswordResetController {
@ -103,6 +106,12 @@ export class PasswordResetController {
relations: ['authIdentities', 'globalRole'], relations: ['authIdentities', 'globalRole'],
}); });
if (!user?.isOwner && !Container.get(License).isWithinUsersLimit()) {
this.logger.debug(
'Request to send password reset email failed because the user limit was reached',
);
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
if ( if (
isSamlCurrentAuthenticationMethod() && isSamlCurrentAuthenticationMethod() &&
!(user?.globalRole.name === 'owner' || user?.settings?.allowSSOManualLogin === true) !(user?.globalRole.name === 'owner' || user?.settings?.allowSSOManualLogin === true)
@ -116,7 +125,6 @@ export class PasswordResetController {
} }
const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap'); const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap');
if (!user?.password || (ldapIdentity && user.disabled)) { if (!user?.password || (ldapIdentity && user.disabled)) {
this.logger.debug( this.logger.debug(
'Request to send password reset email failed because no user was found for the provided email', 'Request to send password reset email failed because no user was found for the provided email',
@ -182,12 +190,21 @@ export class PasswordResetController {
// Timestamp is saved in seconds // Timestamp is saved in seconds
const currentTimestamp = Math.floor(Date.now() / 1000); const currentTimestamp = Math.floor(Date.now() / 1000);
const user = await this.userRepository.findOneBy({ const user = await this.userRepository.findOne({
where: {
id, id,
resetPasswordToken, resetPasswordToken,
resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp), resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp),
},
relations: ['globalRole'],
}); });
if (!user?.isOwner && !Container.get(License).isWithinUsersLimit()) {
this.logger.debug(
'Request to resolve password token failed because the user limit was reached',
{ userId: id },
);
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
if (!user) { if (!user) {
this.logger.debug( this.logger.debug(
'Request to resolve password token failed because no user was found for the provided user ID and reset password token', 'Request to resolve password token failed because no user was found for the provided user ID and reset password token',

View file

@ -17,7 +17,12 @@ import {
withFeatureFlags, withFeatureFlags,
} from '@/UserManagement/UserManagementHelper'; } from '@/UserManagement/UserManagementHelper';
import { issueCookie } from '@/auth/jwt'; import { issueCookie } from '@/auth/jwt';
import { BadRequestError, InternalServerError, NotFoundError } from '@/ResponseHelper'; import {
BadRequestError,
InternalServerError,
NotFoundError,
UnauthorizedError,
} from '@/ResponseHelper';
import { Response } from 'express'; import { Response } from 'express';
import type { Config } from '@/config'; import type { Config } from '@/config';
import { UserRequest, UserSettingsUpdatePayload } from '@/requests'; import { UserRequest, UserSettingsUpdatePayload } from '@/requests';
@ -39,8 +44,11 @@ import type {
SharedWorkflowRepository, SharedWorkflowRepository,
UserRepository, UserRepository,
} from '@db/repositories'; } from '@db/repositories';
import { UserService } from '../user/user.service'; import { UserService } from '@/user/user.service';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { License } from '@/License';
import { Container } from 'typedi';
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
@Authorized(['global', 'owner']) @Authorized(['global', 'owner'])
@RestController('/users') @RestController('/users')
@ -107,6 +115,8 @@ export class UsersController {
*/ */
@Post('/') @Post('/')
async sendEmailInvites(req: UserRequest.Invite) { async sendEmailInvites(req: UserRequest.Invite) {
const isWithinUsersLimit = Container.get(License).isWithinUsersLimit();
if (isSamlLicensedAndEnabled()) { if (isSamlLicensedAndEnabled()) {
this.logger.debug( this.logger.debug(
'SAML is enabled, so users are managed by the Identity Provider and cannot be added through invites', 'SAML is enabled, so users are managed by the Identity Provider and cannot be added through invites',
@ -116,6 +126,13 @@ export class UsersController {
); );
} }
if (!isWithinUsersLimit) {
this.logger.debug(
'Request to send email invite(s) to user(s) failed because the user limit quota has been reached',
);
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
if (!this.config.getEnv('userManagement.isInstanceOwnerSetUp')) { if (!this.config.getEnv('userManagement.isInstanceOwnerSetUp')) {
this.logger.debug( this.logger.debug(
'Request to send email invite(s) to user(s) failed because the owner account is not set up', 'Request to send email invite(s) to user(s) failed because the owner account is not set up',
@ -551,6 +568,14 @@ export class UsersController {
@Post('/:id/reinvite') @Post('/:id/reinvite')
async reinviteUser(req: UserRequest.Reinvite) { async reinviteUser(req: UserRequest.Reinvite) {
const { id: idToReinvite } = req.params; const { id: idToReinvite } = req.params;
const isWithinUsersLimit = Container.get(License).isWithinUsersLimit();
if (!isWithinUsersLimit) {
this.logger.debug(
'Request to send email invite(s) to user(s) failed because the user limit quota has been reached',
);
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
if (!isEmailSetUp()) { if (!isEmailSetUp()) {
this.logger.error('Request to reinvite a user failed because email sending was not set up'); this.logger.error('Request to reinvite a user failed because email sending was not set up');

View file

@ -113,4 +113,14 @@ export class User extends AbstractEntity implements IUser {
computeIsPending(): void { computeIsPending(): void {
this.isPending = this.password === null; this.isPending = this.password === null;
} }
/**
* Whether the user is instance owner
*/
isOwner: boolean;
@AfterLoad()
computeIsOwner(): void {
this.isOwner = this.globalRole?.name === 'owner';
}
} }

View file

@ -9,7 +9,6 @@ import type { ILicensePostResponse, ILicenseReadResponse } from '@/Interfaces';
import { LicenseService } from './License.service'; import { LicenseService } from './License.service';
import { License } from '@/License'; import { License } from '@/License';
import type { AuthenticatedRequest, LicenseRequest } from '@/requests'; import type { AuthenticatedRequest, LicenseRequest } from '@/requests';
import { isInstanceOwner } from '@/PublicApi/v1/handlers/users/users.service.ee';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
@ -34,7 +33,7 @@ licenseController.use((req, res, next) => {
*/ */
licenseController.use((req: AuthenticatedRequest, res, next) => { licenseController.use((req: AuthenticatedRequest, res, next) => {
if (OWNER_ROUTES.includes(req.path) && req.user) { if (OWNER_ROUTES.includes(req.path) && req.user) {
if (!isInstanceOwner(req.user)) { if (!req.user.isOwner) {
LoggerProxy.info('Non-owner attempted to activate or renew a license', { LoggerProxy.info('Non-owner attempted to activate or renew a license', {
userId: req.user.id, userId: req.user.id,
}); });

View file

@ -77,9 +77,6 @@ export const setupPushHandler = (restEndpoint: string, app: Application) => {
} }
return; return;
} }
// Handle authentication
try { try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const authCookie: string = req.cookies?.[AUTH_COOKIE_NAME] ?? ''; const authCookie: string = req.cookies?.[AUTH_COOKIE_NAME] ?? '';

View file

@ -52,7 +52,9 @@ export function setSamlLoginLabel(label: string): void {
config.set(SAML_LOGIN_LABEL, label); config.set(SAML_LOGIN_LABEL, label);
} }
export const isSamlLicensed = () => Container.get(License).isSamlEnabled(); export function isSamlLicensed(): boolean {
return Container.get(License).isSamlEnabled();
}
export function isSamlLicensedAndEnabled(): boolean { export function isSamlLicensedAndEnabled(): boolean {
return isSamlLoginEnabled() && isSamlLicensed() && isSamlCurrentAuthenticationMethod(); return isSamlLoginEnabled() && isSamlLicensed() && isSamlCurrentAuthenticationMethod();

View file

@ -1,5 +1,7 @@
import type { Application } from 'express'; import type { Application } from 'express';
import type { SuperAgentTest } from 'supertest'; import type { SuperAgentTest } from 'supertest';
import { Container } from 'typedi';
import { License } from '@/License';
import validator from 'validator'; import validator from 'validator';
import config from '@/config'; import config from '@/config';
import * as Db from '@/Db'; import * as Db from '@/Db';
@ -84,6 +86,26 @@ describe('POST /login', () => {
const authToken = utils.getAuthToken(response); const authToken = utils.getAuthToken(response);
expect(authToken).toBeDefined(); expect(authToken).toBeDefined();
}); });
test('should throw AuthError for non-owner if not within users limit quota', async () => {
jest.spyOn(Container.get(License), 'isWithinUsersLimit').mockReturnValueOnce(false);
const member = await testDb.createUserShell(globalMemberRole);
const response = await authAgent(member).get('/login');
expect(response.statusCode).toBe(401);
});
test('should not throw AuthError for owner if not within users limit quota', async () => {
jest.spyOn(Container.get(License), 'isWithinUsersLimit').mockReturnValueOnce(false);
const ownerUser = await testDb.createUser({
password: randomValidPassword(),
globalRole: globalOwnerRole,
isOwner: true,
});
const response = await authAgent(ownerUser).get('/login');
expect(response.statusCode).toBe(200);
});
}); });
describe('GET /login', () => { describe('GET /login', () => {
@ -292,6 +314,18 @@ describe('GET /resolve-signup-token', () => {
}); });
}); });
test('should return 403 if user quota reached', async () => {
jest.spyOn(Container.get(License), 'isWithinUsersLimit').mockReturnValueOnce(false);
const memberShell = await testDb.createUserShell(globalMemberRole);
const response = await authOwnerAgent
.get('/resolve-signup-token')
.query({ inviterId: owner.id })
.query({ inviteeId: memberShell.id });
expect(response.statusCode).toBe(403);
});
test('should fail with invalid inputs', async () => { test('should fail with invalid inputs', async () => {
const { id: inviteeId } = await testDb.createUser({ globalRole: globalMemberRole }); const { id: inviteeId } = await testDb.createUser({ globalRole: globalMemberRole });

View file

@ -5,6 +5,7 @@ import {
} from './shared/constants'; } from './shared/constants';
import * as testDb from './shared/testDb'; import * as testDb from './shared/testDb';
import * as utils from './shared/utils'; import * as utils from './shared/utils';
import config from '@/config';
let authlessAgent: SuperAgentTest; let authlessAgent: SuperAgentTest;
let authMemberAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest;
@ -16,6 +17,8 @@ beforeAll(async () => {
authlessAgent = utils.createAgent(app); authlessAgent = utils.createAgent(app);
authMemberAgent = utils.createAuthAgent(app)(member); authMemberAgent = utils.createAuthAgent(app)(member);
config.set('userManagement.isInstanceOwnerSetUp', true);
}); });
afterAll(async () => { afterAll(async () => {

View file

@ -13,6 +13,7 @@ import { randomCredentialPayload } from './shared/random';
import * as testDb from './shared/testDb'; import * as testDb from './shared/testDb';
import type { AuthAgent, SaveCredentialFunction } from './shared/types'; import type { AuthAgent, SaveCredentialFunction } from './shared/types';
import * as utils from './shared/utils'; import * as utils from './shared/utils';
import config from '@/config';
let globalMemberRole: Role; let globalMemberRole: Role;
let owner: User; let owner: User;
@ -39,6 +40,7 @@ beforeAll(async () => {
saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole); saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole);
sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true);
config.set('userManagement.isInstanceOwnerSetUp', true);
}); });
beforeEach(async () => { beforeEach(async () => {

View file

@ -45,6 +45,8 @@ beforeAll(async () => {
authAgent = utils.createAuthAgent(app); authAgent = utils.createAuthAgent(app);
authOwnerAgent = authAgent(owner); authOwnerAgent = authAgent(owner);
authMemberAgent = authAgent(member); authMemberAgent = authAgent(member);
config.set('userManagement.isInstanceOwnerSetUp', true);
}); });
beforeEach(async () => { beforeEach(async () => {

View file

@ -26,6 +26,7 @@ beforeAll(async () => {
authOwnerAgent = authAgent(owner); authOwnerAgent = authAgent(owner);
authMemberAgent = authAgent(member); authMemberAgent = authAgent(member);
config.set('userManagement.isInstanceOwnerSetUp', true);
config.set('license.serverUrl', MOCK_SERVER_URL); config.set('license.serverUrl', MOCK_SERVER_URL);
config.set('license.autoRenewEnabled', true); config.set('license.autoRenewEnabled', true);
config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET); config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET);

View file

@ -11,6 +11,7 @@ import * as utils from '../shared/utils';
import { sampleConfig } from './sampleMetadata'; import { sampleConfig } from './sampleMetadata';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
import { SamlService } from '@/sso/saml/saml.service.ee'; import { SamlService } from '@/sso/saml/saml.service.ee';
import config from '@/config';
import type { SamlUserAttributes } from '@/sso/saml/types/samlUserAttributes'; import type { SamlUserAttributes } from '@/sso/saml/types/samlUserAttributes';
import type { AuthenticationMethod } from 'n8n-workflow'; import type { AuthenticationMethod } from 'n8n-workflow';
@ -32,6 +33,8 @@ beforeAll(async () => {
authOwnerAgent = utils.createAuthAgent(app)(owner); authOwnerAgent = utils.createAuthAgent(app)(owner);
authMemberAgent = utils.createAgent(app, { auth: true, user: someUser }); authMemberAgent = utils.createAgent(app, { auth: true, user: someUser });
noAuthMemberAgent = utils.createAgent(app, { auth: false, user: someUser }); noAuthMemberAgent = utils.createAgent(app, { auth: false, user: someUser });
config.set('userManagement.isInstanceOwnerSetUp', true);
}); });
afterAll(async () => { afterAll(async () => {

View file

@ -181,6 +181,7 @@ export async function createUser(attributes: Partial<User> = {}): Promise<User>
firstName: firstName ?? randomName(), firstName: firstName ?? randomName(),
lastName: lastName ?? randomName(), lastName: lastName ?? randomName(),
globalRoleId: (globalRole ?? (await getGlobalMemberRole())).id, globalRoleId: (globalRole ?? (await getGlobalMemberRole())).id,
globalRole,
...rest, ...rest,
}; };

View file

@ -683,8 +683,10 @@ export function createAgent(
if (options?.apiPath === undefined || options?.apiPath === 'internal') { if (options?.apiPath === undefined || options?.apiPath === 'internal') {
void agent.use(prefix(REST_PATH_SEGMENT)); void agent.use(prefix(REST_PATH_SEGMENT));
if (options?.auth && options?.user) { if (options?.auth && options?.user) {
try {
const { token } = issueJWT(options.user); const { token } = issueJWT(options.user);
agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`); agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`);
} catch {}
} }
} }

View file

@ -3,6 +3,7 @@ import type { Application } from 'express';
import type { User } from '@/databases/entities/User'; import type { User } from '@/databases/entities/User';
import * as testDb from './shared/testDb'; import * as testDb from './shared/testDb';
import * as utils from './shared/utils'; import * as utils from './shared/utils';
import config from '@/config';
import type { AuthAgent } from './shared/types'; import type { AuthAgent } from './shared/types';
import { License } from '@/License'; import { License } from '@/License';
@ -16,6 +17,7 @@ let variablesSpy: jest.SpyInstance<boolean>;
const licenseLike = { const licenseLike = {
isVariablesEnabled: jest.fn().mockReturnValue(true), isVariablesEnabled: jest.fn().mockReturnValue(true),
getVariablesLimit: jest.fn().mockReturnValue(-1), getVariablesLimit: jest.fn().mockReturnValue(-1),
isWithinUsersLimit: jest.fn().mockReturnValue(true),
}; };
beforeAll(async () => { beforeAll(async () => {
@ -28,6 +30,7 @@ beforeAll(async () => {
memberUser = await testDb.createUser(); memberUser = await testDb.createUser();
authAgent = utils.createAuthAgent(app); authAgent = utils.createAuthAgent(app);
config.set('userManagement.isInstanceOwnerSetUp', true);
}); });
beforeEach(async () => { beforeEach(async () => {

View file

@ -14,6 +14,7 @@ import { makeWorkflow } from './shared/utils';
import { randomCredentialPayload } from './shared/random'; import { randomCredentialPayload } from './shared/random';
import { License } from '@/License'; import { License } from '@/License';
import { getSharedWorkflowIds } from '../../src/WorkflowHelpers'; import { getSharedWorkflowIds } from '../../src/WorkflowHelpers';
import config from '@/config';
let owner: User; let owner: User;
let member: User; let member: User;
@ -45,6 +46,7 @@ beforeAll(async () => {
sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true);
await utils.initNodeTypes(); await utils.initNodeTypes();
config.set('userManagement.isInstanceOwnerSetUp', true);
}); });
beforeEach(async () => { beforeEach(async () => {

View file

@ -9,7 +9,7 @@ const MOCK_SERVER_URL = 'https://server.com/v1';
const MOCK_RENEW_OFFSET = 259200; const MOCK_RENEW_OFFSET = 259200;
const MOCK_INSTANCE_ID = 'instance-id'; const MOCK_INSTANCE_ID = 'instance-id';
const MOCK_ACTIVATION_KEY = 'activation-key'; const MOCK_ACTIVATION_KEY = 'activation-key';
const MOCK_FEATURE_FLAG = 'feat:mock'; const MOCK_FEATURE_FLAG = 'feat:sharing';
const MOCK_MAIN_PLAN_ID = '1b765dc4-d39d-4ffe-9885-c56dd67c4b26'; const MOCK_MAIN_PLAN_ID = '1b765dc4-d39d-4ffe-9885-c56dd67c4b26';
describe('License', () => { describe('License', () => {
@ -71,9 +71,9 @@ describe('License', () => {
}); });
test('check fetching feature values', async () => { test('check fetching feature values', async () => {
await license.getFeatureValue(MOCK_FEATURE_FLAG, false); license.getFeatureValue(MOCK_FEATURE_FLAG);
expect(LicenseManager.prototype.getFeatureValue).toHaveBeenCalledWith(MOCK_FEATURE_FLAG, false); expect(LicenseManager.prototype.getFeatureValue).toHaveBeenCalledWith(MOCK_FEATURE_FLAG);
}); });
test('check management jwt', async () => { test('check management jwt', async () => {

View file

@ -37,6 +37,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
settings: {} as IN8nUISettings, settings: {} as IN8nUISettings,
promptsData: {} as IN8nPrompts, promptsData: {} as IN8nPrompts,
userManagement: { userManagement: {
quota: -1,
showSetupOnFirstLoad: false, showSetupOnFirstLoad: false,
smtpSetup: false, smtpSetup: false,
authenticationMethod: UserManagementAuthenticationMethod.Email, authenticationMethod: UserManagementAuthenticationMethod.Email,
@ -169,6 +170,12 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
isDefaultAuthenticationSaml(): boolean { isDefaultAuthenticationSaml(): boolean {
return this.userManagement.authenticationMethod === UserManagementAuthenticationMethod.Saml; return this.userManagement.authenticationMethod === UserManagementAuthenticationMethod.Saml;
}, },
isBelowUserQuota(): boolean {
const userStore = useUsersStore();
return (
this.userManagement.quota === -1 || this.userManagement.quota > userStore.allUsers.length
);
},
}, },
actions: { actions: {
setSettings(settings: IN8nUISettings): void { setSettings(settings: IN8nUISettings): void {

View file

@ -9,7 +9,7 @@
</template> </template>
<div> <div>
<n8n-button <n8n-button
:disabled="ssoStore.isSamlLoginEnabled" :disabled="ssoStore.isSamlLoginEnabled || !settingsStore.isBelowUserQuota"
:label="$locale.baseText('settings.users.invite')" :label="$locale.baseText('settings.users.invite')"
@click="onInvite" @click="onInvite"
size="large" size="large"
@ -19,17 +19,28 @@
</n8n-tooltip> </n8n-tooltip>
</div> </div>
</div> </div>
<div v-if="usersStore.showUMSetupWarning" :class="$style.setupInfoContainer"> <div v-if="!settingsStore.isBelowUserQuota" :class="$style.setupInfoContainer">
<n8n-action-box <n8n-action-box
:heading="$locale.baseText('settings.users.setupToInviteUsers')" :heading="
:buttonText="$locale.baseText('settings.users.setupMyAccount')" $locale.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.title)
:description="`${ "
isSharingEnabled ? '' : $locale.baseText('settings.users.setupToInviteUsersInfo') :description="
}`" $locale.baseText(
@click="redirectToSetup" uiStore.contextBasedTranslationKeys.users.settings.unavailable.description,
)
"
:buttonText="
$locale.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.button)
"
@click="goToUpgrade"
/> />
</div> </div>
<div :class="$style.usersContainer" v-else> <!-- If there's more than 1 user it means the account quota was more than 1 in the past. So we need to allow instance owner to be able to delete users and transfer workflows.
-->
<div
:class="$style.usersContainer"
v-if="settingsStore.isBelowUserQuota || usersStore.allUsers.length > 1"
>
<n8n-users-list <n8n-users-list
:actions="usersListActions" :actions="usersListActions"
:users="usersStore.allUsers" :users="usersStore.allUsers"
@ -43,6 +54,19 @@
@disallowSSOManualLogin="onDisallowSSOManualLogin" @disallowSSOManualLogin="onDisallowSSOManualLogin"
/> />
</div> </div>
<n8n-action-box
v-else
:heading="
$locale.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.title)
"
:description="
$locale.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.description)
"
:buttonText="
$locale.baseText(uiStore.contextBasedTranslationKeys.users.settings.unavailable.button)
"
@click="goToUpgrade"
/>
</div> </div>
</template> </template>
@ -83,12 +107,16 @@ export default defineComponent({
{ {
label: this.$locale.baseText('settings.users.actions.copyInviteLink'), label: this.$locale.baseText('settings.users.actions.copyInviteLink'),
value: 'copyInviteLink', value: 'copyInviteLink',
guard: (user) => !user.firstName && !!user.inviteAcceptUrl, guard: (user) =>
this.settingsStore.isBelowUserQuota && !user.firstName && !!user.inviteAcceptUrl,
}, },
{ {
label: this.$locale.baseText('settings.users.actions.reinvite'), label: this.$locale.baseText('settings.users.actions.reinvite'),
value: 'reinvite', value: 'reinvite',
guard: (user) => !user.firstName && this.settingsStore.isSmtpSetup, guard: (user) =>
this.settingsStore.isBelowUserQuota &&
!user.firstName &&
this.settingsStore.isSmtpSetup,
}, },
{ {
label: this.$locale.baseText('settings.users.actions.delete'), label: this.$locale.baseText('settings.users.actions.delete'),
@ -97,6 +125,7 @@ export default defineComponent({
{ {
label: this.$locale.baseText('settings.users.actions.copyPasswordResetLink'), label: this.$locale.baseText('settings.users.actions.copyPasswordResetLink'),
value: 'copyPasswordResetLink', value: 'copyPasswordResetLink',
guard: () => this.settingsStore.isBelowUserQuota,
}, },
{ {
label: this.$locale.baseText('settings.users.actions.allowSSOManualLogin'), label: this.$locale.baseText('settings.users.actions.allowSSOManualLogin'),

View file

@ -2056,6 +2056,7 @@ export interface IVersionNotificationSettings {
} }
export interface IUserManagementSettings { export interface IUserManagementSettings {
quota: number;
showSetupOnFirstLoad?: boolean; showSetupOnFirstLoad?: boolean;
smtpSetup: boolean; smtpSetup: boolean;
authenticationMethod: AuthenticationMethod; authenticationMethod: AuthenticationMethod;