Created
July 15, 2025 14:59
-
-
Save billsbooth/2d9413e12067330c9b6355554145baef to your computer and use it in GitHub Desktop.
Client Side Sign In
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// PART 1, ACCOUNTS CONTEXT | |
// | |
import { useMemoObj } from '@/helpers/memoobj'; | |
import React, { | |
useContext, | |
memo, | |
PropsWithChildren, | |
useEffect, | |
useMemo, | |
useCallback, | |
useState, | |
} from 'react'; | |
import mixpanel, { mixpanelIdentify } from '@/helpers/trackers'; | |
import { TAccountInformation } from '@/apis/account/types'; | |
import SolanaWallet from '@/helpers/solana-wallet'; | |
import { AccountApi } from '@/apis/account'; | |
import { trackAuthError, trackAuthEvent } from '@/helpers/trackers'; | |
import { TrackableEvent } from '@/constants/events'; | |
import AuthApi from '@/apis/auth'; | |
import { withTimeout } from '@/helpers/utils'; | |
import { | |
PrivyUser, | |
useLoginWithEmail, | |
usePrivy, | |
useIdentityToken, | |
} from '@privy-io/expo'; | |
import { ServerSolanaWalletProvider } from '@/helpers/solana-wallet-provider'; | |
import { PublicKey } from '@solana/web3.js'; | |
import { LogoutReason } from '@/constants/auth'; | |
type TAccountContextValues = { | |
wallet: SolanaWallet | undefined; | |
account: TAccountInformation | undefined; | |
isLoggedIn: boolean; | |
accountLoading: boolean; | |
accountError: Error | null; | |
login: (email: string, code: string) => Promise<void>; | |
logout: (reason: LogoutReason) => Promise<void>; | |
}; | |
const AccountContext = React.createContext<TAccountContextValues>({ | |
wallet: undefined, | |
account: undefined, | |
isLoggedIn: false, | |
accountLoading: false, | |
accountError: null, | |
login: async () => {}, | |
logout: async () => {}, | |
}); | |
export const AccountProvider = memo(({ children }: PropsWithChildren) => { | |
const { loginWithCode: privyLoginWithCode } = useLoginWithEmail(); | |
const { logout: privyLogout } = usePrivy(); | |
const { getIdentityToken } = useIdentityToken(); | |
const [sessionId, setSessionId] = useState<number>(0); | |
const logout = useCallback( | |
async (reason: LogoutReason) => { | |
// Logouts fail if the user is not already logged in. We catch the error and ignore it | |
// to support an idempotent API. | |
try { | |
await AuthApi.logout(); | |
await privyLogout(); | |
setSessionId((prev) => prev + 1); | |
} catch (e) { | |
trackAuthError(e, TrackableEvent.AuthFlowFailLogout, { | |
reason: LogoutReason[reason], | |
}); | |
} | |
trackAuthEvent(TrackableEvent.AuthFlowLogout, { | |
reason: LogoutReason[reason], | |
}); | |
}, | |
[privyLogout, setSessionId] | |
); | |
const login = useCallback( | |
async (email: string, code: string) => { | |
await logout(LogoutReason['login_reset']); | |
const payload = { code, email }; | |
let privyUser: PrivyUser | undefined = undefined; | |
try { | |
trackAuthEvent(TrackableEvent.AuthFlowStartLoginPrivyWithCode, { | |
email, | |
}); | |
privyUser = await withTimeout(privyLoginWithCode(payload)); | |
if (!privyUser) { | |
throw new Error('Invalid code.'); | |
} | |
trackAuthEvent(TrackableEvent.AuthFlowCompleteLoginPrivyWithCode, { | |
email, | |
privy_created_at: privyUser.created_at, | |
privy_user_id: privyUser.id, | |
}); | |
} catch (e) { | |
trackAuthError(e, TrackableEvent.AuthFlowFailLoginPrivyWithCode, { | |
email, | |
}); | |
await logout(LogoutReason['login_error']); | |
throw e; | |
} | |
let jwtToken: string | undefined = undefined; | |
try { | |
trackAuthEvent(TrackableEvent.AuthFlowStartJwtTokenFetch, { | |
email, | |
privy_user_id: privyUser.id, | |
}); | |
jwtToken = (await getIdentityToken()) || undefined; | |
if (!jwtToken) { | |
throw new Error('Failed to get JWT token.'); | |
} | |
trackAuthEvent(TrackableEvent.AuthFlowCompleteJwtTokenFetch, { | |
email, | |
privy_user_id: privyUser.id, | |
}); | |
} catch (e) { | |
trackAuthError(e, TrackableEvent.AuthFlowFailJwtTokenFetch, { | |
email, | |
privy_user_id: privyUser.id, | |
}); | |
await logout(LogoutReason['login_error']); | |
throw e; | |
} | |
try { | |
trackAuthEvent(TrackableEvent.AuthFlowStartBlackwingSignIn, { | |
email, | |
privy_user_id: privyUser.id, | |
}); | |
const success = await AuthApi.signIn(jwtToken); | |
if (!success) { | |
throw new Error('Failed to sign in.'); | |
} | |
trackAuthEvent(TrackableEvent.AuthFlowCompleteBlackwingSignIn, { | |
email, | |
privy_user_id: privyUser.id, | |
}); | |
} catch (e) { | |
trackAuthError(e, TrackableEvent.AuthFlowFailBlackwingSignIn, { | |
email, | |
privy_user_id: privyUser.id, | |
}); | |
await logout(LogoutReason['login_error']); | |
throw e; | |
} | |
setSessionId((prev) => prev + 1); | |
}, | |
[privyLoginWithCode, logout, setSessionId, getIdentityToken] | |
); | |
const { | |
data: account, | |
isLoading: accountFetchLoading, | |
error: accountError, | |
} = AccountApi.useFetchAccount(sessionId.toString()); | |
const wallet = useMemo( | |
() => | |
account | |
? new SolanaWallet( | |
new ServerSolanaWalletProvider(new PublicKey(account.publicAddress)) | |
) | |
: undefined, | |
[account] | |
); | |
const accountContextValue: TAccountContextValues = useMemoObj({ | |
wallet, | |
account: account || undefined, | |
isLoggedIn: !!account, | |
accountLoading: accountFetchLoading, | |
accountError, | |
login, | |
logout, | |
}); | |
// Identify and track user in Mixpanel. | |
useEffect(() => { | |
if (account?.publicAddress) { | |
mixpanelIdentify(account.publicAddress); | |
mixpanel.getPeople().set({ | |
email: account.email, | |
account_address: account.publicAddress, | |
}); | |
} else { | |
mixpanel.getPeople().unset('email'); | |
mixpanel.getPeople().unset('account_address'); | |
} | |
}, [account?.publicAddress, account?.email]); | |
return ( | |
<AccountContext.Provider value={accountContextValue}> | |
{children} | |
</AccountContext.Provider> | |
); | |
}); | |
AccountProvider.displayName = 'AccountProvider'; | |
export function useAccount() { | |
return useContext(AccountContext); | |
} | |
// Part 2: Auth | |
import { LogoutReason } from '@/constants/auth'; | |
import { TrackableEvent } from '@/constants/events'; | |
import axiosFetch from '@/helpers/axios-fetch'; | |
import { axiosErrorConverter } from '@/helpers/errors'; | |
import { trackAuthError } from '@/helpers/trackers'; | |
const api = axiosFetch(); | |
type TSignInResult = { | |
account_address: string; | |
}; | |
const AuthApi = { | |
logout: async function () { | |
try { | |
await api.post(`/logoutv2`); | |
} catch (e) { | |
const ae = axiosErrorConverter(e); | |
if (ae?.status === 403) { | |
trackAuthError(e, TrackableEvent.AuthFlowFailLogout, { | |
reason: LogoutReason['403_forbidden'], | |
}); | |
return; | |
} | |
throw e; | |
} | |
}, | |
signIn: async (jwtToken: string): Promise<boolean> => { | |
const verifyRes = await api.post<TSignInResult, TSignInResult>( | |
`/login/siwp`, | |
undefined, | |
{ | |
headers: { | |
'privy-id-token': jwtToken, | |
}, | |
} | |
); | |
return !!verifyRes.account_address; | |
}, | |
}; | |
export default AuthApi; | |
// | |
// Part 3: Axios Fetch | |
// | |
import axios, { | |
AxiosInstance, | |
AxiosPromise, | |
AxiosResponse, | |
InternalAxiosRequestConfig, | |
AxiosError, | |
} from 'axios'; | |
import { API_BASE_URL } from '@/constants/core'; | |
import { TExtraTrackingData, trackError } from '@/helpers/trackers'; | |
import { TrackableEvent } from '@/constants/events'; | |
const AXIOS_REQUEST_TIMEOUT_MS = 60_000; | |
const AXIOS_REQUEST_TIMEOUT_ERROR_CODE = 'ECONNABORTED'; | |
type APIResponse<T> = { | |
result: T; | |
error?: string; | |
success: boolean; | |
}; | |
const handleResponse = <T>(response: AxiosResponse<APIResponse<T>>): T => { | |
return response.data.result; | |
}; | |
const handleError = (error: AxiosError) => { | |
const extraData: TExtraTrackingData = { | |
code: | |
error.code === AXIOS_REQUEST_TIMEOUT_ERROR_CODE ? 'timeout' : error.code, | |
url: error.config?.url || null, | |
method: error.config?.method || null, | |
timeout_ms: AXIOS_REQUEST_TIMEOUT_MS, | |
base_url: error.config?.baseURL || null, | |
params: error.config?.params ? JSON.stringify(error.config.params) : null, | |
timestamp: new Date().toISOString(), | |
}; | |
trackError(error, TrackableEvent.DidFailAxiosRequest, extraData); | |
return Promise.reject(error); | |
}; | |
const inflightGetRequests = new Map<string, AxiosPromise>(); | |
const defaultAdapter = axios.getAdapter('xhr'); | |
function fetchKey(requestConfig: InternalAxiosRequestConfig): string { | |
let params = '&'; | |
if (requestConfig.params) { | |
const keys = Object.keys(requestConfig.params); | |
keys.sort(); | |
for (const key of keys) { | |
params += key + requestConfig.params[key]; | |
} | |
} | |
let url = ''; | |
if (requestConfig.url) { | |
url = requestConfig.url.toString(); | |
} | |
return requestConfig.baseURL + url + params; | |
} | |
function axiosFetch(): AxiosInstance { | |
const axiosInstance = axios.create({ | |
baseURL: API_BASE_URL, | |
withCredentials: true, | |
headers: { | |
'Accept-Encoding': 'gzip', | |
}, | |
maxRedirects: 0, | |
timeout: AXIOS_REQUEST_TIMEOUT_MS, | |
// Inflight request caching adapter. | |
adapter: (requestConfig: InternalAxiosRequestConfig): AxiosPromise => { | |
if ( | |
requestConfig.method === 'get' && | |
requestConfig.url && | |
!requestConfig.url.toString().includes('/login/siws/input') | |
) { | |
const key = fetchKey(requestConfig); | |
if (inflightGetRequests.has(key)) { | |
return inflightGetRequests.get(key)!; | |
} | |
const request = defaultAdapter(requestConfig); | |
inflightGetRequests.set(key, request); | |
return request.finally(() => inflightGetRequests.delete(key)); | |
} else { | |
return defaultAdapter(requestConfig); | |
} | |
}, | |
}); | |
axiosInstance.interceptors.response.use(handleResponse, handleError); | |
return axiosInstance; | |
} | |
export default axiosFetch; | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment