diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..2ab3d4be5 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v16.20.2 diff --git a/packages/components/src/components/context/LoginHelpersContext.tsx b/packages/components/src/components/context/LoginHelpersContext.tsx index 3a1bcf2ef..93d03ae68 100644 --- a/packages/components/src/components/context/LoginHelpersContext.tsx +++ b/packages/components/src/components/context/LoginHelpersContext.tsx @@ -70,6 +70,24 @@ export const LoginHelpersContext = }) LoginHelpersContext.displayName = 'LoginHelpersContext' +function handleAuthError( + error: unknown, + description = 'Authentication failed', + dialog: ReturnType, +): void { + console.error(description) + if (error) console.error(error) + + const err = + error instanceof Error + ? error + : new Error(typeof error === 'string' ? error : 'Unknown error') + bugsnag.notify(err, { description }) + + if (err.message === 'Canceled' || err.message === 'Timeout') return + dialog.show('Login failed', err.message) +} + export function LoginHelpersProvider(props: LoginHelpersProviderProps) { const [isExecutingOAuth, setIsExecutingOAuth] = useState(false) const [patLoadingState, setPATLoadingState] = useState< @@ -91,7 +109,7 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) { (state) => !!selectors.githubTokenSelector(state), ) - const Dialog = useDialog() + const dialog = useDialog() const fullAccessRef = useRef(false) const initialErrorRef = useRef(error) @@ -116,14 +134,8 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) { dispatch(actions.loginRequest({ appToken })) setIsExecutingOAuth(false) } catch (error) { - const description = 'OAuth execution failed' - console.error(description, error) + handleAuthError(error, 'OAuth execution failed', dialog) setIsExecutingOAuth(false) - - if (error.message === 'Canceled' || error.message === 'Timeout') return - bugsnag.notify(error, { description }) - - Dialog.show('Login failed', `${error || ''}`) } } @@ -132,7 +144,7 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) { > => { let redirected = false const token = await new Promise((resolveToken) => { - Dialog.show( + dialog.show( 'Personal Access Token', constants.LOCAL_ONLY_PERSONAL_ACCESS_TOKEN ? 'It will be stored safely on your local device and only be sent directly to GitHub.' @@ -191,7 +203,7 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) { } return token - }, []) + }, [dialog]) const loginWithGitHubPersonalAccessToken = useCallback(async () => { try { @@ -200,47 +212,49 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) { const token = await promptForPersonalAcessToken() if (!token) throw new Error('Canceled') - if (constants.LOCAL_ONLY_PERSONAL_ACCESS_TOKEN) { - setIsExecutingOAuth(true) - setPATLoadingState('adding') - const response = await axios.get(`${githubBaseApiUrl}/user`, { - headers: { - Authorization: `token ${token}`, - }, - }) - setIsExecutingOAuth(false) - setPATLoadingState(undefined) + setIsExecutingOAuth(true) + setPATLoadingState('adding') - if (!(response?.data?.id && response.data.login)) - throw new Error('Invalid response') + // Validate token with GitHub API + const response = await axios.get(`${githubBaseApiUrl}/user`, { + headers: { + Authorization: `token ${token}`, + }, + }) - if ( - loggedGitHubUserId && - `${response.data.id}` !== `${loggedGitHubUserId}` - ) { - const details = - response.data.login !== loggedGitHubUsername - ? ` (${response.data.login} instead of ${loggedGitHubUsername})` - : ` (ID ${response.data.id} instead of ${loggedGitHubUserId})` - - throw new Error( - `This Personal Access Token seems to be from a different user${details}.`, - ) - } + if (!(response?.data?.id && response.data.login)) { + throw new Error('Invalid response from GitHub API') + } - const scope = `${response.headers['x-oauth-scopes'] || ''}` - .replace(/\s+/g, '') - .split(',') - .filter(Boolean) - - if (scope.length && !scope.includes('repo')) { - throw new Error( - 'You didn\'t include the "repo" permission scope,' + - ' which is required to have access to private repositories.' + - " Your token will be safe on your device, and will never be sent to DevHub's server.", - ) - } + if ( + loggedGitHubUserId && + `${response.data.id}` !== `${loggedGitHubUserId}` + ) { + const details = + response.data.login !== loggedGitHubUsername + ? ` (${response.data.login} instead of ${loggedGitHubUsername})` + : ` (ID ${response.data.id} instead of ${loggedGitHubUserId})` + + throw new Error( + `This Personal Access Token seems to be from a different user${details}.`, + ) + } + + const scope = `${response.headers['x-oauth-scopes'] || ''}` + .replace(/\s+/g, '') + .split(',') + .filter(Boolean) + if (scope.length && !scope.includes('repo')) { + throw new Error( + 'You didn\'t include the "repo" permission scope,' + + ' which is required to have access to private repositories.' + + " Your token will be safe on your device, and will never be sent to DevHub's server.", + ) + } + + // In local-only mode, store the token and use it directly + if (constants.LOCAL_ONLY_PERSONAL_ACCESS_TOKEN) { dispatch( actions.replacePersonalTokenDetails({ tokenDetails: { @@ -248,168 +262,78 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) { token, tokenCreatedAt: new Date().toISOString(), scope, - tokenType: undefined, }, }), ) - } else { - setIsExecutingOAuth(true) - setPATLoadingState('adding') - const response = await axios.post( - `${constants.API_BASE_URL}/github/personal/login`, - { token }, - { headers: getDefaultDevHubHeaders({ appToken: existingAppToken }) }, - ) - setIsExecutingOAuth(false) - setPATLoadingState(undefined) - - const appToken = response.data.appToken - clearOAuthQueryParams() - - if (!appToken) throw new Error('No app token') - - dispatch(actions.loginRequest({ appToken })) - } - } catch (error) { - setIsExecutingOAuth(false) - setPATLoadingState(undefined) - - if (error.message === 'Canceled' || error.message === 'Timeout') return - - const description = 'Authentication failed' - console.error(description, error) - bugsnag.notify(error, { description }) - - Dialog.show('Login failed', `${error || ''}`) - } - }, [existingAppToken, loggedGitHubUserId, loggedGitHubUsername]) - - const addPersonalAccessToken = useCallback(async () => { - await loginWithGitHubPersonalAccessToken() - }, [loginWithGitHubPersonalAccessToken]) - - const removePersonalAccessToken = useCallback(async () => { - if (constants.LOCAL_ONLY_PERSONAL_ACCESS_TOKEN) { - dispatch( - actions.replacePersonalTokenDetails({ - tokenDetails: undefined, - }), - ) - } else { - try { - setPATLoadingState('removing') - - const response = await axios.post( + // Use the personal access token as the app token + dispatch(actions.loginRequest({ appToken: token })) + } else { + // In server mode, exchange the token for an app token + const loginResponse = await axios.post( constants.GRAPHQL_ENDPOINT, { query: ` mutation { - removeGitHubPersonalToken - }`, + loginWithPersonalAccessToken(input: { token: "${token}" }) { + appToken + } + } + `, + }, + { + headers: getDefaultDevHubHeaders({ appToken: existingAppToken }), }, - { headers: getDefaultDevHubHeaders({ appToken: existingAppToken }) }, ) - const { data, errors } = await response.data + const { data, errors } = loginResponse.data - if (errors?.[0]?.message) throw new Error(errors[0].message) - - if (!data?.removeGitHubPersonalToken) { - throw new Error('Not removed.') + if (errors && errors.length) { + throw new Error(errors[0].message || 'GraphQL Error') } - setPATLoadingState(undefined) - - // this is only necessary because we are not re-generating the appToken after removing the personal token, - // which causes the personal token to being added back after a page refresh - dispatch(actions.logout()) - } catch (error) { - console.error(error) - bugsnag.notify(error) + if (!data?.loginWithPersonalAccessToken?.appToken) { + throw new Error('Invalid response') + } - setPATLoadingState(undefined) - Dialog.show( - 'Failed to remove personal token', - `Error: ${error?.message}`, + dispatch( + actions.loginRequest({ + appToken: data.loginWithPersonalAccessToken.appToken, + }), ) } - } - }, [existingAppToken]) - - // handle oauth flow without popup - // that passes the token via query string - useEffect(() => { - const currentURL = Linking.getCurrentURL() - const querystring = url.parse(currentURL).query || '' - const query = qs.parse(querystring) - - if (!query.oauth) return - - const params = getUrlParamsIfMatches(querystring, '') - if (!params) return - try { - const { appToken } = tryParseOAuthParams(params) - clearOAuthQueryParams() - if (!appToken) return - - dispatch(actions.loginRequest({ appToken })) + setIsExecutingOAuth(false) + setPATLoadingState(undefined) } catch (error) { - const description = 'OAuth execution failed' - console.error(description, error) - - if (error.message === 'Canceled' || error.message === 'Timeout') return - bugsnag.notify(error, { description }) - - Dialog.show('Login failed', `Error: ${error?.message}`) - } - }, []) - - // auto start oauth flow after github app installation - useEffect(() => { - const handler = ({ url: uri }: { url: string }) => { - const querystring = url.parse(uri).query || '' - const query = qs.parse(querystring) - - if (query.oauth) return - if (!query.installation_id) return - - void loginWithGitHub() - - setTimeout(() => { - clearQueryStringFromURL(['installation_id', 'setup_action']) - }, 500) - } - - Linking.addEventListener('url', handler) - - handler({ url: Linking.getCurrentURL() }) - - return () => { - Linking.removeEventListener('url', handler) + handleAuthError(error, 'Personal access token login failed', dialog) + setIsExecutingOAuth(false) + setPATLoadingState(undefined) } - }, []) + }, [ + dialog, + dispatch, + githubBaseApiUrl, + loggedGitHubUserId, + loggedGitHubUsername, + promptForPersonalAcessToken, + ]) - useEffect(() => { - if (!error || initialErrorRef.current === error) return - - const message = error && error.message - Dialog.show( - 'Login failed', - `Please try again. ${message ? ` \nError: ${message}` : ''}`, - ) - }, [error]) - - useEffect(() => { - if (!hasGitHubToken && !!existingAppToken && !isLoggingIn) { - dispatch(actions.logout()) + const removePersonalAccessToken = useCallback(async () => { + try { + setPATLoadingState('removing') + dispatch(actions.replacePersonalTokenDetails({ tokenDetails: undefined })) + await Promise.resolve() + setPATLoadingState(undefined) + } catch (error) { + handleAuthError(error, 'Failed to remove personal access token', dialog) + setPATLoadingState(undefined) } - }, [!hasGitHubToken && !!existingAppToken && !isLoggingIn]) + }, [dialog, dispatch]) - const value = useMemo( + const value = useMemo( () => ({ - addPersonalAccessToken, + addPersonalAccessToken: loginWithGitHubPersonalAccessToken, fullAccessRef, isExecutingOAuth, isLoggingIn, @@ -419,12 +343,10 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) { removePersonalAccessToken, }), [ - addPersonalAccessToken, - fullAccessRef, + loginWithGitHubPersonalAccessToken, isExecutingOAuth, isLoggingIn, loginWithGitHub, - loginWithGitHubPersonalAccessToken, patLoadingState, removePersonalAccessToken, ], diff --git a/packages/components/src/redux/sagas/auth.ts b/packages/components/src/redux/sagas/auth.ts index 297bf6304..076fc2070 100644 --- a/packages/components/src/redux/sagas/auth.ts +++ b/packages/components/src/redux/sagas/auth.ts @@ -1,5 +1,5 @@ -import { constants, User } from '@devhub/core' -import axios, { AxiosResponse } from 'axios' +import { constants, User, GitHubTokenDetails } from '@devhub/core' +import axios, { AxiosResponse, AxiosError } from 'axios' import * as StoreReview from 'react-native-store-review' import { REHYDRATE } from 'redux-persist' import { @@ -22,6 +22,66 @@ import * as actions from '../actions' import * as selectors from '../selectors' import { RootState } from '../types' import { ExtractActionFromActionCreator } from '../types/base' +import { AuthError } from '../reducers/auth' + +interface GraphQLErrorResponse extends Error { + response: { + data: { + errors: Array<{ + message: string + }> + } + status: number + } +} + +interface GraphQLResponse { + data: T + status: number + errors?: Array<{ + message: string + }> +} + +interface GraphQLErrorObject { + response: { + data: { + errors: Array<{ + message: string + }> + } + status: number + } +} + +function createGraphQLError( + message: string, + response: GraphQLResponse, +): GraphQLErrorResponse { + const error = new Error(message) as GraphQLErrorResponse + error.response = { + data: { + errors: response.errors || [{ message: 'Unknown GraphQL error' }], + }, + status: response.status, + } + return error +} + +function isGraphQLErrorResponse(error: unknown): error is GraphQLErrorResponse { + if (!error || typeof error !== 'object') return false + + const errorObj = error as GraphQLErrorObject + return !!( + errorObj.response && + typeof errorObj.response === 'object' && + errorObj.response.data && + typeof errorObj.response.data === 'object' && + errorObj.response.data.errors && + Array.isArray(errorObj.response.data.errors) && + typeof errorObj.response.status === 'number' + ) +} function* init() { yield take('LOGIN_SUCCESS') @@ -90,11 +150,81 @@ function* onRehydrate() { function* onLoginRequest( action: ExtractActionFromActionCreator, -) { +): Generator { const { appToken } = action.payload try { - // TODO: Auto generate these typings + if (constants.LOCAL_ONLY_PERSONAL_ACCESS_TOKEN) { + // For local-only mode, we'll use the GitHub API directly + const octokit = github.getOctokitForToken(appToken) + const response = yield octokit.users.getAuthenticated() + + if (!response.data) { + throw new Error('Invalid response from GitHub API') + } + + const user: User = { + _id: `github_${response.data.id}`, + github: { + personal: { + token: appToken, + scope: ['repo'], + tokenType: 'bearer', + tokenCreatedAt: new Date().toISOString(), + login: response.data.login, + } as GitHubTokenDetails, + user: { + id: response.data.id, + nodeId: response.data.node_id, + login: response.data.login, + name: response.data.name || response.data.login, + avatarUrl: response.data.avatar_url, + createdAt: response.data.created_at, + updatedAt: response.data.updated_at, + }, + }, + plan: { + id: 'free', + source: 'none' as const, + type: undefined, + status: 'active', + amount: 0, + currency: 'usd', + trialPeriodDays: 0, + intervalCount: 0, + label: 'Free', + interval: undefined, + quantity: undefined, + startAt: new Date().toISOString(), + cancelAt: undefined, + cancelAtPeriodEnd: false, + trialStartAt: undefined, + trialEndAt: undefined, + currentPeriodStartAt: new Date().toISOString(), + currentPeriodEndAt: undefined, + last4: undefined, + reason: undefined, + users: undefined, + featureFlags: { + columnsLimit: constants.LOCAL_ONLY_PERSONAL_ACCESS_TOKEN ? 999 : -1, + enableFilters: true, + enableSync: false, + enablePrivateRepositories: true, + enablePushNotifications: false, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + lastLoginAt: new Date().toISOString(), + } + + yield put(actions.loginSuccess({ appToken, user })) + return + } + + // Original code for non-local mode const response: AxiosResponse<{ data: { login: { @@ -162,18 +292,13 @@ function* onLoginRequest( avatarUrl } } - freeTrialStartAt - freeTrialEndAt plan { id source type - stripeIds paddleProductId - banner - amount currency trialPeriodDays @@ -186,25 +311,18 @@ function* onLoginRequest( } quantity coupon - dealCode - status - startAt cancelAt cancelAtPeriodEnd - trialStartAt trialEndAt - currentPeriodStartAt currentPeriodEndAt - last4 reason users - featureFlags { columnsLimit enableFilters @@ -212,7 +330,6 @@ function* onLoginRequest( enablePrivateRepositories enablePushNotifications } - createdAt updatedAt } @@ -231,7 +348,11 @@ function* onLoginRequest( const { data, errors } = response.data if (errors && errors.length) { - throw Object.assign(new Error('GraphQL Error'), { response }) + throw createGraphQLError('GraphQL Error', { + data: response.data, + status: response.status, + errors: errors, + }) } if ( @@ -249,26 +370,20 @@ function* onLoginRequest( throw new Error('Invalid response') } - yield put( - actions.loginSuccess({ - appToken: data.login.appToken, - user: data.login.user, - }), - ) + yield put(actions.loginSuccess({ appToken, user: data.login.user })) } catch (error) { - const description = 'Login failed' - bugsnag.notify(error, { description }) - console.error(description, error) - - yield put( - actions.loginFailure( - error && - error.response && - error.response.data && - error.response.data.errors && - error.response.data.errors[0], - ), - ) + console.error('Login failed', error) + const err = error instanceof Error ? error : new Error('Unknown error') + bugsnag.notify(err) + + const authError: AuthError = { + name: err.name, + message: err.message, + status: isGraphQLErrorResponse(error) ? error.response.status : undefined, + response: isGraphQLErrorResponse(error) ? error.response.data : undefined, + } + + yield put(actions.loginFailure(authError)) } } @@ -363,7 +478,11 @@ function* onDeleteAccountRequest() { const { data, errors } = response.data if (errors && errors.length) { - throw Object.assign(new Error('GraphQL Error'), { response }) + throw createGraphQLError('GraphQL Error', { + data: response.data, + status: response.status, + errors: errors, + }) } if (!(data && typeof data.deleteAccount === 'boolean')) { @@ -376,19 +495,15 @@ function* onDeleteAccountRequest() { yield put(actions.deleteAccountSuccess()) } catch (error) { - const description = 'Delete account failed' - bugsnag.notify(error, { description }) - console.error(description, error) - - yield put( - actions.deleteAccountFailure( - error && - error.response && - error.response.data && - error.response.data.errors && - error.response.data.errors[0], - ), - ) + console.error('Delete account failed', error) + const err = error instanceof Error ? error : new Error('Unknown error') + bugsnag.notify(err) + + const deleteError = isGraphQLErrorResponse(error) + ? error + : new Error(err.message) + + yield put(actions.deleteAccountFailure(deleteError)) } } @@ -410,18 +525,18 @@ function* onDeleteAccountSuccess() { export function* authSagas() { yield* all([ - yield* fork(init), - yield* takeLatest(REHYDRATE, onRehydrate), - yield* takeLatest( - [REHYDRATE, 'LOGIN_SUCCESS', 'LOGOUT', 'UPDATE_USER_DATA'], - updateLoggedUserOnTools, - ), yield* takeLatest('LOGIN_REQUEST', onLoginRequest), - yield* takeLatest('LOGIN_FAILURE', onLoginFailure), yield* takeLatest('LOGIN_SUCCESS', onLoginSuccess), + yield* takeLatest('LOGIN_FAILURE', onLoginFailure), yield* takeLatest('DELETE_ACCOUNT_REQUEST', onDeleteAccountRequest), - yield* takeLatest('DELETE_ACCOUNT_FAILURE', onDeleteAccountFailure), yield* takeLatest('DELETE_ACCOUNT_SUCCESS', onDeleteAccountSuccess), + yield* takeLatest('DELETE_ACCOUNT_FAILURE', onDeleteAccountFailure), yield* takeLatest('LOGOUT', onLogout), + yield* takeLatest(REHYDRATE as any, onRehydrate), + yield* takeLatest( + [REHYDRATE, 'LOGIN_SUCCESS', 'LOGOUT', 'UPDATE_USER_DATA'], + updateLoggedUserOnTools, + ), + yield* fork(init), ]) } diff --git a/packages/components/src/screens/LoginScreen.tsx b/packages/components/src/screens/LoginScreen.tsx index 22c3d2a45..cc761da54 100644 --- a/packages/components/src/screens/LoginScreen.tsx +++ b/packages/components/src/screens/LoginScreen.tsx @@ -27,7 +27,7 @@ const SHOW_GITHUB_GRANULAR_OAUTH_LOGIN_BUTTON = constants.ENABLE_GITHUB_OAUTH_SUPPORT && !Platform.isMacOS const SHOW_GITHUB_FULL_ACCESS_LOGIN_BUTTON = false const SHOW_GITHUB_PERSONAL_TOKEN_LOGIN_BUTTON = - constants.ENABLE_GITHUB_PERSONAL_ACCESS_TOKEN_SUPPORT && Platform.isMacOS + constants.ENABLE_GITHUB_PERSONAL_ACCESS_TOKEN_SUPPORT const styles = StyleSheet.create({ container: { diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/packages/core/src/constants.ts @@ -0,0 +1 @@ + \ No newline at end of file