mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
refactor(core): Port 3 more controllers to use DTOs (no-changelog) (#12375)
This commit is contained in:
committed by
GitHub
parent
1d5e891a0d
commit
371a09de96
@@ -0,0 +1,93 @@
|
||||
import { LoginRequestDto } from '../login-request.dto';
|
||||
|
||||
describe('LoginRequestDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'complete valid login request',
|
||||
request: {
|
||||
email: 'test@example.com',
|
||||
password: 'securePassword123',
|
||||
mfaCode: '123456',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'login request without optional MFA',
|
||||
request: {
|
||||
email: 'test@example.com',
|
||||
password: 'securePassword123',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'login request with both mfaCode and mfaRecoveryCode',
|
||||
request: {
|
||||
email: 'test@example.com',
|
||||
password: 'securePassword123',
|
||||
mfaCode: '123456',
|
||||
mfaRecoveryCode: 'recovery-code-123',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'login request with only mfaRecoveryCode',
|
||||
request: {
|
||||
email: 'test@example.com',
|
||||
password: 'securePassword123',
|
||||
mfaRecoveryCode: 'recovery-code-123',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = LoginRequestDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'invalid email',
|
||||
request: {
|
||||
email: 'invalid-email',
|
||||
password: 'securePassword123',
|
||||
},
|
||||
expectedErrorPath: ['email'],
|
||||
},
|
||||
{
|
||||
name: 'empty password',
|
||||
request: {
|
||||
email: 'test@example.com',
|
||||
password: '',
|
||||
},
|
||||
expectedErrorPath: ['password'],
|
||||
},
|
||||
{
|
||||
name: 'missing email',
|
||||
request: {
|
||||
password: 'securePassword123',
|
||||
},
|
||||
expectedErrorPath: ['email'],
|
||||
},
|
||||
{
|
||||
name: 'missing password',
|
||||
request: {
|
||||
email: 'test@example.com',
|
||||
},
|
||||
expectedErrorPath: ['password'],
|
||||
},
|
||||
{
|
||||
name: 'whitespace in email and password',
|
||||
request: {
|
||||
email: ' test@example.com ',
|
||||
password: ' securePassword123 ',
|
||||
},
|
||||
expectedErrorPath: ['email'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = LoginRequestDto.safeParse(request);
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { ResolveSignupTokenQueryDto } from '../resolve-signup-token-query.dto';
|
||||
|
||||
describe('ResolveSignupTokenQueryDto', () => {
|
||||
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'standard UUID',
|
||||
request: {
|
||||
inviterId: validUuid,
|
||||
inviteeId: validUuid,
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = ResolveSignupTokenQueryDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'invalid inviterId UUID',
|
||||
request: {
|
||||
inviterId: 'not-a-valid-uuid',
|
||||
inviteeId: validUuid,
|
||||
},
|
||||
expectedErrorPath: ['inviterId'],
|
||||
},
|
||||
{
|
||||
name: 'invalid inviteeId UUID',
|
||||
request: {
|
||||
inviterId: validUuid,
|
||||
inviteeId: 'not-a-valid-uuid',
|
||||
},
|
||||
expectedErrorPath: ['inviteeId'],
|
||||
},
|
||||
{
|
||||
name: 'missing inviterId',
|
||||
request: {
|
||||
inviteeId: validUuid,
|
||||
},
|
||||
expectedErrorPath: ['inviterId'],
|
||||
},
|
||||
{
|
||||
name: 'missing inviteeId',
|
||||
request: {
|
||||
inviterId: validUuid,
|
||||
},
|
||||
expectedErrorPath: ['inviteeId'],
|
||||
},
|
||||
{
|
||||
name: 'UUID with invalid characters',
|
||||
request: {
|
||||
inviterId: '123e4567-e89b-12d3-a456-42661417400G',
|
||||
inviteeId: validUuid,
|
||||
},
|
||||
expectedErrorPath: ['inviterId'],
|
||||
},
|
||||
{
|
||||
name: 'UUID too long',
|
||||
request: {
|
||||
inviterId: '123e4567-e89b-12d3-a456-426614174001234',
|
||||
inviteeId: validUuid,
|
||||
},
|
||||
expectedErrorPath: ['inviterId'],
|
||||
},
|
||||
{
|
||||
name: 'UUID too short',
|
||||
request: {
|
||||
inviterId: '123e4567-e89b-12d3-a456',
|
||||
inviteeId: validUuid,
|
||||
},
|
||||
expectedErrorPath: ['inviterId'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = ResolveSignupTokenQueryDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
export class LoginRequestDto extends Z.class({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
mfaCode: z.string().optional(),
|
||||
mfaRecoveryCode: z.string().optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
export class ResolveSignupTokenQueryDto extends Z.class({
|
||||
inviterId: z.string().uuid(),
|
||||
inviteeId: z.string().uuid(),
|
||||
}) {}
|
||||
@@ -1,9 +1,22 @@
|
||||
export { AiAskRequestDto } from './ai/ai-ask-request.dto';
|
||||
export { AiChatRequestDto } from './ai/ai-chat-request.dto';
|
||||
export { AiApplySuggestionRequestDto } from './ai/ai-apply-suggestion-request.dto';
|
||||
|
||||
export { LoginRequestDto } from './auth/login-request.dto';
|
||||
export { ResolveSignupTokenQueryDto } from './auth/resolve-signup-token-query.dto';
|
||||
|
||||
export { OwnerSetupRequestDto } from './owner/owner-setup-request.dto';
|
||||
export { DismissBannerRequestDto } from './owner/dismiss-banner-request.dto';
|
||||
|
||||
export { ForgotPasswordRequestDto } from './password-reset/forgot-password-request.dto';
|
||||
export { ResolvePasswordTokenQueryDto } from './password-reset/resolve-password-token-query.dto';
|
||||
export { ChangePasswordRequestDto } from './password-reset/change-password-request.dto';
|
||||
|
||||
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';
|
||||
|
||||
export { VariableListRequestDto } from './variables/variables-list-request.dto';
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { bannerNameSchema } from '../../../schemas/bannerName.schema';
|
||||
import { DismissBannerRequestDto } from '../dismiss-banner-request.dto';
|
||||
|
||||
describe('DismissBannerRequestDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each(
|
||||
bannerNameSchema.options.map((banner) => ({
|
||||
name: `valid banner: ${banner}`,
|
||||
request: { banner },
|
||||
})),
|
||||
)('should validate $name', ({ request }) => {
|
||||
const result = DismissBannerRequestDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'invalid banner string',
|
||||
request: {
|
||||
banner: 'not-a-valid-banner',
|
||||
},
|
||||
expectedErrorPath: ['banner'],
|
||||
},
|
||||
{
|
||||
name: 'non-string banner',
|
||||
request: {
|
||||
banner: 123,
|
||||
},
|
||||
expectedErrorPath: ['banner'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = DismissBannerRequestDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Optional banner', () => {
|
||||
test('should validate empty request', () => {
|
||||
const result = DismissBannerRequestDto.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Exhaustive banner name check', () => {
|
||||
test('should have all banner names defined', () => {
|
||||
const expectedBanners = [
|
||||
'V1',
|
||||
'TRIAL_OVER',
|
||||
'TRIAL',
|
||||
'NON_PRODUCTION_LICENSE',
|
||||
'EMAIL_CONFIRMATION',
|
||||
];
|
||||
|
||||
expect(bannerNameSchema.options).toEqual(expectedBanners);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { OwnerSetupRequestDto } from '../owner-setup-request.dto';
|
||||
|
||||
describe('OwnerSetupRequestDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'complete valid setup request',
|
||||
request: {
|
||||
email: 'owner@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
password: 'SecurePassword123',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = OwnerSetupRequestDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'invalid email',
|
||||
request: {
|
||||
email: 'invalid-email',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
password: 'SecurePassword123',
|
||||
},
|
||||
expectedErrorPath: ['email'],
|
||||
},
|
||||
{
|
||||
name: 'missing first name',
|
||||
request: {
|
||||
email: 'owner@example.com',
|
||||
firstName: '',
|
||||
lastName: 'Doe',
|
||||
password: 'SecurePassword123',
|
||||
},
|
||||
expectedErrorPath: ['firstName'],
|
||||
},
|
||||
{
|
||||
name: 'missing last name',
|
||||
request: {
|
||||
email: 'owner@example.com',
|
||||
firstName: 'John',
|
||||
lastName: '',
|
||||
password: 'SecurePassword123',
|
||||
},
|
||||
expectedErrorPath: ['lastName'],
|
||||
},
|
||||
{
|
||||
name: 'password too short',
|
||||
request: {
|
||||
email: 'owner@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
password: 'short',
|
||||
},
|
||||
expectedErrorPath: ['password'],
|
||||
},
|
||||
{
|
||||
name: 'password without number',
|
||||
request: {
|
||||
email: 'owner@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
password: 'NoNumberPassword',
|
||||
},
|
||||
expectedErrorPath: ['password'],
|
||||
},
|
||||
{
|
||||
name: 'password without uppercase letter',
|
||||
request: {
|
||||
email: 'owner@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
password: 'nouppercasepassword123',
|
||||
},
|
||||
expectedErrorPath: ['password'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = OwnerSetupRequestDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
import { bannerNameSchema } from '../../schemas/bannerName.schema';
|
||||
|
||||
export class DismissBannerRequestDto extends Z.class({
|
||||
banner: bannerNameSchema.optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
import { passwordSchema } from '../../schemas/password.schema';
|
||||
|
||||
export class OwnerSetupRequestDto extends Z.class({
|
||||
email: z.string().email(),
|
||||
firstName: z.string().min(1, 'First name is required'),
|
||||
lastName: z.string().min(1, 'Last name is required'),
|
||||
password: passwordSchema,
|
||||
}) {}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ChangePasswordRequestDto } from '../change-password-request.dto';
|
||||
|
||||
describe('ChangePasswordRequestDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'valid password reset with token',
|
||||
request: {
|
||||
token: 'valid-reset-token-with-sufficient-length',
|
||||
password: 'newSecurePassword123',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid password reset with MFA code',
|
||||
request: {
|
||||
token: 'another-valid-reset-token',
|
||||
password: 'newSecurePassword123',
|
||||
mfaCode: '123456',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = ChangePasswordRequestDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'missing token',
|
||||
request: { password: 'newSecurePassword123' },
|
||||
expectedErrorPath: ['token'],
|
||||
},
|
||||
{
|
||||
name: 'empty token',
|
||||
request: { token: '', password: 'newSecurePassword123' },
|
||||
expectedErrorPath: ['token'],
|
||||
},
|
||||
{
|
||||
name: 'short token',
|
||||
request: { token: 'short', password: 'newSecurePassword123' },
|
||||
expectedErrorPath: ['token'],
|
||||
},
|
||||
{
|
||||
name: 'missing password',
|
||||
request: { token: 'valid-reset-token' },
|
||||
expectedErrorPath: ['password'],
|
||||
},
|
||||
{
|
||||
name: 'password too short',
|
||||
request: {
|
||||
token: 'valid-reset-token',
|
||||
password: 'short',
|
||||
},
|
||||
expectedErrorPath: ['password'],
|
||||
},
|
||||
{
|
||||
name: 'password too long',
|
||||
request: {
|
||||
token: 'valid-reset-token',
|
||||
password: 'a'.repeat(65),
|
||||
},
|
||||
expectedErrorPath: ['password'],
|
||||
},
|
||||
{
|
||||
name: 'password without number',
|
||||
request: {
|
||||
token: 'valid-reset-token',
|
||||
password: 'NoNumberPassword',
|
||||
},
|
||||
expectedErrorPath: ['password'],
|
||||
},
|
||||
{
|
||||
name: 'password without uppercase letter',
|
||||
request: {
|
||||
token: 'valid-reset-token',
|
||||
password: 'nouppercasepassword123',
|
||||
},
|
||||
expectedErrorPath: ['password'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = ChangePasswordRequestDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
test('should handle optional MFA code correctly', () => {
|
||||
const validRequest = {
|
||||
token: 'valid-reset-token',
|
||||
password: 'newSecurePassword123',
|
||||
mfaCode: undefined,
|
||||
};
|
||||
|
||||
const result = ChangePasswordRequestDto.safeParse(validRequest);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle token with special characters', () => {
|
||||
const validRequest = {
|
||||
token: 'valid-reset-token-with-special-!@#$%^&*()_+',
|
||||
password: 'newSecurePassword123',
|
||||
};
|
||||
|
||||
const result = ChangePasswordRequestDto.safeParse(validRequest);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ForgotPasswordRequestDto } from '../forgot-password-request.dto';
|
||||
|
||||
describe('ForgotPasswordRequestDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'valid email',
|
||||
request: { email: 'test@example.com' },
|
||||
},
|
||||
{
|
||||
name: 'email with subdomain',
|
||||
request: { email: 'user@sub.example.com' },
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = ForgotPasswordRequestDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'invalid email format',
|
||||
request: { email: 'invalid-email' },
|
||||
expectedErrorPath: ['email'],
|
||||
},
|
||||
{
|
||||
name: 'missing email',
|
||||
request: {},
|
||||
expectedErrorPath: ['email'],
|
||||
},
|
||||
{
|
||||
name: 'empty email',
|
||||
request: { email: '' },
|
||||
expectedErrorPath: ['email'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = ForgotPasswordRequestDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ResolvePasswordTokenQueryDto } from '../resolve-password-token-query.dto';
|
||||
|
||||
describe('ResolvePasswordTokenQueryDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'valid token',
|
||||
request: { token: 'valid-reset-token' },
|
||||
},
|
||||
{
|
||||
name: 'long token',
|
||||
request: { token: 'x'.repeat(50) },
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = ResolvePasswordTokenQueryDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'missing token',
|
||||
request: {},
|
||||
expectedErrorPath: ['token'],
|
||||
},
|
||||
{
|
||||
name: 'empty token',
|
||||
request: { token: '' },
|
||||
expectedErrorPath: ['token'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = ResolvePasswordTokenQueryDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
import { passwordSchema } from '../../schemas/password.schema';
|
||||
import { passwordResetTokenSchema } from '../../schemas/passwordResetToken.schema';
|
||||
|
||||
export class ChangePasswordRequestDto extends Z.class({
|
||||
token: passwordResetTokenSchema,
|
||||
password: passwordSchema,
|
||||
mfaCode: z.string().optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
export class ForgotPasswordRequestDto extends Z.class({
|
||||
email: z.string().email(),
|
||||
}) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
import { passwordResetTokenSchema } from '../../schemas/passwordResetToken.schema';
|
||||
|
||||
export class ResolvePasswordTokenQueryDto extends Z.class({
|
||||
token: passwordResetTokenSchema,
|
||||
}) {}
|
||||
@@ -5,5 +5,6 @@ export type * from './scaling';
|
||||
export type * from './frontend-settings';
|
||||
export type * from './user';
|
||||
|
||||
export type { BannerName } from './schemas/bannerName.schema';
|
||||
export type { Collaborator } from './push/collaboration';
|
||||
export type { SendWorkerStatusMessage } from './push/worker';
|
||||
|
||||
11
packages/@n8n/api-types/src/schemas/bannerName.schema.ts
Normal file
11
packages/@n8n/api-types/src/schemas/bannerName.schema.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const bannerNameSchema = z.enum([
|
||||
'V1',
|
||||
'TRIAL_OVER',
|
||||
'TRIAL',
|
||||
'NON_PRODUCTION_LICENSE',
|
||||
'EMAIL_CONFIRMATION',
|
||||
]);
|
||||
|
||||
export type BannerName = z.infer<typeof bannerNameSchema>;
|
||||
16
packages/@n8n/api-types/src/schemas/password.schema.ts
Normal file
16
packages/@n8n/api-types/src/schemas/password.schema.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// TODO: Delete these from `cli` after all password-validation code starts using this schema
|
||||
const minLength = 8;
|
||||
const maxLength = 64;
|
||||
|
||||
export const passwordSchema = z
|
||||
.string()
|
||||
.min(minLength, `Password must be ${minLength} to ${maxLength} characters long.`)
|
||||
.max(maxLength, `Password must be ${minLength} to ${maxLength} characters long.`)
|
||||
.refine((password) => /\d/.test(password), {
|
||||
message: 'Password must contain at least 1 number.',
|
||||
})
|
||||
.refine((password) => /[A-Z]/.test(password), {
|
||||
message: 'Password must contain at least 1 uppercase letter.',
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const passwordResetTokenSchema = z.string().min(10, 'Token too short');
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { DismissBannerRequestDto, OwnerSetupRequestDto } from '@n8n/api-types';
|
||||
import type { Response } from 'express';
|
||||
import { anyObject, mock } from 'jest-mock-extended';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import Container from 'typedi';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { Logger } from 'n8n-core';
|
||||
|
||||
import type { AuthService } from '@/auth/auth.service';
|
||||
import config from '@/config';
|
||||
@@ -10,27 +10,31 @@ import type { User } from '@/databases/entities/user';
|
||||
import type { SettingsRepository } from '@/databases/repositories/settings.repository';
|
||||
import type { UserRepository } from '@/databases/repositories/user.repository';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { License } from '@/license';
|
||||
import type { OwnerRequest } from '@/requests';
|
||||
import { PasswordUtility } from '@/services/password.utility';
|
||||
import type { EventService } from '@/events/event.service';
|
||||
import type { PublicUser } from '@/interfaces';
|
||||
import type { AuthenticatedRequest } from '@/requests';
|
||||
import type { PasswordUtility } from '@/services/password.utility';
|
||||
import type { UserService } from '@/services/user.service';
|
||||
import { mockInstance } from '@test/mocking';
|
||||
import { badPasswords } from '@test/test-data';
|
||||
|
||||
describe('OwnerController', () => {
|
||||
const configGetSpy = jest.spyOn(config, 'getEnv');
|
||||
const configSetSpy = jest.spyOn(config, 'set');
|
||||
|
||||
const logger = mock<Logger>();
|
||||
const eventService = mock<EventService>();
|
||||
const authService = mock<AuthService>();
|
||||
const userService = mock<UserService>();
|
||||
const userRepository = mock<UserRepository>();
|
||||
const settingsRepository = mock<SettingsRepository>();
|
||||
mockInstance(License).isWithinUsersLimit.mockReturnValue(true);
|
||||
const passwordUtility = mock<PasswordUtility>();
|
||||
|
||||
const controller = new OwnerController(
|
||||
mock(),
|
||||
mock(),
|
||||
logger,
|
||||
eventService,
|
||||
settingsRepository,
|
||||
authService,
|
||||
userService,
|
||||
Container.get(PasswordUtility),
|
||||
passwordUtility,
|
||||
mock(),
|
||||
userRepository,
|
||||
);
|
||||
@@ -38,38 +42,18 @@ describe('OwnerController', () => {
|
||||
describe('setupOwner', () => {
|
||||
it('should throw a BadRequestError if the instance owner is already setup', async () => {
|
||||
configGetSpy.mockReturnValue(true);
|
||||
await expect(controller.setupOwner(mock(), mock())).rejects.toThrowError(
|
||||
await expect(controller.setupOwner(mock(), mock(), mock())).rejects.toThrowError(
|
||||
new BadRequestError('Instance owner already setup'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a BadRequestError if the email is invalid', async () => {
|
||||
configGetSpy.mockReturnValue(false);
|
||||
const req = mock<OwnerRequest.Post>({ body: { email: 'invalid email' } });
|
||||
await expect(controller.setupOwner(req, mock())).rejects.toThrowError(
|
||||
new BadRequestError('Invalid email address'),
|
||||
);
|
||||
});
|
||||
|
||||
describe('should throw if the password is invalid', () => {
|
||||
Object.entries(badPasswords).forEach(([password, errorMessage]) => {
|
||||
it(password, async () => {
|
||||
configGetSpy.mockReturnValue(false);
|
||||
const req = mock<OwnerRequest.Post>({ body: { email: 'valid@email.com', password } });
|
||||
await expect(controller.setupOwner(req, mock())).rejects.toThrowError(
|
||||
new BadRequestError(errorMessage),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a BadRequestError if firstName & lastName are missing ', async () => {
|
||||
configGetSpy.mockReturnValue(false);
|
||||
const req = mock<OwnerRequest.Post>({
|
||||
body: { email: 'valid@email.com', password: 'NewPassword123', firstName: '', lastName: '' },
|
||||
});
|
||||
await expect(controller.setupOwner(req, mock())).rejects.toThrowError(
|
||||
new BadRequestError('First and last names are mandatory'),
|
||||
expect(userRepository.findOneOrFail).not.toHaveBeenCalled();
|
||||
expect(userRepository.save).not.toHaveBeenCalled();
|
||||
expect(authService.issueCookie).not.toHaveBeenCalled();
|
||||
expect(settingsRepository.update).not.toHaveBeenCalled();
|
||||
expect(configSetSpy).not.toHaveBeenCalled();
|
||||
expect(eventService.emit).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Request to claim instance ownership failed because instance owner already exists',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -80,29 +64,52 @@ describe('OwnerController', () => {
|
||||
authIdentities: [],
|
||||
});
|
||||
const browserId = 'test-browser-id';
|
||||
const req = mock<OwnerRequest.Post>({
|
||||
body: {
|
||||
email: 'valid@email.com',
|
||||
password: 'NewPassword123',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
},
|
||||
user,
|
||||
browserId,
|
||||
});
|
||||
const req = mock<AuthenticatedRequest>({ user, browserId });
|
||||
const res = mock<Response>();
|
||||
const payload = mock<OwnerSetupRequestDto>({
|
||||
email: 'valid@email.com',
|
||||
password: 'NewPassword123',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
});
|
||||
configGetSpy.mockReturnValue(false);
|
||||
userRepository.findOneOrFail.calledWith(anyObject()).mockResolvedValue(user);
|
||||
userRepository.save.calledWith(anyObject()).mockResolvedValue(user);
|
||||
jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token');
|
||||
userRepository.findOneOrFail.mockResolvedValue(user);
|
||||
userRepository.save.mockResolvedValue(user);
|
||||
userService.toPublic.mockResolvedValue(mock<PublicUser>({ id: 'newUserId' }));
|
||||
|
||||
await controller.setupOwner(req, res);
|
||||
const result = await controller.setupOwner(req, res, payload);
|
||||
|
||||
expect(userRepository.findOneOrFail).toHaveBeenCalledWith({
|
||||
where: { role: 'global:owner' },
|
||||
});
|
||||
expect(userRepository.save).toHaveBeenCalledWith(user, { transaction: false });
|
||||
expect(authService.issueCookie).toHaveBeenCalledWith(res, user, browserId);
|
||||
expect(settingsRepository.update).toHaveBeenCalledWith(
|
||||
{ key: 'userManagement.isInstanceOwnerSetUp' },
|
||||
{ value: JSON.stringify(true) },
|
||||
);
|
||||
expect(configSetSpy).toHaveBeenCalledWith('userManagement.isInstanceOwnerSetUp', true);
|
||||
expect(eventService.emit).toHaveBeenCalledWith('instance-owner-setup', { userId: 'userId' });
|
||||
expect(result.id).toEqual('newUserId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dismissBanner', () => {
|
||||
it('should not call dismissBanner if no banner is provided', async () => {
|
||||
const payload = mock<DismissBannerRequestDto>({ banner: undefined });
|
||||
|
||||
const result = await controller.dismissBanner(mock(), mock(), payload);
|
||||
|
||||
expect(settingsRepository.dismissBanner).not.toHaveBeenCalled();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should call dismissBanner with the correct banner name', async () => {
|
||||
const payload = mock<DismissBannerRequestDto>({ banner: 'TRIAL' });
|
||||
|
||||
await controller.dismissBanner(mock(), mock(), payload);
|
||||
|
||||
expect(settingsRepository.dismissBanner).toHaveBeenCalledWith({ bannerName: 'TRIAL' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { LoginRequestDto, ResolveSignupTokenQueryDto } from '@n8n/api-types';
|
||||
import { Response } from 'express';
|
||||
import { Logger } from 'n8n-core';
|
||||
import { ApplicationError } from 'n8n-workflow';
|
||||
import validator from 'validator';
|
||||
|
||||
import { handleEmailLogin, handleLdapLogin } from '@/auth';
|
||||
import { AuthService } from '@/auth/auth.service';
|
||||
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
|
||||
import type { User } from '@/databases/entities/user';
|
||||
import { UserRepository } from '@/databases/repositories/user.repository';
|
||||
import { Get, Post, RestController } from '@/decorators';
|
||||
import { Body, Get, Post, Query, RestController } from '@/decorators';
|
||||
import { AuthError } from '@/errors/response-errors/auth.error';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
@@ -17,7 +16,7 @@ import type { PublicUser } from '@/interfaces';
|
||||
import { License } from '@/license';
|
||||
import { MfaService } from '@/mfa/mfa.service';
|
||||
import { PostHogClient } from '@/posthog';
|
||||
import { AuthenticatedRequest, LoginRequest, UserRequest } from '@/requests';
|
||||
import { AuthenticatedRequest, AuthlessRequest } from '@/requests';
|
||||
import { UserService } from '@/services/user.service';
|
||||
import {
|
||||
getCurrentAuthenticationMethod,
|
||||
@@ -40,10 +39,12 @@ export class AuthController {
|
||||
|
||||
/** Log in a user */
|
||||
@Post('/login', { skipAuth: true, rateLimit: true })
|
||||
async login(req: LoginRequest, res: Response): Promise<PublicUser | undefined> {
|
||||
const { email, password, mfaCode, mfaRecoveryCode } = req.body;
|
||||
if (!email) throw new ApplicationError('Email is required to log in');
|
||||
if (!password) throw new ApplicationError('Password is required to log in');
|
||||
async login(
|
||||
req: AuthlessRequest,
|
||||
res: Response,
|
||||
@Body payload: LoginRequestDto,
|
||||
): Promise<PublicUser | undefined> {
|
||||
const { email, password, mfaCode, mfaRecoveryCode } = payload;
|
||||
|
||||
let user: User | undefined;
|
||||
|
||||
@@ -117,8 +118,12 @@ export class AuthController {
|
||||
|
||||
/** Validate invite token to enable invitee to set up their account */
|
||||
@Get('/resolve-signup-token', { skipAuth: true })
|
||||
async resolveSignupToken(req: UserRequest.ResolveSignUp) {
|
||||
const { inviterId, inviteeId } = req.query;
|
||||
async resolveSignupToken(
|
||||
_req: AuthlessRequest,
|
||||
_res: Response,
|
||||
@Query payload: ResolveSignupTokenQueryDto,
|
||||
) {
|
||||
const { inviterId, inviteeId } = payload;
|
||||
const isWithinUsersLimit = this.license.isWithinUsersLimit();
|
||||
|
||||
if (!isWithinUsersLimit) {
|
||||
@@ -129,24 +134,6 @@ export class AuthController {
|
||||
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
|
||||
}
|
||||
|
||||
if (!inviterId || !inviteeId) {
|
||||
this.logger.debug(
|
||||
'Request to resolve signup token failed because of missing user IDs in query string',
|
||||
{ inviterId, inviteeId },
|
||||
);
|
||||
throw new BadRequestError('Invalid payload');
|
||||
}
|
||||
|
||||
// Postgres validates UUID format
|
||||
for (const userId of [inviterId, inviteeId]) {
|
||||
if (!validator.isUUID(userId)) {
|
||||
this.logger.debug('Request to resolve signup token failed because of invalid user ID', {
|
||||
userId,
|
||||
});
|
||||
throw new BadRequestError('Invalid userId');
|
||||
}
|
||||
}
|
||||
|
||||
const users = await this.userRepository.findManyByIds([inviterId, inviteeId]);
|
||||
|
||||
if (users.length !== 2) {
|
||||
|
||||
@@ -17,7 +17,6 @@ import type { FeatureReturnType } from '@/license';
|
||||
import { License } from '@/license';
|
||||
import { MfaService } from '@/mfa/mfa.service';
|
||||
import { Push } from '@/push';
|
||||
import type { UserSetupPayload } from '@/requests';
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
import { PasswordUtility } from '@/services/password.utility';
|
||||
|
||||
@@ -48,6 +47,16 @@ const tablesToTruncate = [
|
||||
'workflows_tags',
|
||||
];
|
||||
|
||||
type UserSetupPayload = {
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
mfaEnabled?: boolean;
|
||||
mfaSecret?: string;
|
||||
mfaRecoveryCodes?: string[];
|
||||
};
|
||||
|
||||
type ResetRequest = Request<
|
||||
{},
|
||||
{},
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { DismissBannerRequestDto, OwnerSetupRequestDto } from '@n8n/api-types';
|
||||
import { Response } from 'express';
|
||||
import { Logger } from 'n8n-core';
|
||||
import validator from 'validator';
|
||||
|
||||
import { AuthService } from '@/auth/auth.service';
|
||||
import config from '@/config';
|
||||
import { SettingsRepository } from '@/databases/repositories/settings.repository';
|
||||
import { UserRepository } from '@/databases/repositories/user.repository';
|
||||
import { GlobalScope, Post, RestController } from '@/decorators';
|
||||
import { Body, GlobalScope, Post, RestController } from '@/decorators';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { validateEntity } from '@/generic-helpers';
|
||||
import { PostHogClient } from '@/posthog';
|
||||
import { OwnerRequest } from '@/requests';
|
||||
import { AuthenticatedRequest } from '@/requests';
|
||||
import { PasswordUtility } from '@/services/password.utility';
|
||||
import { UserService } from '@/services/user.service';
|
||||
|
||||
@@ -33,8 +33,8 @@ export class OwnerController {
|
||||
* and enable `isInstanceOwnerSetUp` setting.
|
||||
*/
|
||||
@Post('/setup', { skipAuth: true })
|
||||
async setupOwner(req: OwnerRequest.Post, res: Response) {
|
||||
const { email, firstName, lastName, password } = req.body;
|
||||
async setupOwner(req: AuthenticatedRequest, res: Response, @Body payload: OwnerSetupRequestDto) {
|
||||
const { email, firstName, lastName, password } = payload;
|
||||
|
||||
if (config.getEnv('userManagement.isInstanceOwnerSetUp')) {
|
||||
this.logger.debug(
|
||||
@@ -43,31 +43,15 @@ export class OwnerController {
|
||||
throw new BadRequestError('Instance owner already setup');
|
||||
}
|
||||
|
||||
if (!email || !validator.isEmail(email)) {
|
||||
this.logger.debug('Request to claim instance ownership failed because of invalid email', {
|
||||
invalidEmail: email,
|
||||
});
|
||||
throw new BadRequestError('Invalid email address');
|
||||
}
|
||||
|
||||
const validPassword = this.passwordUtility.validate(password);
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
this.logger.debug(
|
||||
'Request to claim instance ownership failed because of missing first name or last name in payload',
|
||||
{ payload: req.body },
|
||||
);
|
||||
throw new BadRequestError('First and last names are mandatory');
|
||||
}
|
||||
|
||||
let owner = await this.userRepository.findOneOrFail({
|
||||
where: { role: 'global:owner' },
|
||||
});
|
||||
owner.email = email;
|
||||
owner.firstName = firstName;
|
||||
owner.lastName = lastName;
|
||||
owner.password = await this.passwordUtility.hash(validPassword);
|
||||
owner.password = await this.passwordUtility.hash(password);
|
||||
|
||||
// TODO: move XSS validation out into the DTO class
|
||||
await validateEntity(owner);
|
||||
|
||||
owner = await this.userRepository.save(owner, { transaction: false });
|
||||
@@ -92,8 +76,13 @@ export class OwnerController {
|
||||
|
||||
@Post('/dismiss-banner')
|
||||
@GlobalScope('banner:dismiss')
|
||||
async dismissBanner(req: OwnerRequest.DismissBanner) {
|
||||
const bannerName = 'banner' in req.body ? (req.body.banner as string) : '';
|
||||
async dismissBanner(
|
||||
_req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Body payload: DismissBannerRequestDto,
|
||||
) {
|
||||
const bannerName = payload.banner;
|
||||
if (!bannerName) return;
|
||||
return await this.settingsRepository.dismissBanner({ bannerName });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import {
|
||||
ChangePasswordRequestDto,
|
||||
ForgotPasswordRequestDto,
|
||||
ResolvePasswordTokenQueryDto,
|
||||
} from '@n8n/api-types';
|
||||
import { Response } from 'express';
|
||||
import { Logger } from 'n8n-core';
|
||||
import validator from 'validator';
|
||||
|
||||
import { AuthService } from '@/auth/auth.service';
|
||||
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
|
||||
import { UserRepository } from '@/databases/repositories/user.repository';
|
||||
import { Get, Post, RestController } from '@/decorators';
|
||||
import { Body, Get, Post, Query, RestController } from '@/decorators';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||
@@ -15,7 +19,7 @@ import { EventService } from '@/events/event.service';
|
||||
import { ExternalHooks } from '@/external-hooks';
|
||||
import { License } from '@/license';
|
||||
import { MfaService } from '@/mfa/mfa.service';
|
||||
import { PasswordResetRequest } from '@/requests';
|
||||
import { AuthlessRequest } from '@/requests';
|
||||
import { PasswordUtility } from '@/services/password.utility';
|
||||
import { UserService } from '@/services/user.service';
|
||||
import { isSamlCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers';
|
||||
@@ -40,7 +44,11 @@ export class PasswordResetController {
|
||||
* Send a password reset email.
|
||||
*/
|
||||
@Post('/forgot-password', { skipAuth: true, rateLimit: { limit: 3 } })
|
||||
async forgotPassword(req: PasswordResetRequest.Email) {
|
||||
async forgotPassword(
|
||||
_req: AuthlessRequest,
|
||||
_res: Response,
|
||||
@Body payload: ForgotPasswordRequestDto,
|
||||
) {
|
||||
if (!this.mailer.isEmailSetUp) {
|
||||
this.logger.debug(
|
||||
'Request to send password reset email failed because emailing was not set up',
|
||||
@@ -50,22 +58,7 @@ export class PasswordResetController {
|
||||
);
|
||||
}
|
||||
|
||||
const { email } = req.body;
|
||||
if (!email) {
|
||||
this.logger.debug(
|
||||
'Request to send password reset email failed because of missing email in payload',
|
||||
{ payload: req.body },
|
||||
);
|
||||
throw new BadRequestError('Email is mandatory');
|
||||
}
|
||||
|
||||
if (!validator.isEmail(email)) {
|
||||
this.logger.debug(
|
||||
'Request to send password reset email failed because of invalid email in payload',
|
||||
{ invalidEmail: email },
|
||||
);
|
||||
throw new BadRequestError('Invalid email address');
|
||||
}
|
||||
const { email } = payload;
|
||||
|
||||
// User should just be able to reset password if one is already present
|
||||
const user = await this.userRepository.findNonShellUser(email);
|
||||
@@ -138,19 +131,12 @@ export class PasswordResetController {
|
||||
* Verify password reset token and user ID.
|
||||
*/
|
||||
@Get('/resolve-password-token', { skipAuth: true })
|
||||
async resolvePasswordToken(req: PasswordResetRequest.Credentials) {
|
||||
const { token } = req.query;
|
||||
|
||||
if (!token) {
|
||||
this.logger.debug(
|
||||
'Request to resolve password token failed because of missing password reset token',
|
||||
{
|
||||
queryString: req.query,
|
||||
},
|
||||
);
|
||||
throw new BadRequestError('');
|
||||
}
|
||||
|
||||
async resolvePasswordToken(
|
||||
_req: AuthlessRequest,
|
||||
_res: Response,
|
||||
@Query payload: ResolvePasswordTokenQueryDto,
|
||||
) {
|
||||
const { token } = payload;
|
||||
const user = await this.authService.resolvePasswordResetToken(token);
|
||||
if (!user) throw new NotFoundError('');
|
||||
|
||||
@@ -170,20 +156,12 @@ export class PasswordResetController {
|
||||
* Verify password reset token and update password.
|
||||
*/
|
||||
@Post('/change-password', { skipAuth: true })
|
||||
async changePassword(req: PasswordResetRequest.NewPassword, res: Response) {
|
||||
const { token, password, mfaCode } = req.body;
|
||||
|
||||
if (!token || !password) {
|
||||
this.logger.debug(
|
||||
'Request to change password failed because of missing user ID or password or reset password token in payload',
|
||||
{
|
||||
payload: req.body,
|
||||
},
|
||||
);
|
||||
throw new BadRequestError('Missing user ID or password or reset password token');
|
||||
}
|
||||
|
||||
const validPassword = this.passwordUtility.validate(password);
|
||||
async changePassword(
|
||||
req: AuthlessRequest,
|
||||
res: Response,
|
||||
@Body payload: ChangePasswordRequestDto,
|
||||
) {
|
||||
const { token, password, mfaCode } = payload;
|
||||
|
||||
const user = await this.authService.resolvePasswordResetToken(token);
|
||||
if (!user) throw new NotFoundError('');
|
||||
@@ -198,7 +176,7 @@ export class PasswordResetController {
|
||||
if (!validToken) throw new BadRequestError('Invalid MFA token.');
|
||||
}
|
||||
|
||||
const passwordHash = await this.passwordUtility.hash(validPassword);
|
||||
const passwordHash = await this.passwordUtility.hash(password);
|
||||
|
||||
await this.userService.update(user.id, { password: passwordHash });
|
||||
|
||||
|
||||
@@ -11,8 +11,16 @@ import { ProjectRepository } from '@/databases/repositories/project.repository';
|
||||
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
|
||||
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
||||
import { UserRepository } from '@/databases/repositories/user.repository';
|
||||
import { GlobalScope, Delete, Get, RestController, Patch, Licensed, Body } from '@/decorators';
|
||||
import { Param } from '@/decorators/args';
|
||||
import {
|
||||
GlobalScope,
|
||||
Delete,
|
||||
Get,
|
||||
RestController,
|
||||
Patch,
|
||||
Licensed,
|
||||
Body,
|
||||
Param,
|
||||
} from '@/decorators';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { Body } from './args';
|
||||
export { Body, Query, Param } from './args';
|
||||
export { RestController } from './rest-controller';
|
||||
export { Get, Post, Put, Patch, Delete } from './route';
|
||||
export { Middleware } from './middleware';
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { VariableListRequestDto } from '@n8n/api-types';
|
||||
|
||||
import { Delete, Get, GlobalScope, Licensed, Patch, Post, RestController } from '@/decorators';
|
||||
import { Query } from '@/decorators/args';
|
||||
import {
|
||||
Delete,
|
||||
Get,
|
||||
GlobalScope,
|
||||
Licensed,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
RestController,
|
||||
} from '@/decorators';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { VariableCountLimitReachedError } from '@/errors/variable-count-limit-reached.error';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import type express from 'express';
|
||||
import type {
|
||||
BannerName,
|
||||
ICredentialDataDecryptedObject,
|
||||
IDataObject,
|
||||
ILoadOptions,
|
||||
@@ -19,7 +18,7 @@ import type { AssignableRole, GlobalRole, User } from '@/databases/entities/user
|
||||
import type { Variables } from '@/databases/entities/variables';
|
||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||
import type { WorkflowHistory } from '@/databases/entities/workflow-history';
|
||||
import type { PublicUser, SecretsProvider, SecretsProviderState } from '@/interfaces';
|
||||
import type { SecretsProvider, SecretsProviderState } from '@/interfaces';
|
||||
|
||||
import type { ProjectRole } from './databases/entities/project-relation';
|
||||
import type { ScopesField } from './services/role.service';
|
||||
@@ -195,42 +194,6 @@ export declare namespace MeRequest {
|
||||
export type SurveyAnswers = AuthenticatedRequest<{}, {}, IPersonalizationSurveyAnswersV4>;
|
||||
}
|
||||
|
||||
export interface UserSetupPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
mfaEnabled?: boolean;
|
||||
mfaSecret?: string;
|
||||
mfaRecoveryCodes?: string[];
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// /owner
|
||||
// ----------------------------------
|
||||
|
||||
export declare namespace OwnerRequest {
|
||||
type Post = AuthenticatedRequest<{}, {}, UserSetupPayload, {}>;
|
||||
|
||||
type DismissBanner = AuthenticatedRequest<{}, {}, Partial<{ bannerName: BannerName }>, {}>;
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// password reset endpoints
|
||||
// ----------------------------------
|
||||
|
||||
export declare namespace PasswordResetRequest {
|
||||
export type Email = AuthlessRequest<{}, {}, Pick<PublicUser, 'email'>>;
|
||||
|
||||
export type Credentials = AuthlessRequest<{}, {}, {}, { userId?: string; token?: string }>;
|
||||
|
||||
export type NewPassword = AuthlessRequest<
|
||||
{},
|
||||
{},
|
||||
Pick<PublicUser, 'password'> & { token?: string; userId?: string; mfaCode?: string }
|
||||
>;
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// /users
|
||||
// ----------------------------------
|
||||
@@ -253,18 +216,6 @@ export declare namespace UserRequest {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ResolveSignUp = AuthlessRequest<
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{ inviterId?: string; inviteeId?: string }
|
||||
>;
|
||||
|
||||
export type SignUp = AuthenticatedRequest<
|
||||
{ id: string },
|
||||
{ inviterId?: string; inviteeId?: string }
|
||||
>;
|
||||
|
||||
export type Delete = AuthenticatedRequest<
|
||||
{ id: string; email: string; identifier: string },
|
||||
{},
|
||||
@@ -295,21 +246,6 @@ export declare namespace UserRequest {
|
||||
>;
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// /login
|
||||
// ----------------------------------
|
||||
|
||||
export type LoginRequest = AuthlessRequest<
|
||||
{},
|
||||
{},
|
||||
{
|
||||
email: string;
|
||||
password: string;
|
||||
mfaCode?: string;
|
||||
mfaRecoveryCode?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
// ----------------------------------
|
||||
// MFA endpoints
|
||||
// ----------------------------------
|
||||
|
||||
@@ -19,6 +19,7 @@ export class PasswordUtility {
|
||||
return await compare(plaintext, hashed);
|
||||
}
|
||||
|
||||
/** @deprecated. All input validation should move to DTOs */
|
||||
validate(plaintext?: string) {
|
||||
if (!plaintext) throw new BadRequestError('Password is mandatory');
|
||||
|
||||
|
||||
@@ -147,6 +147,21 @@ describe('POST /login', () => {
|
||||
const response = await testServer.authAgentFor(ownerUser).get('/login');
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('should fail on invalid email in the payload', async () => {
|
||||
const response = await testServer.authlessAgent.post('/login').send({
|
||||
email: 'invalid-email',
|
||||
password: ownerPassword,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
validation: 'email',
|
||||
code: 'invalid_string',
|
||||
message: 'Invalid email',
|
||||
path: ['email'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /login', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomInt, randomString } from 'n8n-workflow';
|
||||
import { randomString } from 'n8n-workflow';
|
||||
import Container from 'typedi';
|
||||
|
||||
import { AuthService } from '@/auth/auth.service';
|
||||
@@ -239,7 +239,7 @@ describe('Change password with MFA enabled', () => {
|
||||
.send({
|
||||
password: newPassword,
|
||||
token: resetPasswordToken,
|
||||
mfaCode: randomInt(10),
|
||||
mfaCode: randomString(10),
|
||||
})
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Component } from 'vue';
|
||||
import type { NotificationOptions as ElementNotificationOptions } from 'element-plus';
|
||||
import type { Connection } from '@jsplumb/core';
|
||||
import type {
|
||||
BannerName,
|
||||
FrontendSettings,
|
||||
Iso8601DateTimeString,
|
||||
IUserManagementSettings,
|
||||
@@ -38,7 +39,6 @@ import type {
|
||||
ITelemetryTrackProperties,
|
||||
WorkflowSettings,
|
||||
IUserSettings,
|
||||
BannerName,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
NodeConnectionType,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IRestApiContext } from '@/Interface';
|
||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||
import type { BannerName } from 'n8n-workflow';
|
||||
import type { BannerName } from '@n8n/api-types';
|
||||
|
||||
export async function dismissBannerPermanently(
|
||||
context: IRestApiContext,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { computed, useSlots } from 'vue';
|
||||
import type { BannerName } from 'n8n-workflow';
|
||||
import type { BannerName } from '@n8n/api-types';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -53,7 +53,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { dismissBannerPermanently } from '@/api/ui';
|
||||
import type { BannerName } from 'n8n-workflow';
|
||||
import type { BannerName } from '@n8n/api-types';
|
||||
import {
|
||||
addThemeToBody,
|
||||
getPreferredTheme,
|
||||
|
||||
@@ -2840,13 +2840,6 @@ export interface SecretsHelpersBase {
|
||||
listSecrets(provider: string): string[];
|
||||
}
|
||||
|
||||
export type BannerName =
|
||||
| 'V1'
|
||||
| 'TRIAL_OVER'
|
||||
| 'TRIAL'
|
||||
| 'NON_PRODUCTION_LICENSE'
|
||||
| 'EMAIL_CONFIRMATION';
|
||||
|
||||
export type Functionality = 'regular' | 'configuration-node' | 'pairedItem';
|
||||
|
||||
export type CallbackManager = CallbackManagerLC;
|
||||
|
||||
Reference in New Issue
Block a user