diff --git a/app/layout.tsx b/app/layout.tsx index da9869e8d..9bb63f5b9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -13,6 +13,7 @@ import { ToastContainer } from "react-toastify"; import { Toaster } from "sonner"; import { Geist, Geist_Mono } from "next/font/google"; import DeferredAnalytics from "@/components/DeferredAnalytics"; +import AccountOverrideBadge from "@/components/AccountOverrideBadge"; const geist = Geist({ subsets: ["latin"], @@ -71,14 +72,15 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - +
diff --git a/components/AccountOverrideBadge.tsx b/components/AccountOverrideBadge.tsx new file mode 100644 index 000000000..f36f2d8df --- /dev/null +++ b/components/AccountOverrideBadge.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { X } from "lucide-react"; +import { useAccountOverride } from "@/providers/AccountOverrideProvider"; + +/** + * Displays a pill badge when an account override is active. + * Reads from AccountOverrideProvider context. + */ +export default function AccountOverrideBadge() { + const { email, accountIdOverride, clear } = useAccountOverride(); + + if (!accountIdOverride || !email) return null; + + return ( +
+ Viewing as + {email} + +
+ ); +} diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index 5990ba669..5e66d9845 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -13,6 +13,7 @@ import { UIMessage, FileUIPart } from "ai"; import useAvailableModels from "./useAvailableModels"; import { useLocalStorage } from "usehooks-ts"; import { DEFAULT_MODEL } from "@/lib/consts"; +import { useAccountOverride } from "@/providers/AccountOverrideProvider"; import { usePaymentProvider } from "@/providers/PaymentProvider"; import useArtistFilesForMentions from "@/hooks/useArtistFilesForMentions"; import type { KnowledgeBaseEntry } from "@/lib/supabase/getArtistKnowledge"; @@ -163,15 +164,18 @@ export function useVercelChat({ return outputs; }, [knowledgeFiles]); + const { accountIdOverride } = useAccountOverride(); + const chatRequestBody = useMemo( () => ({ roomId: id, artistId, // Only include organizationId if it's not null (schema expects string | undefined) ...(organizationId && { organizationId }), + ...(accountIdOverride && { accountId: accountIdOverride }), model, }), - [id, artistId, organizationId, model], + [id, artistId, organizationId, accountIdOverride, model], ); const { messages, status, stop, sendMessage, setMessages, regenerate } = diff --git a/lib/accounts/fetchAccountIdByEmail.ts b/lib/accounts/fetchAccountIdByEmail.ts new file mode 100644 index 000000000..306e7df19 --- /dev/null +++ b/lib/accounts/fetchAccountIdByEmail.ts @@ -0,0 +1,25 @@ +import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; + +/** + * Fetches an account ID by email address via GET /api/accounts/{email}. + * + * @param email - The email address to look up + * @param accessToken - Bearer token for authentication + * @returns The account ID, or null if not found + */ +export async function fetchAccountIdByEmail( + email: string, + accessToken: string, +): Promise { + const baseUrl = getClientApiBaseUrl(); + const response = await fetch( + `${baseUrl}/api/accounts/${encodeURIComponent(email)}`, + { headers: { Authorization: `Bearer ${accessToken}` } }, + ); + + if (response.status === 404) return null; + if (!response.ok) throw new Error(`Account lookup failed: ${response.status}`); + + const data = await response.json(); + return data.account?.account_id ?? null; +} diff --git a/lib/accounts/override/clearStoredAccountOverride.ts b/lib/accounts/override/clearStoredAccountOverride.ts new file mode 100644 index 000000000..ffe396a42 --- /dev/null +++ b/lib/accounts/override/clearStoredAccountOverride.ts @@ -0,0 +1,9 @@ +import { ACCOUNT_OVERRIDE_STORAGE_KEY } from "@/lib/consts"; + +/** + * Removes the account override from session storage. + */ +export function clearStoredAccountOverride(): void { + window.sessionStorage.removeItem(ACCOUNT_OVERRIDE_STORAGE_KEY); + window.sessionStorage.removeItem(`${ACCOUNT_OVERRIDE_STORAGE_KEY}_email`); +} diff --git a/lib/accounts/override/getStoredAccountOverride.ts b/lib/accounts/override/getStoredAccountOverride.ts new file mode 100644 index 000000000..f7aaa69ca --- /dev/null +++ b/lib/accounts/override/getStoredAccountOverride.ts @@ -0,0 +1,15 @@ +import { ACCOUNT_OVERRIDE_STORAGE_KEY } from "@/lib/consts"; + +/** + * Reads the stored account override from session storage. + */ +export function getStoredAccountOverride(): { + accountId: string | null; + email: string | null; +} { + if (typeof window === "undefined") return { accountId: null, email: null }; + return { + accountId: window.sessionStorage.getItem(ACCOUNT_OVERRIDE_STORAGE_KEY), + email: window.sessionStorage.getItem(`${ACCOUNT_OVERRIDE_STORAGE_KEY}_email`), + }; +} diff --git a/lib/accounts/override/setStoredAccountOverride.ts b/lib/accounts/override/setStoredAccountOverride.ts new file mode 100644 index 000000000..7e0f436fd --- /dev/null +++ b/lib/accounts/override/setStoredAccountOverride.ts @@ -0,0 +1,12 @@ +import { ACCOUNT_OVERRIDE_STORAGE_KEY } from "@/lib/consts"; + +/** + * Persists an account override to session storage. + */ +export function setStoredAccountOverride( + accountId: string, + email: string, +): void { + window.sessionStorage.setItem(ACCOUNT_OVERRIDE_STORAGE_KEY, accountId); + window.sessionStorage.setItem(`${ACCOUNT_OVERRIDE_STORAGE_KEY}_email`, email); +} diff --git a/lib/consts.ts b/lib/consts.ts index df817746f..0323e5e86 100644 --- a/lib/consts.ts +++ b/lib/consts.ts @@ -5,6 +5,7 @@ export const NEW_API_BASE_URL = IS_PROD ? "https://recoup-api.vercel.app" : "https://test-recoup-api.vercel.app"; export const API_OVERRIDE_STORAGE_KEY = "recoup_api_override"; +export const ACCOUNT_OVERRIDE_STORAGE_KEY = "recoup_account_override"; export const IN_PROCESS_PROTOCOL_ADDRESS = IS_PROD ? ("0x540C18B7f99b3b599c6FeB99964498931c211858" as Address) : ("0x6832A997D8616707C7b68721D6E9332E77da7F6C" as Address); diff --git a/providers/AccountOverrideProvider.tsx b/providers/AccountOverrideProvider.tsx new file mode 100644 index 000000000..02117f27e --- /dev/null +++ b/providers/AccountOverrideProvider.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { createContext, useContext, useState, useCallback, ReactNode } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { usePrivy } from "@privy-io/react-auth"; +import { useQuery } from "@tanstack/react-query"; +import { fetchAccountIdByEmail } from "@/lib/accounts/fetchAccountIdByEmail"; +import { getStoredAccountOverride } from "@/lib/accounts/override/getStoredAccountOverride"; +import { setStoredAccountOverride } from "@/lib/accounts/override/setStoredAccountOverride"; +import { clearStoredAccountOverride } from "@/lib/accounts/override/clearStoredAccountOverride"; + +interface AccountOverrideContextType { + accountIdOverride: string | null; + email: string | null; + clear: () => void; +} + +const AccountOverrideContext = createContext({ + accountIdOverride: null, + email: null, + clear: () => {}, +}); + +/** + * Provider that manages the account override lifecycle. + * Reads ?email= from URL, resolves to accountId, persists in session storage. + * Single source of truth for all override consumers. + * Placed inside PrivyProvider because it needs getAccessToken. + */ +export function AccountOverrideProvider({ children }: { children: ReactNode }) { + const searchParams = useSearchParams(); + const router = useRouter(); + const { getAccessToken } = usePrivy(); + const emailParam = searchParams.get("email"); + + const [stored, setStored] = useState(getStoredAccountOverride); + const email = emailParam || stored.email; + const isClear = emailParam === "clear"; + + const { data: resolvedAccountId } = useQuery({ + queryKey: ["accountOverride", email], + queryFn: async () => { + if (isClear) { + clearStoredAccountOverride(); + setStored({ accountId: null, email: null }); + return null; + } + const accessToken = await getAccessToken(); + if (!accessToken) return null; + const accountId = await fetchAccountIdByEmail(email!, accessToken); + if (accountId && email) { + setStoredAccountOverride(accountId, email); + setStored({ accountId, email }); + } + return accountId; + }, + enabled: (!!email || isClear) && !stored.accountId, + staleTime: Infinity, + }); + + const accountIdOverride = stored.accountId || resolvedAccountId || null; + + const clear = useCallback(() => { + clearStoredAccountOverride(); + setStored({ accountId: null, email: null }); + const params = new URLSearchParams(searchParams.toString()); + params.delete("email"); + const newPath = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname; + router.replace(newPath); + }, [searchParams, router]); + + return ( + + {children} + + ); +} + +export function useAccountOverride() { + return useContext(AccountOverrideContext); +} diff --git a/providers/Providers.tsx b/providers/Providers.tsx index dc29b1c5b..3e6fcba85 100644 --- a/providers/Providers.tsx +++ b/providers/Providers.tsx @@ -14,37 +14,40 @@ import { MiniAppProvider } from "./MiniAppProvider"; import { ThemeProvider } from "./ThemeProvider"; import { OrganizationProvider } from "./OrganizationProvider"; import ApiOverrideSync from "./ApiOverrideSync"; +import { AccountOverrideProvider } from "./AccountOverrideProvider"; const queryClient = new QueryClient(); const Providers = ({ children }: { children: React.ReactNode }) => ( - + - - - - - {children} - - - - + + + + + {children} + + + + +