Enable password reset from app.twenty.com with workspace fallback (#18271)

## Summary
- add a working `Forgot your password?` flow on `app.twenty.com` sign-in
- keep existing workspace-domain reset behavior
- when triggered without workspace context, resolve a workspace from the
user when possible, otherwise fallback to `app.twenty.com` reset URL

## Backend
- make `workspaceId` optional in `emailPasswordResetLink` input
- allow nullable `workspaceId` in password reset token DTO
- update reset token generation to accept optional `workspaceId`
- when missing, resolve first workspace by user membership
- if no workspace is found, persist token with `workspaceId = null`
- send reset links via:
  - workspace URL when `workspaceId` exists
  - app front URL + reset path when `workspaceId` is null

## Frontend
- make reset-link mutation `workspaceId` variable optional
- regenerate/patched generated metadata types accordingly
- add `Forgot your password?` in global password step
- allow reset request without workspace context in
`useHandleResetPassword`
- make reset page auto sign-in domain-aware (`workspace` vs `app`)
- apply design-system spacing above the global forgot-password link
(`theme.spacing(4)`)

## Tests
- extend reset-password service tests for:
  - explicit workspace id
  - inferred workspace when workspace id is missing
  - app-domain fallback when no workspace is found
- extend reset-password hook tests for with/without workspace context
- add focused global form test for forgot-password link rendering/click
behavior

## Product behavior for users with multiple workspaces
- no workspace chooser is shown in this flow
- backend uses the first resolvable workspace membership for the
reset-link domain
- password change remains account-level and works across all workspaces


Feature has been tested and is working 

<img width="3268" height="2106" alt="CleanShot 2026-02-26 at 14 09
14@2x"
src="https://github.com/user-attachments/assets/b5db3bed-f3aa-4d35-b54e-66e4d99141f9"
/>

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
This commit is contained in:
Thomas des Francs
2026-03-04 16:14:38 +01:00
committed by GitHub
parent eda905f271
commit aeedcf3353
14 changed files with 343 additions and 103 deletions
@@ -9,18 +9,6 @@ import { TwoFactorAuthenticationStrategy } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ApiKeyTokenInput } from 'src/engine/core-modules/auth/dto/api-key-token.input';
import { AppTokenInput } from 'src/engine/core-modules/auth/dto/app-token.input';
import { AuthorizeAppDTO } from 'src/engine/core-modules/auth/dto/authorize-app.dto';
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
import { EmailPasswordResetLinkDTO } from 'src/engine/core-modules/auth/dto/email-password-reset-link.dto';
import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input';
import { InvalidatePasswordDTO } from 'src/engine/core-modules/auth/dto/invalidate-password.dto';
import { TransientTokenDTO } from 'src/engine/core-modules/auth/dto/transient-token.dto';
import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input';
import { ValidatePasswordResetTokenDTO } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto';
import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.input';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
@@ -30,12 +18,24 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { ApiKeyTokenInput } from 'src/engine/core-modules/auth/dto/api-key-token.input';
import { AppTokenInput } from 'src/engine/core-modules/auth/dto/app-token.input';
import { AuthorizeAppDTO } from 'src/engine/core-modules/auth/dto/authorize-app.dto';
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
import { AvailableWorkspacesAndAccessTokensDTO } from 'src/engine/core-modules/auth/dto/available-workspaces-and-access-tokens.dto';
import { EmailPasswordResetLinkDTO } from 'src/engine/core-modules/auth/dto/email-password-reset-link.dto';
import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input';
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 { GetAuthorizationUrlForSSODTO } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.dto';
import { GetAuthorizationUrlForSSOInput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.input';
import { InvalidatePasswordDTO } from 'src/engine/core-modules/auth/dto/invalidate-password.dto';
import { SignUpDTO } from 'src/engine/core-modules/auth/dto/sign-up.dto';
import { TransientTokenDTO } from 'src/engine/core-modules/auth/dto/transient-token.dto';
import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input';
import { ValidatePasswordResetTokenDTO } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto';
import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.input';
import { VerifyEmailAndGetLoginTokenDTO } from 'src/engine/core-modules/auth/dto/verify-email-and-get-login-token.dto';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/token/services/email-verification-token.service';
@@ -828,11 +828,11 @@ export class AuthResolver {
emailPasswordResetInput.workspaceId,
);
return await this.resetPasswordService.sendEmailPasswordResetLink(
return await this.resetPasswordService.sendEmailPasswordResetLink({
resetToken,
emailPasswordResetInput.email,
context.req.locale,
);
email: emailPasswordResetInput.email,
locale: context.req.locale,
});
}
@Mutation(() => InvalidatePasswordDTO)
@@ -1,6 +1,6 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty, IsUUID } from 'class-validator';
import { IsEmail, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@@ -11,8 +11,8 @@ export class EmailPasswordResetLinkInput {
@IsEmail()
email: string;
@Field(() => UUIDScalarType)
@IsNotEmpty()
@Field(() => UUIDScalarType, { nullable: true })
@IsOptional()
@IsUUID()
workspaceId: string;
workspaceId?: string;
}
@@ -1,15 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType()
export class PasswordResetToken {
@Field(() => String)
passwordResetToken: string;
@Field(() => Date)
passwordResetTokenExpiresAt: Date;
@Field(() => UUIDScalarType)
workspaceId: string;
}
@@ -12,7 +12,6 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
@@ -23,7 +22,6 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { ResetPasswordService } from './reset-password.service';
// To avoid dynamic import issues in Jest
jest.mock('@react-email/render', () => ({
render: jest.fn().mockImplementation(async (_, options) => {
if (options?.plainText) {
@@ -62,24 +60,12 @@ describe('ResetPasswordService', () => {
provide: getRepositoryToken(AppTokenEntity),
useClass: Repository,
},
{
provide: getRepositoryToken(WorkspaceEntity),
useClass: Repository,
},
{
provide: EmailService,
useValue: {
send: jest.fn().mockResolvedValue({ success: true }),
},
},
{
provide: DomainServerConfigService,
useValue: {
getBaseUrl: jest
.fn()
.mockResolvedValue(new URL('http://localhost:3001')),
},
},
{
provide: WorkspaceDomainsService,
useValue: {
@@ -113,7 +99,6 @@ describe('ResetPasswordService', () => {
);
emailService = module.get<EmailService>(EmailService);
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
workspaceDomainsService = module.get<WorkspaceDomainsService>(
WorkspaceDomainsService,
);
@@ -146,16 +131,58 @@ describe('ResetPasswordService', () => {
expect(appTokenRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
userId: '1',
workspaceId: 'workspace-id',
type: AppTokenType.PasswordResetToken,
}),
);
});
it('should resolve workspace when workspaceId is missing', async () => {
const mockUser = { id: '1', email: 'test@example.com' };
const mockWorkspace = { id: 'resolved-workspace-id' };
jest
.spyOn(userService, 'findUserByEmailOrThrow')
.mockResolvedValue(mockUser as UserEntity);
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as WorkspaceEntity);
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
jest
.spyOn(appTokenRepository, 'save')
.mockResolvedValue({} as AppTokenEntity);
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
const result =
await service.generatePasswordResetToken('test@example.com');
expect(result.workspaceId).toBe('resolved-workspace-id');
expect(appTokenRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: 'resolved-workspace-id',
}),
);
});
it('should throw an error if no password auth enabled workspace found', async () => {
const mockUser = { id: '1', email: 'test@example.com' };
jest
.spyOn(userService, 'findUserByEmailOrThrow')
.mockResolvedValue(mockUser as UserEntity);
jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null);
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
await expect(
service.generatePasswordResetToken('test@example.com'),
).rejects.toThrow(AuthException);
});
it('should throw an error if user is not found', async () => {
jest
.spyOn(userService, 'findUserByEmailOrThrow')
.mockRejectedValue(
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
);
await expect(
@@ -181,6 +208,7 @@ describe('ResetPasswordService', () => {
jest
.spyOn(appTokenRepository, 'findOne')
.mockResolvedValue(mockExistingToken as AppTokenEntity);
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
await expect(
service.generatePasswordResetToken('test@example.com', 'workspace-id'),
@@ -214,29 +242,35 @@ describe('ResetPasswordService', () => {
),
);
const result = await service.sendEmailPasswordResetLink(
mockToken,
'test@example.com',
'en',
);
const result = await service.sendEmailPasswordResetLink({
resetToken: mockToken,
email: 'test@example.com',
locale: 'en',
});
expect(result.success).toBe(true);
expect(emailService.send).toHaveBeenCalled();
});
it('should throw an error if user is not found', async () => {
const mockToken = {
workspaceId: 'workspace-id',
passwordResetToken: 'token123',
passwordResetTokenExpiresAt: new Date(),
};
jest
.spyOn(userService, 'findUserByEmailOrThrow')
.mockRejectedValue(
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
);
await expect(
service.sendEmailPasswordResetLink(
{} as any,
'nonexistent@example.com',
'en',
),
service.sendEmailPasswordResetLink({
resetToken: mockToken,
email: 'nonexistent@example.com',
locale: 'en',
}),
).rejects.toThrow(AuthException);
});
});
@@ -297,7 +331,7 @@ describe('ResetPasswordService', () => {
jest
.spyOn(userService, 'findUserByIdOrThrow')
.mockRejectedValue(
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
);
await expect(
@@ -27,8 +27,8 @@ import {
} from 'src/engine/core-modules/auth/auth.exception';
import { type EmailPasswordResetLinkDTO } from 'src/engine/core-modules/auth/dto/email-password-reset-link.dto';
import { type InvalidatePasswordDTO } from 'src/engine/core-modules/auth/dto/invalidate-password.dto';
import { type PasswordResetToken } from 'src/engine/core-modules/auth/dto/password-reset-token.dto';
import { type ValidatePasswordResetTokenDTO } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto';
import { type PasswordResetToken } from 'src/engine/core-modules/auth/types/password-reset-token.type';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
@@ -53,13 +53,19 @@ export class ResetPasswordService {
async generatePasswordResetToken(
email: string,
workspaceId: string,
workspaceId?: string,
): Promise<PasswordResetToken> {
const user = await this.userService.findUserByEmailOrThrow(
email,
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT, {
userFriendlyMessage: msg`User not found.`,
}),
);
const targetWorkspaceId =
workspaceId ??
(await this.findFirstPasswordAuthEnabledWorkspaceIdOrThrow(user.id));
const expiresIn = this.twentyConfigService.get(
'PASSWORD_RESET_TOKEN_EXPIRES_IN',
);
@@ -71,6 +77,8 @@ export class ResetPasswordService {
);
}
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const existingToken = await this.appTokenRepository.findOne({
where: {
userId: user.id,
@@ -89,6 +97,9 @@ export class ResetPasswordService {
throw new AuthException(
`Token has already been generated. Please wait for ${timeToWait} to generate again.`,
AuthExceptionCode.INVALID_INPUT,
{
userFriendlyMessage: msg`Password reset token has already been generated. Please wait for ${timeToWait} to generate again.`,
},
);
}
@@ -98,34 +109,40 @@ export class ResetPasswordService {
.update(plainResetToken)
.digest('hex');
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
await this.appTokenRepository.save({
userId: user.id,
workspaceId: workspaceId,
workspaceId: targetWorkspaceId,
value: hashedResetToken,
expiresAt,
type: AppTokenType.PasswordResetToken,
});
return {
workspaceId,
workspaceId: targetWorkspaceId,
passwordResetToken: plainResetToken,
passwordResetTokenExpiresAt: expiresAt,
};
}
async sendEmailPasswordResetLink(
resetToken: PasswordResetToken,
email: string,
locale: keyof typeof APP_LOCALES,
): Promise<EmailPasswordResetLinkDTO> {
async sendEmailPasswordResetLink({
resetToken,
email,
locale,
}: {
resetToken: PasswordResetToken;
email: string;
locale: keyof typeof APP_LOCALES;
}): Promise<EmailPasswordResetLinkDTO> {
const user = await this.userService.findUserByEmailOrThrow(
email,
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
);
const hasPassword = isDefined(user.passwordHash);
const resetPasswordPath = getAppPath(AppPath.ResetPassword, {
passwordResetToken: resetToken.passwordResetToken,
});
const workspace = await this.workspaceRepository.findOneBy({
id: resetToken.workspaceId,
});
@@ -134,9 +151,7 @@ export class ResetPasswordService {
const link = this.workspaceDomainsService.buildWorkspaceURL({
workspace,
pathname: getAppPath(AppPath.ResetPassword, {
passwordResetToken: resetToken.passwordResetToken,
}),
pathname: resetPasswordPath,
});
const emailData = {
@@ -234,4 +249,34 @@ export class ResetPasswordService {
return { success: true };
}
private async findFirstPasswordAuthEnabledWorkspaceIdOrThrow(
userId: string,
): Promise<string> {
const workspace = await this.workspaceRepository.findOne({
where: {
workspaceUsers: {
user: {
id: userId,
},
},
isPasswordAuthEnabled: true,
},
order: {
createdAt: 'ASC',
},
});
if (!isDefined(workspace)) {
throw new AuthException(
'No password auth enabled workspace found',
AuthExceptionCode.INVALID_INPUT,
{
userFriendlyMessage: msg`No workspace found with password auth enabled.`,
},
);
}
return workspace.id;
}
}
@@ -0,0 +1,5 @@
export type PasswordResetToken = {
passwordResetToken: string;
passwordResetTokenExpiresAt: Date;
workspaceId: string;
};