Add Google Analytics (#8278)

- Add Google Analytics
- Add cross-domain linking between website homepage and cloud dashboard
- Highlight buttons on authentication flows on hover
- Save logged in state as `logged_in` cookie
- Remove saved access token from disk when signing out
- Support `redirect_to` parameter on `/register` page

# Important Notes
None
This commit is contained in:
somebody1234 2023-11-14 22:28:10 +10:00 committed by GitHub
parent c649ed87af
commit 3046e152dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 180 additions and 56 deletions

View File

@ -158,7 +158,9 @@ export function onOpenUrl(url: URL, window: () => electron.BrowserWindow) {
* The credentials file is placed in the user's home directory in the `.enso` subdirectory
* in the `credentials` file. */
function initSaveAccessTokenListener() {
electron.ipcMain.on(ipc.Channel.saveAccessToken, (event, accessToken: string) => {
electron.ipcMain.on(ipc.Channel.saveAccessToken, (event, accessToken: string | null) => {
event.preventDefault()
/** Home directory for the credentials file. */
const credentialsDirectoryName = `.${common.PRODUCT_NAME.toLowerCase()}`
/** File name of the credentials file. */
@ -166,22 +168,28 @@ function initSaveAccessTokenListener() {
/** System agnostic credentials directory home path. */
const credentialsHomePath = path.join(os.homedir(), credentialsDirectoryName)
fs.mkdir(credentialsHomePath, { recursive: true }, error => {
if (error) {
logger.error(`Couldn't create ${credentialsDirectoryName} directory.`)
} else {
fs.writeFile(
path.join(credentialsHomePath, credentialsFileName),
accessToken,
innerError => {
if (innerError) {
logger.error(`Could not write to ${credentialsFileName} file.`)
}
}
)
if (accessToken == null) {
try {
fs.unlinkSync(path.join(credentialsHomePath, credentialsFileName))
} catch {
// Ignored, most likely the path does not exist.
}
})
event.preventDefault()
} else {
fs.mkdir(credentialsHomePath, { recursive: true }, error => {
if (error) {
logger.error(`Couldn't create ${credentialsDirectoryName} directory.`)
} else {
fs.writeFile(
path.join(credentialsHomePath, credentialsFileName),
accessToken,
innerError => {
if (innerError) {
logger.error(`Could not write to ${credentialsFileName} file.`)
}
}
)
}
})
}
})
}

View File

@ -146,7 +146,7 @@ const AUTHENTICATION_API = {
*
* The backend doesn't have access to Electron's `localStorage` so we need to save access token
* to a file. Then the token will be used to sign cloud API requests. */
saveAccessToken: (accessToken: string) => {
saveAccessToken: (accessToken: string | null) => {
electron.ipcRenderer.send(ipc.Channel.saveAccessToken, accessToken)
},
}

View File

@ -5,6 +5,7 @@
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./src/detect": "./src/detect.ts"
"./src/detect": "./src/detect.ts",
"./src/gtag": "./src/gtag.ts"
}
}

View File

@ -0,0 +1,26 @@
/** @file Google Analytics tag. */
// @ts-expect-error This is explicitly not given types as it is a mistake to acess this
// anywhere else.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/strict-boolean-expressions
window.dataLayer = window.dataLayer || []
/** Google Analytics tag function. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function gtag(_action: 'config' | 'event' | 'js' | 'set', ..._args: unknown[]) {
// @ts-expect-error This is explicitly not given types as it is a mistake to acess this
// anywhere else.
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
window.dataLayer.push(arguments)
}
/** Send event to Google Analytics. */
export function event(name: string, params?: object) {
gtag('event', name, params)
}
gtag('js', new Date())
// eslint-disable-next-line @typescript-eslint/naming-convention
gtag('set', 'linker', { accept_incoming: true })
gtag('config', 'G-CLTBJ37MDM')
gtag('config', 'G-DH47F649JC')

View File

@ -26,9 +26,12 @@ self.addEventListener('install', event => {
self.addEventListener('fetch', event => {
const url = new URL(event.request.url)
if (url.hostname === 'localhost' && url.pathname === '/esbuild') {
if (
(url.hostname === 'localhost' || url.hostname === '127.0.0.1') &&
url.pathname === '/esbuild'
) {
return false
} else if (url.hostname === 'localhost') {
} else if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
const responsePromise = caches
.open(constants.CACHE_NAME)
.then(cache => cache.match(event.request))

View File

@ -47,5 +47,15 @@
<noscript>
This page requires JavaScript to run. Please enable it in your browser.
</noscript>
<script
src="https://cdn.jsdelivr.net/npm/@twemoji/api@14.1.2/dist/twemoji.min.js"
integrity="sha384-D6GSzpW7fMH86ilu73eB95ipkfeXcMPoOGVst/L04yqSSe+RTUY0jXcuEIZk0wrT"
crossorigin="anonymous"
></script>
<!-- Google tag (gtag.js) -->
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-CLTBJ37MDM"
></script>
</body>
</html>

View File

@ -10,6 +10,7 @@ import * as common from 'enso-common'
import * as contentConfig from 'enso-content-config'
import * as dashboard from 'enso-authentication'
import * as detect from 'enso-common/src/detect'
import * as gtag from 'enso-common/src/gtag'
import * as remoteLog from './remoteLog'
import GLOBAL_CONFIG from '../../../../gui/config.yaml' assert { type: 'yaml' }
@ -251,6 +252,7 @@ class Main implements AppRunner {
const isInAuthenticationFlow = url.searchParams.has('code') && url.searchParams.has('state')
const authenticationUrl = location.href
if (isInAuthenticationFlow) {
gtag.gtag('event', 'cloud_sign_in_redirect')
history.replaceState(null, '', localStorage.getItem(INITIAL_URL_KEY))
}
const configOptions = contentConfig.OPTIONS.clone()

View File

@ -24,7 +24,7 @@ self.addEventListener('install', event => {
self.addEventListener('fetch', event => {
const url = new URL(event.request.url)
if (url.hostname === 'localhost') {
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
return false
} else {
event.respondWith(

View File

@ -181,8 +181,8 @@ export class Cognito {
}
/** Save the access token to a file for further reuse. */
saveAccessToken(accessToken: string) {
this.amplifyConfig.accessTokenSaver?.(accessToken)
saveAccessToken(accessToken: string | null) {
this.amplifyConfig.saveAccessToken?.(accessToken)
}
/** Return the current {@link UserSession}, or `None` if the user is not logged in.

View File

@ -95,7 +95,7 @@ export default function ControlledInput(props: ControlledInputProps) {
}
: onBlur
}
className="text-sm placeholder-gray-500 pl-10 pr-4 rounded-full border w-full py-2"
className="text-sm placeholder-gray-500 hover:bg-gray-100 focus:bg-gray-100 pl-10 pr-4 rounded-full border transition-all duration-300 w-full py-2"
/>
)
}

View File

@ -21,7 +21,7 @@ export default function Link(props: LinkProps) {
return (
<router.Link
to={to}
className="flex gap-2 items-center font-bold text-blue-500 hover:text-blue-700 text-xs text-center"
className="flex gap-2 items-center font-bold text-blue-500 hover:text-blue-700 focus:text-blue-700 text-xs text-center transition-all duration-300"
>
<SvgMask src={icon} />
{text}

View File

@ -55,7 +55,7 @@ export default function Login() {
event.preventDefault()
await signInWithGoogle()
}}
className="relative rounded-full bg-cloud/10 hover:bg-gray-200 py-2"
className="relative rounded-full bg-cloud/10 hover:bg-cloud/20 focus:bg-cloud/20 transition-all duration-300 py-2"
>
<FontAwesomeIcon icon={fontawesomeIcons.faGoogle} />
Sign up or login with Google
@ -68,7 +68,7 @@ export default function Login() {
event.preventDefault()
await signInWithGitHub()
}}
className="relative rounded-full bg-cloud/10 hover:bg-gray-200 py-2"
className="relative rounded-full bg-cloud/10 hover:bg-cloud/20 focus:bg-cloud/20 transition-all duration-300 py-2"
>
<FontAwesomeIcon icon={fontawesomeIcons.faGithub} />
Sign up or login with GitHub
@ -117,7 +117,7 @@ export default function Login() {
footer={
<router.Link
to={app.FORGOT_PASSWORD_PATH}
className="text-xs text-blue-500 hover:text-blue-700 text-end"
className="text-xs text-blue-500 hover:text-blue-700 focus:text-blue-700 transition-all duration-300 text-end"
>
Forgot Your Password?
</router.Link>

View File

@ -8,6 +8,8 @@ import GoBackIcon from 'enso-assets/go_back.svg'
import LockIcon from 'enso-assets/lock.svg'
import * as authModule from '../providers/auth'
import * as localStorageModule from '../../dashboard/localStorage'
import * as localStorageProvider from '../../providers/localStorage'
import * as string from '../../string'
import * as validation from '../../dashboard/validation'
@ -22,6 +24,7 @@ import SubmitButton from './submitButton'
const REGISTRATION_QUERY_PARAMS = {
organizationId: 'organization_id',
redirectTo: 'redirect_to',
} as const
// ====================
@ -32,11 +35,20 @@ const REGISTRATION_QUERY_PARAMS = {
export default function Registration() {
const auth = authModule.useAuth()
const location = router.useLocation()
const { localStorage } = localStorageProvider.useLocalStorage()
const [email, setEmail] = React.useState('')
const [password, setPassword] = React.useState('')
const [confirmPassword, setConfirmPassword] = React.useState('')
const [isSubmitting, setIsSubmitting] = React.useState(false)
const { organizationId } = parseUrlSearchParams(location.search)
const { organizationId, redirectTo } = parseUrlSearchParams(location.search)
React.useEffect(() => {
if (redirectTo != null) {
localStorage.set(localStorageModule.LocalStorageKey.loginRedirect, redirectTo)
} else {
localStorage.delete(localStorageModule.LocalStorageKey.loginRedirect)
}
}, [localStorage, redirectTo])
return (
<div className="flex flex-col gap-6 text-primary text-sm items-center justify-center min-h-screen">
@ -98,5 +110,6 @@ export default function Registration() {
function parseUrlSearchParams(search: string) {
const query = new URLSearchParams(search)
const organizationId = query.get(REGISTRATION_QUERY_PARAMS.organizationId)
return { organizationId }
const redirectTo = query.get(REGISTRATION_QUERY_PARAMS.redirectTo)
return { organizationId, redirectTo }
}

View File

@ -21,7 +21,7 @@ export default function SubmitButton(props: SubmitButtonProps) {
<button
disabled={disabled}
type="submit"
className="flex gap-2 items-center justify-center focus:outline-none text-white bg-blue-600 hover:bg-blue-700 rounded-full py-2 w-full transition duration-150 ease-in disabled:opacity-50"
className="flex gap-2 items-center justify-center focus:outline-none text-white bg-blue-600 hover:bg-blue-700 focus:bg-blue-700 rounded-full py-2 w-full transition-all duration-300 ease-in disabled:opacity-50"
>
{text}
<SvgMask src={icon} />

View File

@ -71,7 +71,7 @@ export const OAuthRedirect = newtype.newtypeConstructor<OAuthRedirect>()
export type OAuthUrlOpener = (url: string, redirectUrl: string) => void
/** A function used to save the access token to a credentials file. The token is used by the engine
* to issue HTTP requests to the cloud API. */
export type AccessTokenSaver = (accessToken: string) => void
export type AccessTokenSaver = (accessToken: string | null) => void
/** Function used to register a callback. The callback will get called when a deep link is received
* by the app. This is only used in the desktop app (i.e., not in the cloud). This is used when the
* user is redirected back to the app from the system browser, after completing an OAuth flow. */
@ -96,7 +96,6 @@ export const OAUTH_RESPONSE_TYPE = OAuthResponseType('code')
// === AmplifyConfig ===
// =====================
// Eslint does not like "etc.".
/** Configuration for the AWS Amplify library.
*
* This details user pools, federated identity providers, etc. that are used to authenticate users.
@ -107,7 +106,7 @@ export interface AmplifyConfig {
userPoolId: UserPoolId
userPoolWebClientId: UserPoolWebClientId
urlOpener: OAuthUrlOpener | null
accessTokenSaver: AccessTokenSaver | null
saveAccessToken: AccessTokenSaver | null
domain: OAuthDomain
scope: OAuthScope[]
redirectSignIn: OAuthRedirect

View File

@ -9,6 +9,8 @@ import * as toast from 'react-toastify'
import * as sentry from '@sentry/react'
import * as gtag from 'enso-common/src/gtag'
import * as app from '../../components/app'
import type * as authServiceModule from '../service'
import * as backendModule from '../../dashboard/backend'
@ -17,6 +19,7 @@ import * as cognitoModule from '../cognito'
import * as errorModule from '../../error'
import * as http from '../../http'
import * as localBackend from '../../dashboard/localBackend'
import * as localStorageModule from '../../dashboard/localStorage'
import * as localStorageProvider from '../../providers/localStorage'
import * as loggerProvider from '../../providers/logger'
import * as remoteBackend from '../../dashboard/remoteBackend'
@ -272,6 +275,7 @@ export function AuthProvider(props: AuthProviderProps) {
) {
setBackendWithoutSavingType(backend)
}
gtag.event('cloud_open')
let organization: backendModule.UserOrOrganization | null
let user: backendModule.SimpleUser | null
while (true) {
@ -279,7 +283,7 @@ export function AuthProvider(props: AuthProviderProps) {
organization = await backend.usersMe()
try {
user =
organization != null
organization?.isEnabled === true
? (await backend.listUsers()).find(
listedUser => listedUser.email === organization?.email
) ?? null
@ -331,11 +335,15 @@ export function AuthProvider(props: AuthProviderProps) {
user,
}
/** Save access token so can be reused by Enso backend. */
// 34560000 is the recommended max cookie age.
const parentDomain = location.hostname.replace(/^[^.]*\./, '')
document.cookie = `logged_in=yes;max-age=34560000;domain=${parentDomain};samesite=strict;secure`
// Save access token so can it be reused by the backend.
cognito.saveAccessToken(session.accessToken)
/** Execute the callback that should inform the Electron app that the user has logged in.
* This is done to transition the app from the authentication/dashboard view to the IDE. */
// Execute the callback that should inform the Electron app that the user has logged in.
// This is done to transition the app from the authentication/dashboard view to the IDE.
onAuthenticated(session.accessToken)
}
@ -400,6 +408,7 @@ export function AuthProvider(props: AuthProviderProps) {
}
const signUp = async (username: string, password: string, organizationId: string | null) => {
gtag.event('cloud_sign_up')
const result = await cognito.signUp(username, password, organizationId)
if (result.ok) {
toastSuccess(MESSAGES.signUpSuccess)
@ -411,6 +420,7 @@ export function AuthProvider(props: AuthProviderProps) {
}
const confirmSignUp = async (email: string, code: string) => {
gtag.event('cloud_confirm_sign_up')
const result = await cognito.confirmSignUp(email, code)
if (result.err) {
switch (result.val.kind) {
@ -426,6 +436,7 @@ export function AuthProvider(props: AuthProviderProps) {
}
const signInWithPassword = async (email: string, password: string) => {
gtag.event('cloud_sign_in', { provider: 'Email' })
const result = await cognito.signInWithPassword(email, password)
if (result.ok) {
toastSuccess(MESSAGES.signInWithPasswordSuccess)
@ -443,6 +454,7 @@ export function AuthProvider(props: AuthProviderProps) {
toastError('You cannot set your username on the local backend.')
return false
} else {
gtag.event('cloud_user_created')
try {
const organizationId = await authService.cognito.organizationId()
// This should not omit success and error toasts as it is not possible
@ -462,7 +474,15 @@ export function AuthProvider(props: AuthProviderProps) {
pending: MESSAGES.setUsernameLoading,
}
)
navigate(app.DASHBOARD_PATH)
const redirectTo = localStorage.get(
localStorageModule.LocalStorageKey.loginRedirect
)
if (redirectTo != null) {
localStorage.delete(localStorageModule.LocalStorageKey.loginRedirect)
location.href = redirectTo
} else {
navigate(app.DASHBOARD_PATH)
}
return true
} catch {
return false
@ -503,11 +523,15 @@ export function AuthProvider(props: AuthProviderProps) {
}
const signOut = async () => {
const parentDomain = location.hostname.replace(/^[^.]*\./, '')
document.cookie = `logged_in=no;max-age=0;domain=${parentDomain}`
gtag.event('cloud_sign_out')
cognito.saveAccessToken(null)
localStorage.clearUserSpecificEntries()
deinitializeSession()
setInitialized(false)
sentry.setUser(null)
setUserSession(null)
localStorage.clearUserSpecificEntries()
// This should not omit success and error toasts as it is not possible
// to render this optimistically.
await toast.toast.promise(cognito.signOut(), {
@ -523,16 +547,20 @@ export function AuthProvider(props: AuthProviderProps) {
signUp: withLoadingToast(signUp),
confirmSignUp: withLoadingToast(confirmSignUp),
setUsername,
signInWithGoogle: () =>
cognito.signInWithGoogle().then(
signInWithGoogle: () => {
gtag.event('cloud_sign_in', { provider: 'Google' })
return cognito.signInWithGoogle().then(
() => true,
() => false
),
signInWithGitHub: () =>
cognito.signInWithGitHub().then(
)
},
signInWithGitHub: () => {
gtag.event('cloud_sign_in', { provider: 'GitHub' })
return cognito.signInWithGitHub().then(
() => true,
() => false
),
)
},
signInWithPassword: withLoadingToast(signInWithPassword),
forgotPassword: withLoadingToast(forgotPassword),
resetPassword: withLoadingToast(resetPassword),
@ -611,10 +639,18 @@ export function ProtectedLayout() {
* in the process of registering. */
export function SemiProtectedLayout() {
const { session } = useAuth()
const { localStorage } = localStorageProvider.useLocalStorage()
const shouldPreventNavigation = getShouldPreventNavigation()
if (!shouldPreventNavigation && session?.type === UserSessionType.full) {
return <router.Navigate to={app.DASHBOARD_PATH} />
const redirectTo = localStorage.get(localStorageModule.LocalStorageKey.loginRedirect)
if (redirectTo != null) {
localStorage.delete(localStorageModule.LocalStorageKey.loginRedirect)
location.href = redirectTo
return
} else {
return <router.Navigate to={app.DASHBOARD_PATH} />
}
} else {
return <router.Outlet context={session} />
}
@ -628,12 +664,20 @@ export function SemiProtectedLayout() {
* not logged in. */
export function GuestLayout() {
const { session } = useAuth()
const { localStorage } = localStorageProvider.useLocalStorage()
const shouldPreventNavigation = getShouldPreventNavigation()
if (!shouldPreventNavigation && session?.type === UserSessionType.partial) {
return <router.Navigate to={app.SET_USERNAME_PATH} />
} else if (!shouldPreventNavigation && session?.type === UserSessionType.full) {
return <router.Navigate to={app.DASHBOARD_PATH} />
const redirectTo = localStorage.get(localStorageModule.LocalStorageKey.loginRedirect)
if (redirectTo != null) {
localStorage.delete(localStorageModule.LocalStorageKey.loginRedirect)
location.href = redirectTo
return
} else {
return <router.Navigate to={app.DASHBOARD_PATH} />
}
} else {
return <router.Outlet />
}

View File

@ -137,7 +137,7 @@ function loadAmplifyConfig(
/** Load the environment-specific Amplify configuration. */
const baseConfig = AMPLIFY_CONFIGS[config.ENVIRONMENT]
let urlOpener: ((url: string) => void) | null = null
let accessTokenSaver: ((accessToken: string) => void) | null = null
let accessTokenSaver: ((accessToken: string | null) => void) | null = null
if ('authenticationApi' in window) {
/** When running on destop we want to have option to save access token to a file,
* so it can be later reuse when issuing requests to Cloud API. */
@ -164,7 +164,7 @@ function loadAmplifyConfig(
...baseConfig,
...platformConfig,
urlOpener,
accessTokenSaver,
saveAccessToken: accessTokenSaver,
}
}
@ -174,7 +174,7 @@ function openUrlWithExternalBrowser(url: string) {
}
/** Save the access token to a file. */
function saveAccessToken(accessToken: string) {
function saveAccessToken(accessToken: string | null) {
window.authenticationApi.saveAccessToken(accessToken)
}

View File

@ -8,6 +8,7 @@ import DefaultUserIcon from 'enso-assets/default_user.svg'
import TriangleDownIcon from 'enso-assets/triangle_down.svg'
import * as chat from 'enso-chat/chat'
import * as gtag from 'enso-common/src/gtag'
import * as animations from '../../animations'
import * as authProvider from '../../authentication/providers/auth'
@ -263,8 +264,10 @@ function ChatHeader(props: InternalChatHeaderProps) {
setIsThreadListVisible(false)
}
document.addEventListener('click', onClick)
gtag.event('cloud_open_chat')
return () => {
document.removeEventListener('click', onClick)
gtag.event('cloud_close_chat')
}
}, [])

View File

@ -116,11 +116,14 @@ export default function Drive(props: DriveProps) {
React.useEffect(() => {
void (async () => {
if (backend.type !== backendModule.BackendType.local) {
if (
backend.type !== backendModule.BackendType.local &&
organization?.isEnabled === true
) {
setLabels(await backend.listTags())
}
})()
}, [backend])
}, [backend, organization?.isEnabled])
const doUploadFiles = React.useCallback(
(files: File[]) => {

View File

@ -20,6 +20,7 @@ export enum LocalStorageKey {
isTemplatesListOpen = 'is-templates-list-open',
projectStartupInfo = 'project-startup-info',
driveCategory = 'drive-category',
loginRedirect = 'login-redirect',
}
/** The data that can be stored in a {@link LocalStorage}. */
@ -30,6 +31,7 @@ interface LocalStorageData {
[LocalStorageKey.isTemplatesListOpen]: boolean
[LocalStorageKey.projectStartupInfo]: backend.ProjectStartupInfo
[LocalStorageKey.driveCategory]: categorySwitcher.Category
[LocalStorageKey.loginRedirect]: string
}
/** Whether each {@link LocalStorageKey} is user specific.
@ -42,6 +44,7 @@ const IS_USER_SPECIFIC: Record<LocalStorageKey, boolean> = {
[LocalStorageKey.isTemplatesListOpen]: false,
[LocalStorageKey.projectStartupInfo]: true,
[LocalStorageKey.driveCategory]: false,
[LocalStorageKey.loginRedirect]: true,
}
/** A LocalStorage data manager. */
@ -120,6 +123,12 @@ export class LocalStorage {
savedValues[LocalStorageKey.driveCategory]
}
}
if (LocalStorageKey.loginRedirect in savedValues) {
const value = savedValues[LocalStorageKey.loginRedirect]
if (typeof value === 'string') {
this.values[LocalStorageKey.loginRedirect] = value
}
}
if (
this.values[LocalStorageKey.projectStartupInfo] == null &&
this.values[LocalStorageKey.page] === pageSwitcher.Page.editor

View File

@ -19,7 +19,10 @@ declare const self: ServiceWorkerGlobalScope
self.addEventListener('fetch', event => {
const url = new URL(event.request.url)
if (url.hostname === 'localhost' && url.pathname !== '/esbuild') {
if (
(url.hostname === 'localhost' || url.hostname === '127.0.0.1') &&
url.pathname !== '/esbuild'
) {
const responsePromise = /\/[^.]+$/.test(new URL(event.request.url).pathname)
? fetch('/index.html')
: fetch(event.request.url)

View File

@ -50,7 +50,7 @@ interface AuthenticationApi {
* via a deep link. See {@link setDeepLinkHandler} for details. */
setDeepLinkHandler: (callback: (url: string) => void) => void
/** Saves the access token to a file. */
saveAccessToken: (access_token: string) => void
saveAccessToken: (accessToken: string | null) => void
}
// =====================================