Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/apple-profile-name-picture.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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(<LinkedProfilesScreen {...mockProps} />);
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(<LinkedProfilesScreen {...mockProps} />);
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(<LinkedProfilesScreen {...mockProps} />);
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(<LinkedProfilesScreen {...mockProps} />);
expect(screen.getByText("google@example.com")).toBeInTheDocument();
});
Comment on lines +130 to +173
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add an Apple-specific fallback test case.

The fallback scenarios here only validate google. Add one for apple with missing/blank details.name to lock the compliance-critical branch and catch "Apple" default regressions.

Suggested test addition
+    it("should fall back to email when apple profile.details.name is missing", () => {
+      vi.mocked(useProfiles).mockReturnValue({
+        data: [
+          {
+            details: { email: "apple@example.com", name: undefined },
+            type: "apple",
+          },
+        ],
+        isLoading: false,
+        // biome-ignore lint/suspicious/noExplicitAny: Mocking data
+      } as any);
+
+      render(<LinkedProfilesScreen {...mockProps} />);
+      expect(screen.getByText("apple@example.com")).toBeInTheDocument();
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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(<LinkedProfilesScreen {...mockProps} />);
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(<LinkedProfilesScreen {...mockProps} />);
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(<LinkedProfilesScreen {...mockProps} />);
expect(screen.getByText("google@example.com")).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(<LinkedProfilesScreen {...mockProps} />);
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(<LinkedProfilesScreen {...mockProps} />);
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(<LinkedProfilesScreen {...mockProps} />);
expect(screen.getByText("google@example.com")).toBeInTheDocument();
});
it("should fall back to email when apple profile.details.name is missing", () => {
vi.mocked(useProfiles).mockReturnValue({
data: [
{
details: { email: "apple@example.com", name: undefined },
type: "apple",
},
],
isLoading: false,
// biome-ignore lint/suspicious/noExplicitAny: Mocking data
} as any);
render(<LinkedProfilesScreen {...mockProps} />);
expect(screen.getByText("apple@example.com")).toBeInTheDocument();
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.test.tsx`
around lines 130 - 173, Add a new unit test in LinkedProfilesScreen.test.tsx
that mirrors the existing fallback cases but uses type: "apple" to ensure
Apple-specific fallback behavior is covered: mock useProfiles to return a
profile object with details.email set (e.g., "apple@example.com") and
details.name either empty string, whitespace, or undefined, render
<LinkedProfilesScreen {...mockProps} /> and assert
screen.getByText("apple@example.com") is in the document; use the same mocking
pattern and test naming style as the existing tests so the new case (for type
"apple") locks the compliance-critical branch.


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(<LinkedProfilesScreen {...mockProps} />);
expect(screen.getByText("John Doe")).toBeInTheDocument();
});

it("should not display guest profiles", () => {
vi.mocked(useProfiles).mockReturnValue({
data: [{ details: {}, type: "guest" }],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines 33 to +44
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add Apple email fallback when provider name is missing.

If profile.details.name is absent, Apple profiles currently fall through to "Apple" even when profile.details.email exists. That leaves legacy/incomplete Apple records without a usable display identifier.

Suggested fix
   switch (true) {
     case profile.type === "email" && profile.details.email !== undefined:
       return profile.details.email as string;
     case profile.type === "google" && profile.details.email !== undefined:
       return profile.details.email as string;
+    case profile.type === "apple" && profile.details.email !== undefined:
+      return profile.details.email as string;
     case profile.type === "phone" && profile.details.phone !== undefined:
       return profile.details.phone as string;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.tsx`
around lines 33 - 44, Add a new case in the switch(true) block in
LinkedProfilesScreen.tsx that returns profile.details.email when the profile is
an Apple profile with an email but no display name; specifically, add a case
that checks profile.details.email !== undefined && (profile.type === "apple" ||
profile.details.name === undefined && (profile.type as string) === "apple") (or
simply profile.type === "apple" && profile.details.email !== undefined) and
return profile.details.email as string so legacy/incomplete Apple records use
the email as the display identifier.

case (profile.type as string).toLowerCase() === "custom_auth_endpoint":
return "Custom Profile";
default:
Expand Down Expand Up @@ -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
<Img
client={client}
height={iconSize.lg}
loading="eager"
src={profile.details.picture}
style={{
borderRadius: "100%",
}}
width={iconSize.lg}
/>
) : profile.details.address !== undefined ? (
<Container
style={{
Expand Down Expand Up @@ -190,6 +208,12 @@ function LinkedProfile({
}}
>
<Text color="primaryText">
{/*
* 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)}
</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};

Expand Down