diff --git a/examples/react/src/firebase/config.ts b/examples/react/src/firebase/config.ts
index 2d95ca07..90abb862 100644
--- a/examples/react/src/firebase/config.ts
+++ b/examples/react/src/firebase/config.ts
@@ -15,5 +15,10 @@
*/
export const firebaseConfig = {
- // your Firebase config here
+ apiKey: "AIzaSyCvMftIUCD9lUQ3BzIrimfSfBbCUQYZf-I",
+ authDomain: "fir-ui-rework.firebaseapp.com",
+ projectId: "fir-ui-rework",
+ storageBucket: "fir-ui-rework.firebasestorage.app",
+ messagingSenderId: "200312857118",
+ appId: "1:200312857118:web:94e3f69b0e0a4a863f040f",
};
diff --git a/examples/react/src/index.css b/examples/react/src/index.css
index d2a6e9fa..f265e7fc 100644
--- a/examples/react/src/index.css
+++ b/examples/react/src/index.css
@@ -18,4 +18,4 @@
@import "@firebase-ui/styles/tailwind";
/* @import "@firebase-ui/styles/src/themes/dark.css"; */
-/* @import "@firebase-ui/styles/src/themes/brutalist.css"; */
+/* @import "@firebase-ui/styles/src/themes/brutalist.css"; */
\ No newline at end of file
diff --git a/examples/react/src/screens/oauth-screen.tsx b/examples/react/src/screens/oauth-screen.tsx
index 662ccecd..dc57aeb6 100644
--- a/examples/react/src/screens/oauth-screen.tsx
+++ b/examples/react/src/screens/oauth-screen.tsx
@@ -16,12 +16,25 @@
"use client";
-import { GoogleSignInButton, OAuthScreen } from "@firebase-ui/react";
+import {
+ FacebookSignInButton,
+ AppleSignInButton,
+ GitHubSignInButton,
+ GoogleSignInButton,
+ MicrosoftSignInButton,
+ OAuthScreen,
+ TwitterSignInButton,
+} from "@firebase-ui/react";
export default function OAuthScreenPage() {
return (
-
+
+
+
+
+
+
);
}
diff --git a/examples/react/src/screens/sign-in-auth-screen-w-oauth.tsx b/examples/react/src/screens/sign-in-auth-screen-w-oauth.tsx
index d557867a..42e5c213 100644
--- a/examples/react/src/screens/sign-in-auth-screen-w-oauth.tsx
+++ b/examples/react/src/screens/sign-in-auth-screen-w-oauth.tsx
@@ -16,7 +16,15 @@
"use client";
-import { GoogleSignInButton, SignInAuthScreen } from "@firebase-ui/react";
+import {
+ AppleSignInButton,
+ GoogleSignInButton,
+ SignInAuthScreen,
+ FacebookSignInButton,
+ GitHubSignInButton,
+ MicrosoftSignInButton,
+ TwitterSignInButton,
+} from "@firebase-ui/react";
import { useNavigate } from "react-router";
export default function SignInAuthScreenWithOAuthPage() {
@@ -27,7 +35,14 @@ export default function SignInAuthScreenWithOAuthPage() {
onForgotPasswordClick={() => navigate("/password-reset-screen")}
onRegisterClick={() => navigate("/sign-up-auth-screen")}
>
-
+
);
}
diff --git a/packages/core/brands/apple/logo.svg b/packages/core/brands/apple/logo.svg
new file mode 100644
index 00000000..f08dbc70
--- /dev/null
+++ b/packages/core/brands/apple/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/brands/facebook/logo.svg b/packages/core/brands/facebook/logo.svg
new file mode 100644
index 00000000..6dbfa09e
--- /dev/null
+++ b/packages/core/brands/facebook/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/brands/github/logo.svg b/packages/core/brands/github/logo.svg
new file mode 100644
index 00000000..6d487e64
--- /dev/null
+++ b/packages/core/brands/github/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/brands/google/logo.svg b/packages/core/brands/google/logo.svg
new file mode 100644
index 00000000..c0669b38
--- /dev/null
+++ b/packages/core/brands/google/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/brands/line/logo.svg b/packages/core/brands/line/logo.svg
new file mode 100644
index 00000000..cc69bb5f
--- /dev/null
+++ b/packages/core/brands/line/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/brands/microsoft/logo.svg b/packages/core/brands/microsoft/logo.svg
new file mode 100644
index 00000000..23a77fb5
--- /dev/null
+++ b/packages/core/brands/microsoft/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/brands/snapchat/logo.svg b/packages/core/brands/snapchat/logo.svg
new file mode 100644
index 00000000..04cd82e2
--- /dev/null
+++ b/packages/core/brands/snapchat/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/brands/twitter/logo.svg b/packages/core/brands/twitter/logo.svg
new file mode 100644
index 00000000..a21afdb4
--- /dev/null
+++ b/packages/core/brands/twitter/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/core/package.json b/packages/core/package.json
index 3312a1c7..77be4369 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -11,10 +11,12 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
- }
+ },
+ "./brands/*": "./brands/*"
},
"files": [
- "dist"
+ "dist",
+ "brands"
],
"scripts": {
"prepare": "pnpm run build",
diff --git a/packages/react/package.json b/packages/react/package.json
index 1955caa5..8908f2d3 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -17,8 +17,9 @@
],
"scripts": {
"prepare": "pnpm run build",
- "build": "tsup",
+ "build": "tsup && pnpm run build:logos",
"build:local": "pnpm run build && pnpm pack",
+ "build:logos": "pnpm dlx @svgr/cli --icon --typescript --no-index --jsx-runtime automatic --out-dir src/components/logos ../core/brands",
"dev": "tsup --watch",
"lint": "eslint . --ext .ts,.tsx",
"lint:fix": "eslint . --ext .ts,.tsx --fix",
@@ -58,13 +59,14 @@
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "catalog:",
"firebase": "catalog:",
- "nanostores": "catalog:",
"jsdom": "catalog:",
+ "nanostores": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"tsup": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
+ "vite-plugin-svgr": "^4.5.0",
"vitest": "catalog:"
}
}
diff --git a/packages/react/src/auth/index.ts b/packages/react/src/auth/index.ts
index 19df3de6..d3b32cbe 100644
--- a/packages/react/src/auth/index.ts
+++ b/packages/react/src/auth/index.ts
@@ -53,5 +53,14 @@ export { PhoneAuthScreen, type PhoneAuthScreenProps } from "./screens/phone-auth
export { SignInAuthScreen, type SignInAuthScreenProps } from "./screens/sign-in-auth-screen";
export { SignUpAuthScreen, type SignUpAuthScreenProps } from "./screens/sign-up-auth-screen";
-export { GoogleSignInButton, GoogleIcon, type GoogleSignInButtonProps } from "./oauth/google-sign-in-button";
+export { AppleSignInButton, AppleLogo, type AppleSignInButtonProps } from "./oauth/apple-sign-in-button";
+export { FacebookSignInButton, FacebookLogo, type FacebookSignInButtonProps } from "./oauth/facebook-sign-in-button";
+export { GitHubSignInButton, GitHubLogo, type GitHubSignInButtonProps } from "./oauth/github-sign-in-button";
+export { GoogleSignInButton, GoogleLogo, type GoogleSignInButtonProps } from "./oauth/google-sign-in-button";
+export {
+ MicrosoftSignInButton,
+ MicrosoftLogo,
+ type MicrosoftSignInButtonProps,
+} from "./oauth/microsoft-sign-in-button";
+export { TwitterSignInButton, TwitterLogo, type TwitterSignInButtonProps } from "./oauth/twitter-sign-in-button";
export { OAuthButton, type OAuthButtonProps } from "./oauth/oauth-button";
diff --git a/packages/react/src/auth/oauth/apple-sign-in-button.test.tsx b/packages/react/src/auth/oauth/apple-sign-in-button.test.tsx
new file mode 100644
index 00000000..8119cf82
--- /dev/null
+++ b/packages/react/src/auth/oauth/apple-sign-in-button.test.tsx
@@ -0,0 +1,186 @@
+/**
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
+import { render, screen, cleanup } from "@testing-library/react";
+import { AppleLogo, AppleSignInButton } from "./apple-sign-in-button";
+import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils";
+import { registerLocale } from "@firebase-ui/translations";
+import { OAuthProvider } from "firebase/auth";
+
+vi.mock("firebase/auth", () => ({
+ OAuthProvider: class OAuthProvider {
+ constructor(providerId: string) {
+ this.providerId = providerId;
+ }
+ providerId: string;
+ },
+}));
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders with the correct provider", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithApple: "Sign in with Apple",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).toBeDefined();
+ expect(button.getAttribute("data-provider")).toBe("apple.com");
+ });
+
+ it("renders with custom provider when provided", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithApple: "Sign in with Apple",
+ },
+ }),
+ });
+
+ const customProvider = new OAuthProvider("custom.apple.com");
+
+ render(
+
+
+
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).toBeDefined();
+ expect(button.getAttribute("data-provider")).toBe("custom.apple.com");
+ });
+
+ it("renders with the Apple icon", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithApple: "Sign in with Apple",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ const svg = document.querySelector(".fui-provider__icon");
+ expect(svg).toBeDefined();
+ expect(svg).toHaveClass("fui-provider__icon");
+ expect(svg?.tagName.toLowerCase()).toBe("svg");
+ });
+
+ it("renders with the correct translated text", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithApple: "Sign in with Apple",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Sign in with Apple")).toBeDefined();
+ });
+
+ it("renders with different translated text for different locales", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithApple: "Iniciar sesión con Apple",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Iniciar sesión con Apple")).toBeDefined();
+ });
+
+ it("renders as a button with correct classes", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithApple: "Sign in with Apple",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).toHaveClass("fui-provider__button");
+ expect(button.getAttribute("type")).toBe("button");
+ });
+});
+
+describe("", () => {
+ it("renders as an SVG element", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+
+ expect(svg).toBeDefined();
+ expect(svg?.tagName.toLowerCase()).toBe("svg");
+ });
+
+ it("has the correct CSS class", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+
+ expect(svg).toHaveClass("fui-provider__icon");
+ });
+
+ it("forwards custom SVG props", () => {
+ const { container } = render();
+ const svg = container.querySelector('svg[data-testid="custom-svg"]');
+
+ expect(svg).toBeDefined();
+ expect(svg!.getAttribute("width")).toBe("32");
+ expect(svg).toHaveClass("fui-provider__icon");
+ expect(svg).toHaveClass("foo");
+ });
+});
diff --git a/packages/react/src/auth/oauth/apple-sign-in-button.tsx b/packages/react/src/auth/oauth/apple-sign-in-button.tsx
new file mode 100644
index 00000000..1320b04c
--- /dev/null
+++ b/packages/react/src/auth/oauth/apple-sign-in-button.tsx
@@ -0,0 +1,44 @@
+/**
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+"use client";
+
+import { getTranslation } from "@firebase-ui/core";
+import { OAuthProvider } from "firebase/auth";
+import { useUI } from "~/hooks";
+import { OAuthButton } from "./oauth-button";
+import AppleSvgLogo from "~/components/logos/apple/Logo";
+import { cn } from "~/utils/cn";
+
+export type AppleSignInButtonProps = {
+ provider?: OAuthProvider;
+ themed?: boolean;
+};
+
+export function AppleSignInButton({ provider, themed }: AppleSignInButtonProps) {
+ const ui = useUI();
+
+ return (
+
+
+ {getTranslation(ui, "labels", "signInWithApple")}
+
+ );
+}
+
+export function AppleLogo({ className, ...props }: React.SVGProps) {
+ return ;
+}
diff --git a/packages/react/src/auth/oauth/facebook-sign-in-button.test.tsx b/packages/react/src/auth/oauth/facebook-sign-in-button.test.tsx
new file mode 100644
index 00000000..d3c94fbe
--- /dev/null
+++ b/packages/react/src/auth/oauth/facebook-sign-in-button.test.tsx
@@ -0,0 +1,187 @@
+/**
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
+import { render, screen, cleanup } from "@testing-library/react";
+import { FacebookLogo, FacebookSignInButton } from "./facebook-sign-in-button";
+import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils";
+import { registerLocale } from "@firebase-ui/translations";
+
+vi.mock("firebase/auth", () => ({
+ FacebookAuthProvider: class FacebookAuthProvider {
+ constructor() {
+ this.providerId = "facebook.com";
+ }
+ providerId: string;
+ },
+}));
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders with the correct provider", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithFacebook: "Sign in with Facebook",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).toBeDefined();
+ expect(button.getAttribute("data-provider")).toBe("facebook.com");
+ });
+
+ it("renders with custom provider when provided", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithFacebook: "Sign in with Facebook",
+ },
+ }),
+ });
+
+ const customProvider = new (class CustomFacebookProvider {
+ providerId = "custom.facebook.com";
+ })() as any;
+
+ render(
+
+
+
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).toBeDefined();
+ expect(button.getAttribute("data-provider")).toBe("custom.facebook.com");
+ });
+
+ it("renders with the Facebook icon", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithFacebook: "Sign in with Facebook",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ const svg = document.querySelector(".fui-provider__icon");
+ expect(svg).toBeDefined();
+ expect(svg).toHaveClass("fui-provider__icon");
+ expect(svg?.tagName.toLowerCase()).toBe("svg");
+ });
+
+ it("renders with the correct translated text", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithFacebook: "Sign in with Facebook",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Sign in with Facebook")).toBeDefined();
+ });
+
+ it("renders with different translated text for different locales", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithFacebook: "Iniciar sesión con Facebook",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Iniciar sesión con Facebook")).toBeDefined();
+ });
+
+ it("renders as a button with correct classes", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithFacebook: "Sign in with Facebook",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).toHaveClass("fui-provider__button");
+ expect(button.getAttribute("type")).toBe("button");
+ });
+});
+
+describe("", () => {
+ it("renders as an SVG element", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+
+ expect(svg).toBeDefined();
+ expect(svg?.tagName.toLowerCase()).toBe("svg");
+ });
+
+ it("has the correct CSS class", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+
+ expect(svg).toHaveClass("fui-provider__icon");
+ });
+
+ it("forwards custom SVG props", () => {
+ const { container } = render();
+ const svg = container.querySelector('svg[data-testid="custom-svg"]');
+
+ expect(svg).toBeDefined();
+ expect(svg!.getAttribute("width")).toBe("32");
+ expect(svg).toHaveClass("fui-provider__icon");
+ expect(svg).toHaveClass("foo");
+ });
+});
diff --git a/packages/react/src/auth/oauth/facebook-sign-in-button.tsx b/packages/react/src/auth/oauth/facebook-sign-in-button.tsx
new file mode 100644
index 00000000..3065e33e
--- /dev/null
+++ b/packages/react/src/auth/oauth/facebook-sign-in-button.tsx
@@ -0,0 +1,44 @@
+/**
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+"use client";
+
+import { getTranslation } from "@firebase-ui/core";
+import { FacebookAuthProvider } from "firebase/auth";
+import { useUI } from "~/hooks";
+import { OAuthButton } from "./oauth-button";
+import FacebookSvgLogo from "~/components/logos/facebook/Logo";
+import { cn } from "~/utils/cn";
+
+export type FacebookSignInButtonProps = {
+ provider?: FacebookAuthProvider;
+ themed?: boolean;
+};
+
+export function FacebookSignInButton({ provider, themed }: FacebookSignInButtonProps) {
+ const ui = useUI();
+
+ return (
+
+
+ {getTranslation(ui, "labels", "signInWithFacebook")}
+
+ );
+}
+
+export function FacebookLogo({ className, ...props }: React.SVGProps) {
+ return ;
+}
diff --git a/packages/react/src/auth/oauth/github-sign-in-button.test.tsx b/packages/react/src/auth/oauth/github-sign-in-button.test.tsx
new file mode 100644
index 00000000..11352246
--- /dev/null
+++ b/packages/react/src/auth/oauth/github-sign-in-button.test.tsx
@@ -0,0 +1,187 @@
+/**
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
+import { render, screen, cleanup } from "@testing-library/react";
+import { GitHubLogo, GitHubSignInButton } from "./github-sign-in-button";
+import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils";
+import { registerLocale } from "@firebase-ui/translations";
+
+vi.mock("firebase/auth", () => ({
+ GithubAuthProvider: class GithubAuthProvider {
+ constructor() {
+ this.providerId = "github.com";
+ }
+ providerId: string;
+ },
+}));
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders with the correct provider", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithGitHub: "Sign in with GitHub",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).toBeDefined();
+ expect(button.getAttribute("data-provider")).toBe("github.com");
+ });
+
+ it("renders with custom provider when provided", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithGitHub: "Sign in with GitHub",
+ },
+ }),
+ });
+
+ const customProvider = new (class CustomGitHubProvider {
+ providerId = "custom.github.com";
+ })() as any;
+
+ render(
+
+
+
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).toBeDefined();
+ expect(button.getAttribute("data-provider")).toBe("custom.github.com");
+ });
+
+ it("renders with the GitHub icon", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithGitHub: "Sign in with GitHub",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ const svg = document.querySelector(".fui-provider__icon");
+ expect(svg).toBeDefined();
+ expect(svg).toHaveClass("fui-provider__icon");
+ expect(svg?.tagName.toLowerCase()).toBe("svg");
+ });
+
+ it("renders with the correct translated text", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithGitHub: "Sign in with GitHub",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Sign in with GitHub")).toBeDefined();
+ });
+
+ it("renders with different translated text for different locales", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithGitHub: "Iniciar sesión con GitHub",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Iniciar sesión con GitHub")).toBeDefined();
+ });
+
+ it("renders as a button with correct classes", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithGitHub: "Sign in with GitHub",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).toHaveClass("fui-provider__button");
+ expect(button.getAttribute("type")).toBe("button");
+ });
+});
+
+describe("", () => {
+ it("renders as an SVG element", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+
+ expect(svg).toBeDefined();
+ expect(svg?.tagName.toLowerCase()).toBe("svg");
+ });
+
+ it("has the correct CSS class", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+
+ expect(svg).toHaveClass("fui-provider__icon");
+ });
+
+ it("forwards custom SVG props", () => {
+ const { container } = render();
+ const svg = container.querySelector('svg[data-testid="custom-svg"]');
+
+ expect(svg).toBeDefined();
+ expect(svg!.getAttribute("width")).toBe("32");
+ expect(svg).toHaveClass("fui-provider__icon");
+ expect(svg).toHaveClass("foo");
+ });
+});
diff --git a/packages/react/src/auth/oauth/github-sign-in-button.tsx b/packages/react/src/auth/oauth/github-sign-in-button.tsx
new file mode 100644
index 00000000..0603f1d0
--- /dev/null
+++ b/packages/react/src/auth/oauth/github-sign-in-button.tsx
@@ -0,0 +1,44 @@
+/**
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+"use client";
+
+import { getTranslation } from "@firebase-ui/core";
+import { GithubAuthProvider } from "firebase/auth";
+import { useUI } from "~/hooks";
+import { OAuthButton } from "./oauth-button";
+import GitHubSvgLogo from "~/components/logos/github/Logo";
+import { cn } from "~/utils/cn";
+
+export type GitHubSignInButtonProps = {
+ provider?: GithubAuthProvider;
+ themed?: boolean;
+};
+
+export function GitHubSignInButton({ provider, themed }: GitHubSignInButtonProps) {
+ const ui = useUI();
+
+ return (
+
+
+ {getTranslation(ui, "labels", "signInWithGitHub")}
+
+ );
+}
+
+export function GitHubLogo({ className, ...props }: React.SVGProps) {
+ return ;
+}
diff --git a/packages/react/src/auth/oauth/google-sign-in-button.test.tsx b/packages/react/src/auth/oauth/google-sign-in-button.test.tsx
index bf90018d..12adcf70 100644
--- a/packages/react/src/auth/oauth/google-sign-in-button.test.tsx
+++ b/packages/react/src/auth/oauth/google-sign-in-button.test.tsx
@@ -15,26 +15,16 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
-import { GoogleIcon, GoogleSignInButton } from "./google-sign-in-button";
+import { GoogleLogo, GoogleSignInButton } from "./google-sign-in-button";
import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils";
import { registerLocale } from "@firebase-ui/translations";
-import { ComponentProps } from "react";
-
-// Mock the OAuthButton component
-vi.mock("./oauth-button", () => ({
- OAuthButton: ({ children, provider }: ComponentProps<"div"> & { provider: any }) => (
-
- {children}
-
- ),
-}));
-// Mock the GoogleAuthProvider
vi.mock("firebase/auth", () => ({
GoogleAuthProvider: class GoogleAuthProvider {
constructor() {
- // Empty constructor
+ this.providerId = "google.com";
}
+ providerId: string;
},
}));
@@ -62,9 +52,9 @@ describe("", () => {
);
- const oauthButton = screen.getByTestId("oauth-button");
- expect(oauthButton).toBeDefined();
- expect(oauthButton.getAttribute("data-provider")).toBe("GoogleAuthProvider");
+ const button = screen.getByRole("button");
+ expect(button).toBeDefined();
+ expect(button.getAttribute("data-provider")).toBe("google.com");
});
it("renders with custom provider when provided", () => {
@@ -77,20 +67,18 @@ describe("", () => {
});
const customProvider = new (class CustomGoogleProvider {
- constructor() {
- // Empty constructor
- }
- })();
+ providerId = "custom.google.com";
+ })() as any;
render(
-
+
);
- const oauthButton = screen.getByTestId("oauth-button");
- expect(oauthButton).toBeDefined();
- expect(oauthButton.getAttribute("data-provider")).toBe("CustomGoogleProvider");
+ const button = screen.getByRole("button");
+ expect(button).toBeDefined();
+ expect(button.getAttribute("data-provider")).toBe("custom.google.com");
});
it("renders with the Google icon", () => {
@@ -150,7 +138,7 @@ describe("", () => {
expect(screen.getByText("Iniciar sesión con Google")).toBeDefined();
});
- it("passes children to OAuthButton", () => {
+ it("renders as a button with correct classes", () => {
const ui = createMockUI({
locale: registerLocale("test", {
labels: {
@@ -165,21 +153,15 @@ describe("", () => {
);
- const oauthButton = screen.getByTestId("oauth-button");
- expect(oauthButton).toBeDefined();
-
- const svg = oauthButton.querySelector(".fui-provider__icon");
- const text = oauthButton.querySelector("span");
-
- expect(svg).toBeDefined();
- expect(text).toBeDefined();
- expect(text?.textContent).toBe("Sign in with Google");
+ const button = screen.getByRole("button");
+ expect(button).toHaveClass("fui-provider__button");
+ expect(button.getAttribute("type")).toBe("button");
});
});
-describe("", () => {
+describe("", () => {
it("renders as an SVG element", () => {
- const { container } = render();
+ const { container } = render();
const svg = container.querySelector("svg");
expect(svg).toBeDefined();
@@ -187,16 +169,26 @@ describe("", () => {
});
it("has the correct CSS class", () => {
- const { container } = render();
+ const { container } = render();
const svg = container.querySelector("svg");
expect(svg).toHaveClass("fui-provider__icon");
});
it("has the correct viewBox attribute", () => {
- const { container } = render();
+ const { container } = render();
const svg = container.querySelector("svg");
expect(svg?.getAttribute("viewBox")).toBe("0 0 48 48");
});
+
+ it("forwards custom SVG props", () => {
+ const { container } = render();
+ const svg = container.querySelector('svg[data-testid="custom-svg"]');
+
+ expect(svg).toBeDefined();
+ expect(svg!.getAttribute("width")).toBe("32");
+ expect(svg).toHaveClass("fui-provider__icon");
+ expect(svg).toHaveClass("foo");
+ });
});
diff --git a/packages/react/src/auth/oauth/google-sign-in-button.tsx b/packages/react/src/auth/oauth/google-sign-in-button.tsx
index 551a416b..45c03f21 100644
--- a/packages/react/src/auth/oauth/google-sign-in-button.tsx
+++ b/packages/react/src/auth/oauth/google-sign-in-button.tsx
@@ -20,41 +20,25 @@ import { getTranslation } from "@firebase-ui/core";
import { GoogleAuthProvider } from "firebase/auth";
import { useUI } from "~/hooks";
import { OAuthButton } from "./oauth-button";
+import GoogleSvgLogo from "~/components/logos/google/Logo";
+import { cn } from "~/utils/cn";
export type GoogleSignInButtonProps = {
provider?: GoogleAuthProvider;
+ themed?: boolean | "neutral";
};
-export function GoogleSignInButton({ provider }: GoogleSignInButtonProps) {
+export function GoogleSignInButton({ provider, themed }: GoogleSignInButtonProps) {
const ui = useUI();
return (
-
-
+
+
{getTranslation(ui, "labels", "signInWithGoogle")}
);
}
-export function GoogleIcon() {
- return (
-
- );
+export function GoogleLogo({ className, ...props }: React.SVGProps) {
+ return ;
}
diff --git a/packages/react/src/auth/oauth/microsoft-sign-in-button.test.tsx b/packages/react/src/auth/oauth/microsoft-sign-in-button.test.tsx
new file mode 100644
index 00000000..78239903
--- /dev/null
+++ b/packages/react/src/auth/oauth/microsoft-sign-in-button.test.tsx
@@ -0,0 +1,186 @@
+/**
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
+import { render, screen, cleanup } from "@testing-library/react";
+import { MicrosoftLogo, MicrosoftSignInButton } from "./microsoft-sign-in-button";
+import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils";
+import { registerLocale } from "@firebase-ui/translations";
+import { OAuthProvider } from "firebase/auth";
+
+vi.mock("firebase/auth", () => ({
+ OAuthProvider: class OAuthProvider {
+ constructor(providerId: string) {
+ this.providerId = providerId;
+ }
+ providerId: string;
+ },
+}));
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders with the correct provider", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithMicrosoft: "Sign in with Microsoft",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).toBeDefined();
+ expect(button.getAttribute("data-provider")).toBe("microsoft.com");
+ });
+
+ it("renders with custom provider when provided", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithMicrosoft: "Sign in with Microsoft",
+ },
+ }),
+ });
+
+ const customProvider = new OAuthProvider("custom.microsoft.com");
+
+ render(
+
+
+
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).toBeDefined();
+ expect(button.getAttribute("data-provider")).toBe("custom.microsoft.com");
+ });
+
+ it("renders with the Microsoft icon", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithMicrosoft: "Sign in with Microsoft",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ const svg = document.querySelector(".fui-provider__icon");
+ expect(svg).toBeDefined();
+ expect(svg).toHaveClass("fui-provider__icon");
+ expect(svg?.tagName.toLowerCase()).toBe("svg");
+ });
+
+ it("renders with the correct translated text", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithMicrosoft: "Sign in with Microsoft",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Sign in with Microsoft")).toBeDefined();
+ });
+
+ it("renders with different translated text for different locales", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithMicrosoft: "Iniciar sesión con Microsoft",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Iniciar sesión con Microsoft")).toBeDefined();
+ });
+
+ it("renders as a button with correct classes", () => {
+ const ui = createMockUI({
+ locale: registerLocale("test", {
+ labels: {
+ signInWithMicrosoft: "Sign in with Microsoft",
+ },
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).toHaveClass("fui-provider__button");
+ expect(button.getAttribute("type")).toBe("button");
+ });
+});
+
+describe("", () => {
+ it("renders as an SVG element", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+
+ expect(svg).toBeDefined();
+ expect(svg?.tagName.toLowerCase()).toBe("svg");
+ });
+
+ it("has the correct CSS class", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+
+ expect(svg).toHaveClass("fui-provider__icon");
+ });
+
+ it("forwards custom SVG props", () => {
+ const { container } = render();
+ const svg = container.querySelector('svg[data-testid="custom-svg"]');
+
+ expect(svg).toBeDefined();
+ expect(svg!.getAttribute("width")).toBe("32");
+ expect(svg).toHaveClass("fui-provider__icon");
+ expect(svg).toHaveClass("foo");
+ });
+});
diff --git a/packages/react/src/auth/oauth/microsoft-sign-in-button.tsx b/packages/react/src/auth/oauth/microsoft-sign-in-button.tsx
new file mode 100644
index 00000000..7bae44d7
--- /dev/null
+++ b/packages/react/src/auth/oauth/microsoft-sign-in-button.tsx
@@ -0,0 +1,44 @@
+/**
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+"use client";
+
+import { getTranslation } from "@firebase-ui/core";
+import { OAuthProvider } from "firebase/auth";
+import { useUI } from "~/hooks";
+import { OAuthButton } from "./oauth-button";
+import MicrosoftSvgLogo from "~/components/logos/microsoft/Logo";
+import { cn } from "~/utils/cn";
+
+export type MicrosoftSignInButtonProps = {
+ provider?: OAuthProvider;
+ themed?: boolean;
+};
+
+export function MicrosoftSignInButton({ provider, themed }: MicrosoftSignInButtonProps) {
+ const ui = useUI();
+
+ return (
+
+
+ {getTranslation(ui, "labels", "signInWithMicrosoft")}
+
+ );
+}
+
+export function MicrosoftLogo({ className, ...props }: React.SVGProps) {
+ return ;
+}
diff --git a/packages/react/src/auth/oauth/oauth-button.test.tsx b/packages/react/src/auth/oauth/oauth-button.test.tsx
index 4e697623..ad698797 100644
--- a/packages/react/src/auth/oauth/oauth-button.test.tsx
+++ b/packages/react/src/auth/oauth/oauth-button.test.tsx
@@ -17,33 +17,18 @@ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { OAuthButton } from "./oauth-button";
import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils";
-import { registerLocale } from "@firebase-ui/translations";
-import type { AuthProvider } from "firebase/auth";
+import { enUs, registerLocale } from "@firebase-ui/translations";
+import type { AuthProvider, UserCredential } from "firebase/auth";
import { ComponentProps } from "react";
import { signInWithProvider } from "@firebase-ui/core";
+import { FirebaseError } from "firebase/app";
vi.mock("@firebase-ui/core", async (importOriginal) => {
const mod = await importOriginal();
return {
...(mod as object),
signInWithProvider: vi.fn(),
- // TODO: This will need updating when core lands
- FirebaseUIError: class FirebaseUIError extends Error {
- code: string;
- constructor(error: any, _ui: any) {
- const errorCode = error?.code || "unknown";
- const message =
- errorCode === "auth/user-not-found"
- ? "No account found with this email address"
- : errorCode === "auth/wrong-password"
- ? "The password is invalid or the user does not have a password"
- : "An unexpected error occurred";
- super(message);
- this.name = "FirebaseUIError";
- this.code = errorCode;
- }
- },
};
});
@@ -123,7 +108,6 @@ describe("", () => {
it("calls signInWithProvider when clicked", async () => {
const mockSignInWithProvider = vi.mocked(signInWithProvider);
- mockSignInWithProvider.mockResolvedValue(undefined);
const ui = createMockUI();
@@ -144,7 +128,10 @@ describe("", () => {
const { FirebaseUIError } = await import("@firebase-ui/core");
const mockSignInWithProvider = vi.mocked(signInWithProvider);
const ui = createMockUI();
- const mockError = new FirebaseUIError({ code: "auth/user-not-found" }, ui.get());
+ const mockError = new FirebaseUIError(
+ ui.get(),
+ new FirebaseError("auth/user-not-found", "No account found with this email address")
+ );
mockSignInWithProvider.mockRejectedValue(mockError);
render(
@@ -156,10 +143,9 @@ describe("", () => {
const button = screen.getByTestId("oauth-button");
fireEvent.click(button);
- // Wait for error to appear
+ // Next tick - wait for the mock to resolve
await new Promise((resolve) => setTimeout(resolve, 0));
- // The error message will be the translated message for auth/user-not-found
const errorMessage = screen.getByText("No account found with this email address");
expect(errorMessage).toBeDefined();
expect(errorMessage.className).toContain("fui-form__error");
@@ -210,8 +196,8 @@ describe("", () => {
// First call fails, second call succeeds
mockSignInWithProvider
- .mockRejectedValueOnce(new FirebaseUIError({ code: "auth/wrong-password" }, ui.get()))
- .mockResolvedValueOnce(undefined);
+ .mockRejectedValueOnce(new FirebaseUIError(ui.get(), new FirebaseError("auth/wrong-password", "...")))
+ .mockResolvedValueOnce({} as UserCredential);
render(
@@ -225,14 +211,16 @@ describe("", () => {
fireEvent.click(button);
await new Promise((resolve) => setTimeout(resolve, 0));
+ const expectedError = enUs.translations.errors!.wrongPassword!;
+
// The error message will be the translated message for auth/wrong-password
- const errorMessage = screen.getByText("The password is invalid or the user does not have a password");
+ const errorMessage = screen.getByText(expectedError);
expect(errorMessage).toBeDefined();
// Second click - should clear error
fireEvent.click(button);
await new Promise((resolve) => setTimeout(resolve, 0));
- expect(screen.queryByText("The password is invalid or the user does not have a password")).toBeNull();
+ expect(screen.queryByText(expectedError)).toBeNull();
});
});
diff --git a/packages/react/src/auth/oauth/oauth-button.tsx b/packages/react/src/auth/oauth/oauth-button.tsx
index 39dfd4f8..c4c5df72 100644
--- a/packages/react/src/auth/oauth/oauth-button.tsx
+++ b/packages/react/src/auth/oauth/oauth-button.tsx
@@ -25,9 +25,10 @@ import { useUI } from "~/hooks";
export type OAuthButtonProps = PropsWithChildren<{
provider: AuthProvider;
+ themed?: boolean | string;
}>;
-export function OAuthButton({ provider, children }: OAuthButtonProps) {
+export function OAuthButton({ provider, children, themed }: OAuthButtonProps) {
const ui = useUI();
const [error, setError] = useState(null);
@@ -48,7 +49,14 @@ export function OAuthButton({ provider, children }: OAuthButtonProps) {
return (
-