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:
Antoine Moreaux
2025-07-31 12:00:59 +02:00
committed by GitHub
parent 463e2e89c8
commit 00d12e854a
53 changed files with 594 additions and 306 deletions
@@ -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,
@@ -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;
@@ -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,
@@ -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,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(
@@ -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,
};
}
@@ -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);
}
}
@@ -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,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, {
@@ -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,
});