mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
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:
co-authored by
Copilot
Max Peintner
Max Peintner
parent
b1c8781516
commit
942c959d09
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user