diff --git a/src/ui/components/shared/Login/Login.tsx b/src/ui/components/shared/Login/Login.tsx index f1751485b44..3d0baec9be9 100644 --- a/src/ui/components/shared/Login/Login.tsx +++ b/src/ui/components/shared/Login/Login.tsx @@ -1,6 +1,7 @@ import { gql } from "@apollo/client"; import { ExclamationIcon } from "@heroicons/react/outline"; import Link from "next/link"; +import { useRouter } from "next/router"; import { ReactNode, useEffect, useState } from "react"; import { Button } from "replay-next/components/Button"; @@ -8,11 +9,13 @@ import { query } from "shared/graphql/apolloClient"; import { GetConnection, GetConnectionVariables } from "shared/graphql/generated/GetConnection"; import { getReadOnlyParamsFromURL } from "shared/utils/environment"; import { isMacOS } from "shared/utils/os"; +import { UserInfo, useGetUserInfo } from "ui/hooks/users"; import { getAuthClientId, getAuthHost } from "ui/utils/auth"; import { requestBrowserLogin, setUserInBrowserPrefs } from "ui/utils/browser"; import { isTeamMemberInvite } from "ui/utils/onboarding"; import { sendTelemetryEvent } from "ui/utils/telemetry"; import useAuth0 from "ui/utils/useAuth0"; +import useToken from "ui/utils/useToken"; import { OnboardingContentWrapper, OnboardingModalContainer } from "../Onboarding"; @@ -126,6 +129,39 @@ function LoginMessaging() { ); } +function SwitchAccountMessage({ + user, + label, + onSwitch, + onCancel, +}: { + user: UserInfo; + label: string; + onCancel: () => void; + onSwitch: () => void; +}) { + return ( +
+

+ You are already logged in as {user.email}. +

+ + {global.__IS_RECORD_REPLAY_RUNTIME__ ? null : ( + + )} +
+ ); +} + function SocialLogin({ onShowSSOLogin, onLogin, @@ -242,8 +278,16 @@ export default function Login({ challenge?: string; state?: string; }) { - const { loginWithRedirect, error } = useAuth0(); + const router = useRouter(); + const { loginWithRedirect, error, connection } = useAuth0(); const [sso, setSSO] = useState(false); + const [continueToLogin, setContinueToLogin] = useState(false); + const token = useToken(); + const userInfo = useGetUserInfo(); + + // `true` when we're in the process of completing the auth flow from the + // Replay browser + const isCompletingBrowserAuth = Boolean(userInfo && challenge && state); const url = new URL(returnToPath, window.location.origin); if (url.pathname === "/login" || (url.pathname === "/" && url.searchParams.has("state"))) { @@ -255,7 +299,12 @@ export default function Login({ if (challenge && state) { const authHost = getAuthHost(); const clientId = getAuthClientId(); - window.location.href = `https://${authHost}/authorize?response_type=code&code_challenge_method=S256&code_challenge=${challenge}&client_id=${clientId}&redirect_uri=${returnToPath}&scope=openid profile offline_access&state=${state}&audience=https://api.replay.io&connection=${connection}`; + // when continueToLogin was selected, the user was previously logged in + // and wanted to select a different account so force the login prompt by + // passing prompt=login to auth0 + window.location.href = `https://${authHost}/authorize?response_type=code&code_challenge_method=S256&code_challenge=${challenge}&client_id=${clientId}&redirect_uri=${returnToPath}&scope=openid profile offline_access&state=${state}&audience=https://api.replay.io&connection=${connection}&prompt=${ + continueToLogin ? "login" : "" + }`; return; } @@ -266,6 +315,14 @@ export default function Login({ }); }; + const handleUseCurrentAuth = async () => { + if (isCompletingBrowserAuth && connection) { + await onLogin(connection); + } else { + router.push("/"); + } + }; + useEffect(() => { setUserInBrowserPrefs(null); }, []); @@ -273,7 +330,14 @@ export default function Login({ return ( - {global.__IS_RECORD_REPLAY_RUNTIME__ && isOSX ? ( + {token.token && userInfo.email && !continueToLogin ? ( + setContinueToLogin(true)} + onCancel={() => handleUseCurrentAuth()} + /> + ) : global.__IS_RECORD_REPLAY_RUNTIME__ && isOSX ? ( ) : sso ? ( diff --git a/src/ui/utils/useAuth0.ts b/src/ui/utils/useAuth0.ts index ae1c46e1bee..45af6be8b3a 100644 --- a/src/ui/utils/useAuth0.ts +++ b/src/ui/utils/useAuth0.ts @@ -7,10 +7,13 @@ import { useGetUserInfo } from "ui/hooks/users"; import { setAccessTokenInBrowserPrefs } from "./browser"; import useToken from "./useToken"; +export type AuthContext = Auth0ContextInterface | typeof TEST_AUTH; + const TEST_AUTH = { error: undefined, isLoading: false, isAuthenticated: true, + connection: "TEST", user: { sub: "auth0|60351bdaa6afe80068af126e", name: "e2e-testing@replay.io", @@ -24,8 +27,6 @@ const TEST_AUTH = { getAccessTokenSilently: () => Promise.resolve(), }; -export type AuthContext = Auth0ContextInterface | typeof TEST_AUTH; - // TODO [hbenl, ryanjduffy] This function should `useMemo` to memoize the "user" object it returns. // As it is, this hooks prevents components like CommentTool from limiting how often their effects run. export default function useAuth0() { @@ -48,6 +49,7 @@ export default function useAuth0() { error: undefined, isLoading: loading, isAuthenticated: true, + connection: "EXTERNAL", user: loading ? undefined : { @@ -70,5 +72,14 @@ export default function useAuth0() { return TEST_AUTH; } - return { ...auth, loginAndReturn }; + // for social logins, the connection (e.g. google-oauth2) is the prefix. For + // SAML logins, the connection is the client-specific code after the samlp + // prefix (samlp|client-name|user-id). + let connection: string | undefined; + if (auth.user) { + const parts = auth.user.sub.split("|") ?? []; + connection = parts[0] === "samlp" ? parts[1] : parts[0]; + } + + return { ...auth, connection, loginAndReturn }; }