Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .pnpm-store/v11/index.db
Binary file not shown.
Binary file added .pnpm-store/v11/index.db-shm
Binary file not shown.
Empty file added .pnpm-store/v11/index.db-wal
Empty file.
39 changes: 37 additions & 2 deletions apps/api/src/admin/admin-emails.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,30 @@
);
});

it("passes the selected locale separately from template overrides", () => {
const { controller, mail } = makeController();
(mail.renderTemplatePreview as Mock).mockReturnValue({

Check warning on line 41 in apps/api/src/admin/admin-emails.controller.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/admin/admin-emails.controller.spec.ts#L39-L41

Added lines #L39 - L41 were not covered by tests
subject: "Welcome",
html: "<html></html>",
text: "Welcome",
});

controller.previewEmailTemplate("welcome", {

Check warning on line 47 in apps/api/src/admin/admin-emails.controller.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/admin/admin-emails.controller.spec.ts#L47

Added line #L47 was not covered by tests
locale: "en",
displayName: "Alice",
});

expect(mail.renderTemplatePreview).toHaveBeenCalledWith("welcome", "en", {

Check warning on line 52 in apps/api/src/admin/admin-emails.controller.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/admin/admin-emails.controller.spec.ts#L52

Added line #L52 was not covered by tests
displayName: "Alice",
});
});

it("rejects test-send when SMTP isn't configured", async () => {
const { controller, mail } = makeController();
(mail.isConfigured as Mock).mockReturnValue(false);

await expect(
controller.sendTestEmail("welcome", { to: "a@b.com" }),
controller.sendTestEmail("welcome", { to: "a@b.com", locale: "fr" }),
).rejects.toThrow(AppException);
expect(mail.sendTemplateTest).not.toHaveBeenCalled();
});
Expand All @@ -51,7 +69,24 @@
(mail.sendTemplateTest as Mock).mockResolvedValue(false);

await expect(
controller.sendTestEmail("nope", { to: "a@b.com" }),
controller.sendTestEmail("nope", { to: "a@b.com", locale: "fr" }),
).rejects.toThrow(AppException);
});

it("sends the test email in the selected locale", async () => {
const { controller, mail } = makeController();
(mail.sendTemplateTest as Mock).mockResolvedValue(true);

Check warning on line 78 in apps/api/src/admin/admin-emails.controller.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/admin/admin-emails.controller.spec.ts#L76-L78

Added lines #L76 - L78 were not covered by tests

await controller.sendTestEmail("welcome", {

Check warning on line 80 in apps/api/src/admin/admin-emails.controller.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/admin/admin-emails.controller.spec.ts#L80

Added line #L80 was not covered by tests
to: "a@b.com",
locale: "en",
values: { displayName: "Alice" },
});

expect(mail.sendTemplateTest).toHaveBeenCalledWith(

Check warning on line 86 in apps/api/src/admin/admin-emails.controller.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/admin/admin-emails.controller.spec.ts#L86

Added line #L86 was not covered by tests
"welcome",
{ email: "a@b.com", locale: "en" },
{ displayName: "Alice" },
);
});
});
11 changes: 8 additions & 3 deletions apps/api/src/admin/admin-emails.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@ export class AdminEmailsController {
@ApiOkResponse({ type: MailTemplatePreviewResponseDto })
previewEmailTemplate(
@Param("key") key: string,
@Query() overrides: Record<string, string>,
@Query() query: Record<string, string>,
): MailTemplatePreviewDto {
const preview = this.mail.renderTemplatePreview(key, overrides);
const { locale = "fr", ...overrides } = query;
const preview = this.mail.renderTemplatePreview(key, locale, overrides);
if (!preview)
throw new AppException(
HttpStatus.NOT_FOUND,
Expand All @@ -71,7 +72,11 @@ export class AdminEmailsController {
);
}

const sent = await this.mail.sendTemplateTest(key, dto.to, dto.values);
const sent = await this.mail.sendTemplateTest(
key,
{ email: dto.to, locale: dto.locale },
dto.values,
);
if (!sent)
throw new AppException(
HttpStatus.NOT_FOUND,
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/admin/admin-reports.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export class AdminReportsController {
if (authorId) {
const author = await this.prisma.user.findUnique({
where: { id: authorId },
select: { email: true, username: true },
select: { email: true, locale: true, username: true },
});

if (author) {
Expand All @@ -177,6 +177,7 @@ export class AdminReportsController {
targetId: report.targetId,
subjectUserId: authorId,
subjectEmail: author.email,
subjectLocale: author.locale,
subjectUsername: author.username,
legalBasis: body.legalBasis,
reasonCategory: report.category,
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/admin/admin-users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ export class AdminUsersController {
targetId: userId,
subjectUserId: userId,
subjectEmail: user.email,
subjectLocale: user.locale,
subjectUsername: user.username,
legalBasis: body.legalBasis,
reasonText: body.reasonText,
Expand Down
11 changes: 9 additions & 2 deletions apps/api/src/admin/dto/send-test-email.dto.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import type { SendTestEmailRequestDto } from "@loomkeep/shared";
import { IsEmail, IsObject, IsOptional } from "class-validator";
import {
Locale,
type Locale as LocaleCode,
type SendTestEmailRequestDto,
} from "@loomkeep/shared";
import { IsEmail, IsIn, IsObject, IsOptional } from "class-validator";

export class SendTestEmailDto implements SendTestEmailRequestDto {
@IsEmail()
to!: string;

@IsIn(Locale)
locale!: LocaleCode;

@IsOptional()
@IsObject()
values?: Record<string, string>;
Expand Down
85 changes: 77 additions & 8 deletions apps/api/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,9 +281,12 @@
data: expect.objectContaining({ type: "EMAIL_VERIFICATION" }),
}),
);
expect(mail.sendWelcome).toHaveBeenCalledWith("alice@example.com", "Alice");
expect(mail.sendWelcome).toHaveBeenCalledWith(

Check warning on line 284 in apps/api/src/auth/auth.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/auth.service.spec.ts#L284

Added line #L284 was not covered by tests
{ email: "alice@example.com", locale: "en" },
"Alice",
);
expect(mail.sendVerifyEmail).toHaveBeenCalledWith(
"alice@example.com",
{ email: "alice@example.com", locale: "en" },
expect.any(String),
);
expect(security.record).toHaveBeenCalledWith(
Expand All @@ -293,6 +296,69 @@
);
});

it("uses the explicit page locale instead of the header fallback", async () => {
const { service, prisma, mail } = makeService();
(prisma.user.findUnique as Mock)

Check warning on line 301 in apps/api/src/auth/auth.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/auth.service.spec.ts#L299-L301

Added lines #L299 - L301 were not covered by tests
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null);
(prisma.user.create as Mock).mockImplementation(
async ({ data }: { data: Partial<User> }) => makeUser(data),

Check warning on line 305 in apps/api/src/auth/auth.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/auth.service.spec.ts#L304-L305

Added lines #L304 - L305 were not covered by tests
);

await service.register(

Check warning on line 308 in apps/api/src/auth/auth.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/auth.service.spec.ts#L308

Added line #L308 was not covered by tests
{
email: "alice@example.com",
password: "secret1234",
displayName: "Alice",
acceptedTerms: true,
certifiedAge: true,
locale: "en",
},
undefined,
undefined,
"fr",
);

expect(prisma.user.create).toHaveBeenCalledWith(

Check warning on line 322 in apps/api/src/auth/auth.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/auth.service.spec.ts#L322

Added line #L322 was not covered by tests
expect.objectContaining({
data: expect.objectContaining({ locale: "en" }),
}),
);
expect(mail.sendWelcome).toHaveBeenCalledWith(

Check warning on line 327 in apps/api/src/auth/auth.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/auth.service.spec.ts#L327

Added line #L327 was not covered by tests
expect.objectContaining({ email: "alice@example.com", locale: "en" }),
"Alice",
);
});

it("falls back to Accept-Language for clients that do not send a locale", async () => {
const { service, prisma } = makeService();
(prisma.user.findUnique as Mock)

Check warning on line 335 in apps/api/src/auth/auth.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/auth.service.spec.ts#L333-L335

Added lines #L333 - L335 were not covered by tests
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null);
(prisma.user.create as Mock).mockImplementation(
async ({ data }: { data: Partial<User> }) => makeUser(data),

Check warning on line 339 in apps/api/src/auth/auth.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/auth.service.spec.ts#L338-L339

Added lines #L338 - L339 were not covered by tests
);

await service.register(

Check warning on line 342 in apps/api/src/auth/auth.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/auth.service.spec.ts#L342

Added line #L342 was not covered by tests
{
email: "alice@example.com",
password: "secret1234",
displayName: "Alice",
acceptedTerms: true,
certifiedAge: true,
},
undefined,
undefined,
"fr-FR,fr;q=0.9,en;q=0.8",
);

expect(prisma.user.create).toHaveBeenCalledWith(

Check warning on line 355 in apps/api/src/auth/auth.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/auth.service.spec.ts#L355

Added line #L355 was not covered by tests
expect.objectContaining({
data: expect.objectContaining({ locale: "fr" }),
}),
);
});

it("appends a random suffix when the slugified username is taken", async () => {
const { service, prisma } = makeService();
(prisma.user.findUnique as Mock)
Expand Down Expand Up @@ -422,7 +488,7 @@
}),
);
expect(mail.sendNewDeviceLogin).toHaveBeenCalledWith(
user.email,
{ email: user.email, locale: user.locale },
"Chrome · Windows",
"203.0.113.42",
);
Expand Down Expand Up @@ -760,7 +826,7 @@
expect(createArgs.data.type).toBe("PASSWORD_RESET");

expect(mail.sendPasswordResetLink).toHaveBeenCalledWith(
user.email,
{ email: user.email, locale: user.locale },
expect.any(String),
);
const [, token] = (mail.sendPasswordResetLink as Mock).mock.calls[0];
Expand Down Expand Up @@ -843,7 +909,10 @@
expect(prisma.refreshToken.deleteMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
});
expect(mail.sendPasswordChanged).toHaveBeenCalledWith(user.email);
expect(mail.sendPasswordChanged).toHaveBeenCalledWith({

Check warning on line 912 in apps/api/src/auth/auth.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/auth.service.spec.ts#L912

Added line #L912 was not covered by tests
email: user.email,
locale: user.locale,
});
expect(security.record).toHaveBeenCalledWith(
expect.objectContaining({
type: "PASSWORD_RESET",
Expand Down Expand Up @@ -899,7 +968,7 @@
}),
);
expect(mail.sendVerifyEmail).toHaveBeenCalledWith(
user.email,
{ email: user.email, locale: user.locale },
expect.any(String),
);
});
Expand Down Expand Up @@ -999,7 +1068,7 @@
});

expect(mail.sendMfaEmailCode).toHaveBeenCalledWith(
user.email,
{ email: user.email, locale: user.locale },
expect.stringMatching(/^\d{6}$/),
);
expect(result).toMatchObject({ availableMethods: ["email", "recovery"] });
Expand Down Expand Up @@ -1057,7 +1126,7 @@
await service.resendMfaEmailCode("challenge-1");

expect(mail.sendMfaEmailCode).toHaveBeenCalledWith(
user.email,
{ email: user.email, locale: user.locale },
expect.stringMatching(/^\d{6}$/),
);
expect(prisma.mfaLoginChallenge.update).toHaveBeenCalledWith(
Expand Down
39 changes: 29 additions & 10 deletions apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export class AuthService {
passwordHash: await bcrypt.hash(dto.password, BCRYPT_ROUNDS),
displayName: dto.displayName,
username: await this.generateUniqueUsername(dto.displayName),
locale: detectLocale(acceptLanguage),
locale: dto.locale ?? detectLocale(acceptLanguage),
acceptedTermsAt: new Date(),
acceptedTermsVersion: LEGAL_VERSION,
certifiedAgeAt: new Date(),
Expand All @@ -123,8 +123,9 @@ export class AuthService {
expiresAt: new Date(Date.now() + VERIFY_TOKEN_TTL_HOURS * 60 * 60_000),
},
});
await this.mail.sendWelcome(user.email, user.displayName);
await this.mail.sendVerifyEmail(user.email, verifyToken);
const recipient = { email: user.email, locale: user.locale };
await this.mail.sendWelcome(recipient, user.displayName);
await this.mail.sendVerifyEmail(recipient, verifyToken);
await this.security.record({
type: "USER_REGISTERED",
userId: user.id,
Expand Down Expand Up @@ -207,7 +208,10 @@ export class AuthService {
},
}),
]);
await this.mail.sendVerifyEmail(user.email, verifyToken);
await this.mail.sendVerifyEmail(
{ email: user.email, locale: user.locale },
verifyToken,
);
}

/** Accepts either the email or the username as the login identifier. */
Expand Down Expand Up @@ -260,7 +264,10 @@ export class AuthService {
emailCodeExpiresAt = new Date(
Date.now() + MFA_EMAIL_CODE_TTL_MINUTES * 60_000,
);
await this.mail.sendMfaEmailCode(user.email, code);
await this.mail.sendMfaEmailCode(
{ email: user.email, locale: user.locale },
code,
);
}

const challenge = await this.prisma.mfaLoginChallenge.create({
Expand Down Expand Up @@ -315,7 +322,10 @@ export class AuthService {
),
},
});
await this.mail.sendMfaEmailCode(challenge.user.email, code);
await this.mail.sendMfaEmailCode(
{ email: challenge.user.email, locale: challenge.user.locale },
code,
);
}

/**
Expand Down Expand Up @@ -397,8 +407,11 @@ export class AuthService {
const tokens = await this.startSession(promoted, userAgent);

if (isNewDevice) {
const label = deviceLabel(userAgent) ?? "Appareil inconnu";
await this.mail.sendNewDeviceLogin(promoted.email, label, ip ?? null);
await this.mail.sendNewDeviceLogin(
{ email: promoted.email, locale: promoted.locale },
deviceLabel(userAgent),
ip ?? null,
);
await this.security.record({
type: "NEW_DEVICE_LOGIN",
userId: promoted.id,
Expand Down Expand Up @@ -583,7 +596,10 @@ export class AuthService {
},
}),
]);
await this.mail.sendPasswordResetLink(user.email, token);
await this.mail.sendPasswordResetLink(
{ email: user.email, locale: user.locale },
token,
);
}

/**
Expand Down Expand Up @@ -630,7 +646,10 @@ export class AuthService {
where: { userId: stored.userId },
}),
]);
await this.mail.sendPasswordChanged(stored.user.email);
await this.mail.sendPasswordChanged({
email: stored.user.email,
locale: stored.user.locale,
});
await this.security.record({
type: "PASSWORD_RESET",
userId: stored.userId,
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/auth/dto/register.dto.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import type { RegisterRequestDto } from "@loomkeep/shared";
import {
Locale,
type Locale as LocaleCode,
PASSWORD_DIGIT_RE,
PASSWORD_SPECIAL_RE,
PASSWORD_UPPERCASE_RE,
} from "@loomkeep/shared";
import {
Equals,
IsEmail,
IsIn,
IsOptional,
IsString,
Matches,
Expand Down Expand Up @@ -38,6 +41,10 @@ export class RegisterDto implements RegisterRequestDto {
@MaxLength(50)
displayName!: string;

@IsOptional()
@IsIn(Locale)
locale?: LocaleCode;

@Equals(true, { message: "terms of service must be accepted" })
acceptedTerms!: boolean;

Expand Down
Loading
Loading