diff --git a/.changeset/apple-profile-name-picture.md b/.changeset/apple-profile-name-picture.md new file mode 100644 index 00000000000..b34d4708507 --- /dev/null +++ b/.changeset/apple-profile-name-picture.md @@ -0,0 +1,5 @@ +--- +"thirdweb": minor +--- + +Add optional `name` and `picture` fields to `Profile` type to support additional OAuth provider data (Google, Apple, etc.). The UI now displays the user's name when available from OAuth providers, improving Apple Sign In compliance. diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.test.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.test.tsx index d62053b8fe2..d46020e8fc9 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.test.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.test.tsx @@ -110,6 +110,84 @@ describe("LinkedProfilesScreen", () => { expect(screen.getByText("Unknown")).toBeInTheDocument(); }); + it("should display name from profile.details.name when available", () => { + vi.mocked(useProfiles).mockReturnValue({ + data: [ + { + details: { email: "apple@example.com", name: "Jane Doe" }, + type: "apple", + }, + ], + isLoading: false, + // biome-ignore lint/suspicious/noExplicitAny: Mocking data + } as any); + + render(); + expect(screen.getByText("Jane Doe")).toBeInTheDocument(); + expect(screen.queryByText("apple@example.com")).not.toBeInTheDocument(); + }); + + it("should fall back to email when profile.details.name is an empty string", () => { + vi.mocked(useProfiles).mockReturnValue({ + data: [ + { details: { email: "apple@example.com", name: "" }, type: "google" }, + ], + isLoading: false, + // biome-ignore lint/suspicious/noExplicitAny: Mocking data + } as any); + + render(); + expect(screen.getByText("apple@example.com")).toBeInTheDocument(); + }); + + it("should fall back to email when profile.details.name is whitespace only", () => { + vi.mocked(useProfiles).mockReturnValue({ + data: [ + { + details: { email: "google@example.com", name: " " }, + type: "google", + }, + ], + isLoading: false, + // biome-ignore lint/suspicious/noExplicitAny: Mocking data + } as any); + + render(); + expect(screen.getByText("google@example.com")).toBeInTheDocument(); + }); + + it("should fall back to email when profile.details.name is undefined", () => { + vi.mocked(useProfiles).mockReturnValue({ + data: [ + { + details: { email: "google@example.com", name: undefined }, + type: "google", + }, + ], + isLoading: false, + // biome-ignore lint/suspicious/noExplicitAny: Mocking data + } as any); + + render(); + expect(screen.getByText("google@example.com")).toBeInTheDocument(); + }); + + it("should display trimmed name when profile.details.name has leading/trailing spaces", () => { + vi.mocked(useProfiles).mockReturnValue({ + data: [ + { + details: { email: "apple@example.com", name: " John Doe " }, + type: "apple", + }, + ], + isLoading: false, + // biome-ignore lint/suspicious/noExplicitAny: Mocking data + } as any); + + render(); + expect(screen.getByText("John Doe")).toBeInTheDocument(); + }); + it("should not display guest profiles", () => { vi.mocked(useProfiles).mockReturnValue({ data: [{ details: {}, type: "guest" }], diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.tsx index b2b1eaf0e3d..83b1190ac82 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.tsx @@ -9,33 +9,39 @@ import { useSocialProfiles } from "../../../../core/social/useSocialProfiles.js" import { getSocialIcon } from "../../../../core/utils/walletIcon.js"; import { useProfiles } from "../../../hooks/wallets/useProfiles.js"; import { LoadingScreen } from "../../../wallets/shared/LoadingScreen.js"; -import { Container, Line, ModalHeader } from "../../components/basic.js"; -import { IconButton } from "../../components/buttons.js"; import { Img } from "../../components/Img.js"; import { Spacer } from "../../components/Spacer.js"; +import { Container, Line, ModalHeader } from "../../components/basic.js"; +import { IconButton } from "../../components/buttons.js"; import { Text } from "../../components/text.js"; import { Blobbie } from "../Blobbie.js"; +import { MenuButton } from "../MenuButton.js"; import { AddUserIcon } from "../icons/AddUserIcon.js"; import { EmailIcon } from "../icons/EmailIcon.js"; import { FingerPrintIcon } from "../icons/FingerPrintIcon.js"; import { PhoneIcon } from "../icons/PhoneIcon.js"; import type { ConnectLocale } from "../locale/types.js"; -import { MenuButton } from "../MenuButton.js"; import type { WalletDetailsModalScreen } from "./types.js"; -function getProfileDisplayName(profile: Profile) { +function getProfileDisplayName(profile: Profile): string { + // Prefer name if available (from OAuth providers like Google/Apple). + // Use .trim() to reject whitespace-only strings that some backends may return. + if (profile.details.name?.trim()) { + return profile.details.name.trim(); + } + switch (true) { case profile.type === "email" && profile.details.email !== undefined: - return profile.details.email; + return profile.details.email as string; case profile.type === "google" && profile.details.email !== undefined: - return profile.details.email; + return profile.details.email as string; case profile.type === "phone" && profile.details.phone !== undefined: - return profile.details.phone; + return profile.details.phone as string; case profile.details.address !== undefined: return shortenAddress(profile.details.address, 6); case (profile.type as string) === "cognito" && profile.details.email !== undefined: - return profile.details.email; + return profile.details.email as string; case (profile.type as string).toLowerCase() === "custom_auth_endpoint": return "Custom Profile"; default: @@ -154,6 +160,18 @@ function LinkedProfile({ }} width={iconSize.lg} /> + ) : profile.details.picture ? ( + // Fallback to OAuth provider picture (e.g. Google/Apple) when no social profile avatar is available + ) : profile.details.address !== undefined ? ( + {/* + * Name display priority: + * 1. socialProfiles name (real-time on-chain/social lookup) + * 2. profile.details.name (OAuth provider name, e.g. Google/Apple) + * 3. email / phone / shortened address / profile type (via getProfileDisplayName) + */} {socialProfiles?.find((p) => p.avatar)?.name || getProfileDisplayName(profile)} diff --git a/packages/thirdweb/src/wallets/in-app/core/authentication/types.ts b/packages/thirdweb/src/wallets/in-app/core/authentication/types.ts index f91eb56040e..58e35ff5654 100644 --- a/packages/thirdweb/src/wallets/in-app/core/authentication/types.ts +++ b/packages/thirdweb/src/wallets/in-app/core/authentication/types.ts @@ -124,6 +124,10 @@ export type Profile = { email?: string; phone?: string; address?: Address; + /** Full display name from OAuth provider (e.g. Google, Apple). Populated on first sign-in. */ + name?: string; + /** Profile picture URL from OAuth provider (e.g. Google, Apple). Populated on first sign-in. */ + picture?: string; }; };