diff --git a/agent-context/deployment.md b/agent-context/deployment.md index ec4644f..8f9ce63 100644 --- a/agent-context/deployment.md +++ b/agent-context/deployment.md @@ -10,7 +10,7 @@ This playbook captures the end-to-end steps for publishing the peer-mapper stack - `SUPABASE_*` keys from the production project - `VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID` - `FLY_API_TOKEN` (or Render token if using Render) -3. Confirm Supabase has the production tables (`public.users`) and RLS policies applied. +3. Confirm Supabase has the production identity tables (`public.profiles`, `public.profile_credentials`, `public.profiles_cubid`) plus the helper RPC and RLS policies applied. 4. Update `addresses.json` with the last-known deployment artefacts after each broadcast. 5. Run the full quality gate locally: ```bash diff --git a/agent-context/functional-spec.md b/agent-context/functional-spec.md index f02d33a..fef97a6 100644 --- a/agent-context/functional-spec.md +++ b/agent-context/functional-spec.md @@ -18,8 +18,9 @@ The MVP focuses on human-to-human verification and displaying mutual trusted con ### 2.1 Authentication & Identity - As a **user**, I can request a Supabase **magic link** (email) to establish an authenticated session without leaving the app. -- As a **user**, once signed in I can populate my profile in `public.users` with a **Cubid ID**, **display name**, and optional **photo URL** that other features rely on. -- As a **user**, I can optionally connect an **EVM wallet (Nova Wallet)** to fund or sign attestations, and the linked address is stored with my Supabase profile. +- As a **user**, once signed in the system materialises an email-rooted parent profile in Supabase (`public.profiles`) keyed to my `auth_user_id`. +- As a **user**, I can link one or more wallets; each link collects my display name and photo, requests a Cubid ID, and calls `create_profile_with_credential` so Supabase mints an immutable wallet persona beneath my parent profile. +- As a **user**, I can review every wallet persona from the dashboard, with Cubid ID and avatar locked once the profile completes its first handshake. ### 2.2 Creating Attestations (Vouching) @@ -83,12 +84,13 @@ The MVP focuses on human-to-human verification and displaying mutual trusted con - Surfaces inline success/error states ("Check your inbox…", "Failed to send magic link"). 2. **Onboarding (`/(routes)/new-user`)** - - Guides the user through display name + photo capture, Cubid ID generation (`requestCubidId`), and Nova/EVM wallet linking (`ensureWallet`). - - Writes updates via `upsertMyProfile`, showing status banners for generation, wallet linking, and profile persistence. + - Guides the user through display name + photo capture, pre-generates a Cubid ID (`requestCubidId`), and requests wallet access through `ensureWallet`. + - Calls `createWalletProfile` after wallet approval so Supabase can mint the child profile, store the wallet credential, append the Cubid history, and hydrate the store with the resulting bundle. + - Surfaces status banners for Cubid generation, wallet linking, duplicate-address detection, and Supabase persistence before redirecting to the circle view. 3. **Profile (`/(routes)/profile`)** - - Displays read-only Cubid ID + wallet, allows editing display name/photo, and persists changes with `upsertMyProfile`. - - Redirects back to sign-in when no Supabase session is present. + - Shows the parent email summary alongside a read-only grid of wallet personas (name, avatar, Cubid ID, wallet address) with a form to link additional wallets via the same `createWalletProfile` helper. + - Disables edits after a profile is locked, provides a refresh button to re-fetch Supabase state, and redirects back to sign-in when no session is present. 4. **My Circle (`/(routes)/circle`)** - Fetches inbound/outbound attestations through `getProfile(cubidId, { issuer })`, formats freshness (seconds → minutes/hours/days), and lets users query arbitrary Cubid IDs. @@ -130,15 +132,15 @@ The MVP focuses on human-to-human verification and displaying mutual trusted con ## 5. External Interactions -| Component | Purpose | Interaction | -| -------------------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| **Supabase Auth** | Session management & profile storage | Next.js uses `@supabase/supabase-js` for magic link auth and profile CRUD; indexer verifies JWTs using `SUPABASE_JWT_SECRET`. | -| **Cubid SDK** | Authentication & issuance of Cubid IDs | App still invokes Cubid flows post-login to obtain Cubid IDs for attestations. | -| **EAS (Ethereum Attestation Service)** | On-chain attestation registry | FeeGate relays attestations to EAS contracts. | -| **FeeGate Resolver** | Validation & spam control | Enforces per-issuer nonce and fee threshold. | -| **Moonbeam RPC** | Blockchain backend | Transactions and event subscriptions. | -| **Indexer (Node.js)** | Off-chain aggregator | Subscribes to EAS events; exposes REST API. | -| **Frontend (Next.js)** | User interface | Fetches and posts to indexer API. | +| Component | Purpose | Interaction | +| -------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Supabase Auth** | Session management & profile storage | Next.js uses `@supabase/supabase-js` for magic link auth, the `create_profile_with_credential` RPC, and `profiles_enriched` reads; indexer verifies JWTs using `SUPABASE_JWT_SECRET`. | +| **Cubid SDK** | Authentication & issuance of Cubid IDs | App still invokes Cubid flows post-login to obtain Cubid IDs for attestations. | +| **EAS (Ethereum Attestation Service)** | On-chain attestation registry | FeeGate relays attestations to EAS contracts. | +| **FeeGate Resolver** | Validation & spam control | Enforces per-issuer nonce and fee threshold. | +| **Moonbeam RPC** | Blockchain backend | Transactions and event subscriptions. | +| **Indexer (Node.js)** | Off-chain aggregator | Subscribes to EAS events; exposes REST API. | +| **Frontend (Next.js)** | User interface | Fetches and posts to indexer API. | --- @@ -189,7 +191,7 @@ The MVP focuses on human-to-human verification and displaying mutual trusted con ## 10. Deliverables Summary - Working web app on Vercel (Next.js) -- Supabase project linked with `public.users` migration and environment wiring +- Supabase project linked with the multi-profile Supabase schema (`public.profiles`, `public.profile_credentials`, `public.profiles_cubid`) and environment wiring - Smart contracts (EAS fork + FeeGate) deployed on Moonbeam - Indexer API running publicly - Supabase magic-link authentication bridged with Cubid ID assignment diff --git a/agent-context/session-log.md b/agent-context/session-log.md index 5d151f2..5aee487 100644 --- a/agent-context/session-log.md +++ b/agent-context/session-log.md @@ -1,5 +1,9 @@ # session-log.md +session: v13 + +- 2025-11-10 — Session 10 (multi-profile identity refresh): Adopted the Supabase `public.profiles` + `profile_credentials` schema with wallet-scoped children and Cubid history, refreshed the frontend store/pages to manage parent plus wallet personas, regenerated vitest fixtures, and synced the technical + functional specs with the new onboarding and profile flows. + session: v12 - 2025-11-09 — Session 09 (deployment & CI/CD readiness): Generated the workspace lockfile, replaced the placeholder CI with a full lint/build/test pipeline (Node 22 + pnpm + Foundry), authored a deployment playbook, enriched the technical and functional specs with release guidance, extended the EAS addresses ledger for Moonscan links, and logged the session artefacts. diff --git a/agent-context/technical-spec.md b/agent-context/technical-spec.md index da205d7..fff17b7 100644 --- a/agent-context/technical-spec.md +++ b/agent-context/technical-spec.md @@ -2,7 +2,7 @@ Below is a lean, end-to-end blueprint designed to build and ship in 1–2 days during a hackathon. It covers contracts, events, a tiny relayer/indexer, REST APIs, data schemas, and the minimal UI flow required to demo QR-based mutual-friend reveal using **Cubid app-scoped IDs** and **Moonbeam (EVM)**. -Peer-mapper is a lightweight nextjs app which allows people to establish trusted relationships, and view common friends who also trust each other. The aim in the hackathon is to build a crisp, demo-ready MVP flow with on-chain attestations, a relayed signature path, QR in-person handshake, and mutual-friend reveal—with privacy preserved via Cubid app-scoped IDs. Application sessions are backed by Supabase Auth so both the frontend and indexer can trust a shared, RLS-protected `public.users` profile table for minimal metadata (display name, photo, Cubid ID, wallet). +Peer-mapper is a lightweight nextjs app which allows people to establish trusted relationships, and view common friends who also trust each other. The aim in the hackathon is to build a crisp, demo-ready MVP flow with on-chain attestations, a relayed signature path, QR in-person handshake, and mutual-friend reveal—with privacy preserved via Cubid app-scoped IDs. Application sessions are backed by Supabase Auth so both the frontend and indexer can trust an RLS-protected identity graph composed of `public.profiles`, `public.profile_credentials`, and `public.profiles_cubid`, delivering an email-rooted parent profile with wallet-scoped children plus Cubid history. The implementation relies on **first deploying EAS** (Ethereum Attestation Service) and then building the app around it. @@ -118,7 +118,7 @@ FeeGate exposes helper views (`domainSeparator()` + `hashAttestation(...)`) so t - **Attestations are directional.** - **Revocation:** set `trustLevel = NoOpinion` (or explicit EAS revoke) to neutralize a prior vouch. - **Persona privacy:** Users may create multiple Cubid-IDs (personas). Untraceability is achieved by not reusing the same Cubid-ID across contexts. -- **Supabase session store:** Supabase Auth issues sessions. The `public.users` table mirrors Supabase `auth.users.id` in `user_id`, and stores optional `cubid_id`, `display_name`, `photo_url`, and `evm_address`. A trigger keeps `updated_at` current, and RLS policies (`users_self_select`, `users_self_upsert`, `users_self_update`) restrict profile access to the authenticated user while the service role retains full control for trusted backend jobs. +- **Supabase session store:** Supabase Auth issues sessions. Email login materialises a parent row in `public.profiles` (with `auth_user_id` set and `parent_profile_id` null). Every wallet link invokes `create_profile_with_credential(kind := 'wallet', value := address, auth_user := auth.uid())`, minting a child profile, persisting the wallet credential in `public.profile_credentials`, and leaving metadata editable until the profile is locked. Cubid assignments append to `public.profiles_cubid` (a temporal history managed with `valid_from`/`valid_to`), and the `profiles_enriched` view joins the graph for read access. RLS scopes parents to `auth.uid()` and children to their owning parent while the service role retains full access for backend jobs. --- @@ -183,7 +183,7 @@ Revoked events delete cached rows by UID, keeping the view consistent with chain - **App Router** under `frontend/src/app` with shared layout + global styles. - **Zustand stores** - - `useUserStore` persists Supabase session, profile row, and the currently connected wallet. + - `useUserStore` persists the Supabase session, parent profile (email), wallet profile collection, and the active wallet selection. - `useScanStore` caches the latest QR verification payload so `/results` can render after navigation. - **Shared UI** lives in `frontend/src/components/` (`Badge`, `QRDisplay`, `QRScanner`, `UserSessionSummary`). - **API facade** in `frontend/src/lib/api.ts` wraps indexer endpoints (`/profile`, `/attest/prepare`, `/attest/relay`, `/qr/*`). @@ -191,23 +191,23 @@ Revoked events delete cached rows by UID, keeping the view consistent with chain ### Route map & behaviour -| Route | Purpose & implementation notes | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/` | Landing page (`app/page.tsx`) links to each flow in order. | -| `/(routes)/signin` | Magic-link entry (`signin/page.tsx`). Sends OTP via `signInWithOtp`, watches `useUserStore.session` and redirects to onboarding or circle. | -| `/(routes)/new-user` | Profile onboarding (`new-user/page.tsx`). Generates Cubid ID via `requestCubidId`, links wallets through `ensureWallet`, and upserts Supabase profile data. | -| `/(routes)/profile` | Profile maintenance (`profile/page.tsx`). Reads from `useUserStore`, lets users edit display name/photo, persists with `upsertMyProfile`. | -| `/(routes)/circle` | Trust graph view (`circle/page.tsx`). Fetches inbound/outbound attestations via `getProfile`, formats freshness, and supports Cubid search. | -| `/(routes)/vouch` | Delegated attestation flow (`vouch/page.tsx`). Validates inputs, calls `prepareAttestation`, signs typed data through `window.ethereum`, and relays via `/attest/relay`, surfacing lifetime-fee metadata. | -| `/(routes)/scan` | QR handshake orchestrator (`scan/page.tsx`). Displays my QR payload (`QRDisplay`), parses peer payload, requests `/qr/challenge`, collects wallet signatures, and posts to `/qr/verify`. Stores the overlap response in `useScanStore`. | -| `/(routes)/results` | Overlap renderer (`results/page.tsx`). Reads from `useScanStore`, shows badge cards for each shared issuer, and falls back to a helpful prompt when no data exists. | +| Route | Purpose & implementation notes | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/` | Landing page (`app/page.tsx`) links to each flow in order. | +| `/(routes)/signin` | Magic-link entry (`signin/page.tsx`). Sends OTP via `signInWithOtp`, watches `useUserStore.session` and redirects to onboarding or circle. | +| `/(routes)/new-user` | Profile onboarding (`new-user/page.tsx`). Captures display name/photo, fetches a Cubid ID via `requestCubidId`, requests wallet access with `ensureWallet`, and calls `createWalletProfile` so the Supabase RPC can spawn the first wallet persona plus Cubid record. | +| `/(routes)/profile` | Profile maintenance (`profile/page.tsx`). Shows the email-rooted parent profile, lists wallet personas from `useUserStore`, links additional wallets through `createWalletProfile`, and keeps metadata read-only once a profile is locked. | +| `/(routes)/circle` | Trust graph view (`circle/page.tsx`). Fetches inbound/outbound attestations via `getProfile`, formats freshness, and supports Cubid search. | +| `/(routes)/vouch` | Delegated attestation flow (`vouch/page.tsx`). Validates inputs, calls `prepareAttestation`, signs typed data through `window.ethereum`, and relays via `/attest/relay`, surfacing lifetime-fee metadata. | +| `/(routes)/scan` | QR handshake orchestrator (`scan/page.tsx`). Displays my QR payload (`QRDisplay`), parses peer payload, requests `/qr/challenge`, collects wallet signatures, and posts to `/qr/verify`. Stores the overlap response in `useScanStore`. | +| `/(routes)/results` | Overlap renderer (`results/page.tsx`). Reads from `useScanStore`, shows badge cards for each shared issuer, and falls back to a helpful prompt when no data exists. | ### Auth, Cubid & wallet integration -- `AuthProvider` boots Supabase session state and hydrates the profile before any route renders. -- Cubid flow is mocked via `requestCubidId(email)` but stored alongside Supabase user metadata so the indexer has a consistent `cubid_id`. -- `ensureWallet()` normalises Nova/EVM connection requests, storing the selected address in `useUserStore` so successive flows reuse it. -- Pages guard against missing session/profile data by redirecting to sign-in when required. +- `AuthProvider` boots Supabase session state and hydrates the parent + wallet bundle via `fetchMyProfiles` before any route renders. +- `requestCubidId(seed)` primes a Cubid ID for each wallet addition; `createWalletProfile` passes it to the Supabase RPC, which persists the child profile, wallet credential, and Cubid history in one go. +- `ensureWallet()` normalises Nova/EVM connection requests, dedupes already-linked addresses, and stores the active wallet selection in `useUserStore` for reuse. +- Navigation guards rely on `hasCompletedOnboarding` to ensure at least one wallet persona exists before unlocking the rest of the app. ### QR security guardrails 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..e84e9d7 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: useUserStore.getState().walletProfiles, + parentProfile: useUserStore.getState().parentProfile, }), })); 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..e6f9d48 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) { + 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 ? (