Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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 @@
@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);

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

View check run for this annotation

Codecov / codecov/patch

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

Added line #L52 was not covered by tests
if (!preview)
throw new AppException(
HttpStatus.NOT_FOUND,
Expand All @@ -71,7 +72,11 @@
);
}

const sent = await this.mail.sendTemplateTest(key, dto.to, dto.values);
const sent = await this.mail.sendTemplateTest(

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

View check run for this annotation

Codecov / codecov/patch

apps/api/src/admin/admin-emails.controller.ts#L75

Added line #L75 was not covered by tests
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
83 changes: 75 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,67 @@
);
});

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" }) }),
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
);
expect(mail.sendWelcome).toHaveBeenCalledWith(

Check warning on line 325 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#L325

Added line #L325 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 333 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#L331-L333

Added lines #L331 - L333 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 337 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#L336-L337

Added lines #L336 - L337 were not covered by tests
);

await service.register(

Check warning on line 340 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#L340

Added line #L340 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 353 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#L353

Added line #L353 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 +486,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 +824,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 +907,10 @@
expect(prisma.refreshToken.deleteMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
});
expect(mail.sendPasswordChanged).toHaveBeenCalledWith(user.email);
expect(mail.sendPasswordChanged).toHaveBeenCalledWith({

Check warning on line 910 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#L910

Added line #L910 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 +966,7 @@
}),
);
expect(mail.sendVerifyEmail).toHaveBeenCalledWith(
user.email,
{ email: user.email, locale: user.locale },
expect.any(String),
);
});
Expand Down Expand Up @@ -999,7 +1066,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 +1124,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 @@
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 @@
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 @@
},
}),
]);
await this.mail.sendVerifyEmail(user.email, verifyToken);
await this.mail.sendVerifyEmail(

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

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/auth.service.ts#L211

Added line #L211 was not covered by tests
{ 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 @@
emailCodeExpiresAt = new Date(
Date.now() + MFA_EMAIL_CODE_TTL_MINUTES * 60_000,
);
await this.mail.sendMfaEmailCode(user.email, code);
await this.mail.sendMfaEmailCode(

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

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/auth.service.ts#L267

Added line #L267 was not covered by tests
{ email: user.email, locale: user.locale },
code,
);
}

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

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

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/auth.service.ts#L325

Added line #L325 was not covered by tests
{ email: challenge.user.email, locale: challenge.user.locale },
code,
);
}

/**
Expand Down Expand Up @@ -397,8 +407,11 @@
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(

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

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/auth.service.ts#L410

Added line #L410 was not covered by tests
{ 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 @@
},
}),
]);
await this.mail.sendPasswordResetLink(user.email, token);
await this.mail.sendPasswordResetLink(

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

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/auth.service.ts#L599

Added line #L599 was not covered by tests
{ email: user.email, locale: user.locale },
token,
);
}

/**
Expand Down Expand Up @@ -630,7 +646,10 @@
where: { userId: stored.userId },
}),
]);
await this.mail.sendPasswordChanged(stored.user.email);
await this.mail.sendPasswordChanged({

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

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/auth.service.ts#L649

Added line #L649 was not covered by tests
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