Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save billsbooth/2d9413e12067330c9b6355554145baef to your computer and use it in GitHub Desktop.
Save billsbooth/2d9413e12067330c9b6355554145baef to your computer and use it in GitHub Desktop.
Client Side Sign In
// 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