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:
committed by
GitHub
parent
eda905f271
commit
aeedcf3353
@@ -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)
|
||||
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
+62
-28
@@ -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(
|
||||
|
||||
+60
-15
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user