diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index bdcba1dc2..5a5330ce2 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -20,7 +20,6 @@ import { twMerge } from 'tailwind-merge' import { useAccount } from 'wagmi' // import ReferralCampaignModal from '@/components/Home/ReferralCampaignModal' // import FloatingReferralButton from '@/components/Home/FloatingReferralButton' -import { AccountType } from '@/interfaces' import { formatUnits } from 'viem' import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants' import { PostSignupActionManager } from '@/components/Global/PostSignupActionManager' @@ -51,7 +50,7 @@ const BALANCE_WARNING_EXPIRY = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_ export default function Home() { const { showPermissionModal } = useNotifications() - const { balance, address, isFetchingBalance } = useWallet() + const { balance, isFetchingBalance } = useWallet() const { resetFlow: resetClaimBankFlow } = useClaimBankFlow() const { resetWithdrawFlow } = useWithdrawFlow() const { deviceType } = useDeviceType() @@ -64,7 +63,7 @@ export default function Home() { const { disconnect: disconnectWagmi } = useDisconnect() const { triggerHaptic } = useHaptic() - const { isFetchingUser, addAccount, fetchUser } = useAuth() + const { isFetchingUser, fetchUser } = useAuth() const { isUserKycApproved } = useKycStatus() const username = user?.user.username @@ -107,19 +106,6 @@ export default function Home() { resetWithdrawFlow() }, [resetClaimBankFlow, resetWithdrawFlow]) - useEffect(() => { - if (isFetchingUser) return - // We have some users that didn't have the peanut wallet created - // correctly, so we need to create it - if (address && user && !user.accounts.some((a) => a.type === AccountType.PEANUT_WALLET)) { - addAccount({ - accountIdentifier: address, - accountType: 'peanut-wallet', - userId: user.user.userId, - }) - } - }, [user, address, isFetchingUser]) - // always reset external wallet connection on home page useEffect(() => { if (isWagmiConnected) { diff --git a/src/app/(mobile-ui)/layout.tsx b/src/app/(mobile-ui)/layout.tsx index b80369424..8c71adeca 100644 --- a/src/app/(mobile-ui)/layout.tsx +++ b/src/app/(mobile-ui)/layout.tsx @@ -22,6 +22,7 @@ import ForceIOSPWAInstall from '@/components/ForceIOSPWAInstall' import { PUBLIC_ROUTES_REGEX } from '@/constants/routes' import { usePullToRefresh } from '@/hooks/usePullToRefresh' import { useNetworkStatus } from '@/hooks/useNetworkStatus' +import { useAccountSetupRedirect } from '@/hooks/useAccountSetupRedirect' const Layout = ({ children }: { children: React.ReactNode }) => { const pathName = usePathname() @@ -80,6 +81,9 @@ const Layout = ({ children }: { children: React.ReactNode }) => { } }, [user, isFetchingUser, isReady, isPublicPath, router]) + // redirect logged-in users without peanut wallet account to complete setup + const { needsRedirect, isCheckingAccount } = useAccountSetupRedirect() + // show full-page offline screen when user is offline // only show after initialization to prevent flash on initial load // when connection is restored, page auto-reloads (no "back online" screen) @@ -97,8 +101,8 @@ const Layout = ({ children }: { children: React.ReactNode }) => { ) } } else { - // For protected paths, wait for user auth - if (!isReady || isFetchingUser || !hasToken || !user) { + // for protected paths, wait for user auth and account setup check + if (!isReady || isFetchingUser || !hasToken || !user || needsRedirect || isCheckingAccount) { return (
diff --git a/src/app/(setup)/setup/finish/page.tsx b/src/app/(setup)/setup/finish/page.tsx new file mode 100644 index 000000000..fe07da29e --- /dev/null +++ b/src/app/(setup)/setup/finish/page.tsx @@ -0,0 +1,36 @@ +'use client' + +import { Suspense } from 'react' +import PeanutLoading from '@/components/Global/PeanutLoading' +import { SetupWrapper } from '@/components/Setup/components/SetupWrapper' +import SignTestTransaction from '@/components/Setup/Views/SignTestTransaction' +import chillPeanutAnim from '@/animations/GIF_ALPHA_BACKGORUND/512X512_ALPHA_GIF_konradurban_01.gif' + +/** + * finish setup page for users who logged in but haven't completed account setup + * shows test transaction step to verify passkey works before finalizing + */ +function FinishSetupPageContent() { + return ( + + + + ) +} + +export default function FinishSetupPage() { + return ( + }> + + + ) +} diff --git a/src/components/Global/GuestLoginCta/index.tsx b/src/components/Global/GuestLoginCta/index.tsx deleted file mode 100644 index d7dc57aa3..000000000 --- a/src/components/Global/GuestLoginCta/index.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { Button } from '@/components/0_Bruddle' -import Divider from '@/components/0_Bruddle/Divider' -import { useToast } from '@/components/0_Bruddle/Toast' -import { useZeroDev } from '@/hooks/useZeroDev' -import { sanitizeRedirectURL, saveRedirectUrl } from '@/utils' -import { initializeAppKit } from '@/config/wagmi.config' -import { useAppKit } from '@reown/appkit/react' -import * as Sentry from '@sentry/nextjs' -import { useRouter, useSearchParams } from 'next/navigation' -import { useEffect } from 'react' - -interface GuestLoginCtaProps { - hideConnectWallet?: boolean - view?: 'CLAIM' | 'REQUEST' -} - -const GuestLoginCta = ({ hideConnectWallet = false, view }: GuestLoginCtaProps) => { - const { handleLogin, isLoggingIn, address: passkeyAddress } = useZeroDev() - const toast = useToast() - const router = useRouter() - const { open: openReownModal } = useAppKit() - const searchParams = useSearchParams() - - // If user already has a passkey address, auto-redirect to avoid double prompting - useEffect(() => { - if (passkeyAddress && !isLoggingIn) { - console.log('User already has passkey wallet:', passkeyAddress) - } - }, [passkeyAddress, isLoggingIn]) - - const handleSignUp = () => { - saveRedirectUrl() - router.push('/setup') - } - - const handleLoginClick = async () => { - // Prevent double login attempts - if (isLoggingIn || passkeyAddress) { - return - } - - try { - await handleLogin() - const redirect_uri = searchParams.get('redirect_uri') - if (redirect_uri) { - const sanitizedRedirectUrl = sanitizeRedirectURL(redirect_uri) - // Only redirect if the URL is safe (same-origin) - if (sanitizedRedirectUrl) { - router.push(sanitizedRedirectUrl) - } else { - // If redirect_uri was invalid, stay on current page - Sentry.captureException(`Invalid redirect URL ${redirect_uri}`) - } - } - } catch (e) { - toast.error('Error logging in') - Sentry.captureException(e) - } - } - - return ( -
- {/* Primary Sign Up Button */} - - - {/* Secondary Log In Button */} - - - {/* "or" divider and External Wallet Button */} - {!hideConnectWallet && ( - <> - - - - )} -
- ) -} - -export default GuestLoginCta diff --git a/src/components/Setup/Setup.consts.tsx b/src/components/Setup/Setup.consts.tsx index d941513cb..c3e3a46d4 100644 --- a/src/components/Setup/Setup.consts.tsx +++ b/src/components/Setup/Setup.consts.tsx @@ -1,11 +1,9 @@ import chillPeanutAnim from '@/animations/GIF_ALPHA_BACKGORUND/512X512_ALPHA_GIF_konradurban_01.gif' -import peanutWithGlassesAnim from '@/animations/GIF_ALPHA_BACKGORUND/512X512_ALPHA_GIF_konradurban_06.gif' import happyPeanutAnim from '@/animations/GIF_ALPHA_BACKGORUND/512X512_ALPHA_GIF_konradurban_03.gif' import { PEANUTMAN_MOBILE, ThinkingPeanut } from '@/assets' import type { ISetupStep } from '@/components/Setup/Setup.types' -import { InstallPWA, SetupPasskey, SignupStep, JoinBetaStep, CollectEmail, LandingStep } from '@/components/Setup/Views' +import { InstallPWA, SetupPasskey, SignupStep, LandingStep, SignTestTransaction } from '@/components/Setup/Views' import JoinWaitlist from './Views/JoinWaitlist' -import ForceIOSPWAInstall from '../ForceIOSPWAInstall' export const setupSteps: ISetupStep[] = [ { @@ -89,12 +87,12 @@ export const setupSteps: ISetupStep[] = [ contentClassName: 'flex flex-col items-end pt-8 justify-center gap-5', }, { - screenId: 'collect-email', + screenId: 'sign-test-transaction', layoutType: 'signup', - title: 'Stay in the loop', - description: 'Enter your email to finish setup. We’ll send you an update as soon as you get access.', + title: 'Sign a test transaction', + description: "Let's make sure your passkey is working and you have everything set up correctly.", image: chillPeanutAnim.src, - component: CollectEmail, + component: SignTestTransaction, showBackButton: false, showSkipButton: false, contentClassName: 'flex flex-col items-center justify-center gap-5', diff --git a/src/components/Setup/Setup.types.ts b/src/components/Setup/Setup.types.ts index f42079faf..edd15da32 100644 --- a/src/components/Setup/Setup.types.ts +++ b/src/components/Setup/Setup.types.ts @@ -11,7 +11,7 @@ export type ScreenId = | 'success' | 'unsupported-browser' | 'join-beta' - | 'collect-email' + | 'sign-test-transaction' export type LayoutType = 'signup' | 'standard' | 'android-initial-pwa-install' @@ -31,7 +31,7 @@ export type ScreenProps = { 'android-initial-pwa-install': undefined 'unsupported-browser': undefined 'join-beta': undefined - 'collect-email': undefined + 'sign-test-transaction': undefined } export interface StepComponentProps { diff --git a/src/components/Setup/Views/CollectEmail.tsx b/src/components/Setup/Views/CollectEmail.tsx deleted file mode 100644 index e1d7f3c28..000000000 --- a/src/components/Setup/Views/CollectEmail.tsx +++ /dev/null @@ -1,88 +0,0 @@ -'use client' - -import { updateUserById } from '@/app/actions/users' -import { Button } from '@/components/0_Bruddle' -import ErrorAlert from '@/components/Global/ErrorAlert' -import ValidatedInput from '@/components/Global/ValidatedInput' -import { useAuth } from '@/context/authContext' -import { useRouter } from 'next/navigation' -import React, { useState } from 'react' -import { twMerge } from 'tailwind-merge' - -const CollectEmail = () => { - const [email, setEmail] = useState('') - const [isValid, setIsValid] = useState(false) - const [isChanging, setIsChanging] = useState(false) - const [isLoading, setisLoading] = useState(false) - const [error, setError] = useState('') - const { user } = useAuth() - const router = useRouter() - - const validateEmail = async (email: string) => { - const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ - return emailRegex.test(email) - } - - const handleFinishSetup = async () => { - try { - setError('') - setisLoading(true) - const { error } = await updateUserById({ - userId: user?.user.userId, - email, - }) - - if (error && error.includes('Unique constraint failed on the fields: (`email`)')) { - setError('Email already in use.') - return - } - - if (error) { - setError('Something went wrong. Please try again or contact support') - return - } - router.push('/home') - } catch { - setError('Something went wrong. Please try again or contact support') - } finally { - setisLoading(false) - } - } - - return ( -
- { - setIsValid(isValid) - setIsChanging(isChanging) - setEmail(value) - }} - isSetupFlow - isInputChanging={isChanging} - className={twMerge( - !isValid && !isChanging && !!email && 'border-error dark:border-error', - isValid && !isChanging && !!email && 'border-secondary-8 dark:border-secondary-8', - 'rounded-sm' - )} - /> - - - - {(!!error || (!isValid && !isChanging && !!email)) && } -
- ) -} - -export default CollectEmail diff --git a/src/components/Setup/Views/SetupPasskey.tsx b/src/components/Setup/Views/SetupPasskey.tsx index 781fcb559..f43122fef 100644 --- a/src/components/Setup/Views/SetupPasskey.tsx +++ b/src/components/Setup/Views/SetupPasskey.tsx @@ -6,115 +6,46 @@ import { useSetupFlow } from '@/hooks/useSetupFlow' import { useAuth } from '@/context/authContext' import { useDeviceType } from '@/hooks/useGetDeviceType' import { useEffect, useState } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' import * as Sentry from '@sentry/nextjs' -import { WalletProviderType, AccountType } from '@/interfaces' import { WebAuthnError } from '@simplewebauthn/browser' import Link from 'next/link' -import { - getFromCookie, - getRedirectUrl, - getValidRedirectUrl, - clearRedirectUrl, - clearAuthState, - withWebAuthnRetry, - getWebAuthnErrorMessage, -} from '@/utils' -import { POST_SIGNUP_ACTIONS } from '@/components/Global/PostSignupActionManager/post-signup-action.consts' +import { clearAuthState, withWebAuthnRetry, getWebAuthnErrorMessage } from '@/utils' const SetupPasskey = () => { const dispatch = useAppDispatch() - const { username, telegramHandle, inviteCode } = useSetupStore() + const { username } = useSetupStore() const { isLoading, handleNext } = useSetupFlow() const { handleRegister, address } = useZeroDev() - const { user, isFetchingUser } = useAuth() - const { addAccount } = useAuth() + const { user } = useAuth() const { deviceType } = useDeviceType() const [error, setError] = useState(null) - const router = useRouter() - const searchParams = useSearchParams() + // once passkey is registered successfully, move to test transaction step useEffect(() => { - // Dont try to double add the account - if (isFetchingUser || user?.accounts.some((a) => a.type === AccountType.PEANUT_WALLET)) return - if (address && user) { - addAccount({ - accountIdentifier: address, - accountType: WalletProviderType.PEANUT, - userId: user?.user.userId as string, - telegramHandle: telegramHandle.length > 0 ? telegramHandle : undefined, - }) - .then(() => { - const inviteCodeFromCookie = getFromCookie('inviteCode') - - const userInviteCode = inviteCode || inviteCodeFromCookie - - // if no invite code, go to collect email step - if (!userInviteCode) { - handleNext() - return - } - - const redirect_uri = searchParams.get('redirect_uri') - if (redirect_uri) { - const validRedirectUrl = getValidRedirectUrl(redirect_uri, '/home') - // Only redirect if the URL is safe (same-origin) - router.push(validRedirectUrl) - return - // If redirect_uri was invalid, fall through to other redirect logic - } - - const localStorageRedirect = getRedirectUrl() - // redirect based on post signup action config - if (localStorageRedirect) { - const matchedAction = POST_SIGNUP_ACTIONS.find((action) => - action.pathPattern.test(localStorageRedirect) - ) - if (matchedAction) { - router.push('/home') - } else { - clearRedirectUrl() - const validRedirectUrl = getValidRedirectUrl(localStorageRedirect, '/home') - router.push(validRedirectUrl) - } - } else { - router.push('/home') - } - }) - .catch((e) => { - Sentry.captureException(e) - console.error('Error adding account', e) - setError('Error adding account. Please try refreshing the page.') - - // CRITICAL FIX: Clear auth state if account creation fails - // This prevents the user from getting stuck in an unrecoverable state - clearAuthState(user?.user.userId) - }) - .finally(() => { - dispatch(setupActions.setLoading(false)) - }) + if (address) { + handleNext() } - }, [address, user, isFetchingUser]) + }, [address, handleNext]) return (
+ {displayError &&

{displayError}

} +
+
+

+ + Learn more about what Passkeys are + {' '} +

+
+
+
+ ) +} + +export const PasskeyDocsLink = ({ className }: { className?: string }) => { + return ( +

+ + Learn more about what Passkeys are + {' '} +

+ ) +} + +export default SignTestTransaction diff --git a/src/components/Setup/Views/index.ts b/src/components/Setup/Views/index.ts index 82cb898de..eb076a93f 100644 --- a/src/components/Setup/Views/index.ts +++ b/src/components/Setup/Views/index.ts @@ -1,7 +1,7 @@ export { default as InstallPWA } from './InstallPWA' export { default as SetupPasskey } from './SetupPasskey' +export { default as SignTestTransaction } from './SignTestTransaction' export { default as SignupStep } from './Signup' export { default as WelcomeStep } from './Welcome' export { default as LandingStep } from './Landing' export { default as JoinBetaStep } from './JoinBeta' -export { default as CollectEmail } from './CollectEmail' diff --git a/src/components/TransactionDetails/TransactionCard.tsx b/src/components/TransactionDetails/TransactionCard.tsx index 741d643a3..12b3c65b4 100644 --- a/src/components/TransactionDetails/TransactionCard.tsx +++ b/src/components/TransactionDetails/TransactionCard.tsx @@ -22,6 +22,8 @@ import { EHistoryEntryType } from '@/utils/history.utils' import { PerkIcon } from './PerkIcon' import { useHaptic } from 'use-haptic' import LazyLoadErrorBoundary from '@/components/Global/LazyLoadErrorBoundary' +import { PEANUTMAN_LOGO } from '@/assets/peanut' +import InvitesIcon from '../Home/InvitesIcon' // Lazy load transaction details drawer (~40KB) to reduce initial bundle size // Only loaded when user taps a transaction to view details @@ -90,6 +92,8 @@ const TransactionCard: React.FC = ({ const userNameForAvatar = transaction.showFullName && transaction.fullName ? transaction.fullName : transaction.userName const avatarUrl = getAvatarUrl(transaction) + // check if this is a test transaction (setup confirmation) + const isTestTransaction = name === 'Enjoy Peanut!' let displayName = name if (isAddress(displayName)) { displayName = printableAddress(displayName) @@ -127,7 +131,17 @@ const TransactionCard: React.FC = ({
{/* txn avatar component handles icon/initials/colors */} - {isPerkReward ? ( + {isTestTransaction ? ( +
+ Peanut Logo +
+ ) : isPerkReward ? ( <> @@ -166,22 +180,28 @@ const TransactionCard: React.FC = ({
{/* display the action icon and type text */}
- {getActionIcon(type, transaction.direction)} - {isPerkReward ? 'Refund' : getActionText(type)} + {!isTestTransaction && getActionIcon(type, transaction.direction)} + + {isTestTransaction ? 'Setup' : isPerkReward ? 'Refund' : getActionText(type)} + {status && }
{/* amount and status on the right side */} -
-
- {displayAmount} - {currencyDisplayAmount && ( - {currencyDisplayAmount} - )} + {isTestTransaction ? ( + + ) : ( +
+
+ {displayAmount} + {currencyDisplayAmount && ( + {currencyDisplayAmount} + )} +
-
+ )}
diff --git a/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx b/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx index 1abb54d6e..f32c19cd2 100644 --- a/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx +++ b/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx @@ -13,6 +13,7 @@ import { VerifiedUserLabel } from '../UserHeader' import ProgressBar from '../Global/ProgressBar' import { useRouter } from 'next/navigation' import { twMerge } from 'tailwind-merge' +import { PEANUTMAN_LOGO } from '@/assets' export type TransactionDirection = | 'send' @@ -78,6 +79,11 @@ const getTitle = ( } else { const isAddress = isWalletAddress(userName) const displayName = isAddress ? printableAddress(userName) : userName + + // check if this is a test transaction (setup confirmation) + // note: bad check, but its a quick fix for now - kush (18 nov 2025), to be handled in the backend post devconnect. + const isTestTransaction = displayName === 'Enjoy Peanut!' + switch (direction) { case 'send': if (status === 'pending' || status === 'cancelled') { @@ -112,7 +118,11 @@ const getTitle = ( break case 'add': case 'bank_deposit': - titleText = `${status === 'completed' ? 'Added' : 'Adding'} from ${displayName}` + if (isTestTransaction) { + titleText = 'Enjoy Peanut!' + } else { + titleText = `${status === 'completed' ? 'Added' : 'Adding'} from ${displayName}` + } break case 'claim_external': if (status === 'completed') { @@ -141,8 +151,12 @@ const getTitle = ( return titleText } -const getIcon = (direction: TransactionDirection, isLinkTransaction?: boolean): IconName | undefined => { - if (isLinkTransaction) { +const getIcon = ( + direction: TransactionDirection, + isLinkTransaction?: boolean, + isTestTransaction?: boolean +): IconName | undefined => { + if (isLinkTransaction || isTestTransaction) { return undefined } @@ -195,7 +209,9 @@ export const TransactionDetailsHeaderCard: React.FC { if (isAvatarClickable) { @@ -207,65 +223,77 @@ export const TransactionDetailsHeaderCard: React.FC -
-
- {avatarUrl ? ( -
- Icon -
- ) : ( - - )} + {isTestTransaction ? ( +
+
+ Peanut Logo +
+
+

Enjoy Peanut!

+
-
-

- {icon && } - - -
- {status && } -
-

-

+
+ {avatarUrl ? ( +
+ Icon +
+ ) : ( + )} - > - {amountDisplay} -

+
+
+

+ {icon && } + - {convertedAmount &&

≈ {convertedAmount}

} +
+ {status && } +
+

+

+ {amountDisplay} +

- {isNoGoalSet &&

No goal set

} + {convertedAmount &&

≈ {convertedAmount}

} + + {isNoGoalSet &&

No goal set

} +
-
+ )} + {!isNoGoalSet && showProgessBar && goal !== undefined && progress !== undefined && (
diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index fdfd53c27..8e912bf35 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -61,9 +61,9 @@ import { import { mantecaApi } from '@/services/manteca' import { getReceiptUrl } from '@/utils/history.utils' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants' -import TransactionCard from './TransactionCard' import ContributorCard from '../Global/Contributors/ContributorCard' import { requestsApi } from '@/services/requests' +import { PasskeyDocsLink } from '../Setup/Views/SignTestTransaction' export const TransactionDetailsReceipt = ({ transaction, @@ -1387,14 +1387,18 @@ export const TransactionDetailsReceipt = ({
)} - {/* support link section */} - + {/* support link section or passkey docs for test transactions */} + {transaction.userName === 'Enjoy Peanut!' ? ( + + ) : ( + + )} {/* Cancel Link Modal */} diff --git a/src/components/TransactionDetails/transactionTransformer.ts b/src/components/TransactionDetails/transactionTransformer.ts index 214702933..0a271bdc1 100644 --- a/src/components/TransactionDetails/transactionTransformer.ts +++ b/src/components/TransactionDetails/transactionTransformer.ts @@ -327,12 +327,19 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact nameForDetails = 'Bank Account' isPeerActuallyUser = false break - case EHistoryEntryType.DEPOSIT: + case EHistoryEntryType.DEPOSIT: { direction = 'add' transactionCardType = 'add' - nameForDetails = entry.senderAccount?.identifier || 'Deposit Source' + // check if this is a test transaction (0 amount deposit during account setup), ideally this should be handled in the backend, but for now we'll handle it here cuz its a quick fix, and in promisland of post devconnect this should be handled in the backend. + const isTestTransaction = String(entry.amount) === '0' || entry.extraData?.usdAmount === '0' + if (isTestTransaction) { + nameForDetails = 'Enjoy Peanut!' + } else { + nameForDetails = entry.senderAccount?.identifier || 'Deposit Source' + } isPeerActuallyUser = false break + } case EHistoryEntryType.MANTECA_QR_PAYMENT: direction = 'qr_payment' transactionCardType = 'pay' @@ -502,6 +509,10 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact // if showFullName is false or undefined, use username; otherwise use fullName const nameForInitials = showFullName && fullName ? fullName : nameForDetails + // check if this is a test transaction for adding a memo + const isTestDeposit = + entry.type === EHistoryEntryType.DEPOSIT && (String(entry.amount) === '0' || entry.extraData?.usdAmount === '0') + // build the final transactiondetails object for the ui const transactionDetails: TransactionDetails = { id: entry.uuid, @@ -519,7 +530,7 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact // only show verification badge if the other person is a peanut user date: new Date(entry.timestamp), fee: undefined, - memo: entry.memo?.trim(), + memo: isTestDeposit ? 'Your peanut wallet is ready to use!' : entry.memo?.trim(), attachmentUrl: entry.attachmentUrl, cancelledDate: entry.cancelledAt, txHash: entry.txHash, diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index 165bcd4d7..213f4a1ec 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -2,8 +2,9 @@ import { useToast } from '@/components/0_Bruddle/Toast' import { useUserQuery } from '@/hooks/query/user' import * as interfaces from '@/interfaces' -import { useAppDispatch, useUserStore } from '@/redux/hooks' +import { useAppDispatch } from '@/redux/hooks' import { setupActions } from '@/redux/slices/setup-slice' +import { zerodevActions } from '@/redux/slices/zerodev-slice' import { fetchWithSentry, removeFromCookie, @@ -13,7 +14,7 @@ import { } from '@/utils' import { resetCrispProxySessions } from '@/utils/crisp' import { useQueryClient } from '@tanstack/react-query' -import { useRouter, usePathname } from 'next/navigation' +import { useRouter } from 'next/navigation' import { createContext, type ReactNode, useContext, useState, useEffect, useMemo, useCallback } from 'react' import { captureException } from '@sentry/nextjs' // import { PUBLIC_ROUTES_REGEX } from '@/constants/routes' @@ -137,13 +138,23 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { }) if (response.ok) { + // clear user preferences (webauthn key in localStorage) updateUserPreferences(user?.user.userId, { webAuthnKey: undefined }) + + // clear cookies removeFromCookie(WEB_AUTHN_COOKIE_KEY) + document.cookie = 'jwt-token=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT;' + + // clear redirect url clearRedirectUrl() + + // invalidate all queries queryClient.invalidateQueries() - // clear JWT cookie by setting it to expire - document.cookie = 'jwt-token=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT;' + // reset redux state (setup and zerodev) + dispatch(setupActions.resetSetup()) + dispatch(zerodevActions.resetZeroDevState()) + console.log('[Logout] Cleared redux state (setup and zerodev)') // Clear service worker caches to prevent user data leakage // When User A logs out and User B logs in on the same device, cached API responses @@ -177,8 +188,10 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { resetCrispProxySessions() } + // fetch user (should return null after logout) await fetchUser() - dispatch(setupActions.resetSetup()) + + // redirect to setup page router.replace('/setup') toast.success('Logged out successfully') diff --git a/src/context/kernelClient.context.tsx b/src/context/kernelClient.context.tsx index e6d75d4a6..8d2597ef2 100644 --- a/src/context/kernelClient.context.tsx +++ b/src/context/kernelClient.context.tsx @@ -163,7 +163,14 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => { // lifecycle hooks useEffect(() => { - if (!user?.user.userId) return + if (!user?.user.userId) { + // clear webauthn key and clients when user logs out + console.log('[KernelClient] No user found, clearing webAuthnKey and clients') + setWebAuthnKey(undefined) + setClientsByChain({}) + return + } + const userPreferences = getUserPreferences(user.user.userId) const storedWebAuthnKey = userPreferences?.webAuthnKey ?? getFromCookie(WEB_AUTHN_COOKIE_KEY) if (storedWebAuthnKey) { diff --git a/src/hooks/useAccountSetup.ts b/src/hooks/useAccountSetup.ts new file mode 100644 index 000000000..5594a0001 --- /dev/null +++ b/src/hooks/useAccountSetup.ts @@ -0,0 +1,95 @@ +import { useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import * as Sentry from '@sentry/nextjs' +import { useAuth } from '@/context/authContext' +import { WalletProviderType } from '@/interfaces' +import { getRedirectUrl, getValidRedirectUrl, clearRedirectUrl, clearAuthState } from '@/utils' +import { POST_SIGNUP_ACTIONS } from '@/components/Global/PostSignupActionManager/post-signup-action.consts' +import { useSetupStore } from '@/redux/hooks' + +/** + * shared hook for finalizing account setup after test transaction succeeds + * handles adding account to db and navigation logic + */ +export const useAccountSetup = () => { + const { user } = useAuth() + const { addAccount } = useAuth() + const { telegramHandle } = useSetupStore() + const router = useRouter() + const searchParams = useSearchParams() + const [error, setError] = useState(null) + const [isProcessing, setIsProcessing] = useState(false) + + /** + * finalize account setup by adding account to db and navigating + */ + const finalizeAccountSetup = async (address: string) => { + console.log('[useAccountSetup] Starting account finalization', { address, userId: user?.user.userId }) + + if (!user) { + console.error('[useAccountSetup] No user found') + setError('User not found. Please refresh the page.') + return false + } + + setIsProcessing(true) + setError(null) + + try { + console.log('[useAccountSetup] Adding account to database') + await addAccount({ + accountIdentifier: address, + accountType: WalletProviderType.PEANUT, + userId: user.user.userId as string, + telegramHandle: telegramHandle.length > 0 ? telegramHandle : undefined, + }) + console.log('[useAccountSetup] Account added successfully') + + const redirect_uri = searchParams.get('redirect_uri') + if (redirect_uri) { + const validRedirectUrl = getValidRedirectUrl(redirect_uri, '/home') + console.log('[useAccountSetup] Redirecting to redirect_uri:', validRedirectUrl) + router.push(validRedirectUrl) + return true + } + + const localStorageRedirect = getRedirectUrl() + if (localStorageRedirect) { + const matchedAction = POST_SIGNUP_ACTIONS.find((action) => + action.pathPattern.test(localStorageRedirect) + ) + if (matchedAction) { + console.log('[useAccountSetup] Matched post-signup action, redirecting to /home') + router.push('/home') + } else { + clearRedirectUrl() + const validRedirectUrl = getValidRedirectUrl(localStorageRedirect, '/home') + console.log('[useAccountSetup] Redirecting to localStorage redirect:', validRedirectUrl) + router.push(validRedirectUrl) + } + } else { + console.log('[useAccountSetup] No redirect found, going to /home') + router.push('/home') + } + + return true + } catch (e) { + Sentry.captureException(e) + console.error('[useAccountSetup] Error adding account:', e) + setError('Error adding account. Please try refreshing the page.') + + // clear auth state if account creation fails + clearAuthState(user?.user.userId) + return false + } finally { + setIsProcessing(false) + } + } + + return { + finalizeAccountSetup, + isProcessing, + error, + setError, + } +} diff --git a/src/hooks/useAccountSetupRedirect.ts b/src/hooks/useAccountSetupRedirect.ts new file mode 100644 index 000000000..88c3908ce --- /dev/null +++ b/src/hooks/useAccountSetupRedirect.ts @@ -0,0 +1,35 @@ +import { useEffect, useMemo } from 'react' +import { useRouter, usePathname } from 'next/navigation' +import { useAuth } from '@/context/authContext' +import { AccountType } from '@/interfaces' + +/** + * hook to check if logged-in user needs to complete account setup + * returns whether user should be redirected and handles the redirect + * should be used in layouts or pages that require a complete account + */ +export const useAccountSetupRedirect = () => { + const { user, isFetchingUser } = useAuth() + const router = useRouter() + const pathName = usePathname() + + // synchronously check if user needs redirect (runs during render, not after) + const needsRedirect = useMemo(() => { + if (!user || isFetchingUser || pathName === '/setup/finish') return false + + const hasPeanutWalletAccount = user.accounts.some((a) => a.type === AccountType.PEANUT_WALLET) + return !hasPeanutWalletAccount + }, [user, isFetchingUser, pathName]) + + // perform redirect in effect + useEffect(() => { + if (needsRedirect) { + console.log( + '[useAccountSetupRedirect] User logged in without peanut wallet account, redirecting to /setup/finish' + ) + router.push('/setup/finish') + } + }, [needsRedirect, router]) + + return { needsRedirect, isCheckingAccount: isFetchingUser } +} diff --git a/src/hooks/useLogin.tsx b/src/hooks/useLogin.tsx index fc988c5e0..1c4c66183 100644 --- a/src/hooks/useLogin.tsx +++ b/src/hooks/useLogin.tsx @@ -13,6 +13,8 @@ import { useRouter, useSearchParams } from 'next/navigation' * 2. Saved redirect URL from localStorage (if present and safe) * 3. '/home' as fallback * + * Note: The mobile-ui layout handles redirecting users without PEANUT_WALLET accounts to /setup/finish + * * All redirects are sanitized to prevent external URL redirection attacks. * * @returns {Object} Login handlers and state @@ -27,10 +29,11 @@ export const useLogin = () => { const router = useRouter() const [isloginClicked, setIsloginClicked] = useState(false) - // Wait for user to be fetched, then redirect + // wait for user to be fetched, then redirect useEffect(() => { - // Run only if login button is clicked to provide un-intentional redirects. + // run only if login button is clicked to prevent un-intentional redirects if (isloginClicked && user) { + // redirect based on query params or saved redirect url const localStorageRedirect = getRedirectUrl() const redirect_uri = searchParams.get('redirect_uri') if (redirect_uri) { diff --git a/src/utils/index.ts b/src/utils/index.ts index da1813df8..ba54273d9 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -8,6 +8,7 @@ export * from './ens.utils' export * from './history.utils' export * from './auth.utils' export * from './webauthn.utils' +export * from './passkeyDebug' // Bridge utils - explicit exports to avoid naming conflicts export { diff --git a/src/utils/passkeyDebug.ts b/src/utils/passkeyDebug.ts new file mode 100644 index 000000000..c9c022247 --- /dev/null +++ b/src/utils/passkeyDebug.ts @@ -0,0 +1,64 @@ +import * as Sentry from '@sentry/nextjs' + +/** + * captures debug information about passkey and device capabilities + * useful for troubleshooting passkey setup and transaction signing issues + */ +export const capturePasskeyDebugInfo = async (context: string) => { + try { + const debugInfo: Record = { + context, + timestamp: new Date().toISOString(), + // basic environment checks + isSecureContext: window.isSecureContext, + cookieEnabled: navigator.cookieEnabled, + userAgent: navigator.userAgent, + platform: navigator.platform, + // credentials api availability + credentialsApiAvailable: 'credentials' in navigator, + publicKeyCredentialAvailable: typeof window.PublicKeyCredential !== 'undefined', + } + + // check conditional mediation support + if (window.PublicKeyCredential) { + try { + debugInfo.conditionalMediationAvailable = + await window.PublicKeyCredential.isConditionalMediationAvailable() + } catch (e) { + debugInfo.conditionalMediationError = (e as Error).message + } + + // check user verifying platform authenticator availability + try { + debugInfo.platformAuthenticatorAvailable = + await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() + } catch (e) { + debugInfo.platformAuthenticatorError = (e as Error).message + } + } + + // check for stored credentials (non-intrusive check) + if (navigator.credentials) { + try { + console.log('navigator.credentials object:', navigator.credentials) + } catch (e) { + debugInfo.credentialsObjectError = (e as Error).message + } + } + + // log to sentry with all collected info + Sentry.captureMessage(`Passkey Debug Info: ${context}`, { + level: 'info', + extra: debugInfo, + }) + + console.log('[PasskeyDebug]', debugInfo) + return debugInfo + } catch (error) { + console.error('[PasskeyDebug] Error capturing debug info:', error) + Sentry.captureException(error, { + extra: { debugContext: context }, + }) + return null + } +}