Skip to content

Commit

Permalink
🪟 🔧 ☁️ Check email verification status for free connector enrollment (#…
Browse files Browse the repository at this point in the history
…21514)

* check email verification status for real

* Extract email verification error handling to hook

* Move enrollmentModal's text copy to cloud locale

* Make FCP enrollment modal aware of email verification status

* Clean up useFreeConnectorProgram

- rename the function to match its filename
- check the experiment within the hook, not at every call site

* Update FCP modal for users with unverified emails
  • Loading branch information
ambirdsall authored Jan 19, 2023
1 parent 0906ea8 commit 934ecfa
Show file tree
Hide file tree
Showing 10 changed files with 124 additions and 71 deletions.
9 changes: 1 addition & 8 deletions airbyte-webapp/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -745,12 +745,5 @@

"jobs.noAttemptsFailure": "Failed to start job.",

"cloudApi.loginCallbackUrlError": "There was an error connecting to the developer portal. Please try again.",

"freeConnectorProgram.enrollmentModal.title": "Free connector program",
"freeConnectorProgram.enrollmentModal.free": "<p1>Alpha and Beta connectors are free while you're in the program.</p1><p2>The whole connection is free until both the source and destination connector have moved into General Availability (GA).</p2>",
"freeConnectorProgram.enrollmentModal.emailNotification": "We will email you before both connectors in a connection move to GA.",
"freeConnectorProgram.enrollmentModal.cardOnFile": "When both connectors are in GA, the connection will no longer be free. You'll need to have a credit card on file to enroll so Airbyte can handle a connection's transition to paid service.",
"freeConnectorProgram.enrollmentModal.cancelButtonText": "Cancel",
"freeConnectorProgram.enrollmentModal.enrollButtonText": "Enroll now!"
"cloudApi.loginCallbackUrlError": "There was an error connecting to the developer portal. Please try again."
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,18 @@
.iconContainer {
flex: 0 0 82px;
}

.resendEmailLink {
text-decoration: underline;
cursor: pointer;
padding: 0;
color: colors.$dark-blue;
border: none;
background-color: transparent;
font-size: inherit;

&:hover,
&:active {
color: colors.$blue;
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { useEffect, useRef, useState } from "react";
import { FormattedMessage } from "react-intl";

import { Button } from "components/ui/Button";
import { Callout } from "components/ui/Callout";
import { FlexContainer, FlexItem } from "components/ui/Flex";
import { Heading } from "components/ui/Heading";
import { ModalFooter } from "components/ui/Modal/ModalFooter";
Expand All @@ -21,12 +24,19 @@ interface EnrollmentModalContentProps {
closeModal: () => void;
createCheckout: (p: StripeCheckoutSessionCreate) => Promise<StripeCheckoutSessionRead>;
workspaceId: string;
emailVerified: boolean;
sendEmailVerification: () => void;
}

// we have to pass the email verification data and functions in as props, rather than
// directly using useAuthService(), because the modal renders outside of the
// AuthenticationProvider context.
export const EnrollmentModalContent: React.FC<EnrollmentModalContentProps> = ({
closeModal,
createCheckout,
workspaceId,
emailVerified,
sendEmailVerification,
}) => {
const isMountedRef = useRef(false);
const [isLoading, setIsLoading] = useState(false);
Expand Down Expand Up @@ -57,6 +67,28 @@ export const EnrollmentModalContent: React.FC<EnrollmentModalContentProps> = ({
};
}, []);

const EnrollmentEmailVerificationWarning = () => {
const WarningContent = () => (
<Callout>
<FontAwesomeIcon icon={faWarning} />
<Text>
<FormattedMessage
id="freeConnectorProgram.enrollmentModal.unvalidatedEmailWarning"
values={{
resendEmail: (content: React.ReactNode) => (
<button className={styles.resendEmailLink} onClick={sendEmailVerification}>
{content}
</button>
),
}}
/>
</Text>
</Callout>
);

return <>{!emailVerified && <WarningContent />}</>;
};

return (
<>
<FlexContainer alignItems="center" justifyContent="center" className={styles.header}>
Expand Down Expand Up @@ -101,6 +133,7 @@ export const EnrollmentModalContent: React.FC<EnrollmentModalContentProps> = ({
<FormattedMessage id="freeConnectorProgram.enrollmentModal.cardOnFile" />
</Text>
</FlexContainer>
<EnrollmentEmailVerificationWarning />
</FlexContainer>
</div>

Expand All @@ -112,7 +145,7 @@ export const EnrollmentModalContent: React.FC<EnrollmentModalContentProps> = ({
</Button>
</FlexItem>
<FlexItem>
<Button isLoading={isLoading} onClick={startStripeCheckout}>
<Button disabled={!emailVerified} isLoading={isLoading} onClick={startStripeCheckout}>
<FormattedMessage id="freeConnectorProgram.enrollmentModal.enrollButtonText" />
</Button>
</FlexItem>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useModalService } from "hooks/services/Modal";
import { useAuthService } from "packages/cloud/services/auth/AuthService";
import { useStripeCheckout } from "packages/cloud/services/stripe/StripeService";
import { useCurrentWorkspaceId } from "services/workspaces/WorkspacesService";

Expand All @@ -8,13 +9,20 @@ export const useShowEnrollmentModal = () => {
const { openModal, closeModal } = useModalService();
const { mutateAsync: createCheckout } = useStripeCheckout();
const workspaceId = useCurrentWorkspaceId();
const { emailVerified, sendEmailVerification } = useAuthService();

return {
showEnrollmentModal: () => {
openModal({
title: null,
content: () => (
<EnrollmentModalContent workspaceId={workspaceId} createCheckout={createCheckout} closeModal={closeModal} />
<EnrollmentModalContent
workspaceId={workspaceId}
createCheckout={createCheckout}
closeModal={closeModal}
emailVerified={emailVerified}
sendEmailVerification={sendEmailVerification}
/>
),
});
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useCurrentWorkspaceId } from "services/workspaces/WorkspacesService";

import { webBackendGetFreeConnectorProgramInfoForWorkspace } from "../lib/api";

export const useFreeConnectorProgramInfo = () => {
export const useFreeConnectorProgram = () => {
const workspaceId = useCurrentWorkspaceId();
const { cloudApiUrl } = useConfig();
const config = { apiUrl: cloudApiUrl };
Expand All @@ -18,11 +18,9 @@ export const useFreeConnectorProgramInfo = () => {
return useQuery(["freeConnectorProgramInfo", workspaceId], () =>
webBackendGetFreeConnectorProgramInfoForWorkspace({ workspaceId }, requestOptions).then(
({ hasEligibleConnector, hasPaymentAccountSaved }) => {
const showEnrollmentUi = !hasPaymentAccountSaved && hasEligibleConnector && freeConnectorProgramEnabled;
// TODO hardcoding this value to allow testing while data source gets sorted out
const needsEmailVerification = false;
const userIsEligibleToEnroll = !hasPaymentAccountSaved && hasEligibleConnector;

return { showEnrollmentUi, needsEmailVerification };
return freeConnectorProgramEnabled && userIsEligibleToEnroll;
}
)
);
Expand Down
12 changes: 10 additions & 2 deletions airbyte-webapp/src/packages/cloud/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,16 @@

"sidebar.credits": "Credits",

"freeConnectorProgram.youCanEnroll": "You can <enrollLink>enroll</enrollLink> in the Free Connector Program to use Alpha and Beta connectors for <freeText>free</freeText>.",
"freeConnectorProgram.title": "Free Connector Program",
"freeConnectorProgram.enrollNow": "Enroll now!",
"freeConnectorProgram.enroll.description": "Enroll in the <b>Free Connector Program</b> to use Alpha and Beta connectors for <b>free</b>."
"freeConnectorProgram.enroll.description": "Enroll in the <b>Free Connector Program</b> to use Alpha and Beta connectors for <b>free</b>.",
"freeConnectorProgram.enrollmentModal.title": "Free connector program",
"freeConnectorProgram.enrollmentModal.free": "<p1>Alpha and Beta Connectors are free while you're in the program.</p1><p2>The whole Connection is free until both Connectors have move into General Availability (GA)</p2>",
"freeConnectorProgram.enrollmentModal.emailNotification": "We will let you know through email before a Connector you use moves to GA",
"freeConnectorProgram.enrollmentModal.cardOnFile": "When both Connectors are in GA, the Connection will no longer be free. You'll need to have a credit card on file to enroll so Airbyte can handle a Connection's transition to paid service.",
"freeConnectorProgram.enrollmentModal.unvalidatedEmailWarning": "You need to <b>verify your email</b> address before you can enroll in the Free Connector Program. <resendEmail>Re-send verification email</resendEmail>.",
"freeConnectorProgram.enrollmentModal.cancelButtonText": "Cancel",
"freeConnectorProgram.enrollmentModal.enrollButtonText": "Enroll now!",
"freeConnectorProgram.enrollmentModal.unvalidatedEmailButtonText": "Resend email validation",
"freeConnectorProgram.youCanEnroll": "You can <enrollLink>enroll</enrollLink> in the <b>Free Connector Program</b> to use Alpha and Beta connectors for <freeText>free</freeText>."
}
47 changes: 45 additions & 2 deletions airbyte-webapp/src/packages/cloud/services/auth/AuthService.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { User as FirebaseUser } from "firebase/auth";
import { User as FirebaseUser, AuthErrorCodes } from "firebase/auth";
import React, { useCallback, useContext, useMemo, useRef } from "react";
import { useIntl } from "react-intl";
import { useQueryClient } from "react-query";
import { useEffectOnce } from "react-use";
import { Observable, Subject } from "rxjs";

import { ToastType } from "components/ui/Toast";

import { Action, Namespace } from "core/analytics";
import { isCommonRequestError } from "core/request/CommonRequestError";
import { useAnalyticsService } from "hooks/services/Analytics";
import { useNotificationService } from "hooks/services/Notification";
import useTypesafeReducer from "hooks/useTypesafeReducer";
import { AuthProviders, OAuthProviders } from "packages/cloud/lib/auth/AuthProviders";
import { GoogleAuthService } from "packages/cloud/lib/auth/GoogleAuthService";
Expand Down Expand Up @@ -44,6 +48,12 @@ export type AuthLogout = () => Promise<void>;

type OAuthLoginState = "waiting" | "loading" | "done";

enum FirebaseAuthMessageId {
NetworkFailure = "firebase.auth.error.networkRequestFailed",
TooManyRequests = "firebase.auth.error.tooManyRequests",
DefaultError = "firebase.auth.error.default",
}

interface AuthContextApi {
user: User | null;
inited: boolean;
Expand Down Expand Up @@ -78,6 +88,8 @@ export const AuthenticationProvider: React.FC<React.PropsWithChildren<unknown>>
const userService = useGetUserService();
const analytics = useAnalyticsService();
const authService = useInitService(() => new GoogleAuthService(() => auth), [auth]);
const { registerNotification } = useNotificationService();
const { formatMessage } = useIntl();

/**
* Create a user object in the Airbyte database from an existing Firebase user.
Expand Down Expand Up @@ -230,7 +242,38 @@ export const AuthenticationProvider: React.FC<React.PropsWithChildren<unknown>>
await authService.resetPassword(email);
},
async sendEmailVerification(): Promise<void> {
await authService.sendEmailVerifiedLink();
try {
await authService.sendEmailVerifiedLink();
} catch (error) {
switch (error.code) {
case AuthErrorCodes.NETWORK_REQUEST_FAILED:
registerNotification({
id: error.code,
text: formatMessage({
id: FirebaseAuthMessageId.NetworkFailure,
}),
type: ToastType.ERROR,
});
break;
case AuthErrorCodes.TOO_MANY_ATTEMPTS_TRY_LATER:
registerNotification({
id: error.code,
text: formatMessage({
id: FirebaseAuthMessageId.TooManyRequests,
}),
type: ToastType.WARNING,
});
break;
default:
registerNotification({
id: error.code,
text: formatMessage({
id: FirebaseAuthMessageId.DefaultError,
}),
type: ToastType.ERROR,
});
}
}
},
async verifyEmail(code: string): Promise<void> {
await authService.confirmEmailVerify(code);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Spinner } from "components/ui/Spinner";
import { Text } from "components/ui/Text";

import { PageTrackingCodes, useTrackPage } from "hooks/services/Analytics";
import { useFreeConnectorProgramInfo } from "packages/cloud/components/experiments/FreeConnectorProgram/hooks/useFreeConnectorProgram";
import { useFreeConnectorProgram } from "packages/cloud/components/experiments/FreeConnectorProgram/hooks/useFreeConnectorProgram";
import { LargeEnrollmentCallout } from "packages/cloud/components/experiments/FreeConnectorProgram/LargeEnrollmentCallout";
import { useAuthService } from "packages/cloud/services/auth/AuthService";

Expand All @@ -20,8 +20,7 @@ import styles from "./CreditsPage.module.scss";
const CreditsPage: React.FC = () => {
const { emailVerified } = useAuthService();
useTrackPage(PageTrackingCodes.CREDITS);
const { data } = useFreeConnectorProgramInfo();
const showEnrollmentUi = data?.showEnrollmentUi;
const { data: showEnrollmentUi } = useFreeConnectorProgram();

return (
<MainPageWithScroll
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { faEnvelope } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AuthErrorCodes } from "firebase/auth";
import { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { FormattedMessage } from "react-intl";
import styled from "styled-components";

import { Callout } from "components/ui/Callout";
import { ToastType } from "components/ui/Toast";

import { useNotificationService } from "hooks/services/Notification";
import { useAuthService } from "packages/cloud/services/auth/AuthService";

interface Props {
Expand All @@ -28,52 +25,13 @@ const ResendEmailLink = styled.button`
color: ${({ theme }) => theme.mediumPrimaryColor};
`;

enum FirebaseAuthMessageId {
NetworkFailure = "firebase.auth.error.networkRequestFailed",
TooManyRequests = "firebase.auth.error.tooManyRequests",
DefaultError = "firebase.auth.error.default",
}

export const EmailVerificationHint: React.FC<Props> = ({ className }) => {
const { sendEmailVerification } = useAuthService();
const { registerNotification } = useNotificationService();
const { formatMessage } = useIntl();
const [isEmailResend, setIsEmailResend] = useState(false);

const onResendVerificationMail = async () => {
try {
await sendEmailVerification();
setIsEmailResend(true);
} catch (error) {
switch (error.code) {
case AuthErrorCodes.NETWORK_REQUEST_FAILED:
registerNotification({
id: error.code,
text: formatMessage({
id: FirebaseAuthMessageId.NetworkFailure,
}),
type: ToastType.ERROR,
});
break;
case AuthErrorCodes.TOO_MANY_ATTEMPTS_TRY_LATER:
registerNotification({
id: error.code,
text: formatMessage({
id: FirebaseAuthMessageId.TooManyRequests,
}),
type: ToastType.WARNING,
});
break;
default:
registerNotification({
id: error.code,
text: formatMessage({
id: FirebaseAuthMessageId.DefaultError,
}),
type: ToastType.ERROR,
});
}
}
await sendEmailVerification();
setIsEmailResend(true);
};

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Text } from "components/ui/Text";

import { ConnectionStatus } from "core/request/AirbyteClient";
import { useConnectionEditService } from "hooks/services/ConnectionEdit/ConnectionEditService";
import { useFreeConnectorProgramInfo } from "packages/cloud/components/experiments/FreeConnectorProgram/hooks/useFreeConnectorProgram";
import { useFreeConnectorProgram } from "packages/cloud/components/experiments/FreeConnectorProgram/hooks/useFreeConnectorProgram";
import { InlineEnrollmentCallout } from "packages/cloud/components/experiments/FreeConnectorProgram/InlineEnrollmentCallout";

import { ConnectionRoutePaths } from "../types";
Expand All @@ -26,9 +26,7 @@ export const ConnectionPageTitle: React.FC = () => {

const { connection } = useConnectionEditService();

const { data: freeConnectorProgramInfo } = useFreeConnectorProgramInfo();

const displayEnrollmentCallout = freeConnectorProgramInfo?.showEnrollmentUi;
const { data: displayEnrollmentCallout } = useFreeConnectorProgram();

const steps = useMemo(() => {
const steps = [
Expand Down

0 comments on commit 934ecfa

Please sign in to comment.