BREAKING - feat(auth): refactor tokens logic & enhance email verification flow (#13487)
- Replaced `getAuthTokensFromLoginToken` with `getAccessTokensFromLoginToken` for clarity. - Introduced `getWorkspaceAgnosticTokenFromEmailVerificationToken`. - Extended mutation inputs to include `locale` and `verifyEmailNextPath`. - Added email verification check and sending to various handlers. - Updated GraphQL types and hooks to reflect these changes. Fix #13412 --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
+3
-3
@@ -77,10 +77,10 @@ export class ApprovedAccessDomainService {
|
||||
lastName: sender.name.lastName,
|
||||
},
|
||||
serverUrl: this.twentyConfigService.get('SERVER_URL'),
|
||||
locale: 'en',
|
||||
locale: sender.locale,
|
||||
});
|
||||
const html = await render(emailTemplate);
|
||||
const text = await render(emailTemplate, {
|
||||
const html = render(emailTemplate);
|
||||
const text = render(emailTemplate, {
|
||||
plainText: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -27,9 +27,9 @@ import {
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { AvailableWorkspacesAndAccessTokensOutput } from 'src/engine/core-modules/auth/dto/available-workspaces-and-access-tokens.output';
|
||||
import { GetAuthTokenFromEmailVerificationTokenInput } from 'src/engine/core-modules/auth/dto/get-auth-token-from-email-verification-token.input';
|
||||
import { GetAuthorizationUrlForSSOInput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.input';
|
||||
import { GetAuthorizationUrlForSSOOutput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.output';
|
||||
import { GetLoginTokenFromEmailVerificationTokenInput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.input';
|
||||
import { GetLoginTokenFromEmailVerificationTokenOutput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.output';
|
||||
import { SignUpOutput } from 'src/engine/core-modules/auth/dto/sign-up.output';
|
||||
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
|
||||
@@ -51,6 +51,7 @@ import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/re
|
||||
import { I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
|
||||
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
||||
import { TwoFactorAuthenticationVerificationInput } from 'src/engine/core-modules/two-factor-authentication/dto/two-factor-authentication-verification.input';
|
||||
import { TwoFactorAuthenticationExceptionFilter } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication-exception.filter';
|
||||
import { TwoFactorAuthenticationService } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.service';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
@@ -67,7 +68,6 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
|
||||
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
|
||||
import { TwoFactorAuthenticationExceptionFilter } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication-exception.filter';
|
||||
|
||||
import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input';
|
||||
import { LoginToken } from './dto/login-token.entity';
|
||||
@@ -213,7 +213,7 @@ export class AuthResolver {
|
||||
AuthProviderEnum.Password,
|
||||
),
|
||||
tokens: {
|
||||
accessToken:
|
||||
accessOrWorkspaceAgnosticToken:
|
||||
await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken(
|
||||
{
|
||||
userId: user.id,
|
||||
@@ -233,13 +233,13 @@ export class AuthResolver {
|
||||
@UseGuards(PublicEndpointGuard)
|
||||
async getLoginTokenFromEmailVerificationToken(
|
||||
@Args()
|
||||
getLoginTokenFromEmailVerificationTokenInput: GetLoginTokenFromEmailVerificationTokenInput,
|
||||
getAuthTokenFromEmailVerificationTokenInput: GetAuthTokenFromEmailVerificationTokenInput,
|
||||
@Args('origin') origin: string,
|
||||
@AuthProvider() authProvider: AuthProviderEnum,
|
||||
) {
|
||||
const appToken =
|
||||
await this.emailVerificationTokenService.validateEmailVerificationTokenOrThrow(
|
||||
getLoginTokenFromEmailVerificationTokenInput,
|
||||
getAuthTokenFromEmailVerificationTokenInput,
|
||||
);
|
||||
|
||||
const workspace =
|
||||
@@ -264,6 +264,50 @@ export class AuthResolver {
|
||||
return { loginToken, workspaceUrls };
|
||||
}
|
||||
|
||||
@Mutation(() => AvailableWorkspacesAndAccessTokensOutput)
|
||||
@UseGuards(PublicEndpointGuard)
|
||||
async getWorkspaceAgnosticTokenFromEmailVerificationToken(
|
||||
@Args()
|
||||
getAuthTokenFromEmailVerificationTokenInput: GetAuthTokenFromEmailVerificationTokenInput,
|
||||
@AuthProvider() authProvider: AuthProviderEnum,
|
||||
) {
|
||||
const appToken =
|
||||
await this.emailVerificationTokenService.validateEmailVerificationTokenOrThrow(
|
||||
getAuthTokenFromEmailVerificationTokenInput,
|
||||
);
|
||||
|
||||
await this.userService.markEmailAsVerified(appToken.user.id);
|
||||
await this.appTokenRepository.remove(appToken);
|
||||
|
||||
const availableWorkspaces =
|
||||
await this.userWorkspaceService.findAvailableWorkspacesByEmail(
|
||||
appToken.user.email,
|
||||
);
|
||||
|
||||
return {
|
||||
availableWorkspaces:
|
||||
await this.userWorkspaceService.setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch(
|
||||
availableWorkspaces,
|
||||
appToken.user,
|
||||
authProvider,
|
||||
),
|
||||
tokens: {
|
||||
accessOrWorkspaceAgnosticToken:
|
||||
await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken(
|
||||
{
|
||||
userId: appToken.user.id,
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
},
|
||||
),
|
||||
refreshToken: await this.refreshTokenService.generateRefreshToken({
|
||||
userId: appToken.user.id,
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => AuthTokens)
|
||||
@UseGuards(CaptchaGuard, PublicEndpointGuard)
|
||||
async getAuthTokensFromOTP(
|
||||
@@ -321,6 +365,14 @@ export class AuthResolver {
|
||||
user.email,
|
||||
);
|
||||
|
||||
await this.emailVerificationService.sendVerificationEmail(
|
||||
user.id,
|
||||
user.email,
|
||||
undefined,
|
||||
signUpInput.locale ?? SOURCE_LOCALE,
|
||||
signUpInput.verifyEmailRedirectPath,
|
||||
);
|
||||
|
||||
return {
|
||||
availableWorkspaces:
|
||||
await this.userWorkspaceService.setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch(
|
||||
@@ -329,7 +381,7 @@ export class AuthResolver {
|
||||
AuthProviderEnum.Password,
|
||||
),
|
||||
tokens: {
|
||||
accessToken:
|
||||
accessOrWorkspaceAgnosticToken:
|
||||
await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken(
|
||||
{
|
||||
userId: user.id,
|
||||
@@ -402,7 +454,7 @@ export class AuthResolver {
|
||||
user.email,
|
||||
workspace,
|
||||
signUpInput.locale ?? SOURCE_LOCALE,
|
||||
signUpInput.verifyEmailNextPath,
|
||||
signUpInput.verifyEmailRedirectPath,
|
||||
);
|
||||
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
@@ -508,6 +560,8 @@ export class AuthResolver {
|
||||
|
||||
const user = await this.userService.getUserByEmail(email);
|
||||
|
||||
await this.authService.checkIsEmailVerified(user.isEmailVerified);
|
||||
|
||||
const currentUserWorkspace =
|
||||
await this.userWorkspaceService.getUserWorkspaceForUserOrThrow({
|
||||
userId: user.id,
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
@ObjectType()
|
||||
export class ExchangeAuthCode {
|
||||
@Field(() => AuthToken)
|
||||
accessToken: AuthToken;
|
||||
accessOrWorkspaceAgnosticToken: AuthToken;
|
||||
|
||||
@Field(() => AuthToken)
|
||||
refreshToken: AuthToken;
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { ArgsType, Field } from '@nestjs/graphql';
|
||||
import { IsNotEmpty, IsString, IsOptional } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class GetLoginTokenFromEmailVerificationTokenInput {
|
||||
export class GetAuthTokenFromEmailVerificationTokenInput {
|
||||
@Field(() => String)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@@ -43,5 +43,5 @@ export class SignUpInput {
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
verifyEmailNextPath?: string;
|
||||
verifyEmailRedirectPath?: string;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export class ApiKeyToken {
|
||||
@ObjectType()
|
||||
export class AuthTokenPair {
|
||||
@Field(() => AuthToken)
|
||||
accessToken: AuthToken;
|
||||
accessOrWorkspaceAgnosticToken: AuthToken;
|
||||
|
||||
@Field(() => AuthToken)
|
||||
refreshToken: AuthToken;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
@ArgsType()
|
||||
export class UserCredentialsInput {
|
||||
@@ -18,4 +19,14 @@ export class UserCredentialsInput {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
captchaToken?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
locale?: keyof typeof APP_LOCALES;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
verifyEmailRedirectPath?: string;
|
||||
}
|
||||
|
||||
@@ -174,18 +174,22 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.checkIsEmailVerified(user.isEmailVerified);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async checkIsEmailVerified(isEmailVerified: boolean) {
|
||||
const isEmailVerificationRequired = this.twentyConfigService.get(
|
||||
'IS_EMAIL_VERIFICATION_REQUIRED',
|
||||
);
|
||||
|
||||
if (isEmailVerificationRequired && !user.isEmailVerified) {
|
||||
if (isEmailVerificationRequired && !isEmailVerified) {
|
||||
throw new AuthException(
|
||||
'Email is not verified',
|
||||
AuthExceptionCode.EMAIL_NOT_VERIFIED,
|
||||
);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private async validatePassword(
|
||||
@@ -296,7 +300,7 @@ export class AuthService {
|
||||
|
||||
return {
|
||||
tokens: {
|
||||
accessToken,
|
||||
accessOrWorkspaceAgnosticToken: accessToken,
|
||||
refreshToken,
|
||||
},
|
||||
};
|
||||
@@ -474,12 +478,12 @@ export class AuthService {
|
||||
locale: firstUserWorkspace.locale,
|
||||
});
|
||||
|
||||
const html = await render(emailTemplate, { pretty: true });
|
||||
const text = await render(emailTemplate, { plainText: true });
|
||||
const html = render(emailTemplate, { pretty: true });
|
||||
const text = render(emailTemplate, { plainText: true });
|
||||
|
||||
i18n.activate(firstUserWorkspace.locale);
|
||||
|
||||
this.emailService.send({
|
||||
await this.emailService.send({
|
||||
from: `${this.twentyConfigService.get(
|
||||
'EMAIL_FROM_NAME',
|
||||
)} <${this.twentyConfigService.get('EMAIL_FROM_ADDRESS')}>`,
|
||||
@@ -731,7 +735,7 @@ export class AuthService {
|
||||
pathname: '/welcome',
|
||||
searchParams: {
|
||||
tokenPair: JSON.stringify({
|
||||
accessToken:
|
||||
accessOrWorkspaceAgnosticToken:
|
||||
await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken(
|
||||
{
|
||||
userId: user.id,
|
||||
|
||||
+2
-2
@@ -158,8 +158,8 @@ export class ResetPasswordService {
|
||||
|
||||
const emailTemplate = PasswordResetLinkEmail(emailData);
|
||||
|
||||
const html = await render(emailTemplate, { pretty: true });
|
||||
const text = await render(emailTemplate, { plainText: true });
|
||||
const html = render(emailTemplate, { pretty: true });
|
||||
const text = render(emailTemplate, { plainText: true });
|
||||
|
||||
i18n.activate(locale);
|
||||
|
||||
|
||||
+3
-3
@@ -3,13 +3,13 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
|
||||
import { RenewTokenService } from './renew-token.service';
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('RenewTokenService', () => {
|
||||
await service.generateTokensFromRefreshToken(mockRefreshToken);
|
||||
|
||||
expect(result).toEqual({
|
||||
accessToken: mockAccessToken,
|
||||
accessOrWorkspaceAgnosticToken: mockAccessToken,
|
||||
refreshToken: mockNewRefreshToken,
|
||||
});
|
||||
expect(refreshTokenService.verifyRefreshToken).toHaveBeenCalledWith(
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import {
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
|
||||
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
|
||||
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
|
||||
@Injectable()
|
||||
@@ -26,7 +26,7 @@ export class RenewTokenService {
|
||||
) {}
|
||||
|
||||
async generateTokensFromRefreshToken(token: string): Promise<{
|
||||
accessToken: AuthToken;
|
||||
accessOrWorkspaceAgnosticToken: AuthToken;
|
||||
refreshToken: AuthToken;
|
||||
}> {
|
||||
if (!token) {
|
||||
@@ -80,7 +80,7 @@ export class RenewTokenService {
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
accessOrWorkspaceAgnosticToken: accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
+6
-2
@@ -2,8 +2,12 @@ import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class EmailVerificationException extends CustomException {
|
||||
declare code: EmailVerificationExceptionCode;
|
||||
constructor(message: string, code: EmailVerificationExceptionCode) {
|
||||
super(message, code);
|
||||
constructor(
|
||||
message: string,
|
||||
code: EmailVerificationExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
|
||||
) {
|
||||
super(message, code, userFriendlyMessage ?? message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-6
@@ -46,7 +46,7 @@ export class EmailVerificationService {
|
||||
| WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType
|
||||
| undefined,
|
||||
locale: keyof typeof APP_LOCALES,
|
||||
verifyEmailNextPath?: string,
|
||||
verifyEmailRedirectPath?: string,
|
||||
) {
|
||||
if (!this.twentyConfigService.get('IS_EMAIL_VERIFICATION_REQUIRED')) {
|
||||
return { success: false };
|
||||
@@ -60,8 +60,8 @@ export class EmailVerificationService {
|
||||
searchParams: {
|
||||
emailVerificationToken,
|
||||
email,
|
||||
...(isDefined(verifyEmailNextPath)
|
||||
? { nextPath: verifyEmailNextPath }
|
||||
...(isDefined(verifyEmailRedirectPath)
|
||||
? { nextPath: verifyEmailRedirectPath }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
@@ -79,13 +79,12 @@ export class EmailVerificationService {
|
||||
|
||||
const emailTemplate = SendEmailVerificationLinkEmail(emailData);
|
||||
|
||||
const html = await render(emailTemplate);
|
||||
const text = await render(emailTemplate, {
|
||||
const html = render(emailTemplate);
|
||||
const text = render(emailTemplate, {
|
||||
plainText: true,
|
||||
});
|
||||
|
||||
i18n.activate(locale);
|
||||
|
||||
await this.emailService.send({
|
||||
from: `${this.twentyConfigService.get(
|
||||
'EMAIL_FROM_NAME',
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import { PermissionsOnAllObjectRecords } from 'twenty-shared/constants';
|
||||
import { APP_LOCALES } from 'twenty-shared/translations';
|
||||
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -73,7 +73,7 @@ export class UserWorkspace {
|
||||
defaultAvatarUrl: string;
|
||||
|
||||
@Field(() => String, { nullable: false })
|
||||
@Column({ nullable: false, default: 'en', type: 'varchar' })
|
||||
@Column({ nullable: false, default: SOURCE_LOCALE, type: 'varchar' })
|
||||
locale: keyof typeof APP_LOCALES;
|
||||
|
||||
@Field()
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
@@ -94,7 +95,7 @@ export class User {
|
||||
deletedAt: Date;
|
||||
|
||||
@Field(() => String, { nullable: false })
|
||||
@Column({ nullable: false, default: 'en' })
|
||||
@Column({ nullable: false, default: SOURCE_LOCALE })
|
||||
locale: string;
|
||||
|
||||
@OneToMany(() => AppToken, (appToken) => appToken.user, {
|
||||
|
||||
+2
-2
@@ -301,8 +301,8 @@ export class WorkspaceInvitationService {
|
||||
};
|
||||
|
||||
const emailTemplate = SendInviteLinkEmail(emailData);
|
||||
const html = await render(emailTemplate);
|
||||
const text = await render(emailTemplate, {
|
||||
const html = render(emailTemplate);
|
||||
const text = render(emailTemplate, {
|
||||
plainText: true,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user