Add Cody verify your email popup and notice for dotcom (#52165)

Add alerts, notices, popups dotcom for restricting Cody access to
logged-in users with verified emails only.


https://www.loom.com/share/865d8621dbd440a3ae926a647ce4adca

design:
https://www.figma.com/file/DIk3eGdgKszigQVec7Y8SD/Frictionless-cody?type=design&node-id=767-30229&t=ee1VyzmpjtZhgrNq-0

## Test plan

- sign up with a new account
This commit is contained in:
Naman Kumar 2023-05-19 15:53:56 +05:30 committed by GitHub
parent 08bce28d41
commit fbe9f63296
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 334 additions and 154 deletions

View File

@ -37,6 +37,8 @@ interface ChatProps extends ChatClassNames {
copyButtonOnSubmit?: CopyButtonProps['copyButtonOnSubmit']
suggestions?: string[]
setSuggestions?: (suggestions: undefined | []) => void
needsEmailVerification?: boolean
needsEmailVerificationNotice?: React.FunctionComponent
}
interface ChatClassNames extends TranscriptItemClassNames {
@ -51,6 +53,7 @@ export interface ChatUITextAreaProps {
autoFocus: boolean
value: string
required: boolean
disabled?: boolean
onInput: React.FormEventHandler<HTMLElement>
onKeyDown?: (event: React.KeyboardEvent<HTMLElement>, caretPosition: number | null) => void
}
@ -117,6 +120,8 @@ export const Chat: React.FunctionComponent<ChatProps> = ({
copyButtonOnSubmit,
suggestions,
setSuggestions,
needsEmailVerification = false,
needsEmailVerificationNotice: NeedsEmailVerificationNotice,
}) => {
const [inputRows, setInputRows] = useState(5)
const [historyIndex, setHistoryIndex] = useState(inputHistory.length)
@ -198,33 +203,45 @@ export const Chat: React.FunctionComponent<ChatProps> = ({
)
const transcriptWithWelcome = useMemo<ChatMessage[]>(
() => [{ speaker: 'assistant', displayText: welcomeText(afterTips) }, ...transcript],
() => [
{
speaker: 'assistant',
displayText: welcomeText(afterTips),
},
...transcript,
],
[afterTips, transcript]
)
return (
<div className={classNames(className, styles.innerContainer)}>
<Transcript
transcript={transcriptWithWelcome}
messageInProgress={messageInProgress}
messageBeingEdited={messageBeingEdited}
setMessageBeingEdited={setMessageBeingEdited}
fileLinkComponent={fileLinkComponent}
codeBlocksCopyButtonClassName={codeBlocksCopyButtonClassName}
transcriptItemClassName={transcriptItemClassName}
humanTranscriptItemClassName={humanTranscriptItemClassName}
transcriptItemParticipantClassName={transcriptItemParticipantClassName}
transcriptActionClassName={transcriptActionClassName}
className={styles.transcriptContainer}
textAreaComponent={TextArea}
EditButtonContainer={EditButtonContainer}
editButtonOnSubmit={editButtonOnSubmit}
FeedbackButtonsContainer={FeedbackButtonsContainer}
feedbackButtonsOnSubmit={feedbackButtonsOnSubmit}
copyButtonOnSubmit={copyButtonOnSubmit}
submitButtonComponent={SubmitButton}
chatInputClassName={chatInputClassName}
/>
{needsEmailVerification && NeedsEmailVerificationNotice ? (
<div className="flex-1">
<NeedsEmailVerificationNotice />
</div>
) : (
<Transcript
transcript={transcriptWithWelcome}
messageInProgress={messageInProgress}
messageBeingEdited={messageBeingEdited}
setMessageBeingEdited={setMessageBeingEdited}
fileLinkComponent={fileLinkComponent}
codeBlocksCopyButtonClassName={codeBlocksCopyButtonClassName}
transcriptItemClassName={transcriptItemClassName}
humanTranscriptItemClassName={humanTranscriptItemClassName}
transcriptItemParticipantClassName={transcriptItemParticipantClassName}
transcriptActionClassName={transcriptActionClassName}
className={styles.transcriptContainer}
textAreaComponent={TextArea}
EditButtonContainer={EditButtonContainer}
editButtonOnSubmit={editButtonOnSubmit}
FeedbackButtonsContainer={FeedbackButtonsContainer}
feedbackButtonsOnSubmit={feedbackButtonsOnSubmit}
copyButtonOnSubmit={copyButtonOnSubmit}
submitButtonComponent={SubmitButton}
chatInputClassName={chatInputClassName}
/>
)}
<form className={classNames(styles.inputRow, inputRowClassName)}>
{suggestions !== undefined && suggestions.length !== 0 && SuggestionButton ? (
@ -247,6 +264,7 @@ export const Chat: React.FunctionComponent<ChatProps> = ({
value={formInput}
autoFocus={true}
required={true}
disabled={needsEmailVerification}
onInput={({ target }) => {
const { value } = target as HTMLInputElement
inputHandler(value)
@ -256,7 +274,7 @@ export const Chat: React.FunctionComponent<ChatProps> = ({
<SubmitButton
className={styles.submitButton}
onClick={onChatSubmit}
disabled={!!messageInProgress}
disabled={!!messageInProgress || needsEmailVerification}
/>
</div>
{contextStatus && (

View File

@ -187,18 +187,15 @@ export const LegacyLayout: FC<LegacyLayoutProps> = props => {
/>
)}
{!isCodyStandalonePage && (
<GlobalAlerts
authenticatedUser={props.authenticatedUser}
isSourcegraphDotCom={props.isSourcegraphDotCom}
/>
)}
{!isCodyStandalonePage && <GlobalAlerts authenticatedUser={props.authenticatedUser} />}
{!isSiteInit &&
!isSignInOrUp &&
!props.isSourcegraphDotCom &&
!disableFeedbackSurvey &&
!isCodyStandalonePage && <SurveyToast authenticatedUser={props.authenticatedUser} />}
{props.isSourcegraphDotCom && props.authenticatedUser && <CodySurveyToast />}
{props.isSourcegraphDotCom && props.authenticatedUser && (
<CodySurveyToast authenticatedUser={props.authenticatedUser} />
)}
{!isSiteInit && !isSignInOrUp && !isCodyStandalonePage && (
<GlobalNavbar
{...props}

View File

@ -142,3 +142,13 @@
}
}
}
.cody-message-header {
font-size: 0.8rem;
font-weight: bold;
height: 2rem;
svg {
height: 1.5rem;
width: 1.5rem;
}
}

View File

@ -13,11 +13,13 @@ import {
} from '@sourcegraph/cody-ui/src/Chat'
import { FileLinkProps } from '@sourcegraph/cody-ui/src/chat/ContextFiles'
import { CODY_TERMS_MARKDOWN } from '@sourcegraph/cody-ui/src/terms'
import { Button, Icon, TextArea, Link } from '@sourcegraph/wildcard'
import { Button, Icon, TextArea, Link, Tooltip, Alert, Text, H2 } from '@sourcegraph/wildcard'
import { eventLogger } from '../../../tracking/eventLogger'
import { CodyPageIcon } from '../../chat/CodyPageIcon'
import { useChatStoreState } from '../../stores/chat'
import { useCodySidebarStore } from '../../stores/sidebar'
import { useIsCodyEnabled } from '../../useIsCodyEnabled'
import styles from './ChatUi.module.scss'
@ -35,6 +37,7 @@ export const ChatUI = (): JSX.Element => {
transcriptId,
transcriptHistory,
} = useChatStoreState()
const { needsEmailVerification } = useIsCodyEnabled()
const [formInput, setFormInput] = useState('')
const [inputHistory, setInputHistory] = useState<string[] | []>(() =>
@ -75,6 +78,8 @@ export const ChatUI = (): JSX.Element => {
transcriptActionClassName={styles.transcriptAction}
FeedbackButtonsContainer={FeedbackButtons}
feedbackButtonsOnSubmit={onFeedbackSubmit}
needsEmailVerification={needsEmailVerification}
needsEmailVerificationNotice={NeedsEmailVerificationNotice}
/>
)
}
@ -164,8 +169,10 @@ export const AutoResizableTextArea: React.FC<AutoResizableTextAreaProps> = ({
onInput,
onKeyDown,
className,
disabled = false,
}) => {
const { inputNeedsFocus, setFocusProvided } = useCodySidebarStore()
const { needsEmailVerification } = useIsCodyEnabled()
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const { width = 0 } = useResizeObserver({ ref: textAreaRef })
@ -202,16 +209,37 @@ export const AutoResizableTextArea: React.FC<AutoResizableTextAreaProps> = ({
}
return (
<TextArea
ref={textAreaRef}
className={className}
value={value}
onChange={handleChange}
rows={1}
autoFocus={false}
required={true}
onKeyDown={handleKeyDown}
onInput={onInput}
/>
<Tooltip content={needsEmailVerification ? 'Verify your email to use Cody.' : ''}>
<TextArea
ref={textAreaRef}
className={className}
value={value}
onChange={handleChange}
rows={1}
autoFocus={false}
required={true}
onKeyDown={handleKeyDown}
onInput={onInput}
disabled={disabled}
/>
</Tooltip>
)
}
const NeedsEmailVerificationNotice: React.FunctionComponent = () => (
<div className="p-3">
<H2 className={classNames('d-flex gap-1 align-items-center mb-3', styles.codyMessageHeader)}>
<CodyPageIcon /> Cody
</H2>
<Alert variant="warning">
<Text className="mb-0">Verify email</Text>
<Text className="mb-0">
Using Cody requires a verified email.{' '}
<Link to={`${window.context.currentUser?.settingsURL}/emails`} target="_blank" rel="noreferrer">
Resend email verification
</Link>
.
</Text>
</Alert>
</div>
)

View File

@ -8,7 +8,7 @@ import { SearchPatternType } from '@sourcegraph/shared/src/graphql-operations'
import { TelemetryService } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { useIsLightTheme } from '@sourcegraph/shared/src/theme'
import { buildSearchURLQuery } from '@sourcegraph/shared/src/util/url'
import { Alert, Form, Input, LoadingSpinner, Text, Badge, useSessionStorage } from '@sourcegraph/wildcard'
import { Alert, Form, Input, LoadingSpinner, Text, Badge, Link, useSessionStorage } from '@sourcegraph/wildcard'
import { BrandLogo } from '../../components/branding/BrandLogo'
import { useURLSyncedString } from '../../hooks/useUrlSyncedString'
@ -31,8 +31,6 @@ export const CodySearchPage: React.FunctionComponent<CodeSearchPageProps> = ({ a
eventLogger.logPageView('CodySearch')
}, [])
const enabled = useIsCodyEnabled()
const navigate = useNavigate()
/** The value entered by the user in the query input */
@ -109,20 +107,14 @@ export const CodySearchPage: React.FunctionComponent<CodeSearchPageProps> = ({ a
<div className={classNames('d-flex flex-column align-items-center px-3', searchPageStyles.searchPage)}>
<BrandLogo className={searchPageStyles.logo} isLightTheme={isLightTheme} variant="logo" />
<div className="text-muted mt-3 mr-sm-2 pr-2 text-center">Searching millions of public repositories</div>
{enabled.search ? (
<SearchInput
value={input}
onChange={onInputChange}
onSubmit={onSubmit}
loading={loading}
error={inputError}
className={classNames('mt-5 w-100', styles.inputContainer)}
/>
) : (
<Alert variant="info" className="mt-5">
Cody is not enabled on this Sourcegraph instance.
</Alert>
)}
<SearchInput
value={input}
onChange={onInputChange}
onSubmit={onSubmit}
loading={loading}
error={inputError}
className={classNames('mt-5 w-100', styles.inputContainer)}
/>
</div>
)
}
@ -135,6 +127,7 @@ const SearchInput: React.FunctionComponent<{
onSubmit: () => void
className?: string
}> = ({ value, loading, error, onChange, onSubmit: parentOnSubmit, className }) => {
const cody = useIsCodyEnabled()
const onInput = useCallback<React.FormEventHandler<HTMLInputElement>>(
event => {
onChange(event.currentTarget.value)
@ -150,13 +143,25 @@ const SearchInput: React.FunctionComponent<{
[parentOnSubmit]
)
return (
return cody.search ? (
<Form onSubmit={onSubmit} className={className}>
{cody.needsEmailVerification && (
<Alert variant="warning">
<Text className="mb-0">Verify email</Text>
<Text className="mb-0">
Using Cody requires a verified email.{' '}
<Link to={`${window.context.currentUser?.settingsURL}/emails`} target="_blank" rel="noreferrer">
Resend email verification
</Link>
.
</Text>
</Alert>
)}
<Input
inputClassName={styles.input}
value={value}
onInput={onInput}
disabled={loading}
disabled={loading || cody.needsEmailVerification}
autoFocus={true}
placeholder="Search for code or files in natural language..."
/>
@ -174,5 +179,9 @@ const SearchInput: React.FunctionComponent<{
<LoadingSpinner className="mt-2 d-block mx-auto" />
) : null}
</Form>
) : (
<Alert variant="info" className="mt-5">
Cody is not enabled on this Sourcegraph instance.
</Alert>
)
}

View File

@ -14,7 +14,7 @@ import { isErrorLike } from '@sourcegraph/common'
import { eventLogger } from '../../tracking/eventLogger'
import { EventName } from '../../util/constants'
import { CodeMirrorEditor } from '../components/CodeMirrorEditor'
import { useIsCodyEnabled } from '../useIsCodyEnabled'
import { useIsCodyEnabled, isEmailVerificationNeeded } from '../useIsCodyEnabled'
import { EditorStore, useEditorStore } from './editor'
@ -71,6 +71,7 @@ const sortSliceTranscriptHistory = (transcriptHistory: TranscriptJSON[]): Transc
.slice(0, SAVE_MAX_TRANSCRIPT_HISTORY)
export const useChatStoreState = create<CodyChatStore>((set, get): CodyChatStore => {
const needsEmailVerification = isEmailVerificationNeeded()
const fetchTranscriptHistory = (): TranscriptJSON[] => {
try {
const json = JSON.parse(
@ -106,6 +107,10 @@ export const useChatStoreState = create<CodyChatStore>((set, get): CodyChatStore
}
const clearHistory = (): void => {
if (needsEmailVerification) {
return
}
const { client, onEvent } = get()
saveTranscriptHistory([])
if (client && !isErrorLike(client)) {
@ -115,6 +120,10 @@ export const useChatStoreState = create<CodyChatStore>((set, get): CodyChatStore
}
const deleteHistoryItem = (id: string): void => {
if (needsEmailVerification) {
return
}
const { transcriptId } = get()
const transcriptHistory = fetchTranscriptHistory()
@ -128,6 +137,12 @@ export const useChatStoreState = create<CodyChatStore>((set, get): CodyChatStore
const submitMessage = (text: string): void => {
const { client, onEvent, getChatContext } = get()
if (needsEmailVerification) {
onEvent?.('submit')
return
}
if (client && !isErrorLike(client)) {
const { codebase, filePath } = getChatContext()
eventLogger.log(EventName.CODY_SIDEBAR_SUBMIT, {
@ -142,6 +157,12 @@ export const useChatStoreState = create<CodyChatStore>((set, get): CodyChatStore
const editMessage = (text: string): void => {
const { client, onEvent, getChatContext } = get()
if (needsEmailVerification) {
onEvent?.('submit')
return
}
if (client && !isErrorLike(client)) {
const { codebase, filePath } = getChatContext()
eventLogger.log(EventName.CODY_SIDEBAR_EDIT, {
@ -162,6 +183,12 @@ export const useChatStoreState = create<CodyChatStore>((set, get): CodyChatStore
}
): Promise<void> => {
const { client, getChatContext, onEvent } = get()
if (needsEmailVerification) {
onEvent?.('submit')
return
}
if (client && !isErrorLike(client)) {
const { codebase, filePath } = getChatContext()
eventLogger.log(EventName.CODY_SIDEBAR_RECIPE, { repo: codebase, path: filePath, recipeId })
@ -178,6 +205,11 @@ export const useChatStoreState = create<CodyChatStore>((set, get): CodyChatStore
return
}
if (needsEmailVerification) {
onEvent?.('submit')
return
}
if (oldClient && !isErrorLike(oldClient)) {
oldClient.reset()
}

View File

@ -5,9 +5,21 @@ const notEnabled = {
sidebar: false,
search: false,
editorRecipes: false,
needsEmailVerification: false,
}
export const useIsCodyEnabled = (): { chat: boolean; sidebar: boolean; search: boolean; editorRecipes: boolean } => {
interface IsCodyEnabled {
chat: boolean
sidebar: boolean
search: boolean
editorRecipes: boolean
needsEmailVerification: boolean
}
export const isEmailVerificationNeeded = (): boolean =>
window.context?.sourcegraphDotComMode && !window.context?.currentUser?.hasVerifiedEmail
export const useIsCodyEnabled = (): IsCodyEnabled => {
const [chatEnabled] = useFeatureFlag('cody-web-chat')
const [searchEnabled] = useFeatureFlag('cody-web-search')
const [sidebarEnabled] = useFeatureFlag('cody-web-sidebar')
@ -18,18 +30,11 @@ export const useIsCodyEnabled = (): { chat: boolean; sidebar: boolean; search: b
return notEnabled
}
if (
window.context?.sourcegraphDotComMode &&
!window.context?.currentUser?.siteAdmin &&
!window.context?.currentUser?.hasVerifiedEmail
) {
return notEnabled
}
return {
chat: chatEnabled || allEnabled,
sidebar: sidebarEnabled || allEnabled,
search: searchEnabled || allEnabled,
editorRecipes: (editorRecipesEnabled && sidebarEnabled) || allEnabled,
needsEmailVerification: isEmailVerificationNeeded(),
}
}

View File

@ -1,4 +1,4 @@
import React, { useMemo } from 'react'
import React from 'react'
import classNames from 'classnames'
import { parseISO } from 'date-fns'
@ -24,7 +24,6 @@ import styles from './GlobalAlerts.module.scss'
interface Props {
authenticatedUser: AuthenticatedUser | null
isSourcegraphDotCom: boolean
}
// NOTE: The name of the query is also added in the refreshSiteFlags() function
@ -41,23 +40,13 @@ const QUERY = gql`
/**
* Fetches and displays relevant global alerts at the top of the page
*/
export const GlobalAlerts: React.FunctionComponent<Props> = ({ authenticatedUser, isSourcegraphDotCom }) => {
export const GlobalAlerts: React.FunctionComponent<Props> = ({ authenticatedUser }) => {
const settings = useSettings()
const { data } = useQuery<GlobalAlertsSiteFlagsResult, GlobalAlertsSiteFlagsVariables>(QUERY, {
fetchPolicy: 'cache-and-network',
})
const siteFlagsValue = data?.site
const verifyEmailProps = useMemo(() => {
if (!authenticatedUser || !isSourcegraphDotCom) {
return
}
return {
emails: authenticatedUser.emails.filter(({ verified }) => !verified).map(({ email }) => email),
settingsURL: authenticatedUser.settingsURL as string,
}
}, [authenticatedUser, isSourcegraphDotCom])
return (
<div className={classNames('test-global-alert', styles.globalAlerts)}>
{siteFlagsValue && (
@ -118,9 +107,7 @@ export const GlobalAlerts: React.FunctionComponent<Props> = ({ authenticatedUser
</DismissibleAlert>
)}
<Notices alertClassName={styles.alert} location="top" />
{!!verifyEmailProps?.emails.length && (
<VerifyEmailNotices alertClassName={styles.alert} {...verifyEmailProps} />
)}
<VerifyEmailNotices authenticatedUser={authenticatedUser} alertClassName={styles.alert} />
</div>
)
}

View File

@ -1,4 +1,4 @@
import React, { useMemo } from 'react'
import React from 'react'
import classNames from 'classnames'
@ -7,8 +7,9 @@ import { Notice } from '@sourcegraph/shared/src/schema/settings.schema'
import { useSettings } from '@sourcegraph/shared/src/settings/settings'
import { Alert, AlertProps, Markdown } from '@sourcegraph/wildcard'
import { AuthenticatedUser } from '../auth'
import { useIsCodyEnabled } from '../cody/useIsCodyEnabled'
import { DismissibleAlert } from '../components/DismissibleAlert'
import { useFeatureFlag } from '../featureFlags/useFeatureFlag'
import styles from './Notices.module.scss'
@ -83,10 +84,8 @@ export const Notices: React.FunctionComponent<React.PropsWithChildren<Props>> =
interface VerifyEmailNoticesProps {
className?: string
/** Apply this class name to each notice (alongside .alert). */
alertClassName?: string
emails: string[]
settingsURL: string
authenticatedUser: AuthenticatedUser | null
}
/**
@ -95,35 +94,24 @@ interface VerifyEmailNoticesProps {
export const VerifyEmailNotices: React.FunctionComponent<VerifyEmailNoticesProps> = ({
className,
alertClassName,
emails,
settingsURL,
authenticatedUser,
}) => {
const [isEmailVerificationAlertEnabled, status] = useFeatureFlag('ab-email-verification-alert')
const codyEnabled = useIsCodyEnabled()
const notices: Notice[] = useMemo(() => {
if (status !== 'loaded' || !isEmailVerificationAlertEnabled) {
return []
}
return emails.map(
(email): Notice => ({
message: `Please, <a href="${settingsURL}/emails">verify your email</a> <strong>${email
.split('@')
.join('\\@')}</strong>`,
location: 'top',
dismissible: false,
})
if (codyEnabled.needsEmailVerification && authenticatedUser) {
return (
<div className={classNames(styles.notices, className)}>
<NoticeAlert
className={alertClassName}
notice={{
location: 'top',
message: `**NEW**: Cody, our new AI Assistant is available to use for free, simply verify your email address. Didn't get an email? [Resend verification email](${authenticatedUser?.settingsURL}/emails)`,
dismissible: true,
}}
/>
</div>
)
}, [emails, isEmailVerificationAlertEnabled, settingsURL, status])
if (notices.length === 0) {
return null
}
return (
<div className={classNames(styles.notices, className)}>
{notices.map(notice => (
<NoticeAlert key={notice.message} testId="notice-alert" className={alertClassName} notice={notice} />
))}
</div>
)
return null
}

View File

@ -1,11 +1,18 @@
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect } from 'react'
import { mdiEmail } from '@mdi/js'
import { asError, ErrorLike } from '@sourcegraph/common'
import { gql, useMutation } from '@sourcegraph/http-client'
import { useTemporarySetting } from '@sourcegraph/shared/src/settings/temporary'
import { Checkbox, Form, H3, Modal, Text, useLocalStorage } from '@sourcegraph/wildcard'
import { Checkbox, Form, H3, Modal, Text, Button, Icon, useLocalStorage } from '@sourcegraph/wildcard'
import { AuthenticatedUser } from '../../auth'
import { CodyPageIcon } from '../../cody/chat/CodyPageIcon'
import { useIsCodyEnabled } from '../../cody/useIsCodyEnabled'
import { LoaderButton } from '../../components/LoaderButton'
import { SubmitCodySurveyResult, SubmitCodySurveyVariables } from '../../graphql-operations'
import { resendVerificationEmail } from '../../user/settings/emails/UserEmail'
const SUBMIT_CODY_SURVEY = gql`
mutation SubmitCodySurvey($isForWork: Boolean!, $isForPersonal: Boolean!) {
@ -47,7 +54,10 @@ const CodySurveyToastInner: React.FC<{ onSubmitEnd: () => void }> = ({ onSubmitE
return (
<Modal position="center" aria-label="Welcome message">
<H3 className="mb-4">Quick question...</H3>
<H3 className="mb-4 d-flex align-items-center">
<CodyPageIcon />
<span>Just one more thing...</span>
</H3>
<Text className="mb-3">How will you be using Cody, our AI assistant?</Text>
<Form onSubmit={handleSubmit}>
<Checkbox
@ -74,6 +84,68 @@ const CodySurveyToastInner: React.FC<{ onSubmitEnd: () => void }> = ({ onSubmitE
)
}
const CodyVerifyEmailToast: React.FC<{ onNext: () => void; authenticatedUser: AuthenticatedUser }> = ({
onNext,
authenticatedUser,
}) => {
const [sending, setSending] = useState(false)
const [resentEmailTo, setResentEmailTo] = useState<string | null>(null)
const [resendEmailError, setResendEmailError] = useState<ErrorLike | null>(null)
const resend = useCallback(async () => {
const email = (authenticatedUser.emails || []).find(({ verified }) => !verified)?.email
if (email) {
setSending(true)
await resendVerificationEmail(authenticatedUser.id, email, {
onSuccess: () => {
setResentEmailTo(email)
setResendEmailError(null)
setSending(false)
},
onError: (errors: ErrorLike) => {
setResendEmailError(asError(errors))
setResentEmailTo(null)
setSending(false)
},
})
}
}, [authenticatedUser])
return (
<Modal position="center" aria-label="Welcome message">
<H3 className="mb-4">
<Icon svgPath={mdiEmail} className="mr-2" aria-hidden={true} />
Verify your email address
</H3>
<Text>To use Cody, our AI Assistent, you'll need to verify your email address.</Text>
<Text className="d-flex align-items-center">
<span className="mr-1">Didn't get an email?</span>
{sending ? (
<span>Sending...</span>
) : (
<>
<span>Click to </span>
<Button variant="link" className="p-0 ml-1" onClick={resend}>
resend
</Button>
.
</>
)}
</Text>
{resentEmailTo && (
<Text>
Sent verification email to <strong>{resentEmailTo}</strong>.
</Text>
)}
{resendEmailError && <Text>{resendEmailError.message}.</Text>}
<div className="d-flex justify-content-end mt-4">
<Button variant="primary" onClick={onNext}>
Next
</Button>
</div>
</Modal>
)
}
export const useCodySurveyToast = (): {
show: boolean
dismiss: () => void
@ -83,7 +155,16 @@ export const useCodySurveyToast = (): {
// eslint-disable-next-line no-restricted-syntax
const [shouldShowCodySurvey, setShouldShowCodySurvey] = useLocalStorage('cody.survey.show', false)
const [hasSubmitted, setHasSubmitted] = useTemporarySetting('cody.survey.submitted', false)
const dismiss = useCallback(() => setHasSubmitted(true), [setHasSubmitted])
const dismiss = useCallback(() => {
setHasSubmitted(true)
setShouldShowCodySurvey(false)
}, [setHasSubmitted, setShouldShowCodySurvey])
useEffect(() => {
if (shouldShowCodySurvey && hasSubmitted) {
setShouldShowCodySurvey(false)
}
}, [shouldShowCodySurvey, hasSubmitted, setShouldShowCodySurvey])
return {
// we calculate "show" value based whether this a new signup and whether they already have submitted survey
@ -93,11 +174,21 @@ export const useCodySurveyToast = (): {
}
}
export const CodySurveyToast: React.FC = () => {
export const CodySurveyToast: React.FC<{
authenticatedUser?: AuthenticatedUser
}> = ({ authenticatedUser }) => {
const { show, dismiss } = useCodySurveyToast()
const codyEnabled = useIsCodyEnabled()
const [showVerifyEmail, setShowVerifyEmail] = useState(show && codyEnabled.needsEmailVerification)
const dismissVerifyEmail = useCallback(() => setShowVerifyEmail(false), [setShowVerifyEmail])
if (!show) {
return null
}
if (showVerifyEmail && authenticatedUser) {
return <CodyVerifyEmailToast onNext={dismissVerifyEmail} authenticatedUser={authenticatedUser} />
}
return <CodySurveyToastInner onSubmitEnd={dismiss} />
}

View File

@ -193,7 +193,7 @@ export const Layout: React.FC<LegacyLayoutProps> = props => {
/>
)}
<GlobalAlerts authenticatedUser={props.authenticatedUser} isSourcegraphDotCom={props.isSourcegraphDotCom} />
<GlobalAlerts authenticatedUser={props.authenticatedUser} />
{!isSiteInit && !isSignInOrUp && !props.isSourcegraphDotCom && !disableFeedbackSurvey && (
<SurveyToast authenticatedUser={props.authenticatedUser} />
)}

View File

@ -1,4 +1,4 @@
import { FunctionComponent, useState } from 'react'
import { FunctionComponent, useState, useCallback } from 'react'
import { asError, ErrorLike } from '@sourcegraph/common'
import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
@ -28,6 +28,33 @@ interface Props {
onEmailResendVerification?: () => void
}
export const resendVerificationEmail = async (
userID: string,
email: string,
options?: { onSuccess: () => void; onError: (error: ErrorLike) => void }
): Promise<void> => {
try {
dataOrThrowErrors(
await requestGraphQL<ResendVerificationEmailResult, ResendVerificationEmailVariables>(
gql`
mutation ResendVerificationEmail($userID: ID!, $email: String!) {
resendVerificationEmail(user: $userID, email: $email) {
alwaysNil
}
}
`,
{ userID, email }
).toPromise()
)
eventLogger.log('UserEmailAddressVerificationResent')
options?.onSuccess?.()
} catch (error) {
options?.onError?.(error)
}
}
export const UserEmail: FunctionComponent<React.PropsWithChildren<Props>> = ({
user,
email: { email, isPrimary, verified, verificationPending, viewerCanManuallyVerify },
@ -39,10 +66,13 @@ export const UserEmail: FunctionComponent<React.PropsWithChildren<Props>> = ({
}) => {
const [isLoading, setIsLoading] = useState(false)
const handleError = (error: ErrorLike): void => {
onError(asError(error))
setIsLoading(false)
}
const handleError = useCallback(
(error: ErrorLike): void => {
onError(asError(error))
setIsLoading(false)
},
[onError, setIsLoading]
)
const removeEmail = async (): Promise<void> => {
setIsLoading(true)
@ -106,31 +136,16 @@ export const UserEmail: FunctionComponent<React.PropsWithChildren<Props>> = ({
}
}
const resendEmailVerification = async (email: string): Promise<void> => {
const resendEmail = useCallback(async () => {
setIsLoading(true)
try {
dataOrThrowErrors(
await requestGraphQL<ResendVerificationEmailResult, ResendVerificationEmailVariables>(
gql`
mutation ResendVerificationEmail($user: ID!, $email: String!) {
resendVerificationEmail(user: $user, email: $email) {
alwaysNil
}
}
`,
{ user, email }
).toPromise()
)
setIsLoading(false)
eventLogger.log('UserEmailAddressVerificationResent')
onEmailResendVerification?.()
} catch (error) {
handleError(error)
}
}
await resendVerificationEmail(user, email, {
onSuccess: () => {
setIsLoading(false)
onEmailResendVerification?.()
},
onError: handleError,
})
}, [user, email, onEmailResendVerification, handleError])
return (
<>
@ -162,7 +177,7 @@ export const UserEmail: FunctionComponent<React.PropsWithChildren<Props>> = ({
<span className={styles.dot}>&bull;&nbsp;</span>
<Button
className="p-0"
onClick={() => resendEmailVerification(email)}
onClick={resendEmail}
disabled={isLoading || disableControls}
variant="link"
>