From f3a96bf9b78d28fb412aba53544ddb76d5bb3f56 Mon Sep 17 00:00:00 2001 From: Bruno Lemos Date: Mon, 11 Feb 2019 03:25:39 -0200 Subject: [PATCH 01/27] Support oauth token and app oauth token --- packages/components/package.json | 2 +- .../animated/spring/SpringAnimatedIcon.tsx | 6 +- .../components/buttons/GitHubLoginButton.tsx | 4 +- packages/components/src/libs/oauth/helpers.ts | 3 +- packages/components/src/libs/oauth/index.ts | 27 ++++- .../components/src/libs/oauth/index.web.ts | 21 +++- packages/components/src/redux/actions/auth.ts | 8 +- packages/components/src/redux/sagas/auth.ts | 101 +++++------------- .../src/redux/sagas/subscriptions.ts | 7 +- .../components/src/redux/selectors/auth.ts | 66 +++++++++--- packages/components/src/redux/store.ts | 2 +- .../components/src/screens/LoginScreen.tsx | 50 +++++---- packages/core/package.json | 2 +- packages/core/src/types/github.ts | 2 + packages/core/src/types/graphql.ts | 13 ++- yarn.lock | 2 +- 16 files changed, 182 insertions(+), 134 deletions(-) diff --git a/packages/components/package.json b/packages/components/package.json index e8a532792..52a7f313b 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@devhub/core": "0.46.0", - "@octokit/rest": "^16.1.0", + "@octokit/rest": "16.3.0", "axios": "^0.18.0", "bugsnag-js": "^4.7.3", "bugsnag-react": "^1.1.1", diff --git a/packages/components/src/components/animated/spring/SpringAnimatedIcon.tsx b/packages/components/src/components/animated/spring/SpringAnimatedIcon.tsx index b671289a3..6afa1f5e5 100644 --- a/packages/components/src/components/animated/spring/SpringAnimatedIcon.tsx +++ b/packages/components/src/components/animated/spring/SpringAnimatedIcon.tsx @@ -5,4 +5,8 @@ import { createSpringAnimatedComponent } from './helpers' export interface SpringAnimatedIconProps extends Omit {} -export const SpringAnimatedIcon = createSpringAnimatedComponent(Icon) +export const SpringAnimatedIcon = (createSpringAnimatedComponent( + Icon, +) as unknown) as React.ForwardRefExoticComponent< + SpringAnimatedIconProps & React.RefAttributes +> diff --git a/packages/components/src/components/buttons/GitHubLoginButton.tsx b/packages/components/src/components/buttons/GitHubLoginButton.tsx index b1c603137..e9378cb68 100644 --- a/packages/components/src/components/buttons/GitHubLoginButton.tsx +++ b/packages/components/src/components/buttons/GitHubLoginButton.tsx @@ -3,7 +3,7 @@ import React, { useRef } from 'react' import { StyleSheet, TextProps, View } from 'react-native' import { useSpring } from 'react-spring/native' -import { GitHubIcon } from '@devhub/core' +import { GitHubIcon, Omit } from '@devhub/core' import { useCSSVariablesOrSpringAnimatedTheme } from '../../hooks/use-css-variables-or-spring--animated-theme' import { useHover } from '../../hooks/use-hover' import { Platform } from '../../libs/platform' @@ -19,7 +19,7 @@ import { SpringAnimatedView } from '../animated/spring/SpringAnimatedView' import { useTheme } from '../context/ThemeContext' export interface GitHubLoginButtonProps - extends SpringAnimatedTouchableOpacityProps { + extends Omit { horizontal?: boolean leftIcon?: GitHubIcon loading?: boolean diff --git a/packages/components/src/libs/oauth/helpers.ts b/packages/components/src/libs/oauth/helpers.ts index 6fd3e6963..0babcba7f 100644 --- a/packages/components/src/libs/oauth/helpers.ts +++ b/packages/components/src/libs/oauth/helpers.ts @@ -1,6 +1,6 @@ import qs from 'qs' -import { Omit } from '@devhub/core' +import { GitHubAppType, Omit } from '@devhub/core' import { Browser } from '../browser' import { Linking } from '../linking' import { Platform } from '../platform' @@ -8,6 +8,7 @@ import { Platform } from '../platform' export interface OAuthResponseData { app_token?: string code: string + github_app_type: GitHubAppType github_scope: string[] github_token?: string github_token_created_at?: string diff --git a/packages/components/src/libs/oauth/index.ts b/packages/components/src/libs/oauth/index.ts index 288574278..b8b2913f4 100644 --- a/packages/components/src/libs/oauth/index.ts +++ b/packages/components/src/libs/oauth/index.ts @@ -1,14 +1,33 @@ import qs from 'qs' -import { constants } from '@devhub/core' +import { constants, GitHubAppType } from '@devhub/core' import { Browser } from '../browser' -import { getUrlParamsIfMatches, listenForNextUrl } from './helpers' +import { + getUrlParamsIfMatches, + listenForNextUrl, + OAuthResponseData, +} from './helpers' const redirectUri = 'devhub://oauth/github' -export async function executeOAuth(scope: string[]) { - const scopeStr = (scope || []).join(' ') +export async function executeOAuth( + gitHubAppType: 'app', + options?: { appToken?: string }, +): Promise +export async function executeOAuth( + gitHubAppType: 'oauth' | 'both', + options: { appToken?: string; scope?: string[] | undefined }, +): Promise +export async function executeOAuth( + gitHubAppType: GitHubAppType | 'both', + options: { appToken?: string; scope?: string[] | undefined } = {}, +): Promise { + const { appToken, scope } = options + + const scopeStr = (scope || []).join(' ').trim() const querystring = qs.stringify({ + app_token: appToken, + github_app_type: gitHubAppType, scope: scopeStr, redirect_uri: redirectUri, }) diff --git a/packages/components/src/libs/oauth/index.web.ts b/packages/components/src/libs/oauth/index.web.ts index 6e0fe7c62..777108a28 100644 --- a/packages/components/src/libs/oauth/index.web.ts +++ b/packages/components/src/libs/oauth/index.web.ts @@ -1,6 +1,6 @@ import qs from 'qs' -import { constants } from '@devhub/core' +import { constants, GitHubAppType } from '@devhub/core' import { Linking } from '../linking' import { Platform } from '../platform/index.web' import { @@ -31,9 +31,24 @@ function popupWindow(url: string, w: number = 500, h: number = 600) { ) } -export async function executeOAuth(scope: string[]) { - const scopeStr = (scope || []).join(' ') +export async function executeOAuth( + gitHubAppType: 'app', + options?: { appToken?: string }, +): Promise +export async function executeOAuth( + gitHubAppType: 'oauth' | 'both', + options: { appToken?: string; scope?: string[] | undefined }, +): Promise +export async function executeOAuth( + gitHubAppType: GitHubAppType | 'both', + options: { appToken?: string; scope?: string[] | undefined } = {}, +): Promise { + const { appToken, scope } = options + + const scopeStr = (scope || []).join(' ').trim() const querystring = qs.stringify({ + app_token: appToken, + github_app_type: gitHubAppType, scope: scopeStr, redirect_uri: Platform.isElectron ? redirectUri : '', }) diff --git a/packages/components/src/redux/actions/auth.ts b/packages/components/src/redux/actions/auth.ts index 2ae42ff14..4d04a60d6 100644 --- a/packages/components/src/redux/actions/auth.ts +++ b/packages/components/src/redux/actions/auth.ts @@ -2,13 +2,7 @@ import { User } from '@devhub/core' import { createAction, createErrorAction } from '../helpers' import { AuthError } from '../reducers/auth' -export function loginRequest(payload: { - appToken: string - githubScope: string[] | undefined - githubToken: string - githubTokenCreatedAt: string - githubTokenType: 'bearer' | string -}) { +export function loginRequest(payload: { appToken: string }) { return createAction('LOGIN_REQUEST', payload) } diff --git a/packages/components/src/redux/sagas/auth.ts b/packages/components/src/redux/sagas/auth.ts index 738220f61..ba1f5d2cc 100644 --- a/packages/components/src/redux/sagas/auth.ts +++ b/packages/components/src/redux/sagas/auth.ts @@ -1,8 +1,8 @@ import axios, { AxiosResponse } from 'axios' import { REHYDRATE } from 'redux-persist' -import { all, call, put, select, takeLatest } from 'redux-saga/effects' +import { all, put, select, takeLatest } from 'redux-saga/effects' -import { constants, fromGitHubUser, GitHubUser, User } from '@devhub/core' +import { constants, User } from '@devhub/core' import { analytics } from '../../libs/analytics' import { bugsnag } from '../../libs/bugsnag' import * as github from '../../libs/github' @@ -13,29 +13,15 @@ import { ExtractActionFromActionCreator } from '../types/base' function* onRehydrate() { const appToken = yield select(selectors.appTokenSelector) - const githubScope = yield select(selectors.githubScopeSelector) - const githubToken = yield select(selectors.githubTokenSelector) - const githubTokenType = yield select(selectors.githubTokenTypeSelector) - const githubTokenCreatedAt = yield select(selectors.githubTokenTypeSelector) - if (!(appToken && githubToken)) return + if (!appToken) return - yield put( - actions.loginRequest({ - appToken, - githubScope, - githubToken, - githubTokenType, - githubTokenCreatedAt, - }), - ) + yield put(actions.loginRequest({ appToken })) } function* onLoginRequest( action: ExtractActionFromActionCreator, ) { try { - github.authenticate(action.payload.githubToken || '') - // TODO: Auto generate these typings const response: AxiosResponse<{ data: { @@ -46,10 +32,8 @@ function* onLoginRequest( user: { _id: User['_id'] github: { - scope: User['github']['scope'] - token: User['github']['token'] - tokenType: User['github']['tokenType'] - tokenCreatedAt: User['github']['tokenCreatedAt'] + app?: User['github']['app'] + oauth?: User['github']['oauth'] user: { id: User['github']['user']['id'] nodeId: User['github']['user']['nodeId'] @@ -78,10 +62,18 @@ function* onLoginRequest( columns subscriptions github { - scope - token - tokenType - tokenCreatedAt + app { + scope + token + tokenType + tokenCreatedAt + } + oauth { + scope + token + tokenType + tokenCreatedAt + } user { id nodeId @@ -125,64 +117,24 @@ function* onLoginRequest( throw new Error('Invalid response') } - github.authenticate(data.login.user.github.token) - yield put( actions.loginSuccess({ appToken: data.login.appToken, user: data.login.user, }), ) - return } catch (error) { const description = 'Login failed' bugsnag.notify(error, { description }) console.error(description, error) - if ( - error && - error.response && - (error.response.status >= 200 || error.response.status < 500) - ) { - yield put( - actions.loginFailure( - error.response.data && - error.response.data.errors && - error.response.data.errors[0], - ), - ) - return - } - } - - try { - const response = yield call(github.octokit.users.getAuthenticated, {}) - const githubUser = fromGitHubUser(response.data as GitHubUser) - if (!(githubUser && githubUser.id && githubUser.login)) - throw new Error('Invalid response') - yield put( - actions.loginSuccess({ - appToken: action.payload.appToken, - user: { - _id: '', - github: { - scope: action.payload.githubScope || [], - token: action.payload.githubToken, - tokenType: action.payload.githubTokenType || '', - tokenCreatedAt: '', - user: githubUser, - }, - lastLoginAt: new Date().toISOString(), - createdAt: '', - updatedAt: '', - }, - }), + actions.loginFailure( + error.response.data && + error.response.data.errors && + error.response.data.errors[0], + ), ) - } catch (error) { - if (!error.name) error.name = 'AuthError' - console.error('Alternative login failed', error) - yield put(actions.loginFailure(error)) } } @@ -191,7 +143,12 @@ function onLoginSuccess( ) { const { user } = action.payload - github.authenticate(user.github.token) + // TODO: better handle app oauth + github.authenticate( + (user.github.oauth && user.github.oauth.token)! || + (user.github.app && user.github.app.token)!, + ) + analytics.setUser(user._id) bugsnag.setUser(user._id, user.github.user.name || user.github.user.login) } diff --git a/packages/components/src/redux/sagas/subscriptions.ts b/packages/components/src/redux/sagas/subscriptions.ts index cc2f9d061..5a409a144 100644 --- a/packages/components/src/redux/sagas/subscriptions.ts +++ b/packages/components/src/redux/sagas/subscriptions.ts @@ -20,7 +20,6 @@ import { ColumnSubscription, constants, createNotificationsCache, - EnhancedGitHubEvent, EnhancementCache, enhanceNotifications, getGitHubApiHeadersFromHeader, @@ -237,7 +236,11 @@ function* onFetchRequest( const { subscriptionType, subscriptionId, params: _params } = action.payload const subscription = selectors.subscriptionSelector(state, subscriptionId) - const githubToken = selectors.githubTokenSelector(state) + + // TODO: Fix github app token handling + const githubToken = + selectors.githubOAuthTokenSelector(state) || + selectors.githubAppTokenSelector(state) const hasPrivateAccess = selectors.githubHasPrivateAccessSelector(state) const page = Math.max(1, _params.page || 1) diff --git a/packages/components/src/redux/selectors/auth.ts b/packages/components/src/redux/selectors/auth.ts index 0ed5c81b3..6fece4427 100644 --- a/packages/components/src/redux/selectors/auth.ts +++ b/packages/components/src/redux/selectors/auth.ts @@ -6,28 +6,70 @@ export const authErrorSelector = (state: RootState) => s(state).error export const isLoggingInSelector = (state: RootState) => s(state).isLoggingIn -export const appTokenSelector = (state: RootState) => s(state).appToken +export const isLoggedSelector = (state: RootState) => + appTokenSelector(state) && + (githubAppTokenSelector(state) || githubOAuthTokenSelector(state)) + ? !!(s(state).user && s(state).user!._id) + : false -export const githubScopeSelector = (state: RootState) => - s(state).user && s(state).user!.github.scope +export const appTokenSelector = (state: RootState) => s(state).appToken // TODO: Support private repositories after migrating to GitHub App // @see https://github.com/devhubapp/devhub/issues/32 export const githubHasPrivateAccessSelector = (_state: RootState) => false -export const githubTokenSelector = (state: RootState) => - s(state).user && s(state).user!.github.token +export const githubAppTokenDetailsSelector = (state: RootState) => { + const user = s(state).user + return (user && user.github.app) || undefined +} + +export const githubAppScopeSelector = (state: RootState) => { + const tokenDetails = githubAppTokenDetailsSelector(state) + return (tokenDetails && tokenDetails.scope) || undefined +} + +export const githubAppTokenSelector = (state: RootState) => { + const tokenDetails = githubAppTokenDetailsSelector(state) + return (tokenDetails && tokenDetails.token) || undefined +} -export const githubTokenTypeSelector = (state: RootState) => - s(state).user && s(state).user!.github.tokenType +export const githubAppTokenTypeSelector = (state: RootState) => { + const tokenDetails = githubAppTokenDetailsSelector(state) + return (tokenDetails && tokenDetails.tokenType) || undefined +} + +export const githubAppTokenCreatedAtSelector = (state: RootState) => { + const tokenDetails = githubAppTokenDetailsSelector(state) + return (tokenDetails && tokenDetails.tokenCreatedAt) || undefined +} -export const githubTokenCreatedAtSelector = (state: RootState) => - s(state).user && s(state).user!.github.tokenCreatedAt +export const githubOAuthTokenDetailsSelector = (state: RootState) => { + const user = s(state).user + return (user && user.github.oauth) || undefined +} + +export const githubOAuthScopeSelector = (state: RootState) => { + const tokenDetails = githubOAuthTokenDetailsSelector(state) + return (tokenDetails && tokenDetails.scope) || undefined +} + +export const githubOAuthTokenSelector = (state: RootState) => { + const tokenDetails = githubOAuthTokenDetailsSelector(state) + return (tokenDetails && tokenDetails.token) || undefined +} + +export const githubOAuthTokenTypeSelector = (state: RootState) => { + const tokenDetails = githubOAuthTokenDetailsSelector(state) + return (tokenDetails && tokenDetails.tokenType) || undefined +} + +export const githubOAuthTokenCreatedAtSelector = (state: RootState) => { + const tokenDetails = githubOAuthTokenDetailsSelector(state) + return (tokenDetails && tokenDetails.tokenCreatedAt) || undefined +} export const currentUserSelector = (state: RootState) => - appTokenSelector(state) && githubTokenSelector(state) - ? s(state).user - : undefined + isLoggedSelector(state) ? s(state).user : undefined export const currentUsernameSelector = (state: RootState) => { const user = currentUserSelector(state) diff --git a/packages/components/src/redux/store.ts b/packages/components/src/redux/store.ts index 4586a5a67..6707e7236 100644 --- a/packages/components/src/redux/store.ts +++ b/packages/components/src/redux/store.ts @@ -106,7 +106,7 @@ const migrations = { tokenType: oldAuth.githubTokenType || '', tokenCreatedAt: oldAuth.githubTokenCreatedAt || '', user: oldAuth.user, - }, + } as any, createdAt: '', updatedAt: '', lastLoginAt: oldAuth.lastLoginAt || '', diff --git a/packages/components/src/screens/LoginScreen.tsx b/packages/components/src/screens/LoginScreen.tsx index 3c652564e..49e5e4478 100644 --- a/packages/components/src/screens/LoginScreen.tsx +++ b/packages/components/src/screens/LoginScreen.tsx @@ -2,6 +2,7 @@ import qs from 'qs' import React, { useEffect, useRef, useState } from 'react' import { Image, StyleSheet, View } from 'react-native' +import { GitHubTokenDetails } from '@devhub/core' import { SpringAnimatedText } from '../components/animated/spring/SpringAnimatedText' import { GitHubLoginButton } from '../components/buttons/GitHubLoginButton' import { AppVersion } from '../components/common/AppVersion' @@ -98,7 +99,11 @@ export const LoginScreen = React.memo(() => { const params = getUrlParamsIfMatches(querystring, '') if (!params) return - handleOAuth(params) + + const { appToken } = parseOAuthParams(params) + if (!appToken) return + + loginRequest({ appToken }) })() }, []) @@ -118,33 +123,26 @@ export const LoginScreen = React.memo(() => { analytics.trackScreenView('LOGIN_SCREEN') - const handleOAuth = async ( + function parseOAuthParams( params: OAuthResponseData, - _githubScope?: string[], - ) => { + ): { appToken?: string; tokenDetails?: GitHubTokenDetails } { try { if (!(params && params.app_token && params.github_token)) throw new Error('No token received.') const appToken = params.app_token + const githubScope = params.github_scope const githubToken = params.github_token - const githubTokenCreatedAt = params.github_token_created_at || new Date().toISOString() const githubTokenType = params.github_token_type || 'bearer' - const githubScope = - params.github_scope && params.github_scope.length - ? params.github_scope - : _githubScope - - await loginRequest({ - appToken, - githubScope, - githubToken, - githubTokenType, - githubTokenCreatedAt, - }) + const tokenDetails: GitHubTokenDetails = { + scope: githubScope, + token: githubToken, + tokenType: githubTokenType, + tokenCreatedAt: githubTokenCreatedAt, + } if ( Platform.OS === 'web' && @@ -155,6 +153,7 @@ export const LoginScreen = React.memo(() => { const newQuery = { ...params } delete newQuery.app_token delete newQuery.code + delete newQuery.github_app_type delete newQuery.github_scope delete newQuery.github_token delete newQuery.github_token_created_at @@ -169,26 +168,33 @@ export const LoginScreen = React.memo(() => { }`, ) } + + return { appToken, tokenDetails } } catch (error) { const description = 'OAuth failed' console.error(description, error) - if (error.message === 'Canceled' || error.message === 'Timeout') return + if (error.message === 'Canceled' || error.message === 'Timeout') return {} bugsnag.notify(error, { description }) alert(`Login failed. ${error || ''}`) + + return {} } } const loginWithGitHub = async () => { setIsExecutingOAuth(true) - const githubScope = ['read:user', 'user:email', 'notifications', 'read:org'] - try { analytics.trackEvent('engagement', 'login') - const params = await executeOAuth(githubScope) - handleOAuth(params, githubScope) + const params = await executeOAuth('both', { + scope: ['notifications', 'read:user', 'user:email', 'read:org'], + }) + const { appToken } = parseOAuthParams(params) + if (!appToken) return + + loginRequest({ appToken }) } catch (error) { const description = 'OAuth execution failed' console.error(description, error) diff --git a/packages/core/package.json b/packages/core/package.json index 32dca4d50..caf38d9d6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,7 +11,7 @@ "prepare": "cd .. && yarn patch-package" }, "dependencies": { - "@octokit/rest": "^16.1.0", + "@octokit/rest": "16.3.0", "gravatar": "^1.8.0", "json-to-graphql-query": "^1.9.0", "lodash": "^4.17.11", diff --git a/packages/core/src/types/github.ts b/packages/core/src/types/github.ts index 3c2c80be0..9bec8c1fd 100644 --- a/packages/core/src/types/github.ts +++ b/packages/core/src/types/github.ts @@ -1,3 +1,5 @@ +export type GitHubAppType = 'app' | 'oauth' + export type GitHubActivityType = | 'ORG_PUBLIC_EVENTS' | 'PUBLIC_EVENTS' diff --git a/packages/core/src/types/graphql.ts b/packages/core/src/types/graphql.ts index 204a6ca17..7c1c5a856 100644 --- a/packages/core/src/types/graphql.ts +++ b/packages/core/src/types/graphql.ts @@ -32,6 +32,13 @@ export interface GraphQLGitHubUser { updatedAt: string } +export interface GitHubTokenDetails { + scope?: string[] | undefined + token: string + tokenType?: 'bearer' | string + tokenCreatedAt: string +} + export interface User { _id: any columns?: { @@ -45,10 +52,8 @@ export interface User { updatedAt: string } github: { - scope: string[] - token: string - tokenType: string - tokenCreatedAt: string + app?: GitHubTokenDetails + oauth?: GitHubTokenDetails user: GraphQLGitHubUser } createdAt: string diff --git a/yarn.lock b/yarn.lock index 6fc259d19..c7a032855 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1080,7 +1080,7 @@ node-fetch "^2.3.0" universal-user-agent "^2.0.1" -"@octokit/rest@^16.1.0": +"@octokit/rest@16.3.0": version "16.3.0" resolved "https://registry.npmjs.org/@octokit/rest/-/rest-16.3.0.tgz#98a24a3334312a87fff8a2a54c1dca4d0900d54d" integrity sha512-u0HkROLB0nOSfJhkF5FKMg6I12m6cN5S3S73Lwtfgrs9u4LhgUCZN2hC2KDyIaT7nhvNe9Kx0PgxhhD6li6QsA== From 4aa965e2a5d4ccff5a41ecf3a33aad372a263a93 Mon Sep 17 00:00:00 2001 From: Bruno Lemos Date: Mon, 11 Feb 2019 03:41:54 -0200 Subject: [PATCH 02/27] Remove read:org permission --- PRIVACY.md | 16 ++++++++-------- packages/components/src/screens/LoginScreen.tsx | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 23f8180a2..3e4ab34a8 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,21 +1,19 @@ # Privacy Policy + ## DevHub ### Personal user information This app requires GitHub authentication.
-These are all permissions that may be requested to you and their reasons: +These are all permissions that will be requested to you and their reasons: -- [required] `read:user`: Read-only access to the user's profile data, like username, email and avatar; +- [required] `read:user`: Read-only access to the user's profile data; - [required] `user:email`: Read-only access to the user's e-mail, so DevHub has a way to contact its users if necessary, e.g. security disclosures; - [required] `notifications`: Read user's public and private notifications; mark as read; -- [required] `read:org`: Read-only access to the user's organizations; -- [deprecated] `public_repo`: Allow starring repositories (removed while DevHub doesn't have this feature activated); -- [deprecated] `repo`: Read user's private content, like events from private repositories ([not recommended](https://github.com/devhubapp/devhub/issues/32)). ### Diagnostics information This app uses [Bugsnag](https://bugsnag.com), [Google Analytics](https://analytics.google.com/) and [Firebase](https://firebase.google.com/) to collect information about crashes and app usage. -No personal information is ever sent to third parties, only an anonymous id. Services may collect user's IP. Some device information may be included for debugging purposes, like `brand`, `model` and `operation system`. +No personal information is ever sent to third parties, only an anonymous id. Services may collect the user's IP. Some device information may be included for debugging purposes, like `brand`, `model` and `operation system`. ### Security & Limited Liability @@ -24,7 +22,9 @@ DevHub follows good practices of security, but 100% security can't be granted in Client-side communication is encrypted using HTTPS. Server-side tokens are encrypted or behind environment variables. -Disclaimer: DevHub does not access any code from any repository, but GitHub's oauth permissions `public_repo` and `repo` provide write access. These permissions are not asked by DevHub anymore, but you may have already granted them on early versions. You can revoke the tokens in your GitHub settings. Make sure to keep your tokens safe. For example, be extra careful with which browser extensions you have installed. Token safety is user's responsibility. +DevHub does not access any code from any repository and never asks for this permission. + +Client-side token safety is a user's responsibility. We recommend being extra careful with which browser extensions you have installed, for example. ### Support @@ -33,4 +33,4 @@ If you find any bug, please contribute by opening an issue or sending a pull req --- -Updated January 13rd, 2019. +Updated: Feb 11th, 2019. diff --git a/packages/components/src/screens/LoginScreen.tsx b/packages/components/src/screens/LoginScreen.tsx index 49e5e4478..0c22c2b62 100644 --- a/packages/components/src/screens/LoginScreen.tsx +++ b/packages/components/src/screens/LoginScreen.tsx @@ -189,7 +189,7 @@ export const LoginScreen = React.memo(() => { analytics.trackEvent('engagement', 'login') const params = await executeOAuth('both', { - scope: ['notifications', 'read:user', 'user:email', 'read:org'], + scope: ['notifications', 'read:user', 'user:email'], }) const { appToken } = parseOAuthParams(params) if (!appToken) return From 251269fef1027b25496bc6cfa16146843415bd2d Mon Sep 17 00:00:00 2001 From: Bruno Lemos Date: Mon, 11 Feb 2019 03:53:18 -0200 Subject: [PATCH 03/27] Update PRIVACY.md to mention GitHub App permissions --- PRIVACY.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 3e4ab34a8..3c7bedfad 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -4,11 +4,16 @@ ### Personal user information This app requires GitHub authentication.
-These are all permissions that will be requested to you and their reasons: +DevHub requests access to the user's profile data, e-mail and notifications. -- [required] `read:user`: Read-only access to the user's profile data; -- [required] `user:email`: Read-only access to the user's e-mail, so DevHub has a way to contact its users if necessary, e.g. security disclosures; -- [required] `notifications`: Read user's public and private notifications; mark as read; + +### Repository and org access +You have the option to install DevHub's GitHub App in some specific orgs and repositories. +This is required to enable access to activities from private repositories. + +DevHub will have access to issues, pull requests, comments, labels, assignees, milestones, merges, collaborators and some other metadata (e.g. repository name). + +DevHub does not have access to any code from any repository. ### Diagnostics information @@ -22,9 +27,7 @@ DevHub follows good practices of security, but 100% security can't be granted in Client-side communication is encrypted using HTTPS. Server-side tokens are encrypted or behind environment variables. -DevHub does not access any code from any repository and never asks for this permission. - -Client-side token safety is a user's responsibility. We recommend being extra careful with which browser extensions you have installed, for example. +We recommend being extra careful with which browser extensions you have installed to avoid token exposure to third parties. ### Support From df717f1d9fab0572c800616adee1a8e069eed906 Mon Sep 17 00:00:00 2001 From: Bruno Lemos Date: Mon, 11 Feb 2019 04:00:13 -0200 Subject: [PATCH 04/27] Remove read:user and user:email permissions --- packages/components/src/screens/LoginScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/screens/LoginScreen.tsx b/packages/components/src/screens/LoginScreen.tsx index 0c22c2b62..a77e21496 100644 --- a/packages/components/src/screens/LoginScreen.tsx +++ b/packages/components/src/screens/LoginScreen.tsx @@ -189,7 +189,7 @@ export const LoginScreen = React.memo(() => { analytics.trackEvent('engagement', 'login') const params = await executeOAuth('both', { - scope: ['notifications', 'read:user', 'user:email'], + scope: ['notifications'], }) const { appToken } = parseOAuthParams(params) if (!appToken) return From 33ac8337836a8f03c6b1d5b66c49e2974efc881b Mon Sep 17 00:00:00 2001 From: Bruno Lemos Date: Mon, 11 Feb 2019 06:27:54 -0200 Subject: [PATCH 05/27] If token is missing, show button to re-do oauth flow --- .../src/components/cards/EmptyCards.tsx | 31 +++- .../src/components/cards/NoTokenView.tsx | 137 ++++++++++++++++++ .../src/containers/EventCardsContainer.tsx | 19 +++ .../containers/NotificationCardsContainer.tsx | 16 ++ packages/components/src/libs/oauth/index.ts | 8 - .../components/src/libs/oauth/index.web.ts | 8 - .../src/redux/sagas/subscriptions.ts | 31 ++-- .../components/src/redux/selectors/auth.ts | 3 +- .../components/src/screens/LoginScreen.tsx | 88 +++-------- packages/components/src/utils/helpers/auth.ts | 58 ++++++++ .../src/utils/helpers/github/emojis.tsx | 6 +- packages/core/src/utils/constants.ts | 9 +- 12 files changed, 305 insertions(+), 109 deletions(-) create mode 100644 packages/components/src/components/cards/NoTokenView.tsx create mode 100644 packages/components/src/utils/helpers/auth.ts diff --git a/packages/components/src/components/cards/EmptyCards.tsx b/packages/components/src/components/cards/EmptyCards.tsx index 99e022edf..b22fbc464 100644 --- a/packages/components/src/components/cards/EmptyCards.tsx +++ b/packages/components/src/components/cards/EmptyCards.tsx @@ -37,12 +37,13 @@ const getRandomEmoji = () => { // only one message per app running instance // because a chaning message is a bit distractive const clearMessage = getRandomClearMessage() -const emoji = getRandomEmoji() -const emojiImageURL = getEmojiImageURL(emoji) +const randomEmoji = getRandomEmoji() +const randomEmojiImageURL = getEmojiImageURL(randomEmoji) export interface EmptyCardsProps { clearedAt: string | undefined columnId: string + emoji?: GitHubEmoji errorMessage?: string errorTitle?: string fetchNextPage: (() => void) | undefined @@ -54,6 +55,7 @@ export const EmptyCards = React.memo((props: EmptyCardsProps) => { const { clearedAt, columnId, + emoji = 'warning', errorMessage, errorTitle = 'Something went wrong', fetchNextPage, @@ -66,6 +68,7 @@ export const EmptyCards = React.memo((props: EmptyCardsProps) => { actions.setColumnClearedAtFilter, ) + const emojiImageURL = getEmojiImageURL(emoji) const hasError = errorMessage || loadState === 'error' const renderContent = () => { @@ -92,10 +95,26 @@ export const EmptyCards = React.memo((props: EmptyCardsProps) => { if (hasError) { return ( + {!!emojiImageURL && ( + + )} + - {`⚠️\n${errorTitle}`} + {errorTitle} + {!!errorMessage && ( - {`\n${errorMessage}`} + <> + {!!errorTitle && {'\n'}} + {errorMessage} + )} @@ -118,12 +137,12 @@ export const EmptyCards = React.memo((props: EmptyCardsProps) => { {clearMessage} - {!!emojiImageURL && ( + {!!randomEmojiImageURL && ( <> diff --git a/packages/components/src/components/cards/NoTokenView.tsx b/packages/components/src/components/cards/NoTokenView.tsx new file mode 100644 index 000000000..0b8f87c5a --- /dev/null +++ b/packages/components/src/components/cards/NoTokenView.tsx @@ -0,0 +1,137 @@ +import React, { useState } from 'react' +import { Image, Text, View } from 'react-native' + +import { constants, GitHubAppType } from '@devhub/core' +import { useCSSVariablesOrSpringAnimatedTheme } from '../../hooks/use-css-variables-or-spring--animated-theme' +import { useReduxAction } from '../../hooks/use-redux-action' +import { useReduxState } from '../../hooks/use-redux-state' +import { analytics } from '../../libs/analytics' +import { bugsnag } from '../../libs/bugsnag' +import { executeOAuth } from '../../libs/oauth' +import * as actions from '../../redux/actions' +import * as selectors from '../../redux/selectors' +import { contentPadding } from '../../styles/variables' +import { tryParseOAuthParams } from '../../utils/helpers/auth' +import { + getEmojiImageURL, + GitHubEmoji, +} from '../../utils/helpers/github/emojis' +import { SpringAnimatedText } from '../animated/spring/SpringAnimatedText' +import { Button } from '../common/Button' + +export interface NoTokenViewProps { + emoji?: GitHubEmoji + githubAppType: GitHubAppType | 'both' + subtitle?: string + title?: string +} + +export const NoTokenView = React.memo((props: NoTokenViewProps) => { + const { + emoji = 'warning', + githubAppType, + subtitle = 'Required permission is missing', + title = 'Please login again', + } = props + + const [isExecutingOAuth, setIsExecutingOAuth] = useState(false) + const springAnimatedTheme = useCSSVariablesOrSpringAnimatedTheme() + const existingAppToken = useReduxState(selectors.appTokenSelector) + const isLoggingIn = useReduxState(selectors.isLoggingInSelector) + const loginRequest = useReduxAction(actions.loginRequest) + + const emojiImageURL = getEmojiImageURL(emoji) + + async function startOAuth() { + try { + analytics.trackEvent('engagement', `relogin_add_token_${githubAppType}`) + + const params = await executeOAuth(githubAppType, { + appToken: existingAppToken, + scope: + githubAppType === 'oauth' || githubAppType === 'both' + ? constants.DEFAULT_GITHUB_OAUTH_SCOPES + : undefined, + }) + const { appToken } = tryParseOAuthParams(params) + if (!appToken) return + + loginRequest({ appToken }) + } catch (error) { + const description = 'OAuth execution failed' + console.error(description, error) + setIsExecutingOAuth(false) + + if (error.message === 'Canceled' || error.message === 'Timeout') return + bugsnag.notify(error, { description }) + + alert(`Login failed. ${error || ''}`) + } + } + + const renderContent = () => { + return ( + + {!!emojiImageURL && ( + + )} + + + {title} + + {!!subtitle && ( + <> + {!!title && {'\n'}} + {subtitle} + + )} + + + + + )} + + + + + + + GitHub App + + + + + {githubAppToken ? ( + + + + ) : ( + + )} + + + + {!!githubAppToken && ( + <> + + + + + <> + + + {!!( + githubAppToken || installationsLoadState === 'loading' + ) && ( + + + + )} + + + + {installations.map( + (installation, index) => + !!( + installation.account && + installation.account.login && + installation.htmlUrl + ) && ( + + + + + {installation.account.login} + + + + + + + ), + )} + + + )} + + + + ) + }, +) diff --git a/packages/components/src/components/modals/ModalRenderer.tsx b/packages/components/src/components/modals/ModalRenderer.tsx index eb1ae42ed..a97643fc1 100644 --- a/packages/components/src/components/modals/ModalRenderer.tsx +++ b/packages/components/src/components/modals/ModalRenderer.tsx @@ -19,6 +19,7 @@ import { useColumnWidth } from '../context/ColumnWidthContext' import { useAppLayout } from '../context/LayoutContext' import { AddColumnDetailsModal } from './AddColumnDetailsModal' import { AddColumnModal } from './AddColumnModal' +import { AdvancedSettingsModal } from './AdvancedSettingsModal' import { EnterpriseSetupModal } from './EnterpriseSetupModal' import { KeyboardShortcutsModal } from './KeyboardShortcutsModal' @@ -39,6 +40,9 @@ function renderModal(modal: ModalPayloadWithIndex) { /> ) + case 'ADVANCED_SETTINGS': + return = 1} /> + case 'KEYBOARD_SHORTCUTS': return = 1} /> @@ -72,7 +76,9 @@ export function ModalRenderer(props: ModalRendererProps) { const immediate = sizename === '1-small' && - ((currentOpenedModal && currentOpenedModal.name === 'SETTINGS') || + ((currentOpenedModal && + currentOpenedModal.name === 'SETTINGS' && + !previouslyOpenedModal) || (!currentOpenedModal && previouslyOpenedModal && previouslyOpenedModal.name === 'SETTINGS')) diff --git a/packages/components/src/components/modals/SettingsModal.tsx b/packages/components/src/components/modals/SettingsModal.tsx index 40b9d4b3a..33bda1c0b 100644 --- a/packages/components/src/components/modals/SettingsModal.tsx +++ b/packages/components/src/components/modals/SettingsModal.tsx @@ -11,6 +11,7 @@ import { AppVersion } from '../common/AppVersion' import { Avatar } from '../common/Avatar' import { Button } from '../common/Button' import { Spacer } from '../common/Spacer' +import { SubHeader } from '../common/SubHeader' import { useAppLayout } from '../context/LayoutContext' import { ThemePreference } from '../widgets/ThemePreference' @@ -26,6 +27,7 @@ export const SettingsModal = React.memo((props: SettingsModalProps) => { const username = useReduxState(selectors.currentUsernameSelector) const logout = useReduxAction(actions.logout) + const pushModal = useReduxAction(actions.pushModal) return ( { + + + + + ) diff --git a/packages/components/src/libs/confirm/index.shared.ts b/packages/components/src/libs/confirm/index.shared.ts new file mode 100644 index 000000000..5dad63208 --- /dev/null +++ b/packages/components/src/libs/confirm/index.shared.ts @@ -0,0 +1,12 @@ +export type ConfirmFn = ( + title: string, + message: string, + buttons: { + cancelCallback?: () => void + cancelLabel?: string + cancelable?: boolean + confirmCallback?: () => void + confirmLabel?: string + destructive?: boolean + }, +) => void diff --git a/packages/components/src/libs/confirm/index.ts b/packages/components/src/libs/confirm/index.ts new file mode 100644 index 000000000..11b8e8d6f --- /dev/null +++ b/packages/components/src/libs/confirm/index.ts @@ -0,0 +1,34 @@ +import { Alert } from 'react-native' + +import { ConfirmFn } from './index.shared' + +export const confirm: ConfirmFn = ( + title, + message, + { + cancelCallback, + cancelLabel, + cancelable = true, + confirmCallback, + confirmLabel, + destructive = false, + }, +) => { + Alert.alert( + title, + message, + [ + { + text: cancelLabel || 'Cancel', + onPress: cancelCallback || (() => undefined), + style: 'cancel', + }, + { + text: confirmLabel || 'Ok', + onPress: confirmCallback || (() => undefined), + style: destructive ? 'destructive' : undefined, + }, + ], + { cancelable }, + ) +} diff --git a/packages/components/src/libs/confirm/index.web.ts b/packages/components/src/libs/confirm/index.web.ts new file mode 100644 index 000000000..fd6310887 --- /dev/null +++ b/packages/components/src/libs/confirm/index.web.ts @@ -0,0 +1,17 @@ +import { ConfirmFn } from './index.shared' + +export const confirm: ConfirmFn = ( + title, + message, + { cancelCallback, confirmCallback }, +) => { + const _message = title && message ? `${title}\n${message}` : message || title + + const result = window.confirm(_message) + + if (result) { + if (confirmCallback) confirmCallback() + } else { + if (cancelCallback) cancelCallback() + } +} diff --git a/packages/components/src/redux/actions/auth.ts b/packages/components/src/redux/actions/auth.ts index 4d04a60d6..5ec108116 100644 --- a/packages/components/src/redux/actions/auth.ts +++ b/packages/components/src/redux/actions/auth.ts @@ -17,3 +17,15 @@ export function loginFailure(error: E) { export function logout() { return createAction('LOGOUT') } + +export function deleteAccountRequest() { + return createAction('DELETE_ACCOUNT_REQUEST') +} + +export function deleteAccountSuccess() { + return createAction('DELETE_ACCOUNT_SUCCESS') +} + +export function deleteAccountFailure(error: E) { + return createErrorAction('DELETE_ACCOUNT_FAILURE', error) +} diff --git a/packages/components/src/redux/reducers/auth.ts b/packages/components/src/redux/reducers/auth.ts index 793300fc5..4ce8a6dd3 100644 --- a/packages/components/src/redux/reducers/auth.ts +++ b/packages/components/src/redux/reducers/auth.ts @@ -14,6 +14,7 @@ export interface AuthError { export interface State { appToken: string | null error: AuthError | null + isDeletingAccount: boolean isLoggingIn: boolean user: Pick | null } @@ -21,6 +22,7 @@ export interface State { const initialState: State = { appToken: null, error: null, + isDeletingAccount: false, isLoggingIn: false, user: null, } @@ -30,13 +32,14 @@ export const authReducer: Reducer = (state = initialState, action) => { case REHYDRATE as any: return { ...(action.payload && (action.payload as any).auth), - ..._.pick(initialState, ['error', 'isLoggingIn']), + ..._.pick(initialState, ['error', 'isDeletingAccount', 'isLoggingIn']), } case 'LOGIN_REQUEST': return { appToken: action.payload.appToken, error: null, + isDeletingAccount: false, isLoggingIn: true, user: state.user, } @@ -45,6 +48,7 @@ export const authReducer: Reducer = (state = initialState, action) => { return { appToken: action.payload.appToken || state.appToken, error: null, + isDeletingAccount: false, isLoggingIn: false, user: action.payload.user && { _id: action.payload.user._id, @@ -61,6 +65,24 @@ export const authReducer: Reducer = (state = initialState, action) => { error: action.error, } + case 'DELETE_ACCOUNT_REQUEST': + return { + ...state, + isDeletingAccount: true, + } + + case 'DELETE_ACCOUNT_SUCCESS': + return { + ...state, + isDeletingAccount: false, + } + + case 'DELETE_ACCOUNT_FAILURE': + return { + ...state, + isDeletingAccount: false, + } + default: return state } diff --git a/packages/components/src/redux/sagas/auth.ts b/packages/components/src/redux/sagas/auth.ts index 9c60e3b33..30c2bd05d 100644 --- a/packages/components/src/redux/sagas/auth.ts +++ b/packages/components/src/redux/sagas/auth.ts @@ -182,12 +182,86 @@ function onLogout() { clearOAuthQueryParams() } +function* onDeleteAccountRequest() { + const appToken = yield select(selectors.appTokenSelector) + + try { + const response: AxiosResponse<{ + data: { + deleteAccount: boolean | null + } + errors?: any[] + }> = yield axios.post( + constants.GRAPHQL_ENDPOINT, + { + query: `mutation { + deleteAccount + }`, + }, + { + headers: { + Authorization: `bearer ${appToken}`, + }, + }, + ) + + const { data, errors } = response.data + + if (errors && errors.length) { + throw Object.assign(new Error('GraphQL Error'), { response }) + } + + if (!(data && typeof data.deleteAccount === 'boolean')) { + throw new Error('Invalid response') + } + + if (!(data && data.deleteAccount)) { + throw new Error('Failed to delete account') + } + + 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], + ), + ) + } +} + +function onDeleteAccountFailure( + action: ExtractActionFromActionCreator, +) { + bugsnag.notify(action.error) + alert( + `Oops. Failed to delete account. Please try again.\n\n${(action.error && + action.error.message) || + action.error || + ''}`.trim(), + ) +} + +function* onDeleteAccountSuccess() { + yield put(actions.logout()) +} + export function* authSagas() { yield all([ yield takeLatest(REHYDRATE, onRehydrate), - yield takeLatest('LOGIN_FAILURE', onLoginFailure), yield takeLatest('LOGIN_REQUEST', onLoginRequest), + yield takeLatest('LOGIN_FAILURE', onLoginFailure), yield takeLatest('LOGIN_SUCCESS', onLoginSuccess), + yield takeLatest('DELETE_ACCOUNT_REQUEST', onDeleteAccountRequest), + yield takeLatest('DELETE_ACCOUNT_FAILURE', onDeleteAccountFailure), + yield takeLatest('DELETE_ACCOUNT_SUCCESS', onDeleteAccountSuccess), yield takeLatest('LOGOUT', onLogout), ]) } diff --git a/packages/components/src/redux/selectors/auth.ts b/packages/components/src/redux/selectors/auth.ts index f98de50ca..a56d9eb37 100644 --- a/packages/components/src/redux/selectors/auth.ts +++ b/packages/components/src/redux/selectors/auth.ts @@ -5,6 +5,9 @@ const s = (state: RootState) => state.auth || {} export const authErrorSelector = (state: RootState) => s(state).error +export const isDeletingAccountSelector = (state: RootState) => + s(state).isDeletingAccount + export const isLoggingInSelector = (state: RootState) => s(state).isLoggingIn export const isLoggedSelector = (state: RootState) => diff --git a/packages/components/src/redux/store.ts b/packages/components/src/redux/store.ts index 8c37617e8..d873f92d6 100644 --- a/packages/components/src/redux/store.ts +++ b/packages/components/src/redux/store.ts @@ -93,6 +93,7 @@ const migrations = { draft.auth = { appToken: oldAuth.appToken, error: null, + isDeletingAccount: false, isLoggingIn: false, user: oldAuth.user && From 56179435fc6d58ddb31cc9ee53fb9e760b31b5f4 Mon Sep 17 00:00:00 2001 From: Bruno Lemos Date: Mon, 25 Feb 2019 23:27:14 -0300 Subject: [PATCH 24/27] Change /oauth/github to /github/oauth --- packages/components/src/libs/oauth/index.ts | 4 ++-- packages/components/src/libs/oauth/index.web.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/src/libs/oauth/index.ts b/packages/components/src/libs/oauth/index.ts index b022fad7e..babec20d5 100644 --- a/packages/components/src/libs/oauth/index.ts +++ b/packages/components/src/libs/oauth/index.ts @@ -8,7 +8,7 @@ import { OAuthResponseData, } from './helpers' -const redirectUri = 'devhub://oauth/github' +const redirectUri = 'devhub://github/oauth' export async function executeOAuth( gitHubAppType: GitHubAppType | 'both', @@ -25,7 +25,7 @@ export async function executeOAuth( }) // console.log('[OAUTH] Opening browser...') - Browser.openURL(`${constants.API_BASE_URL}/oauth/github?${querystring}`) + Browser.openURL(`${constants.API_BASE_URL}/github/oauth?${querystring}`) const url = await listenForNextUrl() // console.log('[OAUTH] Received URL:', url) diff --git a/packages/components/src/libs/oauth/index.web.ts b/packages/components/src/libs/oauth/index.web.ts index 80b37f68c..dd6c37699 100644 --- a/packages/components/src/libs/oauth/index.web.ts +++ b/packages/components/src/libs/oauth/index.web.ts @@ -10,7 +10,7 @@ import { OAuthResponseData, } from './helpers' -const redirectUri = 'devhub://oauth/github' +const redirectUri = 'devhub://github/oauth' const popupTarget = !__DEV__ && @@ -49,7 +49,7 @@ export async function executeOAuth( // console.log('[OAUTH] Opening popup...') const popup = popupWindow( - `${constants.API_BASE_URL}/oauth/github?${querystring}`, + `${constants.API_BASE_URL}/github/oauth?${querystring}`, ) try { From fa96babf588127c4f02f92ea434a157543ef4cd3 Mon Sep 17 00:00:00 2001 From: Bruno Lemos Date: Tue, 26 Feb 2019 05:29:13 -0300 Subject: [PATCH 25/27] Fix GitHub App flow on mobile and electron The redirect_uri is sent to the server, which will save in the session and redirect to this after installation the GitHub App, so the app gets open via devhub://. --- .../src/components/cards/EmptyCards.tsx | 5 +- .../partials/rows/PrivateNotificationRow.tsx | 15 ++---- .../components/src/components/common/Link.tsx | 4 +- .../modals/AdvancedSettingsModal.tsx | 19 +++----- .../src/containers/EventCardsContainer.tsx | 15 +++--- .../components/src/libs/browser/index.ios.ts | 6 +++ .../src/libs/linking/index-electron.ts | 7 ++- .../src/libs/linking/index-native.ts | 28 ++++++++++- .../components/src/libs/linking/index.d.ts | 5 +- packages/components/src/libs/oauth/index.ts | 5 +- .../components/src/libs/oauth/index.web.ts | 34 +++++++------ .../components/src/screens/LoginScreen.tsx | 40 +++++++++------- .../components/src/screens/MainScreen.tsx | 48 ++++++++++++++----- packages/components/src/utils/helpers/auth.ts | 4 +- .../components/src/utils/helpers/shared.ts | 33 +++++++++++++ packages/core/src/utils/constants.ts | 13 ----- 16 files changed, 185 insertions(+), 96 deletions(-) diff --git a/packages/components/src/components/cards/EmptyCards.tsx b/packages/components/src/components/cards/EmptyCards.tsx index d9941a81d..0c7ac0773 100644 --- a/packages/components/src/components/cards/EmptyCards.tsx +++ b/packages/components/src/components/cards/EmptyCards.tsx @@ -72,7 +72,10 @@ export const EmptyCards = React.memo((props: EmptyCardsProps) => { const hasError = errorMessage || loadState === 'error' const renderContent = () => { - if (loadState === 'loading_first') { + if ( + loadState === 'loading_first' || + (loadState === 'loading' && !refresh && !fetchNextPage) + ) { return ( diff --git a/packages/components/src/components/common/Link.tsx b/packages/components/src/components/common/Link.tsx index a2f086d2d..0839d8d46 100644 --- a/packages/components/src/components/common/Link.tsx +++ b/packages/components/src/components/common/Link.tsx @@ -33,7 +33,7 @@ export function Link(props: LinkProps) { analyticsLabel, href, mobileProps, - openOnNewTab = true, + openOnNewTab: _openOnNewTab = true, webProps, enableBackgroundHover, enableForegroundHover, @@ -41,6 +41,8 @@ export function Link(props: LinkProps) { ...otherProps } = props + const openOnNewTab = _openOnNewTab || Platform.isElectron + const initialTheme = useTheme(theme => { cacheRef.current.theme = theme updateStyles() diff --git a/packages/components/src/components/modals/AdvancedSettingsModal.tsx b/packages/components/src/components/modals/AdvancedSettingsModal.tsx index 1a4da509d..ba3507d9d 100644 --- a/packages/components/src/components/modals/AdvancedSettingsModal.tsx +++ b/packages/components/src/components/modals/AdvancedSettingsModal.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import { Alert, ScrollView, View } from 'react-native' +import { ScrollView, View } from 'react-native' import { constants, GitHubAppType } from '@devhub/core' import { useCSSVariablesOrSpringAnimatedTheme } from '../../hooks/use-css-variables-or-spring--animated-theme' @@ -13,6 +13,7 @@ import * as selectors from '../../redux/selectors' import * as colors from '../../styles/colors' import { contentPadding } from '../../styles/variables' import { tryParseOAuthParams } from '../../utils/helpers/auth' +import { getGitHubAppInstallUri } from '../../utils/helpers/shared' import { SpringAnimatedIcon } from '../animated/spring/SpringAnimatedIcon' import { SpringAnimatedText } from '../animated/spring/SpringAnimatedText' import { ModalColumn } from '../columns/ModalColumn' @@ -122,9 +123,7 @@ export const AdvancedSettingsModal = React.memo( width: 52, paddingHorizontal: contentPadding, }} - href={`https://github.com/settings/connections/applications/${ - constants.GITHUB_OAUTH_CLIENT_ID - }`} + href={`${constants.API_BASE_URL}/github/oauth/manage`} openOnNewTab size={32} > @@ -180,14 +179,12 @@ export const AdvancedSettingsModal = React.memo( {githubAppToken ? ( @@ -247,10 +244,8 @@ export const AdvancedSettingsModal = React.memo( loadingIndicatorStyle={{ transform: [{ scale: 0.8 }], }} - href={`https://github.com/apps/${ - constants.GITHUB_APP_CANNONICAL_ID - }/installations/new`} - openOnNewTab + href={getGitHubAppInstallUri()} + openOnNewTab={false} size={32} > - if ( - ownerResponse.loadingState === 'loading' || - installationsLoadState === 'loading' - ) { + if (ownerResponse.loadingState === 'loading') { return ( { SafariView.addEventListener('onDismiss', () => { StatusBar.setHidden(false, 'fade') }) + +Linking.addEventListener('url', ({ url }) => { + if (!(url && url.startsWith('devhub://'))) return + + SafariView.dismiss() +}) diff --git a/packages/components/src/libs/linking/index-electron.ts b/packages/components/src/libs/linking/index-electron.ts index 27629f5f6..293a5cfca 100644 --- a/packages/components/src/libs/linking/index-electron.ts +++ b/packages/components/src/libs/linking/index-electron.ts @@ -19,8 +19,11 @@ export const Linking: LinkingCrossPlatform = { async canOpenURL(url: string) { return window.ipc.sendSync('can-open-url', url) }, - async getInitialURL() { - return '' + getCurrentURL() { + return window.location.href || '' + }, + getInitialURL() { + return LinkingOriginal.getInitialURL() }, openURL: (url: string): Promise => { return LinkingOriginal.openURL(url) diff --git a/packages/components/src/libs/linking/index-native.ts b/packages/components/src/libs/linking/index-native.ts index 75aab64e2..05ac278ca 100644 --- a/packages/components/src/libs/linking/index-native.ts +++ b/packages/components/src/libs/linking/index-native.ts @@ -1 +1,27 @@ -export { Linking } from 'react-native' +import { Linking as LinkingOriginal } from 'react-native' + +import { Platform } from '../platform' +import { LinkingCrossPlatform } from './index' + +let currentURL: string = '' +let initialURL: string = '' + +LinkingOriginal.getInitialURL().then(url => { + initialURL = url || '' +}) + +LinkingOriginal.addEventListener('url', e => { + currentURL = e.url || '' +}) + +export const Linking: LinkingCrossPlatform = { + addEventListener: LinkingOriginal.addEventListener.bind(LinkingOriginal), + canOpenURL: LinkingOriginal.canOpenURL.bind(LinkingOriginal), + getCurrentURL: + Platform.OS === 'web' ? () => window.location.href || '' : () => currentURL, + getInitialURL: () => initialURL, + openURL: LinkingOriginal.openURL.bind(LinkingOriginal), + removeEventListener: LinkingOriginal.removeEventListener.bind( + LinkingOriginal, + ), +} diff --git a/packages/components/src/libs/linking/index.d.ts b/packages/components/src/libs/linking/index.d.ts index 4fb5e3347..28ac35110 100644 --- a/packages/components/src/libs/linking/index.d.ts +++ b/packages/components/src/libs/linking/index.d.ts @@ -8,8 +8,9 @@ export interface LinkingCrossPlatform { handler: (payload: { url: string }) => void, ) => void canOpenURL(url: string): Promise - getInitialURL(): Promise - openURL(url: string): void + getCurrentURL(): string + getInitialURL(): string + openURL(url: string): Promise } export const Linking: LinkingCrossPlatform diff --git a/packages/components/src/libs/oauth/index.ts b/packages/components/src/libs/oauth/index.ts index babec20d5..a566d518b 100644 --- a/packages/components/src/libs/oauth/index.ts +++ b/packages/components/src/libs/oauth/index.ts @@ -2,6 +2,7 @@ import qs from 'qs' import { constants, GitHubAppType } from '@devhub/core' import { Browser } from '../browser' +import { Platform } from '../platform' import { getUrlParamsIfMatches, listenForNextUrl, @@ -20,8 +21,10 @@ export async function executeOAuth( const querystring = qs.stringify({ app_token: appToken, github_app_type: gitHubAppType, - scope: scopeStr, + is_electron: Platform.isElectron, + platform: Platform.OS, redirect_uri: redirectUri, + scope: scopeStr, }) // console.log('[OAUTH] Opening browser...') diff --git a/packages/components/src/libs/oauth/index.web.ts b/packages/components/src/libs/oauth/index.web.ts index dd6c37699..afa6601e5 100644 --- a/packages/components/src/libs/oauth/index.web.ts +++ b/packages/components/src/libs/oauth/index.web.ts @@ -1,4 +1,5 @@ import qs from 'qs' +import url from 'url' import { constants, GitHubAppType } from '@devhub/core' import { Linking } from '../linking' @@ -12,23 +13,26 @@ import { const redirectUri = 'devhub://github/oauth' -const popupTarget = - !__DEV__ && - (Platform.realOS !== 'web' || - (Platform.realOS === 'web' && - window.location.search.includes('installation_id=')) || - Platform.isStandalone || - (navigator.userAgent || '').includes('Edge')) +function getPopupTarget() { + const currentURL = Linking.getCurrentURL() + const query = qs.parse(url.parse(currentURL).query || '') + + return !__DEV__ && + (Platform.realOS !== 'web' || + query.installation_id || + Platform.isStandalone || + (navigator.userAgent || '').includes('Edge')) ? '_self' : '_blank' +} -function popupWindow(url: string, w: number = 500, h: number = 600) { +function popupWindow(uri: string, w: number = 500, h: number = 600) { const left = (window.screen.width - w) / 2 const top = (window.screen.height - h) / 2 return window.open( - url, - popupTarget, + uri, + getPopupTarget(), `resizable=yes, width=${w}, height=${h}, top=${top}, left=${left}`, ) } @@ -43,8 +47,10 @@ export async function executeOAuth( const querystring = qs.stringify({ app_token: appToken, github_app_type: gitHubAppType, - scope: scopeStr, + is_electron: Platform.isElectron, + platform: Platform.OS, redirect_uri: Platform.isElectron ? redirectUri : '', + scope: scopeStr, }) // console.log('[OAUTH] Opening popup...') @@ -56,10 +62,10 @@ export async function executeOAuth( let params: OAuthResponseData | null if (Platform.isElectron && (await Linking.canOpenURL(redirectUri))) { - const url = await listenForNextUrl() - // console.log('[OAUTH] Received URL:', url) + const uri = await listenForNextUrl() + // console.log('[OAUTH] Received URL:', uri) - params = getUrlParamsIfMatches(url, redirectUri) + params = getUrlParamsIfMatches(uri, redirectUri) // console.log('[OAUTH] URL params:', params) } else { params = await listenForNextMessageData(popup) diff --git a/packages/components/src/screens/LoginScreen.tsx b/packages/components/src/screens/LoginScreen.tsx index 9a1c4a17c..a6df9c55c 100644 --- a/packages/components/src/screens/LoginScreen.tsx +++ b/packages/components/src/screens/LoginScreen.tsx @@ -1,6 +1,7 @@ import qs from 'qs' import React, { useEffect, useRef, useState } from 'react' import { Image, StyleSheet, View } from 'react-native' +import url from 'url' import { constants } from '@devhub/core' import { SpringAnimatedText } from '../components/animated/spring/SpringAnimatedText' @@ -13,6 +14,7 @@ import { useReduxAction } from '../hooks/use-redux-action' import { useReduxState } from '../hooks/use-redux-state' import { analytics } from '../libs/analytics' import { bugsnag } from '../libs/bugsnag' +import { Linking } from '../libs/linking' import { executeOAuth } from '../libs/oauth' import { getUrlParamsIfMatches } from '../libs/oauth/helpers' import { Platform } from '../libs/platform' @@ -97,10 +99,11 @@ export const LoginScreen = React.memo(() => { // that passes the token via query string useEffect(() => { ;(async () => { - if (Platform.OS !== 'web') return + const currentURL = await Linking.getCurrentURL() + const querystring = url.parse(currentURL).query || '' + const query = qs.parse(querystring) - const querystring = window.location.search - if (!(querystring && querystring.includes('oauth=true'))) return + if (!query.oauth) return const params = getUrlParamsIfMatches(querystring, '') if (!params) return @@ -124,26 +127,27 @@ export const LoginScreen = React.memo(() => { // auto start oauth flow after github app installation useEffect(() => { - ;(async () => { - if (Platform.OS !== 'web') return + const handler = ({ url: uri }: { url: string }) => { + const querystring = url.parse(uri).query || '' + const query = qs.parse(querystring) - const querystring = window.location.search - if (!(querystring && querystring.includes('installation_id='))) return - if (querystring.includes('oauth=true')) return + if (query.oauth) return + if (!query.installation_id) return - const query = querystring.replace(new RegExp(`^[?]?`), '') - const params = qs.parse(query) - if (!(params && params.installation_id)) return + loginWithGitHub() - const { - installation_id: installationId, - setup_action: _setupAction, - } = params + setTimeout(() => { + clearQueryStringFromURL(['installation_id', 'setup_action']) + }, 500) + } - clearQueryStringFromURL(['installation_id', 'setup_action']) + Linking.addEventListener('url', handler) - loginWithGitHub() - })() + handler({ url: Linking.getCurrentURL() }) + + return () => { + Linking.removeEventListener('url', handler) + } }, []) useEffect( diff --git a/packages/components/src/screens/MainScreen.tsx b/packages/components/src/screens/MainScreen.tsx index 3635b54a6..81abc6f14 100644 --- a/packages/components/src/screens/MainScreen.tsx +++ b/packages/components/src/screens/MainScreen.tsx @@ -2,6 +2,7 @@ import _ from 'lodash' import qs from 'qs' import React, { useCallback, useEffect, useMemo, useRef } from 'react' import { Dimensions, StyleSheet, View } from 'react-native' +import url from 'url' import { Screen } from '../components/common/Screen' import { Separator } from '../components/common/Separator' @@ -22,6 +23,7 @@ import { useReduxAction } from '../hooks/use-redux-action' import { useReduxState } from '../hooks/use-redux-state' import { analytics } from '../libs/analytics' import { emitter } from '../libs/emitter' +import { Linking } from '../libs/linking' import { Platform } from '../libs/platform' import * as actions from '../redux/actions' import * as selectors from '../redux/selectors' @@ -40,6 +42,7 @@ const styles = StyleSheet.create({ export const MainScreen = React.memo(() => { const { appOrientation } = useAppLayout() + const appToken = useReduxState(selectors.appTokenSelector)! const columnIds = useReduxState(selectors.columnIdsSelector) const currentOpenedModal = useReduxState(selectors.currentOpenedModal) const focusedColumnId = useFocusedColumn() @@ -48,6 +51,9 @@ export const MainScreen = React.memo(() => { const moveColumn = useReduxAction(actions.moveColumn) const popModal = useReduxAction(actions.popModal) const pushModal = useReduxAction(actions.pushModal) + const refreshInstallationsRequest = useReduxAction( + actions.refreshInstallationsRequest, + ) const replaceModal = useReduxAction(actions.replaceModal) const syncDown = useReduxAction(actions.syncDown) @@ -81,20 +87,40 @@ export const MainScreen = React.memo(() => { [isVisible], ) - useEffect(() => { - ;(async () => { - if (Platform.OS !== 'web') return + useEffect( + () => { + const handler = ({ + isInitial, + url: uri, + }: { + isInitial?: boolean + url: string + }) => { + const querystring = url.parse(uri).query || '' + const query = qs.parse(querystring) + + if (!query.installation_id) return + + clearQueryStringFromURL(['installation_id', 'setup_action']) + + if (!isInitial) { + refreshInstallationsRequest({ + appToken, + includeInstallationToken: true, + }) + } + } - const querystring = window.location.search - if (!(querystring && querystring.includes('installation_id='))) return + Linking.addEventListener('url', handler) - const query = querystring.replace(new RegExp(`^[?]?`), '') - const params = qs.parse(query) - if (!(params && params.installation_id)) return + handler({ isInitial: true, url: Linking.getCurrentURL() }) - clearQueryStringFromURL(['installation_id', 'setup_action']) - })() - }, []) + return () => { + Linking.removeEventListener('url', handler) + } + }, + [appToken], + ) const horizontalSidebar = appOrientation === 'portrait' diff --git a/packages/components/src/utils/helpers/auth.ts b/packages/components/src/utils/helpers/auth.ts index ef396be05..a8c616ea5 100644 --- a/packages/components/src/utils/helpers/auth.ts +++ b/packages/components/src/utils/helpers/auth.ts @@ -9,8 +9,10 @@ export function clearQueryStringFromURL(fields: string[]) { !( Platform.OS === 'web' && !Platform.isElectron && + typeof window !== 'undefined' && window.history && - window.history.replaceState + window.history.replaceState && + window.location ) ) return diff --git a/packages/components/src/utils/helpers/shared.ts b/packages/components/src/utils/helpers/shared.ts index 49cf7df7e..a383e727f 100644 --- a/packages/components/src/utils/helpers/shared.ts +++ b/packages/components/src/utils/helpers/shared.ts @@ -1,5 +1,7 @@ +import qs from 'qs' import { findDOMNode } from 'react-dom' +import { constants } from '@devhub/core' import { Platform } from '../../libs/platform' export function findNode(ref: any) { @@ -16,3 +18,34 @@ export function findNode(ref: any) { return node } + +export function getGitHubAppInstallUri( + options: { + redirectUri?: string | undefined + suggestedTargetId?: number | string | undefined + repositoryIds?: Array | undefined + } = {}, +) { + const query: Record = {} + + const redirectUri = + options.redirectUri || + (Platform.OS === 'ios' || Platform.OS === 'android' || Platform.isElectron + ? 'devhub://' + : Platform.OS === 'web' + ? window.location.origin + : '') + + if (redirectUri) query.redirect_uri = redirectUri + if (options.repositoryIds) query.repository_ids = options.repositoryIds + if (options.suggestedTargetId) + query.suggested_target_id = options.suggestedTargetId + + const querystring = qs.stringify(query, { + arrayFormat: 'brackets', + encode: false, + }) + const baseUri = `${constants.API_BASE_URL}/github/app/install` + + return `${baseUri}${querystring ? `?${querystring}` : ''}` +} diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts index 1a2853031..0171c96ed 100644 --- a/packages/core/src/utils/constants.ts +++ b/packages/core/src/utils/constants.ts @@ -13,16 +13,3 @@ export const DEFAULT_PAGINATION_PER_PAGE = 10 export const API_BASE_URL = 'https://api.devhubapp.com' export const GRAPHQL_ENDPOINT = `${API_BASE_URL}/graphql` - -export const GITHUB_OAUTH_CLIENT_ID = - process.env.NODE_ENV === 'development' - ? 'b9e8939fe03b6d43f63c' - : '081d51ecc94dea9a425a' - -export const GITHUB_APP_CLIENT_ID = - process.env.NODE_ENV === 'development' - ? 'Iv1.3a9a22eb0411a34c' - : 'Iv1.dd926dcdad794eb8' - -export const GITHUB_APP_CANNONICAL_ID = - process.env.NODE_ENV === 'development' ? 'devhub-localhost-app' : 'devhub-app' From 4b45c27c6a3e8c4f421aa98aa9d68e6c6a53801d Mon Sep 17 00:00:00 2001 From: Bruno Lemos Date: Tue, 26 Feb 2019 05:40:59 -0300 Subject: [PATCH 26/27] Tweak when private message is shown on notification card --- .../components/src/components/cards/NotificationCard.tsx | 4 +++- .../components/src/redux/selectors/github/installations.ts | 7 ------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/components/src/components/cards/NotificationCard.tsx b/packages/components/src/components/cards/NotificationCard.tsx index 5f6d17406..585d1b675 100644 --- a/packages/components/src/components/cards/NotificationCard.tsx +++ b/packages/components/src/components/cards/NotificationCard.tsx @@ -60,6 +60,7 @@ export const NotificationCard = React.memo((props: NotificationCardProps) => { const itemRef = useRef(null) const springAnimatedTheme = useSpringAnimatedTheme() + /* const hasPrivateAccess = useReduxState(state => selectors.githubHasPrivateAccessToRepoSelector( state, @@ -67,6 +68,7 @@ export const NotificationCard = React.memo((props: NotificationCardProps) => { repoName, ), ) + */ useEffect( () => { @@ -94,7 +96,7 @@ export const NotificationCard = React.memo((props: NotificationCardProps) => { const isPrivateAndCantSee = !!( isPrivate && - !hasPrivateAccess && + // !hasPrivateAccess && !notification.enhanced ) diff --git a/packages/components/src/redux/selectors/github/installations.ts b/packages/components/src/redux/selectors/github/installations.ts index bda1feeac..fe7cf5e39 100644 --- a/packages/components/src/redux/selectors/github/installations.ts +++ b/packages/components/src/redux/selectors/github/installations.ts @@ -115,10 +115,3 @@ export const githubHasPrivateAccessToRepoSelector = ( return !!installationTokenByRepoSelector(state, ownerName, repoName) } */ -export const githubHasPrivateAccessToRepoSelector = ( - state: RootState, - ownerName: string | undefined, - _repoName: string | undefined, -) => { - return !!githubHasPrivateAccessToOwnerSelector(state, ownerName) -} From ae356f4b9c6fbce6b36c8718b553367b112e535d Mon Sep 17 00:00:00 2001 From: Bruno Lemos Date: Tue, 26 Feb 2019 06:24:29 -0300 Subject: [PATCH 27/27] Show "Free trial" banner on private columns --- .../columns/EventOrNotificationColumn.tsx | 20 ++++++ .../common/FreeTrialHeaderMessage.tsx | 30 ++++++++ .../src/components/common/HeaderMessage.tsx | 69 +++++++++++++++++++ .../src/containers/EventCardsContainer.tsx | 2 +- tslint.json | 1 + 5 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 packages/components/src/components/common/FreeTrialHeaderMessage.tsx create mode 100644 packages/components/src/components/common/HeaderMessage.tsx diff --git a/packages/components/src/components/columns/EventOrNotificationColumn.tsx b/packages/components/src/components/columns/EventOrNotificationColumn.tsx index 187c15ea1..9d98a5e72 100644 --- a/packages/components/src/components/columns/EventOrNotificationColumn.tsx +++ b/packages/components/src/components/columns/EventOrNotificationColumn.tsx @@ -7,7 +7,9 @@ import { EnhancedGitHubEvent, EnhancedGitHubNotification, getColumnHeaderDetails, + isEventPrivate, isItemRead, + isNotificationPrivate, } from '@devhub/core' import { useReduxAction } from '../../hooks/use-redux-action' import { useReduxState } from '../../hooks/use-redux-state' @@ -19,6 +21,7 @@ import { activityColumnHasAnyFilter, notificationColumnHasAnyFilter, } from '../../utils/helpers/filters' +import { FreeTrialHeaderMessage } from '../common/FreeTrialHeaderMessage' import { Spacer } from '../common/Spacer' import { Column } from './Column' import { ColumnHeader } from './ColumnHeader' @@ -85,6 +88,21 @@ export const EventOrNotificationColumn = React.memo( }, ) + const hasValidPaidPlan = false // TODO + + const isFreeTrial = + !hasValidPaidPlan && + (column.type === 'activity' + ? (filteredItems as any[]).some((item: EnhancedGitHubEvent) => + isEventPrivate(item), + ) + : column.type === 'notifications' + ? (filteredItems as any[]).some( + (item: EnhancedGitHubNotification) => + isNotificationPrivate(item) && !!item.enhanced, + ) + : false) + const setColumnClearedAtFilter = useReduxAction( actions.setColumnClearedAtFilter, ) @@ -249,6 +267,8 @@ export const EventOrNotificationColumn = React.memo( setColumnOptionsContainerHeight(e.nativeEvent.layout.height) }} > + {!!isFreeTrial && } + {columnOptionsContainerHeight > 0 && ( + alert( + 'Access to private repositories will be a paid feature' + + ' once DevHub is available on GitHub Marketplace. ' + + 'Price yet to be defined.' + + '\n' + + "For now, it's free." + + '\n' + + '\n' + + 'If you want DevHub to keep being improved and maintaned, ' + + "consider purchasing the paid plan once it's available.\n" + + '\n' + + 'Thank you!' + + '\n' + + '@brunolemos, creator of DevHub.', + ) + } + > + Free trial. Learn more. + + ) +} diff --git a/packages/components/src/components/common/HeaderMessage.tsx b/packages/components/src/components/common/HeaderMessage.tsx new file mode 100644 index 000000000..ea98b8e1f --- /dev/null +++ b/packages/components/src/components/common/HeaderMessage.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import { StyleSheet, TextProps, View } from 'react-native' + +import { useCSSVariablesOrSpringAnimatedTheme } from '../../hooks/use-css-variables-or-spring--animated-theme' +import { contentPadding } from '../../styles/variables' +import { SpringAnimatedText } from '../animated/spring/SpringAnimatedText' +import { + SpringAnimatedTouchableOpacity, + SpringAnimatedTouchableOpacityProps, +} from '../animated/spring/SpringAnimatedTouchableOpacity' + +export const HeaderMessageColor = 'rgba(0, 0, 0, 0.15)' + +export interface HeaderMessageProps + extends SpringAnimatedTouchableOpacityProps { + children: string | React.ReactNode + textStyle?: TextProps['style'] +} + +const styles = StyleSheet.create({ + container: { + flexGrow: 1, + alignSelf: 'stretch', + alignItems: 'center', + justifyContent: 'center', + padding: contentPadding / 2, + }, + text: { + flexGrow: 1, + lineHeight: 14, + fontSize: 11, + textAlign: 'center', + }, +}) + +export function HeaderMessage(props: HeaderMessageProps) { + const { children, style, textStyle, ...restProps } = props + + const springAnimatedTheme = useCSSVariablesOrSpringAnimatedTheme() + + return ( + + + {typeof children === 'string' ? ( + + {children} + + ) : ( + children + )} + + + ) +} diff --git a/packages/components/src/containers/EventCardsContainer.tsx b/packages/components/src/containers/EventCardsContainer.tsx index 24a2db15b..42b9d8c5f 100644 --- a/packages/components/src/containers/EventCardsContainer.tsx +++ b/packages/components/src/containers/EventCardsContainer.tsx @@ -201,7 +201,7 @@ export const EventCardsContainer = React.memo(