feat(login): translations (#10849)

# Which Problems Are Solved

Replace this example text with a concise list of problems that this PR
solves.
For example:
- password complexity requirements have hardcoded English text
- password, loginname, register and verify components have hardcoded
Engilsh error messages/alerts

# How the Problems Are Solved

Replace this example text with a concise list of changes that this PR
introduces.
For example:
- adds i18n for password complexity requirements
- adds i18n for password, loginname, register and verify components
error messages/alerts

# Additional Changes

- small change in code/styles for icons in PasswordComplexity to make
sure that icons keep size

# Additional Context

N.A

---------

Co-authored-by: Adam Kida <[email protected]>
This commit is contained in:
Max Peintner
2025-10-09 11:11:03 +02:00
committed by GitHub
co-authored by Adam Kida
parent 794054c075
commit 64e14de091
22 changed files with 972 additions and 195 deletions
@@ -70,7 +70,7 @@ export function ChangePasswordForm({
password: values.password,
})
.catch(() => {
setError("Could not change password");
setError(t("change.errors.couldNotChangePassword"));
return;
})
.finally(() => {
@@ -81,13 +81,13 @@ export function ChangePasswordForm({
setError(
typeof changeResponse.error === "string"
? changeResponse.error
: "Unknown error",
: t("change.errors.unknownError")
);
return;
}
if (!changeResponse) {
setError("Could not change password");
setError(t("change.errors.couldNotChangePassword"));
return;
}
@@ -102,7 +102,7 @@ export function ChangePasswordForm({
requestId,
})
.catch(() => {
setError("Could not verify password");
setError(t("change.errors.couldNotVerifyPassword"));
return;
})
.finally(() => {
@@ -1,64 +1,148 @@
import {
cleanup,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { PasswordComplexity } from "./password-complexity";
const matchesTitle = `Matches`;
const doesntMatchTitle = `Doesn't match`;
import { NextIntlClientProvider } from "next-intl";
describe("<PasswordComplexity/>", () => {
describe.each`
settingsMinLength | password | expectSVGTitle
${5} | ${"Password1!"} | ${matchesTitle}
${30} | ${"Password1!"} | ${doesntMatchTitle}
${0} | ${"Password1!"} | ${matchesTitle}
${undefined} | ${"Password1!"} | ${false}
`(
`With settingsMinLength=$settingsMinLength, password=$password, expectSVGTitle=$expectSVGTitle`,
({ settingsMinLength, password, expectSVGTitle }) => {
const feedbackElementLabel = /password length/i;
beforeEach(() => {
render(
<PasswordComplexity
password={password}
equals
passwordComplexitySettings={{
minLength: settingsMinLength,
const messages = {
password: {
complexity: {
length: "Must be at least {minLength} characters long.",
hasSymbol: "Must include a symbol.",
hasNumber: "Must include a number.",
hasUppercase: "Must include an uppercase letter.",
hasLowercase: "Must include a lowercase letter.",
equals: "Password confirmation matched.",
matches: "Matches",
doesNotMatch: "Doesn't match",
},
},
};
afterEach(cleanup);
test("should render length check when minLength is defined", () => {
render(
<NextIntlClientProvider locale="en" messages={messages}>
<PasswordComplexity
password="Password1!"
equals
passwordComplexitySettings={
{
minLength: BigInt(5),
requiresLowercase: false,
requiresUppercase: false,
requiresNumber: false,
requiresSymbol: false,
resourceOwnerType: 0, // ResourceOwnerType.RESOURCE_OWNER_TYPE_UNSPECIFIED,
}}
/>,
);
});
afterEach(cleanup);
resourceOwnerType: 0,
} as any
}
/>
</NextIntlClientProvider>,
);
if (expectSVGTitle === false) {
test(`should not render the feedback element`, async () => {
await waitFor(() => {
expect(
screen.queryByText(feedbackElementLabel),
).not.toBeInTheDocument();
});
});
} else {
test(`Should show one SVG with title ${expectSVGTitle}`, async () => {
await waitFor(async () => {
const svg = within(
screen.getByText(feedbackElementLabel)
.parentElement as HTMLElement,
).findByRole("img");
expect(await svg).toHaveTextContent(expectSVGTitle);
});
});
}
},
);
const lengthCheck = screen.getByTestId("length-check");
expect(lengthCheck).toBeInTheDocument();
expect(lengthCheck.querySelector("svg")).toBeInTheDocument();
});
test("should not render length check when minLength is undefined", () => {
render(
<NextIntlClientProvider locale="en" messages={messages}>
<PasswordComplexity
password="Password1!"
equals
passwordComplexitySettings={
{
minLength: BigInt(0),
requiresLowercase: false,
requiresUppercase: false,
requiresNumber: false,
requiresSymbol: false,
resourceOwnerType: 0,
} as any
}
/>
</NextIntlClientProvider>,
);
expect(screen.queryByTestId("length-check")).toBeInTheDocument();
});
test("should render check icon when password meets length requirement", () => {
render(
<NextIntlClientProvider locale="en" messages={messages}>
<PasswordComplexity
password="Password1!"
equals
passwordComplexitySettings={
{
minLength: BigInt(5),
requiresLowercase: false,
requiresUppercase: false,
requiresNumber: false,
requiresSymbol: false,
resourceOwnerType: 0,
} as any
}
/>
</NextIntlClientProvider>,
);
const lengthCheck = screen.getByTestId("length-check");
const svg = lengthCheck.querySelector("svg");
expect(svg).toHaveClass("text-green-500");
});
test("should render cross icon when password does not meet length requirement", () => {
render(
<NextIntlClientProvider locale="en" messages={messages}>
<PasswordComplexity
password="Pass"
equals
passwordComplexitySettings={
{
minLength: BigInt(10),
requiresLowercase: false,
requiresUppercase: false,
requiresNumber: false,
requiresSymbol: false,
resourceOwnerType: 0,
} as any
}
/>
</NextIntlClientProvider>,
);
const lengthCheck = screen.getByTestId("length-check");
const svg = lengthCheck.querySelector("svg");
expect(svg).toHaveClass("text-warn-light-500");
});
test("should render all complexity checks", () => {
render(
<NextIntlClientProvider locale="en" messages={messages}>
<PasswordComplexity
password="Password1!"
equals
passwordComplexitySettings={
{
minLength: BigInt(8),
requiresLowercase: true,
requiresUppercase: true,
requiresNumber: true,
requiresSymbol: true,
resourceOwnerType: 0,
} as any
}
/>
</NextIntlClientProvider>,
);
expect(screen.getByTestId("length-check")).toBeInTheDocument();
expect(screen.getByTestId("symbol-check")).toBeInTheDocument();
expect(screen.getByTestId("number-check")).toBeInTheDocument();
expect(screen.getByTestId("uppercase-check")).toBeInTheDocument();
expect(screen.getByTestId("lowercase-check")).toBeInTheDocument();
});
});
@@ -5,6 +5,8 @@ import {
upperCaseValidator,
} from "@/helpers/validators";
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
import { useTranslations } from "next-intl";
import { Translated } from "@/components/translated";
type Props = {
passwordComplexitySettings: PasswordComplexitySettings;
@@ -12,42 +14,55 @@ type Props = {
equals: boolean;
};
const check = (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="las la-check mr-2 h-6 w-6 text-lg text-green-500 dark:text-green-500"
role="img"
>
<title>Matches</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
);
const cross = (
<svg
className="las la-times mr-2 h-6 w-6 text-lg text-warn-light-500 dark:text-warn-dark-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
role="img"
>
<title>Doesn&apos;t match</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
);
function CheckIcon({ title }: { title: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="las la-check mr-2 h-6 w-6 flex-none text-lg text-green-500 dark:text-green-500"
role="img"
>
<title>{title}</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
);
}
function CrossIcon({ title }: { title: string }) {
return (
<svg
className="las la-times mr-2 h-6 w-6 flex-none text-lg text-warn-light-500 dark:text-warn-dark-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
role="img"
>
<title>{title}</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
);
}
function renderIcon(matched: boolean, t: ReturnType<typeof useTranslations>) {
return matched ? (
<CheckIcon title={t("complexity.matches")} />
) : (
<CrossIcon title={t("complexity.doesNotMatch")} />
);
}
const desc =
"text-14px leading-4 text-input-light-label dark:text-input-dark-label";
@@ -56,6 +71,7 @@ export function PasswordComplexity({
password,
equals,
}: Props) {
const t = useTranslations("password");
const hasMinLength = password?.length >= passwordComplexitySettings.minLength;
const hasSymbol = symbolValidator(password);
const hasNumber = numberValidator(password);
@@ -66,33 +82,47 @@ export function PasswordComplexity({
<div className="mb-4 grid grid-cols-2 gap-x-8 gap-y-2">
{passwordComplexitySettings.minLength != undefined ? (
<div className="flex flex-row items-center" data-testid="length-check">
{hasMinLength ? check : cross}
{renderIcon(hasMinLength, t)}
<span className={desc}>
Password length {passwordComplexitySettings.minLength.toString()}
<Translated
i18nKey="complexity.length"
namespace="password"
data={{ minLength: passwordComplexitySettings.minLength.toString() }}
/>
</span>
</div>
) : (
<span />
)}
<div className="flex flex-row items-center" data-testid="symbol-check">
{hasSymbol ? check : cross}
<span className={desc}>has Symbol</span>
{renderIcon(hasSymbol, t)}
<span className={desc}>
<Translated i18nKey="complexity.hasSymbol" namespace="password" />
</span>
</div>
<div className="flex flex-row items-center" data-testid="number-check">
{hasNumber ? check : cross}
<span className={desc}>has Number</span>
{renderIcon(hasNumber, t)}
<span className={desc}>
<Translated i18nKey="complexity.hasNumber" namespace="password" />
</span>
</div>
<div className="flex flex-row items-center" data-testid="uppercase-check">
{hasUppercase ? check : cross}
<span className={desc}>has uppercase</span>
{renderIcon(hasUppercase, t)}
<span className={desc}>
<Translated i18nKey="complexity.hasUppercase" namespace="password" />
</span>
</div>
<div className="flex flex-row items-center" data-testid="lowercase-check">
{hasLowercase ? check : cross}
<span className={desc}>has lowercase</span>
{renderIcon(hasLowercase, t)}
<span className={desc}>
<Translated i18nKey="complexity.hasLowercase" namespace="password" />
</span>
</div>
<div className="flex flex-row items-center" data-testid="equal-check">
{equals ? check : cross}
<span className={desc}>equals</span>
{renderIcon(equals, t)}
<span className={desc}>
<Translated i18nKey="complexity.equals" namespace="password" />
</span>
</div>
</div>
);
+4 -5
View File
@@ -52,9 +52,8 @@ export function PasswordForm({ loginSettings, loginName, organization, requestId
}),
requestId,
})
.catch((error) => {
setError("Could not verify password");
console.error("Error verifying password:", error);
.catch(() => {
setError(t("verify.errors.couldNotVerifyPassword"));
return;
})
.finally(() => {
@@ -82,7 +81,7 @@ export function PasswordForm({ loginSettings, loginName, organization, requestId
requestId,
})
.catch(() => {
setError("Could not reset password");
setError(t("verify.errors.couldNotResetPassword"));
return;
})
.finally(() => {
@@ -94,7 +93,7 @@ export function PasswordForm({ loginSettings, loginName, organization, requestId
return;
}
setInfo("Password was reset. Please check your email.");
setInfo(t("verify.info.passwordResetSent"));
const params = new URLSearchParams({
loginName: loginName,
+1 -1
View File
@@ -72,7 +72,7 @@ export function RegisterForm({
requestId: requestId,
})
.catch(() => {
setError("Could not register user");
setError(t("errors.couldNotRegisterUser"));
return;
})
.finally(() => {
@@ -77,7 +77,7 @@ export function SetPasswordForm({
requestId,
})
.catch(() => {
setError("Could not reset password");
setError(t("set.errors.couldNotResetPassword"));
return;
})
.finally(() => {
@@ -104,7 +104,7 @@ export function SetPasswordForm({
const changeResponse = await changePassword(payload)
.catch(() => {
setError("Could not set password");
setError(t("set.errors.couldNotSetPassword"));
return;
})
.finally(() => {
@@ -117,7 +117,7 @@ export function SetPasswordForm({
}
if (!changeResponse) {
setError("Could not set password");
setError(t("set.errors.couldNotSetPassword"));
return;
}
@@ -141,7 +141,7 @@ export function SetPasswordForm({
requestId,
})
.catch(() => {
setError("Could not verify password");
setError(t("set.errors.couldNotVerifyPassword"));
return;
})
.finally(() => {
@@ -71,7 +71,7 @@ export function SetRegisterPasswordForm({
password: values.password,
})
.catch(() => {
setError("Could not register user");
setError(t("errors.couldNotRegisterUser"));
return;
})
.finally(() => {
+1 -1
View File
@@ -60,7 +60,7 @@ export function UsernameForm({
suffix,
})
.catch(() => {
setError("An internal error occurred");
setError(t("errors.internalError"));
return;
})
.finally(() => {
+2 -2
View File
@@ -57,7 +57,7 @@ export function VerifyForm({
isInvite: isInvite,
})
.catch(() => {
setError("Could not resend email");
setError(t("errors.couldNotResendEmail"));
return;
})
.finally(() => {
@@ -87,7 +87,7 @@ export function VerifyForm({
requestId: requestId,
})
.catch(() => {
setError("Could not verify user");
setError(t("errors.couldNotVerifyUser"));
return;
})
.finally(() => {