diff --git a/lib/api/useApiFetch.tsx b/lib/api/useApiFetch.tsx index 056c5a946c..ab6ff8a38c 100644 --- a/lib/api/useApiFetch.tsx +++ b/lib/api/useApiFetch.tsx @@ -35,6 +35,7 @@ export default function useApiFetch() { { pathParams, queryParams, fetchParams, logError, chain }: Params = {}, ) => { const apiToken = cookies.get(cookies.NAMES.API_TOKEN); + const rewardsApiToken = cookies.get(cookies.NAMES.REWARDS_API_TOKEN); const apiTempToken = cookies.get(cookies.NAMES.API_TEMP_TOKEN); const showScamTokens = cookies.get(cookies.NAMES.SHOW_SCAM_TOKENS) === 'true'; @@ -49,6 +50,9 @@ export default function useApiFetch() { 'api-v2-temp-token': apiTempToken, 'show-scam-tokens': showScamTokens ? 'true' : undefined, } : {}), + ...(apiName === 'rewards' && rewardsApiToken ? { + Authorization: `Bearer ${ rewardsApiToken }`, + } : {}), ...resource.headers, ...fetchParams?.headers, }, Boolean) as HeadersInit; diff --git a/lib/contexts/rewards.tsx b/lib/contexts/rewards.tsx index a42cbec38a..735196e939 100644 --- a/lib/contexts/rewards.tsx +++ b/lib/contexts/rewards.tsx @@ -1,8 +1,7 @@ -import type { UseQueryResult } from '@tanstack/react-query'; -import { useQueryClient } from '@tanstack/react-query'; +import { useQueryClient, type UseQueryResult } from '@tanstack/react-query'; import { useToggle } from '@uidotdev/usehooks'; import { useRouter } from 'next/router'; -import React, { createContext, useContext, useEffect, useMemo, useCallback } from 'react'; +import React, { createContext, useContext, useEffect, useMemo, useCallback, useState } from 'react'; import { useSignMessage, useSwitchChain } from 'wagmi'; import type * as rewards from '@blockscout/points-types'; @@ -33,13 +32,14 @@ type TRewardsContext = { referralsQuery: ContextQueryResult; rewardsConfigQuery: ContextQueryResult; checkUserQuery: ContextQueryResult; - apiToken: string | undefined; isInitialized: boolean; isLoginModalOpen: boolean; + isAuth: boolean; openLoginModal: () => void; closeLoginModal: () => void; - saveApiToken: (token: string | undefined) => void; login: (refCode: string) => Promise<{ isNewUser: boolean; reward?: string; invalidRefCodeError?: boolean }>; + onLoginSuccess: (token: string) => void; + logout: () => Promise; claim: () => Promise; }; @@ -58,13 +58,14 @@ const initialState = { referralsQuery: defaultQueryResult, rewardsConfigQuery: defaultQueryResult, checkUserQuery: defaultQueryResult, - apiToken: undefined, isInitialized: false, isLoginModalOpen: false, + isAuth: false, openLoginModal: () => {}, closeLoginModal: () => {}, - saveApiToken: () => {}, login: async() => ({ isNewUser: false }), + onLoginSuccess: () => {}, + logout: async() => {}, claim: async() => {}, }; @@ -108,73 +109,36 @@ type Props = { export function RewardsContextProvider({ children }: Props) { const router = useRouter(); - const queryClient = useQueryClient(); const apiFetch = useApiFetch(); + const queryClient = useQueryClient(); + const { address } = useAccount(); const { signMessageAsync } = useSignMessage(); const { switchChainAsync } = useSwitchChain(); const profileQuery = useProfileQuery(); const [ isLoginModalOpen, setIsLoginModalOpen ] = useToggle(false); - const [ isInitialized, setIsInitialized ] = useToggle(false); - const [ apiToken, setApiToken ] = React.useState(); + const [ isInitialized, setIsInitialized ] = useState(false); + const [ isAuth, setIsAuth ] = useState(false); - // Initialize state with the API token from cookies - useEffect(() => { - if (!profileQuery.isLoading) { - const token = cookies.get(cookies.NAMES.REWARDS_API_TOKEN); - const registeredAddress = getRegisteredAddress(token || ''); - if (registeredAddress === profileQuery.data?.address_hash) { - setApiToken(token); - } - setIsInitialized(true); - } - }, [ setIsInitialized, profileQuery ]); + const enabled = feature.isEnabled && isAuth; - // Save the API token to cookies and state - const saveApiToken = useCallback((token: string | undefined) => { - if (token) { - cookies.set(cookies.NAMES.REWARDS_API_TOKEN, token, { expires: 365 }); - } else { - cookies.remove(cookies.NAMES.REWARDS_API_TOKEN); - } - setApiToken(token); - }, []); - - const [ queryOptions, fetchParams ] = useMemo(() => [ - { enabled: Boolean(apiToken) && feature.isEnabled }, - { headers: { Authorization: `Bearer ${ apiToken }` } }, - ], [ apiToken ]); - - const balancesQuery = useApiQuery('rewards:user_balances', { queryOptions, fetchParams }); - const dailyRewardQuery = useApiQuery('rewards:user_daily_check', { queryOptions, fetchParams }); - const referralsQuery = useApiQuery('rewards:user_referrals', { queryOptions, fetchParams }); + const balancesQuery = useApiQuery('rewards:user_balances', { queryOptions: { enabled } }); + const dailyRewardQuery = useApiQuery('rewards:user_daily_check', { queryOptions: { enabled } }); + const referralsQuery = useApiQuery('rewards:user_referrals', { queryOptions: { enabled } }); const rewardsConfigQuery = useApiQuery('rewards:config', { queryOptions: { enabled: feature.isEnabled } }); const checkUserQuery = useApiQuery('rewards:check_user', { queryOptions: { enabled: feature.isEnabled }, pathParams: { address } }); - // Reset queries when the API token is removed - useEffect(() => { - if (isInitialized && !apiToken) { - queryClient.resetQueries({ queryKey: getResourceKey('rewards:user_balances'), exact: true }); - queryClient.resetQueries({ queryKey: getResourceKey('rewards:user_daily_check'), exact: true }); - queryClient.resetQueries({ queryKey: getResourceKey('rewards:user_referrals'), exact: true }); - } - }, [ isInitialized, apiToken, queryClient ]); - // Handle 401 error useEffect(() => { - if (apiToken && balancesQuery.error?.status === 401) { - saveApiToken(undefined); - } - }, [ balancesQuery.error, apiToken, saveApiToken ]); - - // Check if the profile address is the same as the registered address - useEffect(() => { - const registeredAddress = getRegisteredAddress(apiToken || ''); - if (registeredAddress && !profileQuery.isLoading && profileQuery.data?.address_hash !== registeredAddress) { - setApiToken(undefined); + if (balancesQuery.error?.status === 401) { + const rewardsToken = cookies.get(cookies.NAMES.REWARDS_API_TOKEN); + if (rewardsToken) { + cookies.remove(cookies.NAMES.REWARDS_API_TOKEN); + setIsAuth(false); + } } - }, [ apiToken, profileQuery, setApiToken ]); + }, [ balancesQuery.error?.status ]); // Handle referral code in the URL useEffect(() => { @@ -182,11 +146,11 @@ export function RewardsContextProvider({ children }: Props) { if (refCode && isInitialized) { cookies.set(cookies.NAMES.REWARDS_REFERRAL_CODE, refCode); removeQueryParam(router, 'ref'); - if (!apiToken) { + if (!isAuth) { setIsLoginModalOpen(true); } } - }, [ router, apiToken, isInitialized, setIsLoginModalOpen ]); + }, [ router, isInitialized, setIsLoginModalOpen, isAuth ]); const errorToast = useCallback((error: unknown) => { const apiError = getErrorObjPayload<{ message: string }>(error); @@ -196,6 +160,41 @@ export function RewardsContextProvider({ children }: Props) { }); }, [ ]); + const onLoginSuccess = useCallback((token: string) => { + cookies.set(cookies.NAMES.REWARDS_API_TOKEN, token, { expires: 365 }); + setIsAuth(true); + }, [ ]); + + const logout = useCallback(async() => { + const rewardsToken = cookies.get(cookies.NAMES.REWARDS_API_TOKEN); + if (rewardsToken) { + await apiFetch('rewards:logout', { fetchParams: { method: 'POST' } }); + queryClient.resetQueries({ queryKey: getResourceKey('rewards:user_balances'), exact: true }); + queryClient.resetQueries({ queryKey: getResourceKey('rewards:user_daily_check'), exact: true }); + queryClient.resetQueries({ queryKey: getResourceKey('rewards:user_referrals'), exact: true }); + + cookies.remove(cookies.NAMES.REWARDS_API_TOKEN); + } + setIsAuth(false); + }, [ apiFetch, queryClient ]); + + // Initialize state with the API token from cookies + useEffect(() => { + if (!profileQuery.isLoading) { + const token = cookies.get(cookies.NAMES.REWARDS_API_TOKEN); + if (token && profileQuery.data?.address_hash) { + // Check if the profile address is the same as the registered address + const registeredAddress = getRegisteredAddress(token); + if (registeredAddress === profileQuery.data.address_hash) { + setIsAuth(true); + } else { + logout(); + } + } + setIsInitialized(true); + } + }, [ profileQuery.isLoading, profileQuery.data?.address_hash, logout ]); + // Login to the rewards program const login = useCallback(async(refCode: string) => { try { @@ -227,7 +226,7 @@ export function RewardsContextProvider({ children }: Props) { }, }, }) as rewards.AuthLoginResponse; - saveApiToken(loginResponse.token); + onLoginSuccess(loginResponse.token); return { isNewUser: loginResponse.created, reward: checkCodeResponse.reward, @@ -236,7 +235,7 @@ export function RewardsContextProvider({ children }: Props) { errorToast(_error); throw _error; } - }, [ address, apiFetch, checkUserQuery.data?.exists, switchChainAsync, signMessageAsync, saveApiToken, errorToast ]); + }, [ address, apiFetch, switchChainAsync, checkUserQuery.data?.exists, signMessageAsync, onLoginSuccess, errorToast ]); // Claim daily reward const claim = useCallback(async() => { @@ -244,14 +243,13 @@ export function RewardsContextProvider({ children }: Props) { await apiFetch('rewards:user_daily_claim', { fetchParams: { method: 'POST', - ...fetchParams, }, }) as rewards.DailyRewardClaimResponse; } catch (_error) { errorToast(_error); throw _error; } - }, [ apiFetch, errorToast, fetchParams ]); + }, [ apiFetch, errorToast ]); const openLoginModal = React.useCallback(() => { setIsLoginModalOpen(true); @@ -271,19 +269,21 @@ export function RewardsContextProvider({ children }: Props) { referralsQuery, rewardsConfigQuery, checkUserQuery, - apiToken, - saveApiToken, isInitialized, isLoginModalOpen, openLoginModal, closeLoginModal, login, + onLoginSuccess, + logout, claim, + isAuth, }; }, [ balancesQuery, dailyRewardQuery, checkUserQuery, - apiToken, login, claim, referralsQuery, rewardsConfigQuery, isInitialized, saveApiToken, + login, claim, referralsQuery, rewardsConfigQuery, isInitialized, isLoginModalOpen, openLoginModal, closeLoginModal, + isAuth, logout, onLoginSuccess, ]); return ( diff --git a/lib/hooks/useRewardsActivity.tsx b/lib/hooks/useRewardsActivity.tsx index da3dc8d3e1..c681e4131d 100644 --- a/lib/hooks/useRewardsActivity.tsx +++ b/lib/hooks/useRewardsActivity.tsx @@ -20,14 +20,14 @@ type RewardsActivityEndpoint = 'rewards:user_activity_track_usage'; export default function useRewardsActivity() { - const { apiToken } = useRewardsContext(); + const { isAuth } = useRewardsContext(); const apiFetch = useApiFetch(); const lastExploreTime = useRef(0); const profileQuery = useProfileQuery(); const checkActivityPassQuery = useApiQuery('rewards:user_check_activity_pass', { queryOptions: { - enabled: feature.isEnabled && Boolean(apiToken) && Boolean(profileQuery.data?.address_hash), + enabled: feature.isEnabled && isAuth && Boolean(profileQuery.data?.address_hash), }, queryParams: { address: profileQuery.data?.address_hash ?? '', @@ -44,7 +44,7 @@ export default function useRewardsActivity() { }, []); const makeRequest = useCallback(async(endpoint: RewardsActivityEndpoint, params: Record) => { - if (!apiToken || !checkActivityPassQuery.data?.is_valid) { + if (!isAuth || !checkActivityPassQuery.data?.is_valid) { return; } @@ -53,11 +53,10 @@ export default function useRewardsActivity() { fetchParams: { method: 'POST', body: params, - headers: { Authorization: `Bearer ${ apiToken }` }, }, }); } catch {} - }, [ apiFetch, checkActivityPassQuery.data, apiToken ]); + }, [ apiFetch, checkActivityPassQuery.data, isAuth ]); const trackTransaction = useCallback(async(from: string, to: string, chainId?: string) => { return ( @@ -84,7 +83,7 @@ export default function useRewardsActivity() { const trackUsage = useCallback((action: string) => { // check here because this function is called on page load - if (!apiToken || !checkActivityPassQuery.data?.is_valid) { + if (!isAuth || !checkActivityPassQuery.data?.is_valid) { return; } @@ -103,7 +102,7 @@ export default function useRewardsActivity() { action, chain_id: config.chain.id ?? '', }); - }, [ makeRequest, apiToken, checkActivityPassQuery.data ]); + }, [ makeRequest, isAuth, checkActivityPassQuery.data ]); return { trackTransaction, diff --git a/ui/pages/RewardsDashboard.tsx b/ui/pages/RewardsDashboard.tsx index c7ba536273..941c052555 100644 --- a/ui/pages/RewardsDashboard.tsx +++ b/ui/pages/RewardsDashboard.tsx @@ -23,7 +23,7 @@ import PageTitle from 'ui/shared/Page/PageTitle'; import useRedirectForInvalidAuthToken from 'ui/snippets/auth/useRedirectForInvalidAuthToken'; const RewardsDashboard = () => { - const { balancesQuery, apiToken, referralsQuery, rewardsConfigQuery, dailyRewardQuery, isInitialized } = useRewardsContext(); + const { balancesQuery, isAuth, referralsQuery, rewardsConfigQuery, dailyRewardQuery, isInitialized } = useRewardsContext(); const { nextAchievementText, isLoading: isBadgesLoading, badgesQuery } = useStreakBadges(); const streakModal = useDisclosure(); const isMobile = useIsMobile(); @@ -33,10 +33,10 @@ const RewardsDashboard = () => { useRedirectForInvalidAuthToken(); useEffect(() => { - if (!config.features.rewards.isEnabled || (isInitialized && !apiToken)) { + if (!config.features.rewards.isEnabled || (isInitialized && !isAuth)) { window.location.assign('/'); } - }, [ isInitialized, apiToken ]); + }, [ isInitialized, isAuth ]); useEffect(() => { setIsError(balancesQuery.isError || referralsQuery.isError || rewardsConfigQuery.isError || dailyRewardQuery.isError); diff --git a/ui/rewards/RewardsButton.tsx b/ui/rewards/RewardsButton.tsx index 3f65bfe85a..343bc0ffed 100644 --- a/ui/rewards/RewardsButton.tsx +++ b/ui/rewards/RewardsButton.tsx @@ -16,7 +16,7 @@ type Props = { }; const RewardsButton = ({ variant = 'header', size }: Props) => { - const { isInitialized, apiToken, openLoginModal, dailyRewardQuery, balancesQuery } = useRewardsContext(); + const { isInitialized, isAuth, openLoginModal, dailyRewardQuery, balancesQuery } = useRewardsContext(); const isMobile = useIsMobile(); const isLoading = !isInitialized || dailyRewardQuery.isLoading || balancesQuery.isLoading; @@ -28,15 +28,15 @@ const RewardsButton = ({ variant = 'header', size }: Props) => { diff --git a/ui/rewards/dashboard/tabs/ActivityTab.tsx b/ui/rewards/dashboard/tabs/ActivityTab.tsx index 925bd732da..08b50de64a 100644 --- a/ui/rewards/dashboard/tabs/ActivityTab.tsx +++ b/ui/rewards/dashboard/tabs/ActivityTab.tsx @@ -44,7 +44,7 @@ function getMaxAmount(rewards: Record | undefined) { } export default function ActivityTab() { - const { apiToken, rewardsConfigQuery } = useRewardsContext(); + const { isAuth, rewardsConfigQuery } = useRewardsContext(); const explorersModal = useDisclosure(); const taskDetailsModal = useDisclosure(); const isMobile = useIsMobile(); @@ -53,7 +53,7 @@ export default function ActivityTab() { const profileQuery = useProfileQuery(); const checkActivityPassQuery = useApiQuery('rewards:user_check_activity_pass', { queryOptions: { - enabled: feature.isEnabled && Boolean(apiToken) && Boolean(profileQuery.data?.address_hash), + enabled: feature.isEnabled && isAuth && Boolean(profileQuery.data?.address_hash), }, queryParams: { address: profileQuery.data?.address_hash ?? '', @@ -61,10 +61,9 @@ export default function ActivityTab() { }); const activityQuery = useApiQuery('rewards:user_activity', { queryOptions: { - enabled: Boolean(apiToken) && feature.isEnabled, + enabled: isAuth && feature.isEnabled, placeholderData: USER_ACTIVITY, }, - fetchParams: { headers: { Authorization: `Bearer ${ apiToken }` } }, }); const instancesQuery = useApiQuery('rewards:instances', { queryOptions: { enabled: feature.isEnabled }, diff --git a/ui/rewards/hooks/useStreakBadges.ts b/ui/rewards/hooks/useStreakBadges.ts index 3543ae5a16..0bdd9de7d5 100644 --- a/ui/rewards/hooks/useStreakBadges.ts +++ b/ui/rewards/hooks/useStreakBadges.ts @@ -9,11 +9,11 @@ import { useRewardsContext } from 'lib/contexts/rewards'; const feature = config.features.rewards; export default function useStreakBadges() { - const { apiToken, dailyRewardQuery } = useRewardsContext(); + const { isAuth, dailyRewardQuery } = useRewardsContext(); const badgesQuery = useApiQuery<'rewards:user_badges', unknown, GetAvailableBadgesResponse>('rewards:user_badges', { queryOptions: { - enabled: feature.isEnabled && Boolean(apiToken), + enabled: feature.isEnabled && isAuth, select: (data) => ({ ...data, items: data.items @@ -21,7 +21,6 @@ export default function useStreakBadges() { .slice(0, 3), // UI limit }), }, - fetchParams: { headers: { Authorization: `Bearer ${ apiToken }` } }, }); const nextAchievementText = useMemo(() => { diff --git a/ui/snippets/auth/AuthModal.tsx b/ui/snippets/auth/AuthModal.tsx index ce9b5d84fc..d7123f6f83 100644 --- a/ui/snippets/auth/AuthModal.tsx +++ b/ui/snippets/auth/AuthModal.tsx @@ -39,8 +39,7 @@ const AuthModal = ({ initialScreen, onClose, mixpanelConfig, closeOnError }: Pro const [ isSuccess, setIsSuccess ] = React.useState(false); const [ rewardsApiToken, setRewardsApiToken ] = React.useState(undefined); - const { saveApiToken } = useRewardsContext(); - + const { onLoginSuccess: onRewardsLoginSuccess } = useRewardsContext(); const router = useRouter(); const csrfQuery = useGetCsrfToken(); const queryClient = useQueryClient(); @@ -93,11 +92,11 @@ const AuthModal = ({ initialScreen, onClose, mixpanelConfig, closeOnError }: Pro if ('rewardsToken' in screen && screen.rewardsToken) { setRewardsApiToken(screen.rewardsToken); - saveApiToken(screen.rewardsToken); + onRewardsLoginSuccess(screen.rewardsToken); } onNextStep(screen); - }, [ initialScreen, mixpanelConfig?.account_link_info.source, onNextStep, csrfQuery, queryClient, saveApiToken ]); + }, [ initialScreen, mixpanelConfig?.account_link_info.source, onNextStep, csrfQuery, queryClient, onRewardsLoginSuccess ]); const onModalClose = React.useCallback(() => { onClose(isSuccess, rewardsApiToken); diff --git a/ui/snippets/auth/useLogout.ts b/ui/snippets/auth/useLogout.ts index 92c49c0acc..f78c6bdb67 100644 --- a/ui/snippets/auth/useLogout.ts +++ b/ui/snippets/auth/useLogout.ts @@ -7,6 +7,7 @@ import type { Route } from 'nextjs-routes'; import config from 'configs/app'; import useApiFetch from 'lib/api/useApiFetch'; import { getResourceKey } from 'lib/api/useApiQuery'; +import { useRewardsContext } from 'lib/contexts/rewards'; import * as cookies from 'lib/cookies'; import * as mixpanel from 'lib/mixpanel'; import { toaster } from 'toolkit/chakra/toaster'; @@ -25,6 +26,7 @@ export default function useLogout() { const router = useRouter(); const queryClient = useQueryClient(); const apiFetch = useApiFetch(); + const { logout: rewardsLogout } = useRewardsContext(); return React.useCallback(async() => { try { @@ -32,14 +34,7 @@ export default function useLogout() { cookies.remove(cookies.NAMES.API_TOKEN); if (config.features.rewards.isEnabled) { - const rewardsToken = cookies.get(cookies.NAMES.REWARDS_API_TOKEN); - if (rewardsToken) { - await apiFetch('rewards:logout', { fetchParams: { - method: 'POST', - headers: { Authorization: `Bearer ${ rewardsToken }` }, - } }); - cookies.remove(cookies.NAMES.REWARDS_API_TOKEN); - } + rewardsLogout(); } mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_ACCESS, { Action: 'Logged out' }, { send_immediately: true }); @@ -66,5 +61,5 @@ export default function useLogout() { description: 'Please try again later', }); } - }, [ apiFetch, queryClient, router ]); + }, [ apiFetch, rewardsLogout, queryClient, router ]); } diff --git a/ui/snippets/navigation/vertical/NavLinkRewards.tsx b/ui/snippets/navigation/vertical/NavLinkRewards.tsx index 1f14b95722..3ab6e26086 100644 --- a/ui/snippets/navigation/vertical/NavLinkRewards.tsx +++ b/ui/snippets/navigation/vertical/NavLinkRewards.tsx @@ -15,18 +15,18 @@ type Props = { const NavLinkRewards = ({ isCollapsed, onClick }: Props) => { const router = useRouter(); - const { openLoginModal, dailyRewardQuery, apiToken, isInitialized } = useRewardsContext(); + const { openLoginModal, dailyRewardQuery, isAuth, isInitialized } = useRewardsContext(); const pathname = '/account/merits'; const nextRoute = { pathname } as Route; const handleClick = useCallback((e: React.MouseEvent) => { - if (isInitialized && !apiToken) { + if (isInitialized && !isAuth) { e.preventDefault(); openLoginModal(); } onClick?.(); - }, [ onClick, isInitialized, apiToken, openLoginModal ]); + }, [ onClick, isInitialized, isAuth, openLoginModal ]); if (!config.features.rewards.isEnabled) { return null;