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
2 changes: 1 addition & 1 deletion packages/react/src/auth/screens/email-link-auth-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function EmailLinkAuthScreen({ children, onEmailSent }: EmailLinkAuthScre
{children ? (
<>
<Divider>{getTranslation(ui, "messages", "dividerOr")}</Divider>
<div className="space-y-4">{children}</div>
<div className="fui-screen__children">{children}</div>
</>
) : null}
</CardContent>
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/auth/screens/oauth-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function OAuthScreen({ children }: OAuthScreenProps) {
<CardTitle>{titleText}</CardTitle>
<CardSubtitle>{subtitleText}</CardSubtitle>
</CardHeader>
<CardContent>
<CardContent className="fui-screen__children">
{children}
<Policies />
</CardContent>
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/auth/screens/phone-auth-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function PhoneAuthScreen({ children, ...props }: PhoneAuthScreenProps) {
{children ? (
<>
<Divider>{getTranslation(ui, "messages", "dividerOr")}</Divider>
<div className="space-y-4">{children}</div>
<div className="fui-screen__children">{children}</div>
</>
) : null}
</CardContent>
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/auth/screens/sign-in-auth-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function SignInAuthScreen({ children, ...props }: SignInAuthScreenProps)
{children ? (
<>
<Divider>{getTranslation(ui, "messages", "dividerOr")}</Divider>
<div className="space-y-4">{children}</div>
<div className="fui-screen__children">{children}</div>
</>
) : null}
</CardContent>
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/auth/screens/sign-up-auth-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function SignUpAuthScreen({ children, ...props }: SignUpAuthScreenProps)
{children ? (
<>
<Divider>{getTranslation(ui, "messages", "dividerOr")}</Divider>
<div className="space-y-4">{children}</div>
<div className="fui-screen__children">{children}</div>
</>
) : null}
</CardContent>
Expand Down
114 changes: 104 additions & 10 deletions packages/react/src/components/policies.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
* limitations under the License.
*/

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, fireEvent, cleanup } from "@testing-library/react";
import { Policies } from "./policies";
import { FirebaseUIProvider } from "~/context";
import { createMockUI } from "~/tests/utils";
Expand All @@ -25,8 +25,12 @@ describe("<Policies />", () => {
vi.clearAllMocks();
});

it("renders component with terms and privacy links", () => {
render(
afterEach(() => {
cleanup();
});

it("renders component with terms and privacy links using anchor tags", () => {
const { container } = render(
<FirebaseUIProvider
ui={createMockUI()}
policies={{
Expand All @@ -39,27 +43,117 @@ describe("<Policies />", () => {
);

// Check that the text and links are rendered
expect(screen.getByText(/By continuing, you agree to our/)).toBeInTheDocument();
expect(container.querySelector(".fui-policies")).toBeInTheDocument();

const tosLink = screen.getByText("Terms of Service");
const tosLink = container.querySelector('a[href="https://example.com/terms"]');
expect(tosLink).toBeInTheDocument();
expect(tosLink.tagName).toBe("A");
expect(tosLink?.tagName).toBe("A");
expect(tosLink).toHaveAttribute("target", "_blank");
expect(tosLink).toHaveAttribute("rel", "noopener noreferrer");
expect(tosLink).toHaveTextContent("Terms of Service");

const privacyLink = screen.getByText("Privacy Policy");
const privacyLink = container.querySelector('a[href="https://example.com/privacy"]');
expect(privacyLink).toBeInTheDocument();
expect(privacyLink.tagName).toBe("A");
expect(privacyLink?.tagName).toBe("A");
expect(privacyLink).toHaveAttribute("target", "_blank");
expect(privacyLink).toHaveAttribute("rel", "noopener noreferrer");
expect(privacyLink).toHaveTextContent("Privacy Policy");
});

it("renders component with custom navigation handler using buttons", () => {
const mockNavigate = vi.fn();
const { container } = render(
<FirebaseUIProvider
ui={createMockUI()}
policies={{
termsOfServiceUrl: "https://example.com/terms",
privacyPolicyUrl: "https://example.com/privacy",
onNavigate: mockNavigate,
}}
>
<Policies />
</FirebaseUIProvider>
);

// Check that the text and buttons are rendered
expect(container.querySelector(".fui-policies")).toBeInTheDocument();

const tosButton = container.querySelector("button");
expect(tosButton).toBeInTheDocument();
expect(tosButton?.tagName).toBe("BUTTON");
expect(tosButton).not.toHaveAttribute("href");
expect(tosButton).not.toHaveAttribute("target");
expect(tosButton).toHaveTextContent("Terms of Service");

const privacyButton = container.querySelectorAll("button")[1];
expect(privacyButton).toBeInTheDocument();
expect(privacyButton?.tagName).toBe("BUTTON");
expect(privacyButton).not.toHaveAttribute("href");
expect(privacyButton).not.toHaveAttribute("target");
expect(privacyButton).toHaveTextContent("Privacy Policy");

fireEvent.click(tosButton!);
expect(mockNavigate).toHaveBeenCalledWith("https://example.com/terms");

fireEvent.click(privacyButton!);
expect(mockNavigate).toHaveBeenCalledWith("https://example.com/privacy");
});

it("handles URL objects correctly", () => {
const termsUrl = new URL("https://example.com/terms");
const privacyUrl = new URL("https://example.com/privacy");
const { container } = render(
<FirebaseUIProvider
ui={createMockUI()}
policies={{
termsOfServiceUrl: termsUrl,
privacyPolicyUrl: privacyUrl,
}}
>
<Policies />
</FirebaseUIProvider>
);

const tosLink = container.querySelector('a[href="https://example.com/terms"]');
expect(tosLink).toHaveAttribute("href", "https://example.com/terms");

const privacyLink = container.querySelector('a[href="https://example.com/privacy"]');
expect(privacyLink).toHaveAttribute("href", "https://example.com/privacy");
});

it("returns null when both tosUrl and privacyPolicyUrl are not provided", () => {
it("returns null when policies are not provided", () => {
const { container } = render(
<FirebaseUIProvider ui={createMockUI()} policies={undefined}>
<Policies />
</FirebaseUIProvider>
);
expect(container).toBeEmptyDOMElement();
});

it("handles custom navigation with URL objects", () => {
const mockNavigate = vi.fn();
const termsUrl = new URL("https://example.com/terms");
const privacyUrl = new URL("https://example.com/privacy");
const { container } = render(
<FirebaseUIProvider
ui={createMockUI()}
policies={{
termsOfServiceUrl: termsUrl,
privacyPolicyUrl: privacyUrl,
onNavigate: mockNavigate,
}}
>
<Policies />
</FirebaseUIProvider>
);

const tosButton = container.querySelector("button");
const privacyButton = container.querySelectorAll("button")[1];

fireEvent.click(tosButton!);
expect(mockNavigate).toHaveBeenCalledWith(termsUrl);

fireEvent.click(privacyButton!);
expect(mockNavigate).toHaveBeenCalledWith(privacyUrl);
});
});
77 changes: 21 additions & 56 deletions packages/react/src/components/policies.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,15 @@
*/

import { getTranslation } from "@firebase-ui/core";
import { createContext, useContext } from "react";
import { cloneElement, createContext, useContext } from "react";
import { useUI } from "~/hooks";

export type PolicyURL =
| string
| URL
| (() => string | URL | void)
| Promise<string | URL | void>
| (() => Promise<string | URL | void>);
export type PolicyURL = string | URL;

export interface PolicyProps {
termsOfServiceUrl: PolicyURL;
privacyPolicyUrl: PolicyURL;
onNavigate?: (url: PolicyURL) => void;
}

const PolicyContext = createContext<PolicyProps | undefined>(undefined);
Expand All @@ -44,64 +40,33 @@ export function Policies() {
return null;
}

const { termsOfServiceUrl, privacyPolicyUrl } = policies;

async function handleUrl(urlOrFunction: PolicyURL) {
let url: string | URL | void;

if (typeof urlOrFunction === "function") {
const urlOrPromise = urlOrFunction();
if (typeof urlOrPromise === "string" || urlOrPromise instanceof URL) {
url = urlOrPromise;
} else {
url = await urlOrPromise;
}
} else if (urlOrFunction instanceof Promise) {
url = await urlOrFunction;
} else {
url = urlOrFunction;
}

if (url) {
window.open(url.toString(), "_blank");
}
}

const termsText = getTranslation(ui, "labels", "termsOfService");
const privacyText = getTranslation(ui, "labels", "privacyPolicy");
const { termsOfServiceUrl, privacyPolicyUrl, onNavigate } = policies;
const termsAndPrivacyText = getTranslation(ui, "messages", "termsAndPrivacy");

const parts = termsAndPrivacyText.split(/(\{tos\}|\{privacy\})/);

const Handler = onNavigate ? <button /> : <a target="_blank" rel="noopener noreferrer" />;

return (
<div className="text-text-muted text-xs text-start">
<div className="fui-policies">
{parts.map((part: string, index: number) => {
if (part === "{tos}") {
return (
<a
key={index}
onClick={() => handleUrl(termsOfServiceUrl)}
target="_blank"
rel="noopener noreferrer"
className="text-text-muted hover:underline font-semibold"
>
{termsText}
</a>
);
return cloneElement(Handler, {
key: index,
onClick: onNavigate ? () => onNavigate(termsOfServiceUrl) : undefined,
href: onNavigate ? undefined : termsOfServiceUrl,
children: getTranslation(ui, "labels", "termsOfService"),
});
}

if (part === "{privacy}") {
return (
<a
key={index}
onClick={() => handleUrl(privacyPolicyUrl)}
target="_blank"
rel="noopener noreferrer"
className="text-text-muted hover:underline font-semibold"
>
{privacyText}
</a>
);
return cloneElement(Handler, {
key: index,
onClick: onNavigate ? () => onNavigate(privacyPolicyUrl) : undefined,
href: onNavigate ? undefined : privacyPolicyUrl,
children: getTranslation(ui, "labels", "privacyPolicy"),
});
}

return <span key={index}>{part}</span>;
})}
</div>
Expand Down
17 changes: 15 additions & 2 deletions packages/styles/src/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@
@apply pt-24 max-w-md mx-auto;
}

:where(.fui-screen .fui-screen__children) {
@apply space-y-2;
}

:where(.fui-card) {
@apply bg-background p-10 border border-border rounded-card space-y-6;
}
Expand Down Expand Up @@ -145,15 +149,15 @@
}

:where(.fui-divider) {
@apply flex items-center gap-3;
@apply flex items-center gap-3 my-4;
}

:where(.fui-divider__line) {
@apply flex-1 h-px bg-border;
}

:where(.fui-divider__text) {
@apply text-text-muted text-xs my-2;
@apply text-text-muted text-xs;
}

:where(.fui-phone-input) {
Expand Down Expand Up @@ -188,6 +192,15 @@
@apply absolute left-8 top-1/2 -translate-y-1/2 text-sm pointer-events-none text-text;
}

:where(.fui-policies) {
@apply text-text-muted text-center text-xs;
}

:where(.fui-policies a, .fui-policies button) {
@apply hover:underline font-semibold;
}


.fui-provider__button[data-provider="google.com"][data-themed="true"] {
--google-primary: #131314;
--color-primary: var(--google-primary);
Expand Down