Adds global CTA banner (#21950)

Co-authored-by: TJ Kandala <kandalatj@gmail.com>
This commit is contained in:
Artem Ruts 2021-06-10 12:38:50 -04:00 committed by GitHub
parent 45ccd0c273
commit 8d9622bcbc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 253 additions and 91 deletions

View File

@ -272,10 +272,7 @@ export const Layout: React.FunctionComponent<LayoutProps> = props => {
keyboardShortcutForShow={KEYBOARD_SHORTCUT_SHOW_HELP}
keyboardShortcuts={props.keyboardShortcuts}
/>
<GlobalAlerts
isSiteAdmin={!!props.authenticatedUser && props.authenticatedUser.siteAdmin}
settingsCascade={props.settingsCascade}
/>
<GlobalAlerts authenticatedUser={props.authenticatedUser} settingsCascade={props.settingsCascade} />
{!isSiteInit && <SurveyToast authenticatedUser={props.authenticatedUser} />}
{!isSiteInit && !isSignInOrUp && (
<GlobalNavbar

View File

@ -80,6 +80,7 @@ import { aggregateStreamingSearch } from './search/stream'
import { listUserRepositories } from './site-admin/backend'
import { SiteAdminAreaRoute } from './site-admin/SiteAdminArea'
import { SiteAdminSideBarGroups } from './site-admin/SiteAdminSidebar'
import { GitHubServiceScopeProvider } from './site/GitHubCodeHostScopeAlert/GithubScopeProvider'
import { ThemePreference } from './theme'
import { eventLogger } from './tracking/eventLogger'
import { withActivation } from './tracking/withActivation'
@ -510,68 +511,70 @@ class ColdSourcegraphWebApp extends React.Component<SourcegraphWebAppProps, Sour
<Route
path="/"
render={routeComponentProps => (
<LayoutWithActivation
{...props}
{...routeComponentProps}
authenticatedUser={authenticatedUser}
viewerSubject={this.state.viewerSubject}
settingsCascade={this.state.settingsCascade}
showBatchChanges={this.props.showBatchChanges}
// Theme
isLightTheme={this.isLightTheme()}
themePreference={this.state.themePreference}
onThemePreferenceChange={this.onThemePreferenceChange}
// Search query
navbarSearchQueryState={this.state.navbarSearchQueryState}
onNavbarQueryChange={this.onNavbarQueryChange}
fetchHighlightedFileLineRanges={fetchHighlightedFileLineRanges}
parsedSearchQuery={this.state.parsedSearchQuery}
setParsedSearchQuery={this.setParsedSearchQuery}
patternType={this.state.searchPatternType}
setPatternType={this.setPatternType}
caseSensitive={this.state.searchCaseSensitivity}
setCaseSensitivity={this.setCaseSensitivity}
versionContext={this.state.versionContext}
setVersionContext={this.setVersionContext}
availableVersionContexts={this.state.availableVersionContexts}
previousVersionContext={this.state.previousVersionContext}
// Extensions
platformContext={this.platformContext}
extensionsController={this.extensionsController}
telemetryService={eventLogger}
isSourcegraphDotCom={window.context.sourcegraphDotComMode}
showRepogroupHomepage={this.state.showRepogroupHomepage}
showOnboardingTour={this.state.showOnboardingTour}
showSearchContext={this.state.showSearchContext}
hasUserAddedRepositories={this.state.hasUserAddedRepositories}
hasUserAddedExternalServices={this.state.hasUserAddedExternalServices}
showSearchContextManagement={this.state.showSearchContextManagement}
selectedSearchContextSpec={this.getSelectedSearchContextSpec()}
setSelectedSearchContextSpec={this.setSelectedSearchContextSpec}
getUserSearchContextNamespaces={getUserSearchContextNamespaces}
fetchAutoDefinedSearchContexts={fetchAutoDefinedSearchContexts}
fetchSearchContexts={fetchSearchContexts}
fetchSearchContext={fetchSearchContext}
createSearchContext={createSearchContext}
updateSearchContext={updateSearchContext}
deleteSearchContext={deleteSearchContext}
convertVersionContextToSearchContext={convertVersionContextToSearchContext}
isSearchContextSpecAvailable={isSearchContextSpecAvailable}
defaultSearchContextSpec={this.state.defaultSearchContextSpec}
showEnterpriseHomePanels={this.state.showEnterpriseHomePanels}
globbing={this.state.globbing}
showMultilineSearchConsole={this.state.showMultilineSearchConsole}
showQueryBuilder={this.state.showQueryBuilder}
enableSmartQuery={this.state.enableSmartQuery}
enableCodeMonitoring={this.state.enableCodeMonitoring}
fetchSavedSearches={fetchSavedSearches}
fetchRecentSearches={fetchRecentSearches}
fetchRecentFileViews={fetchRecentFileViews}
streamSearch={aggregateStreamingSearch}
onUserExternalServicesOrRepositoriesUpdate={
this.onUserExternalServicesOrRepositoriesUpdate
}
/>
<GitHubServiceScopeProvider authenticatedUser={authenticatedUser}>
<LayoutWithActivation
{...props}
{...routeComponentProps}
authenticatedUser={authenticatedUser}
viewerSubject={this.state.viewerSubject}
settingsCascade={this.state.settingsCascade}
showBatchChanges={this.props.showBatchChanges}
// Theme
isLightTheme={this.isLightTheme()}
themePreference={this.state.themePreference}
onThemePreferenceChange={this.onThemePreferenceChange}
// Search query
navbarSearchQueryState={this.state.navbarSearchQueryState}
onNavbarQueryChange={this.onNavbarQueryChange}
fetchHighlightedFileLineRanges={fetchHighlightedFileLineRanges}
parsedSearchQuery={this.state.parsedSearchQuery}
setParsedSearchQuery={this.setParsedSearchQuery}
patternType={this.state.searchPatternType}
setPatternType={this.setPatternType}
caseSensitive={this.state.searchCaseSensitivity}
setCaseSensitivity={this.setCaseSensitivity}
versionContext={this.state.versionContext}
setVersionContext={this.setVersionContext}
availableVersionContexts={this.state.availableVersionContexts}
previousVersionContext={this.state.previousVersionContext}
// Extensions
platformContext={this.platformContext}
extensionsController={this.extensionsController}
telemetryService={eventLogger}
isSourcegraphDotCom={window.context.sourcegraphDotComMode}
showRepogroupHomepage={this.state.showRepogroupHomepage}
showOnboardingTour={this.state.showOnboardingTour}
showSearchContext={this.state.showSearchContext}
hasUserAddedRepositories={this.state.hasUserAddedRepositories}
hasUserAddedExternalServices={this.state.hasUserAddedExternalServices}
showSearchContextManagement={this.state.showSearchContextManagement}
selectedSearchContextSpec={this.getSelectedSearchContextSpec()}
setSelectedSearchContextSpec={this.setSelectedSearchContextSpec}
getUserSearchContextNamespaces={getUserSearchContextNamespaces}
fetchAutoDefinedSearchContexts={fetchAutoDefinedSearchContexts}
fetchSearchContexts={fetchSearchContexts}
fetchSearchContext={fetchSearchContext}
createSearchContext={createSearchContext}
updateSearchContext={updateSearchContext}
deleteSearchContext={deleteSearchContext}
convertVersionContextToSearchContext={convertVersionContextToSearchContext}
isSearchContextSpecAvailable={isSearchContextSpecAvailable}
defaultSearchContextSpec={this.state.defaultSearchContextSpec}
showEnterpriseHomePanels={this.state.showEnterpriseHomePanels}
globbing={this.state.globbing}
showMultilineSearchConsole={this.state.showMultilineSearchConsole}
showQueryBuilder={this.state.showQueryBuilder}
enableSmartQuery={this.state.enableSmartQuery}
enableCodeMonitoring={this.state.enableCodeMonitoring}
fetchSavedSearches={fetchSavedSearches}
fetchRecentSearches={fetchRecentSearches}
fetchRecentFileViews={fetchRecentFileViews}
streamSearch={aggregateStreamingSearch}
onUserExternalServicesOrRepositoriesUpdate={
this.onUserExternalServicesOrRepositoriesUpdate
}
/>
</GitHubServiceScopeProvider>
)}
/>
</BrowserRouter>

View File

@ -237,3 +237,43 @@ export function queryExternalServices(
})
)
}
interface ExternalServicesScopeVariables {
namespace: Scalars['ID']
}
interface ExternalServicesScopeResult {
externalServices: {
nodes: {
id: ExternalServiceFields['id']
kind: ExternalServiceFields['kind']
grantedScopes: string[]
}[]
}
}
export function queryExternalServicesScope(
variables: ExternalServicesScopeVariables
): Observable<ExternalServicesScopeResult['externalServices']> {
return requestGraphQL<ExternalServicesScopeResult, ExternalServicesScopeVariables>(
gql`
query ExternalServicesScopes($namespace: ID!) {
externalServices(first: null, after: null, namespace: $namespace) {
nodes {
id
kind
grantedScopes
}
}
}
`,
variables
).pipe(
map(({ data, errors }) => {
if (!data || !data.externalServices || errors) {
throw createAggregateError(errors)
}
return data.externalServices
})
)
}

View File

@ -7,12 +7,14 @@ import { Markdown } from '@sourcegraph/shared/src/components/Markdown'
import { isSettingsValid, SettingsCascadeProps } from '@sourcegraph/shared/src/settings/settings'
import { renderMarkdown } from '@sourcegraph/shared/src/util/markdown'
import { AuthenticatedUser } from '../auth'
import { DismissibleAlert } from '../components/DismissibleAlert'
import { Settings } from '../schema/settings.schema'
import { SiteFlags } from '../site'
import { siteFlags } from '../site/backend'
import { DockerForMacAlert } from '../site/DockerForMacAlert'
import { FreeUsersExceededAlert } from '../site/FreeUsersExceededAlert'
import { GitHubScopeAlert } from '../site/GitHubCodeHostScopeAlert/GitHubScopeAlert'
import { LicenseExpirationAlert } from '../site/LicenseExpirationAlert'
import { NeedsRepositoryConfigurationAlert } from '../site/NeedsRepositoryConfigurationAlert'
@ -20,7 +22,7 @@ import { GlobalAlert } from './GlobalAlert'
import { Notices } from './Notices'
interface Props extends SettingsCascadeProps {
isSiteAdmin: boolean
authenticatedUser: AuthenticatedUser | null
}
interface State {
@ -61,6 +63,9 @@ export class GlobalAlerts extends React.PureComponent<Props, State> {
)}
{/* Only show if the user has already added repositories; if not yet, the user wouldn't experience any Docker for Mac perf issues anyway. */}
{window.context.likelyDockerOnMac && <DockerForMacAlert className="global-alerts__alert" />}
{window.context.sourcegraphDotComMode && (
<GitHubScopeAlert authenticatedUser={this.props.authenticatedUser} />
)}
{this.state.siteFlags.alerts.map((alert, index) => (
<GlobalAlert key={index} alert={alert} className="global-alerts__alert" />
))}

View File

@ -205,6 +205,11 @@ export const commonWebGraphQlResults: Partial<WebGraphQlOperations & SharedGraph
pageInfo: { hasNextPage: false, endCursor: null },
},
}),
ExternalServicesScopes: () => ({
externalServices: {
nodes: [],
},
}),
FetchFeatureFlags: () => ({
viewerFeatureFlags: [],
}),

View File

@ -0,0 +1,44 @@
import InfoIcon from 'mdi-react/InfoCircleIcon'
import React, { FunctionComponent } from 'react'
import { Link } from 'react-router-dom'
import { DismissibleAlert } from '../../components/DismissibleAlert'
import { githubRepoScopeRequired } from '../../user/settings/cloud-ga'
import { useGitHubScopeContext } from './GithubScopeProvider'
interface Props {
authenticatedUser: { id: string; tags: string[] } | null
}
export const GITHUB_SCOPE_ALERT_KEY = 'GitHubPrivateScopeAlert'
/**
* A global alert telling authenticated users if they need to update GitHub code
* host token to access the private repositories.
*/
export const GitHubScopeAlert: FunctionComponent<Props> = ({ authenticatedUser }) => {
const { scopes } = useGitHubScopeContext()
const shouldTryToDisplayAlert = (): boolean => {
if (!authenticatedUser || scopes === null) {
return false
}
return githubRepoScopeRequired(authenticatedUser.tags, scopes)
}
return shouldTryToDisplayAlert() ? (
<DismissibleAlert
partialStorageKey={GITHUB_SCOPE_ALERT_KEY}
className="alert alert-info d-flex align-items-center"
>
<InfoIcon className="redesign-d-none icon-inline mr-2 flex-shrink-0" />
Update your&nbsp;
<Link className="site-alert__link" to="/user/settings/code-hosts">
<span className="underline">GitHub code host connection</span>
</Link>
&nbsp;to search private code with Sourcegraph.
</DismissibleAlert>
) : null
}

View File

@ -0,0 +1,59 @@
import React, { FunctionComponent, useState, useEffect, useCallback, createContext, useContext } from 'react'
import { queryExternalServicesScope } from '../../components/externalServices/backend'
import { ExternalServiceKind } from '../../graphql-operations'
type Scopes = string[] | null
type SetScopes = (scopes: Scopes) => void
interface GitHubScopeContext {
scopes: Scopes
setScopes: SetScopes
}
const GitHubScopeContext = createContext<GitHubScopeContext | undefined>(undefined)
interface Props {
children: React.ReactNode
authenticatedUser: { id: string; tags: string[] } | null
}
export const GitHubServiceScopeProvider: FunctionComponent<Props> = ({ children, authenticatedUser }) => {
const [scopes, setScopes] = useState<string[] | null>(null)
const fetchGitHubServiceScope = useCallback(async (): Promise<void> => {
if (authenticatedUser) {
// fetch all code hosts for given user
const { nodes: fetchedServices } = await queryExternalServicesScope({
namespace: authenticatedUser.id,
}).toPromise()
// check if user has a GitHub code host
const gitHubService = fetchedServices.find(({ kind }) => kind === ExternalServiceKind.GITHUB)
if (gitHubService) {
setScopes(gitHubService.grantedScopes)
}
}
}, [authenticatedUser])
useEffect(() => {
fetchGitHubServiceScope().catch(() => {
// there's no actionable information we can display here
})
}, [fetchGitHubServiceScope])
const { Provider } = GitHubScopeContext
return <Provider value={{ scopes, setScopes }}>{children}</Provider>
}
export const useGitHubScopeContext = (): GitHubScopeContext => {
const context = useContext(GitHubScopeContext)
if (context === undefined) {
throw new Error('useCount must be used within a GitHubServiceScopeProvider')
}
return context
}

View File

@ -18,7 +18,7 @@ interface CodeHostItemProps {
name: string
icon: React.ComponentType<{ className?: string }>
navigateToAuthProvider: (kind: ExternalServiceKind) => void
updateAuthRequired?: boolean
isTokenUpdateRequired: boolean
// optional service object fields when the code host connection is active
service?: ListExternalServiceFields
@ -31,7 +31,7 @@ export const CodeHostItem: React.FunctionComponent<CodeHostItemProps> = ({
service,
kind,
name,
updateAuthRequired,
isTokenUpdateRequired,
icon: Icon,
navigateToAuthProvider,
onDidRemove,
@ -121,7 +121,7 @@ export const CodeHostItem: React.FunctionComponent<CodeHostItemProps> = ({
</button>
)
) : (
updateAuthRequired &&
isTokenUpdateRequired &&
(oauthInFlight ? (
<LoaderButton
type="button"

View File

@ -12,6 +12,7 @@ import { AddExternalServiceOptions } from '../../../components/externalServices/
import { PageTitle } from '../../../components/PageTitle'
import { Scalars, ExternalServiceKind, ListExternalServiceFields } from '../../../graphql-operations'
import { SourcegraphContext } from '../../../jscontext'
import { useGitHubScopeContext } from '../../../site/GitHubCodeHostScopeAlert/GithubScopeProvider'
import { eventLogger } from '../../../tracking/eventLogger'
import { UserExternalServicesOrRepositoriesUpdateProps } from '../../../util'
import { githubRepoScopeRequired } from '../cloud-ga'
@ -64,7 +65,15 @@ export const UserAddCodeHostsPage: React.FunctionComponent<UserAddCodeHostsPageP
onUserExternalServicesOrRepositoriesUpdate,
}) => {
const [statusOrError, setStatusOrError] = useState<Status>()
const [updateAuthRequired, setUpdateAuthRequired] = useState(false)
const { scopes: gitHubScopes, setScopes: setGitHubScopes } = useGitHubScopeContext()
// If we have a GitHub service, check whether we need to prompt the user to
// update their scope
const isGitHubTokenUpdateRequired = gitHubScopes ? githubRepoScopeRequired(user.tags, gitHubScopes) : false
useEffect(() => {
eventLogger.logViewEvent('UserSettingsCodeHostConnections')
}, [])
const fetchExternalServices = useCallback(async () => {
setStatusOrError('loading')
@ -83,21 +92,19 @@ export const UserAddCodeHostsPage: React.FunctionComponent<UserAddCodeHostsPageP
setStatusOrError(services)
// If we have a GitHub service, check whether we need to prompt the user to
// update their scope
const gitHubService = services.GITHUB
if (gitHubService) {
const scopes = gitHubService.grantedScopes || []
setUpdateAuthRequired(githubRepoScopeRequired(user.tags, scopes))
}
const repoCount = fetchedServices.reduce((sum, codeHost) => sum + codeHost.repoCount, 0)
onUserExternalServicesOrRepositoriesUpdate(fetchedServices.length, repoCount)
}, [user.id, user.tags, onUserExternalServicesOrRepositoriesUpdate, setUpdateAuthRequired])
}, [user.id, onUserExternalServicesOrRepositoriesUpdate])
useEffect(() => {
eventLogger.logViewEvent('UserSettingsCodeHostConnections')
}, [])
const resetScopeAndFetchServices = useCallback((): void => {
// after the token is updated - we'll set GitHub's scopes to null and
// hide the global CTA banner
setGitHubScopes(null)
fetchExternalServices().catch(error => {
setStatusOrError(asError(error))
})
}, [fetchExternalServices, setGitHubScopes])
useEffect(() => {
fetchExternalServices().catch(error => {
@ -159,7 +166,7 @@ export const UserAddCodeHostsPage: React.FunctionComponent<UserAddCodeHostsPageP
return [
...servicesWithProblems.map(getServiceWarningFragment),
getAddReposBanner(notYetSyncedServiceNames),
getGitHubUpdateAuthBanner(updateAuthRequired),
getGitHubUpdateAuthBanner(isGitHubTokenUpdateRequired),
]
}
@ -235,25 +242,27 @@ export const UserAddCodeHostsPage: React.FunctionComponent<UserAddCodeHostsPageP
{codeHostExternalServices && isServicesByKind(statusOrError) ? (
<Container>
<ul className="list-group">
{Object.entries(codeHostExternalServices).map(([id, { kind, defaultDisplayName, icon }]) =>
authProvidersByKind[kind] ? (
{Object.entries(codeHostExternalServices).map(([id, { kind, defaultDisplayName, icon }]) => {
const isTokenUpdateRequired = ExternalServiceKind.GITHUB && isGitHubTokenUpdateRequired
return authProvidersByKind[kind] ? (
<li key={id} className="list-group-item user-code-hosts-page__code-host-item">
<CodeHostItem
service={isServicesByKind(statusOrError) ? statusOrError[kind] : undefined}
kind={kind}
name={defaultDisplayName}
updateAuthRequired={
id && kind === ExternalServiceKind.GITHUB ? updateAuthRequired : false
}
isTokenUpdateRequired={isTokenUpdateRequired}
navigateToAuthProvider={navigateToAuthProvider}
icon={icon}
onDidAdd={addNewService}
onDidRemove={fetchExternalServices}
onDidRemove={
isTokenUpdateRequired ? resetScopeAndFetchServices : fetchExternalServices
}
onDidError={handleError}
/>
</li>
) : null
)}
})}
</ul>
</Container>
) : (