From fb11e7d57569e9902028e60f8f95ac361d90e388 Mon Sep 17 00:00:00 2001 From: QSchlegel Date: Tue, 16 Dec 2025 12:47:14 +0100 Subject: [PATCH] Refactor wallet authorization and session handling - Simplified the wallet authorization process by integrating the WalletAuthModal into the RootLayout component, preventing duplicate prompts. - Enhanced user experience by automatically triggering authorization when the modal opens if the autoAuthorize flag is set. - Improved error handling for wallet address retrieval, falling back to unused addresses if no used addresses are found. - Updated various components to ensure they only query user data when the user address is valid, reducing unnecessary API calls. - Added loading skeletons for better user feedback during wallet data fetching. --- package.json | 2 +- .../common/modals/WalletAuthModal.tsx | 93 ++++++-- .../common/overall-layout/layout.tsx | 201 ++++++++++++++++-- .../mobile-wrappers/user-dropdown-wrapper.tsx | 11 +- .../wallet-data-loader-wrapper.tsx | 59 +---- .../common/overall-layout/user-drop-down.tsx | 11 +- .../homepage/wallets/WalletCardSkeleton.tsx | 33 +++ .../wallets/WalletInviteCardSkeleton.tsx | 26 +++ .../pages/homepage/wallets/index.tsx | 81 ++++++- .../pages/homepage/wallets/invite/index.tsx | 37 +++- .../wallet/info/signers/card-show-signers.tsx | 11 +- src/hooks/useUser.ts | 4 +- src/hooks/useUserWallets.ts | 20 ++ src/pages/api-docs.tsx | 29 ++- src/pages/api/trpc/[trpc].ts | 10 +- src/server/api/routers/wallets.ts | 87 +++++++- src/utils/api.ts | 91 +++++++- vercel.json | 10 + 18 files changed, 694 insertions(+), 122 deletions(-) create mode 100644 src/components/pages/homepage/wallets/WalletCardSkeleton.tsx create mode 100644 src/components/pages/homepage/wallets/WalletInviteCardSkeleton.tsx create mode 100644 vercel.json diff --git a/package.json b/package.json index de6a082a..dd58fdb6 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "db:studio": "prisma studio", "db:update": "prisma format && prisma db push && prisma generate", "dev": "next dev", - "postinstall": "prisma format && prisma generate", + "postinstall": "prisma generate", "lint": "next lint", "start": "next start", "test": "jest", diff --git a/src/components/common/modals/WalletAuthModal.tsx b/src/components/common/modals/WalletAuthModal.tsx index 6a337671..fb3b2e2d 100644 --- a/src/components/common/modals/WalletAuthModal.tsx +++ b/src/components/common/modals/WalletAuthModal.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useWallet } from "@meshsdk/react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; @@ -9,14 +9,16 @@ interface WalletAuthModalProps { open: boolean; onClose: () => void; onAuthorized?: () => void; + autoAuthorize?: boolean; // If true, automatically trigger authorization when modal opens } -export function WalletAuthModal({ address, open, onClose, onAuthorized }: WalletAuthModalProps) { +export function WalletAuthModal({ address, open, onClose, onAuthorized, autoAuthorize = false }: WalletAuthModalProps) { const { wallet, connected } = useWallet(); const { toast } = useToast(); const [submitting, setSubmitting] = useState(false); + const [hasAutoAuthorized, setHasAutoAuthorized] = useState(false); - const handleAuthorize = async () => { + const handleAuthorize = useCallback(async () => { if (!wallet || !connected) { toast({ title: "No wallet connected", @@ -27,11 +29,33 @@ export function WalletAuthModal({ address, open, onClose, onAuthorized }: Wallet } setSubmitting(true); try { - // Resolve the payment address the wallet uses (match Swagger flow) - const usedAddresses = await wallet.getUsedAddresses(); - const signingAddress = usedAddresses[0]; + // Resolve the payment address the wallet uses + // Try used addresses first, fall back to unused addresses if needed + let signingAddress: string | undefined; + try { + const usedAddresses = await wallet.getUsedAddresses(); + signingAddress = usedAddresses[0]; + } catch (error) { + if (error instanceof Error && error.message.includes("account changed")) { + throw error; + } + // If getUsedAddresses fails for other reasons, try unused addresses + } + + // Fall back to unused addresses if no used addresses found + if (!signingAddress) { + try { + const unusedAddresses = await wallet.getUnusedAddresses(); + signingAddress = unusedAddresses[0]; + } catch (error) { + if (error instanceof Error && error.message.includes("account changed")) { + throw error; + } + } + } + if (!signingAddress) { - throw new Error("No used addresses found for wallet"); + throw new Error("No addresses found for wallet"); } // 1) Get nonce from existing endpoint @@ -100,7 +124,6 @@ export function WalletAuthModal({ address, open, onClose, onAuthorized }: Wallet onAuthorized?.(); onClose(); } catch (error: any) { - console.error("WalletAuthModal authorize error:", error); toast({ title: "Authorization failed", description: error?.message || "Unable to authorize wallet. Please try again.", @@ -109,16 +132,56 @@ export function WalletAuthModal({ address, open, onClose, onAuthorized }: Wallet } finally { setSubmitting(false); } - }; + }, [wallet, connected, toast, onAuthorized, onClose]); + + // Auto-authorize when modal opens if autoAuthorize is true (only once) + useEffect(() => { + if (open && autoAuthorize && !hasAutoAuthorized && wallet && connected && !submitting) { + // Small delay to ensure modal is fully rendered before triggering wallet prompt + const timeoutId = setTimeout(() => { + setHasAutoAuthorized(true); + void handleAuthorize(); + }, 100); + + return () => clearTimeout(timeoutId); + } + }, [open, autoAuthorize, hasAutoAuthorized, wallet, connected, submitting, handleAuthorize]); + + // Reset auto-authorize flag when modal closes + useEffect(() => { + if (!open) { + setHasAutoAuthorized(false); + } + }, [open]); return ( - !open && !submitting && onClose()}> - + { + // Prevent closing during authorization + if (!open && !submitting) { + onClose(); + } + }}> + { + // Prevent closing by clicking outside during authorization + if (submitting) { + e.preventDefault(); + } + }} onEscapeKeyDown={(e) => { + // Prevent closing with Escape key during authorization + if (submitting) { + e.preventDefault(); + } + }}> Authorize this wallet To use this wallet with multisig, we need to confirm you control it by signing a short message. This does not move any funds or create a transaction. + {autoAuthorize && !hasAutoAuthorized && ( + + Please approve the signing request in your wallet. + + )}
@@ -127,9 +190,11 @@ export function WalletAuthModal({ address, open, onClose, onAuthorized }: Wallet
- + {!autoAuthorize && ( + + )} diff --git a/src/components/common/overall-layout/layout.tsx b/src/components/common/overall-layout/layout.tsx index 40cbba9b..34ba8724 100644 --- a/src/components/common/overall-layout/layout.tsx +++ b/src/components/common/overall-layout/layout.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, Component, ReactNode, useMemo, useCallback } from "react"; +import React, { useEffect, Component, ReactNode, useMemo, useCallback, useState, useRef } from "react"; import { useRouter } from "next/router"; import Link from "next/link"; import { useNostrChat } from "@jinglescode/nostr-chat-plugin"; @@ -11,6 +11,7 @@ import useAppWallet from "@/hooks/useAppWallet"; import { useWalletContext, WalletState } from "@/hooks/useWalletContext"; import useMultisigWallet from "@/hooks/useMultisigWallet"; import { AlertCircle, RefreshCw } from "lucide-react"; +import { WalletAuthModal } from "@/components/common/modals/WalletAuthModal"; import SessionProvider from "@/components/SessionProvider"; import { getServerSession } from "next-auth"; @@ -60,11 +61,8 @@ class WalletErrorBoundary extends Component< } componentDidCatch(error: Error, errorInfo: any) { - console.error('Error caught by wallet boundary:', error, errorInfo); - // Handle specific wallet errors if (error.message.includes("account changed")) { - console.log("Wallet account changed error caught by boundary, reloading page..."); window.location.reload(); return; } @@ -86,7 +84,7 @@ export default function RootLayout({ const { wallet } = useWallet(); const { state: walletState, connectedWalletInstance } = useWalletContext(); const address = useAddress(); - const { user, isLoading } = useUser(); + const { user, isLoading: isLoadingUser } = useUser(); const router = useRouter(); const { appWallet } = useAppWallet(); const { multisigWallet } = useMultisigWallet(); @@ -96,6 +94,12 @@ export default function RootLayout({ const setUserAddress = useUserStore((state) => state.setUserAddress); const ctx = api.useUtils(); + // State for wallet authorization modal + const [showAuthModal, setShowAuthModal] = useState(false); + const [checkingSession, setCheckingSession] = useState(false); + const [hasCheckedSession, setHasCheckedSession] = useState(false); // Prevent duplicate checks + const [showPostAuthLoading, setShowPostAuthLoading] = useState(false); // Show loading after authorization + // Use WalletState for connection check const connected = String(walletState) === String(WalletState.CONNECTED); // Use connectedWalletInstance if available, otherwise fall back to wallet @@ -106,17 +110,19 @@ export default function RootLayout({ // Global error handler for unhandled promise rejections useEffect(() => { const handleUnhandledRejection = (event: PromiseRejectionEvent) => { - console.error('Unhandled promise rejection:', event.reason); - // Handle wallet-related errors specifically if (event.reason && typeof event.reason === 'object') { const error = event.reason as Error; if (error.message && error.message.includes("account changed")) { - console.log("Account changed error caught by global handler, reloading page..."); event.preventDefault(); // Prevent the error from being logged to console window.location.reload(); return; } + // Handle "too many requests" errors silently (rate limiting) + if (error.message && error.message.includes("too many requests")) { + event.preventDefault(); // Prevent the error from being logged to console + return; + } } }; @@ -156,10 +162,9 @@ export default function RootLayout({ if (context?.previous) { ctx.user.getUserByAddress.setData({ address: variables.address }, context.previous); } - console.error("Error creating user:", err); + // Error creating user - handled silently }, onSuccess: (_, variables) => { - console.log("User created/updated successfully, invalidating user query"); // Invalidate to ensure we have the latest data void ctx.user.getUserByAddress.invalidate({ address: variables.address }); }, @@ -197,10 +202,9 @@ export default function RootLayout({ if (context?.previous && variables.address) { ctx.user.getUserByAddress.setData({ address: variables.address }, context.previous); } - console.error("Error updating user:", err); + // Error updating user - handled silently }, onSuccess: (_, variables) => { - console.log("User updated successfully, invalidating user query"); if (variables.address) { void ctx.user.getUserByAddress.invalidate({ address: variables.address }); } @@ -213,19 +217,58 @@ export default function RootLayout({ setUserAddress(address); } }, [address, setUserAddress]); + + // Also try to get address from wallet directly if useAddress doesn't work + const fetchingAddressRef = useRef(false); + useEffect(() => { + // Prevent multiple simultaneous calls + if (fetchingAddressRef.current) return; + + if (connected && activeWallet && !address && !userAddress) { + fetchingAddressRef.current = true; + activeWallet.getUsedAddresses() + .then((addresses) => { + if (addresses && addresses.length > 0) { + setUserAddress(addresses[0]!); + fetchingAddressRef.current = false; + } else { + return activeWallet.getUnusedAddresses(); + } + }) + .then((addresses) => { + if (addresses && addresses.length > 0 && !userAddress) { + setUserAddress(addresses[0]!); + } + fetchingAddressRef.current = false; + }) + .catch((error) => { + // Handle "too many requests" error gracefully + if (error instanceof Error && error.message.includes("too many requests")) { + // Silently ignore rate limit errors - address will be fetched later + } + fetchingAddressRef.current = false; + }); + } + }, [connected, activeWallet, address, userAddress, setUserAddress]); // Initialize wallet and create user when connected useEffect(() => { - if (!connected || !activeWallet || user || !address) return; + // Use userAddress from store instead of address from hook (hook might not work) + const walletAddress = userAddress || address; + if (!connected || !activeWallet || user || !walletAddress) { + return; + } async function initializeWallet() { - if (!address) return; + if (!walletAddress) return; try { // Get stake address const stakeAddresses = await activeWallet.getRewardAddresses(); const stakeAddress = stakeAddresses[0]; - if (!stakeAddress) return; + if (!stakeAddress) { + return; + } // Get DRep key hash (optional) let drepKeyHash = ""; @@ -241,13 +284,12 @@ export default function RootLayout({ // Create or update user const nostrKey = generateNsec(); createUser({ - address, + address: walletAddress, stakeAddress, drepKeyHash, nostrKey: JSON.stringify(nostrKey), }); } catch (error) { - console.error("Error initializing wallet:", error); if (error instanceof Error && error.message.includes("account changed")) { window.location.reload(); } @@ -255,7 +297,93 @@ export default function RootLayout({ } initializeWallet(); - }, [connected, activeWallet, user, address, createUser, generateNsec]); + }, [connected, activeWallet, user, userAddress, address, createUser, generateNsec]); + + // Check wallet session and show authorization modal for first-time connections + // Check session as soon as wallet is connected and address is available (don't wait for user) + // Use userAddress from store (which we set from wallet) instead of address from hook + const walletAddressForSession = userAddress || address; + // Only check session once per wallet connection (prevent duplicate checks) + const shouldCheckSession = !!connected && !!walletAddressForSession && !checkingSession && !hasCheckedSession && walletAddressForSession.length > 0; + const { data: walletSessionData, isLoading: isLoadingWalletSession, refetch: refetchWalletSession } = api.auth.getWalletSession.useQuery( + { address: walletAddressForSession ?? "" }, + { + enabled: shouldCheckSession, + refetchOnWindowFocus: false, + refetchOnMount: false, // Don't refetch on mount to prevent duplicate checks + } + ); + + + useEffect(() => { + // Only check session once per wallet connection + // Use userAddress from store (which we set from wallet) instead of address from hook + const walletAddressForCheck = userAddress || address; + if (!connected || !walletAddressForCheck || walletAddressForCheck.length === 0 || showAuthModal || checkingSession || hasCheckedSession) { + return; + } + + // Wait for query to finish loading + if (isLoadingWalletSession) { + return; + } + + // Check if wallet has an active session + // Only show modal if we have data (not undefined) and wallet is not authorized + if (walletSessionData !== undefined) { + setHasCheckedSession(true); // Mark as checked to prevent duplicate checks + const hasSession = walletSessionData.authorized ?? false; + + if (!hasSession) { + // Wallet is connected but doesn't have a session - show authorization modal + setCheckingSession(true); + setShowAuthModal(true); + } + } + }, [connected, user, userAddress, address, walletSessionData, showAuthModal, checkingSession, isLoadingWalletSession, hasCheckedSession]); + + // Reset hasCheckedSession when wallet disconnects or address changes + useEffect(() => { + if (!connected) { + setHasCheckedSession(false); + setCheckingSession(false); + setShowAuthModal(false); + } + }, [connected]); + + // Reset hasCheckedSession when address changes (different wallet connected) + const prevAddressRef = useRef(undefined); + useEffect(() => { + const currentAddress = userAddress || address; + if (prevAddressRef.current !== undefined && prevAddressRef.current !== currentAddress) { + // Address changed, reset session check + setHasCheckedSession(false); + setCheckingSession(false); + setShowAuthModal(false); + } + prevAddressRef.current = currentAddress; + }, [userAddress, address]); + + const handleAuthModalClose = useCallback(() => { + setShowAuthModal(false); + setCheckingSession(false); + setHasCheckedSession(true); // Mark as checked to prevent showing modal again + // Don't refetch here - let the natural query refetch handle it if needed + }, []); + + const handleAuthModalAuthorized = useCallback(() => { + setShowAuthModal(false); + setCheckingSession(false); + setHasCheckedSession(true); // Mark as checked so we don't check again + // Show loading skeleton for smooth transition + setShowPostAuthLoading(true); + // Refetch session after authorization to update state (but don't show modal again) + void refetchWalletSession(); + // Hide loading after a brief delay to allow data to load + setTimeout(() => { + setShowPostAuthLoading(false); + }, 1000); + }, [refetchWalletSession]); // Memoize computed route values const isWalletPath = useMemo(() => router.pathname.includes("/wallets/[wallet]"), [router.pathname]); @@ -281,7 +409,6 @@ export default function RootLayout({ setLastWalletStakingEnabled(stakingEnabled); } catch (error) { // Don't update state on error - keep the last known value - console.error("Error checking staking status:", error); } } }, [router.query.wallet, isWalletPath, appWallet, multisigWallet]); @@ -303,17 +430,34 @@ export default function RootLayout({ const showWalletMenu = useMemo(() => isLoggedIn && (isWalletPath || !!lastVisitedWalletId), [isLoggedIn, isWalletPath, lastVisitedWalletId]); // Don't show background loading when wallet is connecting or just connected (button already shows spinner) - // The connect button shows a spinner when: connecting OR (connected && (!user || userLoading)) + // The connect button shows a spinner when: connecting OR (connected && address exists but user doesn't exist yet and user is loading) const isConnecting = useMemo(() => String(walletState) === String(WalletState.CONNECTING), [walletState]); - const isButtonShowingSpinner = useMemo(() => isConnecting || (connected && (!user || isLoading)), [isConnecting, connected, user, isLoading]); - const shouldShowBackgroundLoading = useMemo(() => isLoading && !isButtonShowingSpinner, [isLoading, isButtonShowingSpinner]); + // Only show button spinner if we're actually connecting, or if we have an address but no user yet + const isButtonShowingSpinner = useMemo(() => { + const result = isConnecting || (connected && !!address && !user && isLoadingUser); + return result; + }, [isConnecting, connected, address, user, isLoadingUser]); + + // Only show background loading if: + // 1. User is loading AND user doesn't exist yet (if user exists, no need to show loading) + // 2. We have an address (user query is actually running) + // 3. The button spinner is not showing (to avoid double spinners) + // 4. User address is set (to ensure query is enabled) + const shouldShowBackgroundLoading = useMemo(() => { + // Don't show loading if user already exists (even if query is still loading) + if (user) { + return false; + } + const result = isLoadingUser && !!address && address.length > 0 && !!userAddress && !isButtonShowingSpinner; + return result; + }, [isLoadingUser, address, userAddress, isButtonShowingSpinner, user]); // Memoize wallet ID for menu const walletIdForMenu = useMemo(() => (router.query.wallet as string) || lastVisitedWalletId || undefined, [router.query.wallet, lastVisitedWalletId]); return (
- {shouldShowBackgroundLoading && ( + {(shouldShowBackgroundLoading || showPostAuthLoading) && (
@@ -475,6 +619,17 @@ export default function RootLayout({
+ + {/* Wallet Authorization Modal - shows when wallet is connected but not authorized */} + {(userAddress || address) && ( + + )}
); } diff --git a/src/components/common/overall-layout/mobile-wrappers/user-dropdown-wrapper.tsx b/src/components/common/overall-layout/mobile-wrappers/user-dropdown-wrapper.tsx index 81e6c85d..1f8c471e 100644 --- a/src/components/common/overall-layout/mobile-wrappers/user-dropdown-wrapper.tsx +++ b/src/components/common/overall-layout/mobile-wrappers/user-dropdown-wrapper.tsx @@ -72,9 +72,14 @@ export default function UserDropDownWrapper({ } } - const { data: discordData } = api.user.getUserDiscordId.useQuery({ - address: userAddress ?? "", - }); + const { data: discordData } = api.user.getUserDiscordId.useQuery( + { + address: userAddress ?? "", + }, + { + enabled: !!userAddress && userAddress.length > 0, + } + ); async function handleCopyAddress() { try { diff --git a/src/components/common/overall-layout/mobile-wrappers/wallet-data-loader-wrapper.tsx b/src/components/common/overall-layout/mobile-wrappers/wallet-data-loader-wrapper.tsx index afd9f921..18d7e2ef 100644 --- a/src/components/common/overall-layout/mobile-wrappers/wallet-data-loader-wrapper.tsx +++ b/src/components/common/overall-layout/mobile-wrappers/wallet-data-loader-wrapper.tsx @@ -12,7 +12,7 @@ import { getDRepIds } from "@meshsdk/core-cst"; import { BlockfrostDrepInfo } from "@/types/governance"; import { Button } from "@/components/ui/button"; import { useProxyActions } from "@/lib/zustand/proxy"; -import { WalletAuthModal } from "@/components/common/modals/WalletAuthModal"; +// WalletAuthModal is now handled in layout.tsx to avoid duplicate prompts import { useUserStore } from "@/lib/zustand/user"; interface WalletDataLoaderWrapperProps { @@ -27,7 +27,6 @@ export default function WalletDataLoaderWrapper({ const { appWallet } = useAppWallet(); const { multisigWallet } = useMultisigWallet(); const [loading, setLoading] = useState(false); - const [showAuthModal, setShowAuthModal] = useState(false); const prevWalletIdRef = useRef(null); const ctx = api.useUtils(); @@ -57,12 +56,8 @@ export default function WalletDataLoaderWrapper({ const userAddress = useUserStore((state) => state.userAddress); - const walletSessionQuery = api.auth.getWalletSession.useQuery( - { address: userAddress ?? "" }, - { - enabled: !!userAddress, - }, - ); + // Session check is now handled in layout.tsx to avoid duplicate modals + // Removed walletSessionQuery from here to prevent duplicate authorization prompts async function fetchUtxos() { if (appWallet) { @@ -254,18 +249,8 @@ export default function WalletDataLoaderWrapper({ } }, [appWallet]); - useEffect(() => { - if (!userAddress) return; - if (walletSessionQuery.isLoading || walletSessionQuery.error) return; - if (walletSessionQuery.data && !walletSessionQuery.data.authorized) { - setShowAuthModal(true); - } - }, [ - userAddress, - walletSessionQuery.isLoading, - walletSessionQuery.error, - walletSessionQuery.data, - ]); + // Session check and authorization modal are now handled in layout.tsx + // This prevents duplicate authorization prompts if (mode === "button") { return ( @@ -279,22 +264,7 @@ export default function WalletDataLoaderWrapper({ > - {userAddress && ( - setShowAuthModal(false)} - onAuthorized={() => { - void walletSessionQuery.refetch(); - void ctx.transaction.getPendingTransactions.invalidate(); - void ctx.transaction.getAllTransactions.invalidate(); - void ctx.signable.getPendingSignables.invalidate(); - void ctx.signable.getCompleteSignables.invalidate(); - void ctx.proxy.getProxiesByUserOrWallet.invalidate(); - void ctx.migration.getPendingMigrations.invalidate(); - }} - /> - )} + {/* Authorization modal is handled in layout.tsx to avoid duplicate prompts */} ); } @@ -309,22 +279,7 @@ export default function WalletDataLoaderWrapper({ Refresh Wallet - {userAddress && ( - setShowAuthModal(false)} - onAuthorized={() => { - void walletSessionQuery.refetch(); - void ctx.transaction.getPendingTransactions.invalidate(); - void ctx.transaction.getAllTransactions.invalidate(); - void ctx.signable.getPendingSignables.invalidate(); - void ctx.signable.getCompleteSignables.invalidate(); - void ctx.proxy.getProxiesByUserOrWallet.invalidate(); - void ctx.migration.getPendingMigrations.invalidate(); - }} - /> - )} + {/* Authorization modal is handled in layout.tsx to avoid duplicate prompts */} ); } \ No newline at end of file diff --git a/src/components/common/overall-layout/user-drop-down.tsx b/src/components/common/overall-layout/user-drop-down.tsx index 817092e2..9e5a2c0f 100644 --- a/src/components/common/overall-layout/user-drop-down.tsx +++ b/src/components/common/overall-layout/user-drop-down.tsx @@ -61,9 +61,14 @@ export default function UserDropDown() { } } - const { data: discordData } = api.user.getUserDiscordId.useQuery({ - address: userAddress ?? "", - }); + const { data: discordData } = api.user.getUserDiscordId.useQuery( + { + address: userAddress ?? "", + }, + { + enabled: !!userAddress && userAddress.length > 0, + } + ); return ( diff --git a/src/components/pages/homepage/wallets/WalletCardSkeleton.tsx b/src/components/pages/homepage/wallets/WalletCardSkeleton.tsx new file mode 100644 index 00000000..57187c23 --- /dev/null +++ b/src/components/pages/homepage/wallets/WalletCardSkeleton.tsx @@ -0,0 +1,33 @@ +import CardUI from "@/components/common/card-content"; +import RowLabelInfo from "@/components/common/row-label-info"; +import WalletBalanceSkeleton from "./WalletBalanceSkeleton"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function WalletCardSkeleton() { + return ( + + + + + + + +
+
+
+ + } + /> + } + /> +
+
+ + + ); +} + diff --git a/src/components/pages/homepage/wallets/WalletInviteCardSkeleton.tsx b/src/components/pages/homepage/wallets/WalletInviteCardSkeleton.tsx new file mode 100644 index 00000000..9ffd7cfa --- /dev/null +++ b/src/components/pages/homepage/wallets/WalletInviteCardSkeleton.tsx @@ -0,0 +1,26 @@ +import RowLabelInfo from "@/components/common/row-label-info"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function WalletInviteCardSkeleton() { + return ( + + + + + + + +
+
+
+ } + /> +
+
+ + + ); +} + diff --git a/src/components/pages/homepage/wallets/index.tsx b/src/components/pages/homepage/wallets/index.tsx index c3c961b1..645af3fa 100644 --- a/src/components/pages/homepage/wallets/index.tsx +++ b/src/components/pages/homepage/wallets/index.tsx @@ -20,25 +20,59 @@ import SectionTitle from "@/components/common/section-title"; import WalletBalance from "./WalletBalance"; import EmptyWalletsState from "./EmptyWalletsState"; import SectionExplanation from "./SectionExplanation"; +import WalletCardSkeleton from "./WalletCardSkeleton"; +import WalletInviteCardSkeleton from "./WalletInviteCardSkeleton"; export default function PageWallets() { - const { wallets } = useUserWallets(); + const { wallets, isLoading: isLoadingWallets } = useUserWallets(); const [showArchived, setShowArchived] = useState(false); const userAddress = useUserStore((state) => state.userAddress); - const { data: newPendingWallets } = api.wallet.getUserNewWallets.useQuery( + const { data: newPendingWallets, isLoading: isLoadingNewWallets } = api.wallet.getUserNewWallets.useQuery( { address: userAddress! }, { enabled: userAddress !== undefined, + retry: (failureCount, error) => { + // Don't retry on authorization errors (403) + if (error && typeof error === "object") { + const err = error as { + code?: string; + message?: string; + data?: { code?: string; httpStatus?: number }; + shape?: { code?: string; message?: string }; + }; + const errorMessage = err.message || err.shape?.message || ""; + const isAuthError = + err.code === "FORBIDDEN" || + err.data?.code === "FORBIDDEN" || + err.data?.httpStatus === 403 || + err.shape?.code === "FORBIDDEN" || + errorMessage.includes("Address mismatch"); + if (isAuthError) return false; + } + return failureCount < 1; + }, }, ); - const { data: getUserNewWalletsNotOwner } = + const { data: getUserNewWalletsNotOwner, isLoading: isLoadingNewWalletsNotOwner } = api.wallet.getUserNewWalletsNotOwner.useQuery( { address: userAddress! }, { enabled: userAddress !== undefined, + retry: (failureCount, error) => { + // Don't retry on authorization errors (403) + if (error && typeof error === "object") { + const err = error as { code?: string; message?: string; data?: { code?: string } }; + const isAuthError = + err.code === "FORBIDDEN" || + err.data?.code === "FORBIDDEN" || + err.message?.includes("Address mismatch"); + if (isAuthError) return false; + } + return failureCount < 1; + }, }, ); @@ -68,8 +102,15 @@ export default function PageWallets() {
- {wallets && wallets.length === 0 && } - {wallets && + {isLoadingWallets && ( + <> + + + + + )} + {!isLoadingWallets && wallets && wallets.length === 0 && } + {!isLoadingWallets && wallets && wallets .filter((wallet) => showArchived || !wallet.isArchived) .sort((a, b) => @@ -93,7 +134,19 @@ export default function PageWallets() { })}
- {newPendingWallets && newPendingWallets.length > 0 && ( + {isLoadingNewWallets && ( + <> + New Wallets to be created + +
+ + +
+ + )} + {!isLoadingNewWallets && newPendingWallets && newPendingWallets.length > 0 && ( <> New Wallets to be created )} - {getUserNewWalletsNotOwner && getUserNewWalletsNotOwner.length > 0 && ( + {isLoadingNewWalletsNotOwner && ( + <> + + New Wallets awaiting creation + + +
+ + +
+ + )} + {!isLoadingNewWalletsNotOwner && getUserNewWalletsNotOwner && getUserNewWalletsNotOwner.length > 0 && ( <> New Wallets awaiting creation diff --git a/src/components/pages/homepage/wallets/invite/index.tsx b/src/components/pages/homepage/wallets/invite/index.tsx index 6a1617a1..6ed4fbfd 100644 --- a/src/components/pages/homepage/wallets/invite/index.tsx +++ b/src/components/pages/homepage/wallets/invite/index.tsx @@ -43,10 +43,45 @@ export default function PageNewWalletInvite() { const utils = api.useUtils(); + // Check if user has a wallet session before querying + const { data: walletSession } = api.auth.getWalletSession.useQuery( + { address: userAddress ?? "" }, + { + enabled: !!userAddress && userAddress.length > 0, + refetchOnWindowFocus: false, + }, + ); + const isAuthorized = walletSession?.authorized ?? false; + const { data: newWallet } = api.wallet.getNewWallet.useQuery( { walletId: newWalletId! }, { - enabled: pathIsNewWallet && newWalletId !== undefined, + enabled: pathIsNewWallet && newWalletId !== undefined && isAuthorized, + retry: (failureCount, error) => { + // Don't retry on authorization errors (403/401) + if (error && typeof error === "object") { + const err = error as { + code?: string; + message?: string; + data?: { code?: string; httpStatus?: number }; + shape?: { code?: string; message?: string }; + }; + const errorMessage = err.message || err.shape?.message || ""; + const isAuthError = + err.code === "FORBIDDEN" || + err.code === "UNAUTHORIZED" || + err.data?.code === "FORBIDDEN" || + err.data?.code === "UNAUTHORIZED" || + err.data?.httpStatus === 403 || + err.data?.httpStatus === 401 || + err.shape?.code === "FORBIDDEN" || + err.shape?.code === "UNAUTHORIZED" || + errorMessage.includes("Not authorized") || + errorMessage.includes("Unauthorized"); + if (isAuthError) return false; + } + return failureCount < 1; + }, }, ); diff --git a/src/components/pages/wallet/info/signers/card-show-signers.tsx b/src/components/pages/wallet/info/signers/card-show-signers.tsx index f72dc493..5b93b57a 100644 --- a/src/components/pages/wallet/info/signers/card-show-signers.tsx +++ b/src/components/pages/wallet/info/signers/card-show-signers.tsx @@ -125,9 +125,14 @@ export default function ShowSigners({ appWallet }: ShowSignersProps) { addresses: appWallet.signersAddresses, }); - const { data: currentUserDiscordId } = api.user.getUserDiscordId.useQuery({ - address: userAddress ?? "", - }); + const { data: currentUserDiscordId } = api.user.getUserDiscordId.useQuery( + { + address: userAddress ?? "", + }, + { + enabled: !!userAddress && userAddress.length > 0, + } + ); const signersList = useMemo(() => { async function signVerify() { diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index 974c7618..0ca5b944 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -3,10 +3,12 @@ import { api } from "@/utils/api"; export default function useUser() { const userAddress = useUserStore((state) => state.userAddress); + const queryEnabled = userAddress !== undefined && userAddress !== null && userAddress !== ""; + const { data: user, isLoading, error } = api.user.getUserByAddress.useQuery( { address: userAddress! }, { - enabled: userAddress !== undefined && userAddress !== null && userAddress !== "", + enabled: queryEnabled, retry: false, refetchOnWindowFocus: false, staleTime: 1 * 60 * 1000, // 1 minute (user data) diff --git a/src/hooks/useUserWallets.ts b/src/hooks/useUserWallets.ts index 4d316778..c0e30017 100644 --- a/src/hooks/useUserWallets.ts +++ b/src/hooks/useUserWallets.ts @@ -13,6 +13,26 @@ export default function useUserWallets() { enabled: userAddress !== undefined, staleTime: 1 * 60 * 1000, // 1 minute (user/wallet data) gcTime: 5 * 60 * 1000, // 5 minutes + retry: (failureCount, error) => { + // Don't retry on authorization errors (403) + if (error && typeof error === "object") { + const err = error as { + code?: string; + message?: string; + data?: { code?: string; httpStatus?: number }; + shape?: { code?: string; message?: string }; + }; + const errorMessage = err.message || err.shape?.message || ""; + const isAuthError = + err.code === "FORBIDDEN" || + err.data?.code === "FORBIDDEN" || + err.data?.httpStatus === 403 || + err.shape?.code === "FORBIDDEN" || + errorMessage.includes("Address mismatch"); + if (isAuthError) return false; + } + return failureCount < 1; // Only retry once for other errors + }, }, ); diff --git a/src/pages/api-docs.tsx b/src/pages/api-docs.tsx index 235283f8..435ee57d 100644 --- a/src/pages/api-docs.tsx +++ b/src/pages/api-docs.tsx @@ -26,9 +26,32 @@ export default function ApiDocs() { setGeneratedToken(null); // Clear previous token setCopied(false); // Reset copy state try { - // Get the wallet address - const addresses = await wallet.getUsedAddresses(); - const address = addresses[0]; + // Get the wallet address - try used addresses first, fall back to unused + let address: string | undefined; + try { + const usedAddresses = await wallet.getUsedAddresses(); + address = usedAddresses[0]; + } catch (error) { + if (error instanceof Error && error.message.includes("account changed")) { + throw error; + } + } + + // Fall back to unused addresses if no used addresses found + if (!address) { + try { + const unusedAddresses = await wallet.getUnusedAddresses(); + address = unusedAddresses[0]; + } catch (error) { + if (error instanceof Error && error.message.includes("account changed")) { + throw error; + } + } + } + + if (!address) { + throw new Error("No addresses found for wallet"); + } // Step 1: Get nonce const nonceResponse = await fetch(`/api/v1/getNonce?address=${address}`); diff --git a/src/pages/api/trpc/[trpc].ts b/src/pages/api/trpc/[trpc].ts index a89080cd..68d7034a 100644 --- a/src/pages/api/trpc/[trpc].ts +++ b/src/pages/api/trpc/[trpc].ts @@ -18,9 +18,17 @@ export default createNextApiHandler({ error.message.includes("P1008") || error.message.includes("P1017"); + // Skip logging expected authorization errors (403/401, address mismatch, not authorized) + const isExpectedAuthError = + error.code === "FORBIDDEN" || + error.code === "UNAUTHORIZED" || + error.message.includes("Address mismatch") || + error.message.includes("Not authorized") || + error.message.includes("Unauthorized"); + if (isConnectionError) { console.error(`Database connection error on ${path ?? ""}: ${error.message}`); - } else if (env.NODE_ENV === "development") { + } else if (!isExpectedAuthError && env.NODE_ENV === "development") { console.error(`tRPC failed on ${path ?? ""}: ${error.message}`); } }, diff --git a/src/server/api/routers/wallets.ts b/src/server/api/routers/wallets.ts index 1e9a2568..1eec027e 100644 --- a/src/server/api/routers/wallets.ts +++ b/src/server/api/routers/wallets.ts @@ -30,20 +30,63 @@ const assertWalletAccess = async (ctx: any, walletId: string, requester: string) return wallet; }; -const assertNewWalletAccess = async (ctx: any, walletId: string, requester: string) => { +// Check if user is the owner of the wallet +const assertNewWalletOwnerAccess = async (ctx: any, walletId: string, requester: string) => { const wallet = await ctx.db.newWallet.findUnique({ where: { id: walletId } }); if (!wallet) { throw new TRPCError({ code: "NOT_FOUND", message: "Wallet not found" }); } + + // Check if requester is the owner (exact match) + const isOwner = wallet.ownerAddress === requester; + + // Also check if ownerAddress is in sessionWallets (user might have multiple authorized wallets) + const sessionWallets: string[] = (ctx as any).sessionWallets ?? []; + const isOwnerViaSession = sessionWallets.includes(wallet.ownerAddress); + + if (!isOwner && !isOwnerViaSession) { + throw new TRPCError({ code: "FORBIDDEN", message: "Only the owner can perform this action" }); + } + return wallet; +}; + +// Check if user is a signer or owner (for read access) +const assertNewWalletSignerAccess = async (ctx: any, walletId: string, requester: string) => { + const wallet = await ctx.db.newWallet.findUnique({ where: { id: walletId } }); + if (!wallet) { + throw new TRPCError({ code: "NOT_FOUND", message: "Wallet not found" }); + } + + // Check if user is the owner (owners always have full access) + const isOwner = wallet.ownerAddress === requester; + + // Also check if ownerAddress is in sessionWallets (user might have multiple authorized wallets) + const sessionWallets: string[] = (ctx as any).sessionWallets ?? []; + const isOwnerViaSession = sessionWallets.includes(wallet.ownerAddress); + + if (isOwner || isOwnerViaSession) { + return wallet; + } + + // Check if user is a signer const isSigner = Array.isArray(wallet.signersAddresses) && wallet.signersAddresses.includes(requester); - const isOwner = wallet.ownerAddress === requester || wallet.ownerAddress === "all"; - if (!isSigner && !isOwner) { + + // Also check if any signer address is in sessionWallets + const isSignerViaSession = Array.isArray(wallet.signersAddresses) && + wallet.signersAddresses.some((addr: string) => sessionWallets.includes(addr)); + + if (!isSigner && !isSignerViaSession) { throw new TRPCError({ code: "FORBIDDEN", message: "Not authorized for this wallet" }); } return wallet; }; +// Check if user can read the wallet (signer or owner) +const assertNewWalletAccess = async (ctx: any, walletId: string, requester: string) => { + return assertNewWalletSignerAccess(ctx, walletId, requester); +}; + export const walletRouter = createTRPCRouter({ // Read operations stay public but validate signer membership by address param getUserWallets: publicProcedure @@ -325,7 +368,8 @@ export const walletRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }) => { const sessionAddress = requireSessionAddress(ctx); - await assertNewWalletAccess(ctx, input.walletId, sessionAddress); + // Only owners can update the entire wallet + await assertNewWalletOwnerAccess(ctx, input.walletId, sessionAddress); const numRequired = (input.scriptType === "all" || input.scriptType === "any") ? null : input.numRequiredSigners; return ctx.db.newWallet.update({ where: { @@ -357,7 +401,8 @@ export const walletRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }) => { const sessionAddress = requireSessionAddress(ctx); - await assertNewWalletAccess(ctx, input.walletId, sessionAddress); + // Only owners can update all signers + await assertNewWalletOwnerAccess(ctx, input.walletId, sessionAddress); return ctx.db.newWallet.update({ where: { id: input.walletId, @@ -380,7 +425,34 @@ export const walletRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }) => { const sessionAddress = requireSessionAddress(ctx); - await assertNewWalletAccess(ctx, input.walletId, sessionAddress); + const wallet = await assertNewWalletSignerAccess(ctx, input.walletId, sessionAddress); + + // Check if user is the owner - owners can update all descriptions + const isOwner = wallet.ownerAddress === sessionAddress; + + if (!isOwner) { + // Non-owners can only update their own description + // Find the signer's index in the signersAddresses array + const signerIndex = wallet.signersAddresses.indexOf(sessionAddress); + if (signerIndex < 0) { + throw new TRPCError({ code: "FORBIDDEN", message: "You are not a signer of this wallet" }); + } + + // Verify that only the signer's own description is being changed + // All other descriptions must remain the same + for (let i = 0; i < wallet.signersDescriptions.length; i++) { + if (i !== signerIndex && wallet.signersDescriptions[i] !== input.signersDescriptions[i]) { + throw new TRPCError({ code: "FORBIDDEN", message: "You can only update your own description" }); + } + } + + // Verify the array lengths match + if (input.signersDescriptions.length !== wallet.signersDescriptions.length) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Descriptions array length mismatch" }); + } + } + + // Owners can update all, signers can only update their own (validated above) return ctx.db.newWallet.update({ where: { id: input.walletId, @@ -431,7 +503,8 @@ export const walletRouter = createTRPCRouter({ .input(z.object({ walletId: z.string() })) .mutation(async ({ ctx, input }) => { const sessionAddress = requireSessionAddress(ctx); - await assertNewWalletAccess(ctx, input.walletId, sessionAddress); + // Only owners can delete the wallet + await assertNewWalletOwnerAccess(ctx, input.walletId, sessionAddress); return ctx.db.newWallet.delete({ where: { id: input.walletId, diff --git a/src/utils/api.ts b/src/utils/api.ts index f537eb1f..0c888d42 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -28,9 +28,32 @@ export const api = createTRPCNext({ */ links: [ loggerLink({ - enabled: (opts) => - process.env.NODE_ENV === "development" || - (opts.direction === "down" && opts.result instanceof Error), + enabled: (opts) => { + // Don't log expected authorization errors (403/401 errors) + if (opts.direction === "down" && opts.result instanceof Error) { + const error = opts.result as { + code?: string; + message?: string; + data?: { code?: string; httpStatus?: number }; + shape?: { code?: string; message?: string }; + }; + const errorMessage = error.message || error.shape?.message || ""; + const isExpectedAuthError = + error.code === "FORBIDDEN" || + error.code === "UNAUTHORIZED" || + error.data?.code === "FORBIDDEN" || + error.data?.code === "UNAUTHORIZED" || + error.data?.httpStatus === 403 || + error.data?.httpStatus === 401 || + error.shape?.code === "FORBIDDEN" || + error.shape?.code === "UNAUTHORIZED" || + errorMessage.includes("Address mismatch") || + errorMessage.includes("Not authorized") || + errorMessage.includes("Unauthorized"); + if (isExpectedAuthError) return false; + } + return process.env.NODE_ENV === "development"; + }, }), httpBatchLink({ /** @@ -42,6 +65,68 @@ export const api = createTRPCNext({ url: `${getBaseUrl()}/api/trpc`, }), ], + queryClientConfig: { + defaultOptions: { + queries: { + retry: (failureCount, error) => { + // Don't retry on authorization errors (403/401) + if (error && typeof error === "object") { + const err = error as { + code?: string; + message?: string; + data?: { code?: string; httpStatus?: number }; + shape?: { code?: string; message?: string }; + }; + const errorMessage = err.message || err.shape?.message || ""; + const isAuthError = + err.code === "FORBIDDEN" || + err.code === "UNAUTHORIZED" || + err.data?.code === "FORBIDDEN" || + err.data?.code === "UNAUTHORIZED" || + err.data?.httpStatus === 403 || + err.data?.httpStatus === 401 || + err.shape?.code === "FORBIDDEN" || + err.shape?.code === "UNAUTHORIZED" || + errorMessage.includes("Address mismatch") || + errorMessage.includes("Not authorized") || + errorMessage.includes("Unauthorized"); + if (isAuthError) return false; + } + // Default retry behavior for other errors + return failureCount < 3; + }, + }, + mutations: { + retry: (failureCount, error) => { + // Don't retry mutations on authorization errors + if (error && typeof error === "object") { + const err = error as { + code?: string; + message?: string; + data?: { code?: string; httpStatus?: number }; + shape?: { code?: string; message?: string }; + }; + const errorMessage = err.message || err.shape?.message || ""; + const isAuthError = + err.code === "FORBIDDEN" || + err.code === "UNAUTHORIZED" || + err.data?.code === "FORBIDDEN" || + err.data?.code === "UNAUTHORIZED" || + err.data?.httpStatus === 403 || + err.data?.httpStatus === 401 || + err.shape?.code === "FORBIDDEN" || + err.shape?.code === "UNAUTHORIZED" || + errorMessage.includes("Address mismatch") || + errorMessage.includes("Not authorized") || + errorMessage.includes("Unauthorized"); + if (isAuthError) return false; + } + // Don't retry mutations by default + return false; + }, + }, + }, + }, }; }, /** diff --git a/vercel.json b/vercel.json new file mode 100644 index 00000000..ffaa2ad7 --- /dev/null +++ b/vercel.json @@ -0,0 +1,10 @@ +{ + "functions": { + "src/pages/api/**/*.ts": { + "maxDuration": 60 + } + }, + "framework": "nextjs", + "regions": ["fra1"] +} +