fix(login): resolve localized legal links in signup (#11913)

# Which Problems Are Solved

- Login V2 signup does not resolve the `{{.Lang}}` placeholder in ToS,
Privacy Policy, and help links.
- Clicking those links on the registration page can open URL-encoded
placeholders like `%7B%7B.Lang%7D%7D` instead of the active
language-specific page.

# How the Problems Are Solved

- Adds a small login utility to resolve `{{.Lang}}` placeholders against
the active Login V2 locale.
- Updates the signup legal acceptance component to use the resolved
links before rendering them.
- Applies the same resolution to the optional help link shown alongside
the legal acceptance text.

# Additional Changes

- Adds unit tests for the link placeholder resolver.
- Adds a component test covering localized ToS, Privacy Policy, and help
links on the signup page.

# Additional Context

- Closes #11907

---------

Co-authored-by: Copilot <[email protected]>
Co-authored-by: Max Peintner <[email protected]>
Co-authored-by: Max Peintner <[email protected]>
This commit is contained in:
Chandan Semwal
2026-04-30 13:45:03 +00:00
committed by GitHub
co-authored by Copilot Max Peintner Max Peintner
parent b1c8781516
commit 942c959d09
4 changed files with 88 additions and 8 deletions
@@ -0,0 +1,37 @@
import { cleanup, render, screen } from "@testing-library/react";
import { create } from "@zitadel/client";
import { LegalAndSupportSettingsSchema } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb";
import type { AnchorHTMLAttributes } from "react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { PrivacyPolicyCheckboxes } from "./privacy-policy-checkboxes";
vi.mock("next-intl", () => ({
useLocale: () => "fr",
useTranslations: () => (key: string) => key,
}));
vi.mock("next/link", () => ({
default: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
<a href={href} {...props}>
{children}
</a>
),
}));
describe("PrivacyPolicyCheckboxes", () => {
afterEach(cleanup);
test("resolves language placeholders in legal links with the active locale", () => {
const legal = create(LegalAndSupportSettingsSchema, {
tosLink: "https://demo.com/tos-{{.Lang}}",
privacyPolicyLink: "https://demo.com/privacy-{{.Lang}}",
helpLink: "https://demo.com/help-{{.Lang}}",
});
render(<PrivacyPolicyCheckboxes legal={legal} onChange={vi.fn()} />);
expect(screen.getByTestId("tos-link")).toHaveAttribute("href", "https://demo.com/tos-fr");
expect(screen.getByTestId("privacy-policy-link")).toHaveAttribute("href", "https://demo.com/privacy-fr");
expect(screen.getByTestId("help-link")).toHaveAttribute("href", "https://demo.com/help-fr");
});
});
@@ -1,5 +1,7 @@
"use client"; "use client";
import { resolveLocalizedLegalLink } from "@/lib/legal-links";
import { LegalAndSupportSettings } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb"; import { LegalAndSupportSettings } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb";
import { useLocale } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { Checkbox } from "./checkbox"; import { Checkbox } from "./checkbox";
@@ -16,15 +18,19 @@ type AcceptanceState = {
}; };
export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) { export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {
const locale = useLocale();
const [acceptanceState, setAcceptanceState] = useState<AcceptanceState>({ const [acceptanceState, setAcceptanceState] = useState<AcceptanceState>({
tosAccepted: false, tosAccepted: false,
privacyPolicyAccepted: false, privacyPolicyAccepted: false,
}); });
const helpLink = resolveLocalizedLegalLink(legal?.helpLink, locale);
const tosLink = resolveLocalizedLegalLink(legal?.tosLink, locale);
const privacyPolicyLink = resolveLocalizedLegalLink(legal?.privacyPolicyLink, locale);
// Helper function to check if all required checkboxes are accepted // Helper function to check if all required checkboxes are accepted
const checkAllAccepted = (newState: AcceptanceState) => { const checkAllAccepted = (newState: AcceptanceState) => {
const hasTosLink = !!legal?.tosLink; const hasTosLink = !!tosLink;
const hasPrivacyLink = !!legal?.privacyPolicyLink; const hasPrivacyLink = !!privacyPolicyLink;
// Check that all required checkboxes are accepted // Check that all required checkboxes are accepted
return (!hasTosLink || newState.tosAccepted) && (!hasPrivacyLink || newState.privacyPolicyAccepted); return (!hasTosLink || newState.tosAccepted) && (!hasPrivacyLink || newState.privacyPolicyAccepted);
@@ -34,9 +40,9 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {
<> <>
<p className="text-text-light-secondary-500 dark:text-text-dark-secondary-500 mt-4 flex flex-row items-center text-sm"> <p className="text-text-light-secondary-500 dark:text-text-dark-secondary-500 mt-4 flex flex-row items-center text-sm">
<Translated i18nKey="agreeTo" namespace="register" /> <Translated i18nKey="agreeTo" namespace="register" />
{legal?.helpLink && ( {helpLink && (
<span> <span>
<Link href={legal.helpLink} target="_blank"> <Link href={helpLink} target="_blank" aria-label="Open help in a new tab" data-testid="help-link">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
@@ -55,7 +61,7 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {
</span> </span>
)} )}
</p> </p>
{legal?.tosLink && ( {tosLink && (
<div className="mt-4 flex items-center"> <div className="mt-4 flex items-center">
<Checkbox <Checkbox
className="mr-4" className="mr-4"
@@ -74,14 +80,14 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {
<div className="mr-4 w-[28rem]"> <div className="mr-4 w-[28rem]">
<p className="text-text-light-500 dark:text-text-dark-500 text-sm"> <p className="text-text-light-500 dark:text-text-dark-500 text-sm">
<Link href={legal.tosLink} className="underline" target="_blank"> <Link href={tosLink} className="underline" target="_blank" data-testid="tos-link">
<Translated i18nKey="termsOfService" namespace="register" /> <Translated i18nKey="termsOfService" namespace="register" />
</Link> </Link>
</p> </p>
</div> </div>
</div> </div>
)} )}
{legal?.privacyPolicyLink && ( {privacyPolicyLink && (
<div className="mt-4 flex items-center"> <div className="mt-4 flex items-center">
<Checkbox <Checkbox
className="mr-4" className="mr-4"
@@ -100,7 +106,7 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {
<div className="mr-4 w-[28rem]"> <div className="mr-4 w-[28rem]">
<p className="text-text-light-500 dark:text-text-dark-500 text-sm"> <p className="text-text-light-500 dark:text-text-dark-500 text-sm">
<Link href={legal.privacyPolicyLink} className="underline" target="_blank"> <Link href={privacyPolicyLink} className="underline" target="_blank" data-testid="privacy-policy-link">
<Translated i18nKey="privacyPolicy" namespace="register" /> <Translated i18nKey="privacyPolicy" namespace="register" />
</Link> </Link>
</p> </p>
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { resolveLocalizedLegalLink } from "./legal-links";
describe("resolveLocalizedLegalLink", () => {
it("replaces the language placeholder with the active locale", () => {
expect(resolveLocalizedLegalLink("https://example.com/{{.Lang}}/terms", "de")).toBe("https://example.com/de/terms");
});
it("replaces every language placeholder in the link", () => {
expect(resolveLocalizedLegalLink("https://example.com/{{.Lang}}/docs?lang={{.Lang}}", "fr")).toBe(
"https://example.com/fr/docs?lang=fr",
);
});
it("returns the original link when no placeholder is present", () => {
expect(resolveLocalizedLegalLink("https://example.com/terms", "en")).toBe("https://example.com/terms");
});
it("returns the original link when locale is missing", () => {
expect(resolveLocalizedLegalLink("https://example.com/{{.Lang}}/terms", undefined)).toBe(
"https://example.com/{{.Lang}}/terms",
);
});
it("returns undefined when the link is missing", () => {
expect(resolveLocalizedLegalLink(undefined, "en")).toBeUndefined();
});
});
+9
View File
@@ -0,0 +1,9 @@
const LANGUAGE_PLACEHOLDER = "{{.Lang}}";
export function resolveLocalizedLegalLink(link: string | undefined, locale: string | undefined): string | undefined {
if (!link || !locale) {
return link;
}
return link.replaceAll(LANGUAGE_PLACEHOLDER, locale);
}