From f4a6598b5f68cbe48686de3a8ac56c6fd27c805a Mon Sep 17 00:00:00 2001 From: KazanderDad <98373366+KazanderDad@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:38:32 -0500 Subject: [PATCH 1/3] feat(frontend): support wallet profile onboarding --- frontend/__tests__/app-header.test.tsx | 25 +- frontend/__tests__/auth-provider.test.tsx | 51 ++- frontend/__tests__/home-page.test.tsx | 25 +- frontend/__tests__/new-user-page.test.tsx | 86 +++- frontend/__tests__/profile-page.test.tsx | 90 +++-- frontend/__tests__/profile.test.ts | 244 +++++++---- frontend/__tests__/results-page.test.tsx | 30 +- frontend/__tests__/scan-camera-page.test.tsx | 40 +- frontend/__tests__/scan-qr-page.test.tsx | 42 +- frontend/src/app/circle/page.tsx | 13 +- frontend/src/app/new-user/page.tsx | 105 +++-- frontend/src/app/page.tsx | 17 +- frontend/src/app/profile/page.tsx | 352 +++++++++++----- frontend/src/app/scan/camera/page.tsx | 17 +- frontend/src/app/scan/my-qr/page.tsx | 20 +- frontend/src/app/signin/page.tsx | 6 +- frontend/src/app/vouch/page.tsx | 5 +- frontend/src/components/AppHeader.tsx | 24 +- frontend/src/components/AuthProvider.tsx | 28 +- .../src/components/UserSessionSummary.tsx | 35 +- frontend/src/lib/onboarding.ts | 43 +- frontend/src/lib/profile.ts | 210 ++++++++-- frontend/src/lib/store.ts | 87 +++- ...0_rebuild_identity_schema_from_scratch.sql | 381 ++++++++++++++++++ 24 files changed, 1575 insertions(+), 401 deletions(-) create mode 100644 supabase/migrations/20251110000100_rebuild_identity_schema_from_scratch.sql diff --git a/frontend/__tests__/app-header.test.tsx b/frontend/__tests__/app-header.test.tsx index 5d06556..ced9834 100644 --- a/frontend/__tests__/app-header.test.tsx +++ b/frontend/__tests__/app-header.test.tsx @@ -32,7 +32,30 @@ describe("AppHeader", () => { act(() => { useUserStore.setState({ session, - user: { user_id: "1", display_name: "Sky Trail", cubid_id: "sky", evm_address: "0x123" }, + parentProfile: { + id: "parent", + parent_profile_id: null, + display_name: null, + photo_url: null, + cubid_id: null, + locked_at: null, + created_at: "2025-01-01T00:00:00Z", + auth_user_id: "1", + email_address: "agent@example.com", + }, + walletProfiles: [ + { + id: "wallet-1", + parent_profile_id: "parent", + display_name: "Sky Trail", + photo_url: null, + cubid_id: "sky", + locked_at: null, + created_at: "2025-01-02T00:00:00Z", + wallet_address: "0x123", + }, + ], + activeWalletProfileId: "wallet-1", walletAddress: null, initialised: true, }); diff --git a/frontend/__tests__/auth-provider.test.tsx b/frontend/__tests__/auth-provider.test.tsx index c075e6f..2c40527 100644 --- a/frontend/__tests__/auth-provider.test.tsx +++ b/frontend/__tests__/auth-provider.test.tsx @@ -10,8 +10,8 @@ const { getSessionMock, onAuthStateChangeMock } = vi.hoisted(() => ({ onAuthStateChangeMock: vi.fn(), })); -const { fetchMyProfileMock } = vi.hoisted(() => ({ - fetchMyProfileMock: vi.fn(), +const { fetchMyProfilesMock } = vi.hoisted(() => ({ + fetchMyProfilesMock: vi.fn(), })); vi.mock("../src/lib/auth", () => ({ @@ -20,7 +20,7 @@ vi.mock("../src/lib/auth", () => ({ })); vi.mock("../src/lib/profile", () => ({ - fetchMyProfile: fetchMyProfileMock, + fetchMyProfiles: fetchMyProfilesMock, })); describe("AuthProvider", () => { @@ -35,10 +35,34 @@ describe("AuthProvider", () => { it("bootstraps the session and profile on mount", async () => { const session = { user: { id: "user-123", email: "user@example.com" } } as unknown as Session; - const profile = { user_id: "user-123", display_name: "Test User" }; + const bundle = { + parent: { + id: "parent-profile", + parent_profile_id: null, + display_name: null, + photo_url: null, + cubid_id: null, + locked_at: null, + created_at: "2025-01-01T00:00:00Z", + auth_user_id: "user-123", + email_address: "user@example.com", + }, + wallets: [ + { + id: "wallet-profile", + parent_profile_id: "parent-profile", + display_name: "Sky Trail", + photo_url: null, + cubid_id: "cubid_sky", + locked_at: null, + created_at: "2025-01-02T00:00:00Z", + wallet_address: "0x1234", + }, + ], + }; getSessionMock.mockResolvedValueOnce(session); - fetchMyProfileMock.mockResolvedValueOnce(profile); + fetchMyProfilesMock.mockResolvedValueOnce(bundle); let authCallback: (nextSession: Session | null) => void = () => undefined; onAuthStateChangeMock.mockImplementation((callback: typeof authCallback) => { @@ -55,17 +79,21 @@ describe("AuthProvider", () => { await waitFor(() => { expect(useUserStore.getState().session).toBe(session); }); - expect(useUserStore.getState().user).toEqual(profile); + expect(useUserStore.getState().parentProfile).toEqual(bundle.parent); + expect(useUserStore.getState().walletProfiles).toEqual(bundle.wallets); + expect(useUserStore.getState().activeWalletProfileId).toBe(bundle.wallets[0]?.id ?? null); expect(useUserStore.getState().initialised).toBe(true); - expect(fetchMyProfileMock).toHaveBeenCalledTimes(1); + expect(fetchMyProfilesMock).toHaveBeenCalledTimes(1); - fetchMyProfileMock.mockResolvedValueOnce(null); + fetchMyProfilesMock.mockResolvedValueOnce({ parent: null, wallets: [] }); authCallback(null); await waitFor(() => { expect(useUserStore.getState().session).toBeNull(); }); - expect(useUserStore.getState().user).toBeNull(); + expect(useUserStore.getState().parentProfile).toBeNull(); + expect(useUserStore.getState().walletProfiles).toEqual([]); + expect(useUserStore.getState().activeWalletProfileId).toBeNull(); unmount(); }); @@ -87,9 +115,10 @@ describe("AuthProvider", () => { }); expect(useUserStore.getState().session).toBeNull(); - expect(useUserStore.getState().user).toBeNull(); + expect(useUserStore.getState().parentProfile).toBeNull(); + expect(useUserStore.getState().walletProfiles).toEqual([]); expect(useUserStore.getState().initialised).toBe(true); - expect(fetchMyProfileMock).not.toHaveBeenCalled(); + expect(fetchMyProfilesMock).not.toHaveBeenCalled(); errorSpy.mockRestore(); }); diff --git a/frontend/__tests__/home-page.test.tsx b/frontend/__tests__/home-page.test.tsx index 180ef68..f2642eb 100644 --- a/frontend/__tests__/home-page.test.tsx +++ b/frontend/__tests__/home-page.test.tsx @@ -43,7 +43,30 @@ describe("Home page", () => { act(() => { useUserStore.setState({ session, - user: { user_id: "1", display_name: "Agent Maple", cubid_id: "maple", evm_address: "0x123" }, + parentProfile: { + id: "parent", + parent_profile_id: null, + display_name: null, + photo_url: null, + cubid_id: null, + locked_at: null, + created_at: "2025-01-01T00:00:00Z", + auth_user_id: "1", + email_address: "user@example.com", + }, + walletProfiles: [ + { + id: "wallet-1", + parent_profile_id: "parent", + display_name: "Agent Maple", + photo_url: null, + cubid_id: "maple", + locked_at: null, + created_at: "2025-01-02T00:00:00Z", + wallet_address: "0x123", + }, + ], + activeWalletProfileId: "wallet-1", walletAddress: null, initialised: true, }); diff --git a/frontend/__tests__/new-user-page.test.tsx b/frontend/__tests__/new-user-page.test.tsx index b62708f..267e85c 100644 --- a/frontend/__tests__/new-user-page.test.tsx +++ b/frontend/__tests__/new-user-page.test.tsx @@ -10,7 +10,8 @@ const { pushMock, requestCubidIdMock, ensureWalletMock, - upsertMyProfileMock, + createWalletProfileMock, + fetchMyProfilesMock, uploadMock, getPublicUrlMock, createObjectURLMock, @@ -19,7 +20,8 @@ const { pushMock: vi.fn(), requestCubidIdMock: vi.fn<[], Promise>(), ensureWalletMock: vi.fn<[], Promise>(), - upsertMyProfileMock: vi.fn(), + createWalletProfileMock: vi.fn(), + fetchMyProfilesMock: vi.fn(), uploadMock: vi.fn(), getPublicUrlMock: vi.fn(), createObjectURLMock: vi.fn(() => "blob:preview"), @@ -49,12 +51,14 @@ vi.mock("../src/lib/onboarding", () => ({ session: { user: { id: "user-1", email: "user@example.com" }, } as unknown as Session, - profile: { user_id: "user-1" }, + walletProfiles: [], + parentProfile: null, }), })); vi.mock("../src/lib/profile", () => ({ - upsertMyProfile: (...args: unknown[]) => upsertMyProfileMock(...args), + createWalletProfile: (...args: unknown[]) => createWalletProfileMock(...args), + fetchMyProfiles: (...args: unknown[]) => fetchMyProfilesMock(...args), })); vi.mock("../src/lib/wallet", () => ({ @@ -77,10 +81,56 @@ describe("NewUserPage", () => { pushMock.mockReset(); requestCubidIdMock.mockResolvedValue("cubid_testabcd"); ensureWalletMock.mockResolvedValue("0xwallet"); - upsertMyProfileMock.mockImplementation(async (payload) => ({ - user_id: "user-1", - ...payload, - })); + createWalletProfileMock.mockResolvedValue({ + parent: { + id: "parent", + parent_profile_id: null, + display_name: null, + photo_url: null, + cubid_id: null, + locked_at: null, + created_at: "2025-01-01T00:00:00Z", + auth_user_id: "user-1", + email_address: "user@example.com", + }, + wallets: [ + { + id: "wallet-1", + parent_profile_id: "parent", + display_name: "Maple Leaf", + photo_url: "https://supabase.test/avatar.png", + cubid_id: "cubid_testabcd", + locked_at: null, + created_at: "2025-01-02T00:00:00Z", + wallet_address: "0xwallet", + }, + ], + }); + fetchMyProfilesMock.mockResolvedValue({ + parent: { + id: "parent", + parent_profile_id: null, + display_name: null, + photo_url: null, + cubid_id: null, + locked_at: null, + created_at: "2025-01-01T00:00:00Z", + auth_user_id: "user-1", + email_address: "user@example.com", + }, + wallets: [ + { + id: "wallet-1", + parent_profile_id: "parent", + display_name: "Maple Leaf", + photo_url: "https://supabase.test/avatar.png", + cubid_id: "cubid_testabcd", + locked_at: null, + created_at: "2025-01-02T00:00:00Z", + wallet_address: "0xwallet", + }, + ], + }); uploadMock.mockResolvedValue({ error: null }); getPublicUrlMock.mockImplementation((path: string) => ({ data: { publicUrl: `https://supabase.test/${path}` }, @@ -116,7 +166,8 @@ describe("NewUserPage", () => { } else { delete globalUrl.revokeObjectURL; } - upsertMyProfileMock.mockReset(); + createWalletProfileMock.mockReset(); + fetchMyProfilesMock.mockReset(); requestCubidIdMock.mockReset(); ensureWalletMock.mockReset(); uploadMock.mockReset(); @@ -155,20 +206,21 @@ describe("NewUserPage", () => { await user.click(screen.getByRole("button", { name: /connect wallet/i })); await waitFor(() => expect(ensureWalletMock).toHaveBeenCalled()); - await waitFor(() => expect(upsertMyProfileMock).toHaveBeenCalledWith({ evm_address: "0xwallet" })); + await waitFor(() => + expect(createWalletProfileMock).toHaveBeenCalledWith({ + address: "0xwallet", + displayName: "Maple Leaf", + photoUrl: expect.stringContaining("https://supabase.test/"), + cubidId: "cubid_testabcd", + }), + ); const cubidInput = await screen.findByDisplayValue("cubid_testabcd"); expect(cubidInput).toHaveAttribute("readonly"); await user.click(screen.getByRole("button", { name: /finish/i })); - await waitFor(() => - expect(upsertMyProfileMock).toHaveBeenCalledWith({ - cubid_id: "cubid_testabcd", - display_name: "Maple Leaf", - photo_url: expect.stringContaining("https://supabase.test/"), - }), - ); + await waitFor(() => expect(fetchMyProfilesMock).toHaveBeenCalled()); await waitFor(() => expect(pushMock).toHaveBeenCalledWith("/circle")); }); }); diff --git a/frontend/__tests__/profile-page.test.tsx b/frontend/__tests__/profile-page.test.tsx index 7508c3f..afbfe1c 100644 --- a/frontend/__tests__/profile-page.test.tsx +++ b/frontend/__tests__/profile-page.test.tsx @@ -6,6 +6,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import ProfilePage from "../src/app/profile/page"; import { useUserStore } from "../src/lib/store"; +const { requestCubidIdMock } = vi.hoisted(() => ({ + requestCubidIdMock: vi.fn(() => new Promise(() => {})), +})); + +vi.mock("../src/lib/cubid", async () => { + const actual = await vi.importActual("../src/lib/cubid"); + return { + ...actual, + requestCubidId: requestCubidIdMock, + }; +}); + const { replaceMock } = vi.hoisted(() => ({ replaceMock: vi.fn(), })); @@ -19,6 +31,7 @@ vi.mock("next/navigation", () => ({ describe("ProfilePage", () => { beforeEach(() => { replaceMock.mockReset(); + requestCubidIdMock.mockClear(); act(() => { useUserStore.getState().reset(); }); @@ -31,13 +44,30 @@ describe("ProfilePage", () => { act(() => { useUserStore.setState({ session, - user: { - user_id: session.user.id, - cubid_id: "cubid_me", - display_name: "Maple Leaf", - photo_url: "https://example.com/photo.png", - evm_address: "0x123", + parentProfile: { + id: "parent", + parent_profile_id: null, + display_name: null, + photo_url: null, + cubid_id: null, + locked_at: null, + created_at: "2025-01-01T00:00:00Z", + auth_user_id: session.user.id, + email_address: session.user.email ?? null, }, + walletProfiles: [ + { + id: "wallet-1", + parent_profile_id: "parent", + display_name: "Maple Leaf", + photo_url: "https://example.com/photo.png", + cubid_id: "cubid_me", + locked_at: null, + created_at: "2025-01-02T00:00:00Z", + wallet_address: "0x123", + }, + ], + activeWalletProfileId: "wallet-1", walletAddress: null, initialised: true, }); @@ -50,37 +80,51 @@ describe("ProfilePage", () => { }); }); - it("shows an on-page preview of the profile", () => { - render(); + it("shows linked wallet profiles and account summary", async () => { + await act(async () => { + render(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); - expect(screen.getByText(/This name can be a nickname/i)).toBeInTheDocument(); - expect(screen.getByText(/Maple Leaf/)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /connect wallet/i })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /Wallet profiles/i })).toBeInTheDocument(); + expect(screen.getByText(/Linked wallets/i)).toBeInTheDocument(); - const previewHeading = screen.getByRole("heading", { name: /Preview for peers/i }); - const previewAside = previewHeading.closest("aside"); - expect(previewAside).not.toBeNull(); - if (previewAside) { - expect(within(previewAside).getByText(/Cubid ID: cubid_me/)).toBeInTheDocument(); + const walletCard = screen.getByText("Maple Leaf").closest("li"); + expect(walletCard).not.toBeNull(); + if (walletCard) { + expect(within(walletCard).getByText(/Cubid ID: cubid_me/i)).toBeInTheDocument(); + expect(within(walletCard).getByText(/Active/i)).toBeInTheDocument(); } - const previewImage = screen.getByAltText(/Profile photo preview/i) as HTMLImageElement; - expect(previewImage.src).toContain("https://example.com/photo.png"); + expect(screen.getByText(/Signed in as/i)).toBeInTheDocument(); + expect(screen.getByText(/user@example.com/)).toBeInTheDocument(); }); it("surfaces a warning when the photo URL cannot load", async () => { const user = userEvent.setup(); - render(); + await act(async () => { + render(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); const photoInput = screen.getByLabelText(/Photo URL/i); await user.clear(photoInput); await user.type(photoInput, "https://example.com/broken.png"); - const previewImage = screen.getByAltText(/Profile photo preview/i); - fireEvent.error(previewImage); + const previewImage = screen.getByAltText(/Wallet preview/i); + act(() => { + fireEvent.error(previewImage); + }); + + expect(screen.getByText(/Image error/i)).toBeInTheDocument(); - expect(screen.getByText(/We couldn’t load this image/i)).toBeInTheDocument(); - expect(screen.getByText("!")).toBeInTheDocument(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); }); }); diff --git a/frontend/__tests__/profile.test.ts b/frontend/__tests__/profile.test.ts index 6efb9a4..84f9b68 100644 --- a/frontend/__tests__/profile.test.ts +++ b/frontend/__tests__/profile.test.ts @@ -1,26 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fetchMyProfile, upsertMyProfile } from "../src/lib/profile"; -import type { UserProfile } from "../src/lib/store"; +import { createWalletProfile, fetchMyProfiles } from "../src/lib/profile"; -const { - getUserMock, - fromMock, - upsertMock, - upsertSelectMock, - upsertSingleMock, - selectMock, - selectEqMock, - maybeSingleMock, -} = vi.hoisted(() => ({ +const { getUserMock, fromMock, rpcMock, updateMock, updateEqMock, insertMock } = vi.hoisted(() => ({ getUserMock: vi.fn(), fromMock: vi.fn(), - upsertMock: vi.fn(), - upsertSelectMock: vi.fn(), - upsertSingleMock: vi.fn(), - selectMock: vi.fn(), - selectEqMock: vi.fn(), - maybeSingleMock: vi.fn(), + rpcMock: vi.fn(), + updateMock: vi.fn(), + updateEqMock: vi.fn(), + insertMock: vi.fn(), })); vi.mock("../src/lib/supabaseClient", () => ({ @@ -29,6 +17,7 @@ vi.mock("../src/lib/supabaseClient", () => ({ getUser: getUserMock, }, from: fromMock, + rpc: rpcMock, }), })); @@ -36,75 +25,178 @@ describe("profile service", () => { beforeEach(() => { getUserMock.mockReset(); fromMock.mockReset(); - upsertMock.mockReset(); - upsertSelectMock.mockReset(); - upsertSingleMock.mockReset(); - selectMock.mockReset(); - selectEqMock.mockReset(); - maybeSingleMock.mockReset(); - - fromMock.mockReturnValue({ - upsert: upsertMock, - select: selectMock, - }); - - upsertMock.mockReturnValue({ - select: upsertSelectMock, - }); + rpcMock.mockReset(); + updateMock.mockReset(); + updateEqMock.mockReset(); + insertMock.mockReset(); + }); - upsertSelectMock.mockReturnValue({ - single: upsertSingleMock, - }); + it("fetches parent and wallet profiles", async () => { + const userId = "user-1"; + const parentRow = { + id: "parent-profile", + auth_user_id: userId, + parent_profile_id: null, + display_name: null, + photo_url: null, + locked_at: null, + created_at: "2025-01-01T00:00:00Z", + cubid_id: null, + email_address: "user@example.com", + wallet_address: null, + }; + const walletRow = { + id: "wallet-profile", + auth_user_id: null, + parent_profile_id: "parent-profile", + display_name: "Casey", + photo_url: "https://example.com/avatar.png", + locked_at: null, + created_at: "2025-01-02T00:00:00Z", + cubid_id: "cubid_casey", + email_address: null, + wallet_address: "0x1234", + }; - selectMock.mockReturnValue({ - eq: selectEqMock, + getUserMock.mockResolvedValue({ data: { user: { id: userId } }, error: null }); + + let call = 0; + fromMock.mockImplementation((table: string) => { + if (table !== "profiles_enriched") { + throw new Error(`Unexpected table ${table}`); + } + if (call === 0) { + call++; + return { + select: () => ({ + eq: () => ({ + is: () => ({ maybeSingle: () => Promise.resolve({ data: parentRow, error: null }) }), + }), + }), + }; + } + if (call === 1) { + call++; + return { + select: () => ({ + eq: () => ({ + order: () => Promise.resolve({ data: [walletRow], error: null }), + }), + }), + }; + } + throw new Error("Unexpected call count"); }); - selectEqMock.mockReturnValue({ - maybeSingle: maybeSingleMock, + const result = await fetchMyProfiles(); + + expect(result.parent).toEqual({ + id: parentRow.id, + parent_profile_id: null, + display_name: null, + photo_url: null, + cubid_id: null, + locked_at: null, + created_at: parentRow.created_at, + auth_user_id: userId, + email_address: parentRow.email_address, }); + expect(result.wallets).toEqual([ + { + id: walletRow.id, + parent_profile_id: walletRow.parent_profile_id, + display_name: walletRow.display_name, + photo_url: walletRow.photo_url, + cubid_id: walletRow.cubid_id, + locked_at: walletRow.locked_at, + created_at: walletRow.created_at, + wallet_address: walletRow.wallet_address, + }, + ]); }); - it("upserts the current user profile", async () => { - const profile: UserProfile = { - user_id: "user-1", + it("creates a wallet profile and returns the refreshed bundle", async () => { + const userId = "user-1"; + const parentRow = { + id: "parent-profile", + auth_user_id: userId, + parent_profile_id: null, + display_name: null, + photo_url: null, + locked_at: null, + created_at: "2025-01-01T00:00:00Z", + cubid_id: null, + email_address: "user@example.com", + wallet_address: null, + }; + const walletRow = { + id: "wallet-profile", + auth_user_id: null, + parent_profile_id: "parent-profile", display_name: "Casey", + photo_url: "https://example.com/avatar.png", + locked_at: null, + created_at: "2025-01-02T00:00:00Z", + cubid_id: "cubid_casey", + email_address: null, + wallet_address: "0x1234", }; - getUserMock.mockResolvedValue({ data: { user: { id: profile.user_id } }, error: null }); - upsertSingleMock.mockResolvedValue({ data: profile, error: null }); - - const result = await upsertMyProfile({ display_name: "Casey" }); - - expect(fromMock).toHaveBeenCalledWith("users"); - expect(upsertMock).toHaveBeenCalledWith({ user_id: profile.user_id, display_name: "Casey" }, { onConflict: "user_id" }); - expect(result).toEqual(profile); - }); - - it("throws when no Supabase session is present", async () => { - getUserMock.mockResolvedValue({ data: { user: null }, error: null }); - - await expect(upsertMyProfile({ display_name: "Casey" })).rejects.toThrow("No Supabase session"); - }); - - it("returns the profile when it exists", async () => { - getUserMock.mockResolvedValue({ data: { user: { id: "user-1" } }, error: null }); - maybeSingleMock.mockResolvedValue({ data: { user_id: "user-1", cubid_id: "cubid" }, error: null }); - - const result = await fetchMyProfile(); - - expect(fromMock).toHaveBeenCalledWith("users"); - expect(selectMock).toHaveBeenCalledWith("*"); - expect(selectEqMock).toHaveBeenCalledWith("user_id", "user-1"); - expect(result).toEqual({ user_id: "user-1", cubid_id: "cubid" }); - }); - - it("returns null when no profile row exists", async () => { - getUserMock.mockResolvedValue({ data: { user: { id: "user-1" } }, error: null }); - maybeSingleMock.mockResolvedValue({ data: null, error: { code: "PGRST116" } }); + getUserMock.mockResolvedValue({ data: { user: { id: userId } }, error: null }); + + rpcMock.mockResolvedValue({ data: { id: walletRow.id }, error: null }); + + fromMock.mockImplementation((table: string) => { + if (table === "profiles") { + return { + update: updateMock.mockReturnValue({ eq: updateEqMock.mockResolvedValue({ error: null }) }), + }; + } + if (table === "profiles_cubid") { + return { + insert: insertMock.mockResolvedValue({ error: null }), + }; + } + if (table === "profiles_enriched") { + return { + select: () => ({ + eq: (column: string) => { + if (column === "auth_user_id") { + return { + is: () => ({ maybeSingle: () => Promise.resolve({ data: parentRow, error: null }) }), + }; + } + if (column === "parent_profile_id") { + return { + order: () => Promise.resolve({ data: [walletRow], error: null }), + }; + } + throw new Error(`Unexpected column ${column}`); + }, + is: () => ({ maybeSingle: () => Promise.resolve({ data: null, error: null }) }), + }), + }; + } + throw new Error(`Unexpected table ${table}`); + }); - const result = await fetchMyProfile(); + const bundle = await createWalletProfile({ + address: walletRow.wallet_address!, + displayName: walletRow.display_name!, + photoUrl: walletRow.photo_url!, + cubidId: walletRow.cubid_id!, + }); - expect(result).toBeNull(); + expect(rpcMock).toHaveBeenCalledWith("create_profile_with_credential", { + auth_user: userId, + kind: "wallet", + value: walletRow.wallet_address, + }); + expect(updateMock).toHaveBeenCalledWith({ display_name: walletRow.display_name, photo_url: walletRow.photo_url }); + expect(insertMock).toHaveBeenCalledWith({ + cubid_id: walletRow.cubid_id, + profile_id: walletRow.id, + }); + expect(bundle.wallets[0]?.wallet_address).toBe(walletRow.wallet_address); }); }); diff --git a/frontend/__tests__/results-page.test.tsx b/frontend/__tests__/results-page.test.tsx index 87a89fb..428f521 100644 --- a/frontend/__tests__/results-page.test.tsx +++ b/frontend/__tests__/results-page.test.tsx @@ -20,16 +20,34 @@ describe("ResultsPage", () => { useUserStore.getState().reset(); }); - const session = { access_token: "token", user: { id: "user-1" } } as unknown as Session; + const session = { access_token: "token", user: { id: "user-1", email: "user@example.com" } } as unknown as Session; act(() => { useUserStore.setState({ session, - user: { - user_id: "user-1", - cubid_id: "cubid_me", - display_name: "Maple", - evm_address: "0x123", + parentProfile: { + id: "parent", + parent_profile_id: null, + display_name: null, + photo_url: null, + cubid_id: null, + locked_at: null, + created_at: "2025-01-01T00:00:00Z", + auth_user_id: "user-1", + email_address: "user@example.com", }, + walletProfiles: [ + { + id: "wallet-1", + parent_profile_id: "parent", + display_name: "Maple", + photo_url: "https://example.com/avatar.png", + cubid_id: "cubid_me", + locked_at: null, + created_at: "2025-01-02T00:00:00Z", + wallet_address: "0x123", + }, + ], + activeWalletProfileId: "wallet-1", walletAddress: null, initialised: true, }); diff --git a/frontend/__tests__/scan-camera-page.test.tsx b/frontend/__tests__/scan-camera-page.test.tsx index 81a927a..9d7b094 100644 --- a/frontend/__tests__/scan-camera-page.test.tsx +++ b/frontend/__tests__/scan-camera-page.test.tsx @@ -71,16 +71,36 @@ describe("CameraPage", () => { user: { id: "user-1", email: "user@example.com" }, } as unknown as Session; - useUserStore.setState({ - session, - user: { - user_id: session.user.id, - cubid_id: "cubid_me", - display_name: "Maple", - evm_address: "0xViewer", - }, - walletAddress: null, - initialised: true, + act(() => { + useUserStore.setState({ + session, + parentProfile: { + id: "parent", + parent_profile_id: null, + display_name: null, + photo_url: null, + cubid_id: null, + locked_at: null, + created_at: "2025-01-01T00:00:00Z", + auth_user_id: session.user.id, + email_address: session.user.email ?? null, + }, + walletProfiles: [ + { + id: "wallet-1", + parent_profile_id: "parent", + display_name: "Maple", + photo_url: "https://example.com/avatar.png", + cubid_id: "cubid_me", + locked_at: null, + created_at: "2025-01-02T00:00:00Z", + wallet_address: "0xViewer", + }, + ], + activeWalletProfileId: "wallet-1", + walletAddress: null, + initialised: true, + }); }); getUserMediaMock.mockResolvedValue({ diff --git a/frontend/__tests__/scan-qr-page.test.tsx b/frontend/__tests__/scan-qr-page.test.tsx index 4b1d36b..1447ada 100644 --- a/frontend/__tests__/scan-qr-page.test.tsx +++ b/frontend/__tests__/scan-qr-page.test.tsx @@ -42,17 +42,37 @@ describe("MyQrPage", () => { useScanStore.getState().reset(); }); - const session = { access_token: "token", user: { id: "user-1" } } as unknown as Session; - useUserStore.setState({ - session, - user: { - user_id: "user-1", - cubid_id: "cubid_me", - display_name: "Casey Rivers", - evm_address: "0x123", - }, - walletAddress: null, - initialised: true, + const session = { access_token: "token", user: { id: "user-1", email: "user@example.com" } } as unknown as Session; + act(() => { + useUserStore.setState({ + session, + parentProfile: { + id: "parent", + parent_profile_id: null, + display_name: null, + photo_url: null, + cubid_id: null, + locked_at: null, + created_at: "2025-01-01T00:00:00Z", + auth_user_id: "user-1", + email_address: "user@example.com", + }, + walletProfiles: [ + { + id: "wallet-1", + parent_profile_id: "parent", + display_name: "Casey Rivers", + photo_url: "https://example.com/avatar.png", + cubid_id: "cubid_me", + locked_at: null, + created_at: "2025-01-02T00:00:00Z", + wallet_address: "0x123", + }, + ], + activeWalletProfileId: "wallet-1", + walletAddress: null, + initialised: true, + }); }); }); diff --git a/frontend/src/app/circle/page.tsx b/frontend/src/app/circle/page.tsx index a082485..3739254 100644 --- a/frontend/src/app/circle/page.tsx +++ b/frontend/src/app/circle/page.tsx @@ -15,8 +15,9 @@ function formatFreshness(seconds: number): string { } export default function CirclePage() { - const { profile, ready } = useRequireCompletedOnboarding(); - const walletAddress = useUserStore((state) => state.walletAddress ?? state.user?.evm_address ?? null); + const { activeWalletProfile, ready } = useRequireCompletedOnboarding(); + const connectedWalletAddress = useUserStore((state) => state.walletAddress); + const walletAddress = connectedWalletAddress ?? activeWalletProfile?.wallet_address ?? null; const [data, setData] = useState(null); const [status, setStatus] = useState(null); @@ -27,8 +28,8 @@ export default function CirclePage() { setData(null); return; } - const cubidId = profile?.cubid_id ?? null; - const issuer = walletAddress ?? profile?.evm_address ?? null; + const cubidId = activeWalletProfile?.cubid_id ?? null; + const issuer = walletAddress ?? null; if (!cubidId) { setData(null); setStatus(null); @@ -64,7 +65,7 @@ export default function CirclePage() { return () => { cancelled = true; }; - }, [profile?.cubid_id, profile?.evm_address, ready, walletAddress]); + }, [activeWalletProfile?.cubid_id, ready, walletAddress]); const groupedByCircle = useMemo(() => { if (!data) { @@ -129,7 +130,7 @@ export default function CirclePage() {

Outbound ({data.outbound.length})

- Credentials you've issued as {walletAddress ?? profile?.evm_address} grouped by circle. + Credentials you've issued as {walletAddress ?? "your active wallet"} grouped by circle.

{groupedByCircle.length === 0 ? ( diff --git a/frontend/src/app/new-user/page.tsx b/frontend/src/app/new-user/page.tsx index 91c1dee..53a142f 100644 --- a/frontend/src/app/new-user/page.tsx +++ b/frontend/src/app/new-user/page.tsx @@ -5,7 +5,7 @@ import { type ChangeEvent, type FormEvent, useEffect, useMemo, useRef, useState import { isValidCubidId, requestCubidId } from "../../lib/cubid"; import { useRestrictToIncompleteOnboarding } from "../../lib/onboarding"; -import { upsertMyProfile } from "../../lib/profile"; +import { createWalletProfile, fetchMyProfiles } from "../../lib/profile"; import { useUserStore } from "../../lib/store"; import { getSupabaseClient } from "../../lib/supabaseClient"; import { ensureWallet } from "../../lib/wallet"; @@ -22,15 +22,22 @@ type OnboardingStep = 0 | 1 | 2; export default function NewUserPage() { const router = useRouter(); - const { session, profile, ready } = useRestrictToIncompleteOnboarding(); - const setUser = useUserStore((state) => state.setUser); + const { session, walletProfiles, ready } = useRestrictToIncompleteOnboarding(); + const setParentProfile = useUserStore((state) => state.setParentProfile); + const setWalletProfiles = useUserStore((state) => state.setWalletProfiles); + const setActiveWalletProfile = useUserStore((state) => state.setActiveWalletProfile); const setWalletAddress = useUserStore((state) => state.setWalletAddress); const walletAddress = useUserStore((state) => state.walletAddress); - const initialCubidId = useMemo(() => profile?.cubid_id ?? createRandomCubidId(), [profile?.cubid_id]); + const existingWalletProfile = walletProfiles[0] ?? null; + + const initialCubidId = useMemo( + () => existingWalletProfile?.cubid_id ?? createRandomCubidId(), + [existingWalletProfile?.cubid_id], + ); const [form, setForm] = useState({ - displayName: profile?.display_name ?? "", - photoUrl: profile?.photo_url ?? "", + displayName: existingWalletProfile?.display_name ?? "", + photoUrl: existingWalletProfile?.photo_url ?? "", cubidId: initialCubidId, }); const [step, setStep] = useState(0); @@ -40,14 +47,14 @@ export default function NewUserPage() { const [uploadingPhoto, setUploadingPhoto] = useState(false); const [photoFile, setPhotoFile] = useState(null); const [photoLink, setPhotoLink] = useState(""); - const [photoPreview, setPhotoPreview] = useState(profile?.photo_url ?? null); + const [photoPreview, setPhotoPreview] = useState(existingWalletProfile?.photo_url ?? null); const hasRequestedCubidId = useRef(false); - const latestProfileRef = useRef(profile); + const latestProfileRef = useRef(existingWalletProfile); const previewObjectUrl = useRef(null); useEffect(() => { - latestProfileRef.current = profile; - }, [profile]); + latestProfileRef.current = existingWalletProfile; + }, [existingWalletProfile]); function updatePhotoPreview(value: string | null, isObjectUrl: boolean) { if (previewObjectUrl.current) { @@ -62,14 +69,14 @@ export default function NewUserPage() { useEffect(() => { setForm((prev) => ({ - displayName: profile?.display_name ?? "", - photoUrl: profile?.photo_url ?? "", - cubidId: profile?.cubid_id ?? prev.cubidId ?? initialCubidId, + displayName: existingWalletProfile?.display_name ?? "", + photoUrl: existingWalletProfile?.photo_url ?? "", + cubidId: existingWalletProfile?.cubid_id ?? prev.cubidId ?? initialCubidId, })); - if (profile?.photo_url) { - updatePhotoPreview(profile.photo_url, false); + if (existingWalletProfile?.photo_url) { + updatePhotoPreview(existingWalletProfile.photo_url, false); } - }, [initialCubidId, profile?.cubid_id, profile?.display_name, profile?.photo_url]); + }, [existingWalletProfile?.cubid_id, existingWalletProfile?.display_name, existingWalletProfile?.photo_url, initialCubidId]); useEffect(() => { return () => { @@ -84,7 +91,7 @@ export default function NewUserPage() { if (!ready || !session?.user?.email) { return; } - if (profile?.cubid_id || hasRequestedCubidId.current) { + if (existingWalletProfile?.cubid_id || hasRequestedCubidId.current) { return; } @@ -101,7 +108,7 @@ export default function NewUserPage() { const message = err instanceof Error ? err.message : "Failed to generate Cubid ID"; setError(message); }); - }, [profile?.cubid_id, ready, session?.user?.email]); + }, [existingWalletProfile?.cubid_id, ready, session?.user?.email]); if (!ready) { return ( @@ -116,11 +123,44 @@ export default function NewUserPage() { setError(null); setStatus("Requesting wallet access…"); try { + if (!form.displayName.trim()) { + throw new Error("Add your name before connecting a wallet"); + } + if (!form.photoUrl) { + throw new Error("Upload or link a photo before connecting a wallet"); + } + if (!isValidCubidId(form.cubidId)) { + throw new Error("Cubid ID must match cubid_[a-z0-9]{4,32}"); + } const address = await ensureWallet(); - const updated = await upsertMyProfile({ evm_address: address }); - setUser(updated); + const lowerAddress = address.toLowerCase(); + const existing = walletProfiles.find( + (profile) => profile.wallet_address && profile.wallet_address.toLowerCase() === lowerAddress, + ); + if (existing) { + setWalletAddress(address); + setActiveWalletProfile(existing.id); + setStatus("Wallet reconnected"); + return; + } + + const bundle = await createWalletProfile({ + address, + displayName: form.displayName, + photoUrl: form.photoUrl, + cubidId: form.cubidId, + }); + setParentProfile(bundle.parent); + setWalletProfiles(bundle.wallets); + const newProfile = + bundle.wallets.find( + (profile) => profile.wallet_address && profile.wallet_address.toLowerCase() === lowerAddress, + ) ?? bundle.wallets[bundle.wallets.length - 1] ?? null; + if (newProfile) { + setActiveWalletProfile(newProfile.id); + } setWalletAddress(address); - setStatus("Wallet linked"); + setStatus("Wallet profile created"); } catch (err) { const message = err instanceof Error ? err.message : "Wallet connection failed"; setError(message); @@ -138,15 +178,16 @@ export default function NewUserPage() { setError("Cubid ID must match cubid_[a-z0-9]{4,32}"); return; } + if (!walletProfiles.length && !walletAddress) { + setError("Connect at least one wallet to finish onboarding"); + return; + } setSaving(true); - setStatus("Saving profile…"); + setStatus("Finishing onboarding…"); try { - const updated = await upsertMyProfile({ - cubid_id: form.cubidId, - display_name: form.displayName, - photo_url: form.photoUrl, - }); - setUser(updated); + const bundle = await fetchMyProfiles(); + setParentProfile(bundle.parent); + setWalletProfiles(bundle.wallets); setStatus("Profile saved"); router.push("/circle"); } catch (err) { @@ -257,7 +298,7 @@ export default function NewUserPage() {

Welcome to Trust Me Bro

- We'll gather a few details to build your profile: your name, a photo, and your wallet. + We'll gather a few details to build your profile: your name, a photo, and your wallet.

@@ -289,7 +330,7 @@ export default function NewUserPage() { {step === 0 ? (