Require accepting Terms of Service and Privacy Policy when registering (#10772)

- Frontend part of https://github.com/enso-org/cloud-v2/issues/1427
- Show ToS immediately, before login screen
- Add entries to devtools to be able to clear ToS

# Important Notes
None
This commit is contained in:
somebody1234 2024-08-29 03:51:31 +10:00 committed by GitHub
parent 50d919d952
commit 0ef074b8e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 453 additions and 272 deletions

View File

@ -765,7 +765,7 @@ export async function login(
await locateLoginButton(page).click() await locateLoginButton(page).click()
await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible() await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible()
if (first) { if (first) {
await passTermsAndConditionsDialog({ page, setupAPI }) await passAgreementsDialog({ page, setupAPI })
await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible() await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible()
} }
}) })
@ -840,11 +840,12 @@ async function mockDate({ page }: MockParams) {
}) })
} }
/** Pass the Terms and conditions dialog. */ /** Pass the Agreements dialog. */
export async function passTermsAndConditionsDialog({ page }: MockParams) { export async function passAgreementsDialog({ page }: MockParams) {
await test.test.step('Accept Terms and Conditions', async () => { await test.test.step('Accept Terms and Conditions', async () => {
await page.waitForSelector('#terms-of-service-modal') await page.waitForSelector('#agreements-modal')
await page.getByRole('checkbox').click() await page.getByRole('checkbox').and(page.getByTestId('terms-of-service-checkbox')).click()
await page.getByRole('checkbox').and(page.getByTestId('privacy-policy-checkbox')).click()
await page.getByRole('button', { name: 'Accept' }).click() await page.getByRole('button', { name: 'Accept' }).click()
}) })
} }

View File

@ -83,6 +83,14 @@ export default class RegisterPageActions extends BaseActions {
await this.page.getByPlaceholder(TEXT.emailPlaceholder).fill(email) await this.page.getByPlaceholder(TEXT.emailPlaceholder).fill(email)
await this.page.getByPlaceholder(TEXT.passwordPlaceholder).fill(password) await this.page.getByPlaceholder(TEXT.passwordPlaceholder).fill(password)
await this.page.getByPlaceholder(TEXT.confirmPasswordPlaceholder).fill(confirmPassword) await this.page.getByPlaceholder(TEXT.confirmPasswordPlaceholder).fill(confirmPassword)
await this.page
.getByRole('checkbox')
.and(this.page.getByTestId('terms-of-service-checkbox'))
.click()
await this.page
.getByRole('checkbox')
.and(this.page.getByTestId('privacy-policy-checkbox'))
.click()
await this.page await this.page
.getByRole('button', { name: TEXT.register, exact: true }) .getByRole('button', { name: TEXT.register, exact: true })
.getByText(TEXT.register) .getByText(TEXT.register)

View File

@ -65,7 +65,7 @@ test.test('asset panel contents', ({ page }) =>
}) })
.login() .login()
.do(async (thePage) => { .do(async (thePage) => {
await actions.passTermsAndConditionsDialog({ page: thePage }) await actions.passAgreementsDialog({ page: thePage })
}) })
.driveTable.clickRow(0) .driveTable.clickRow(0)
.toggleAssetPanel() .toggleAssetPanel()

View File

@ -12,7 +12,7 @@ test.test('login and logout', ({ page }) =>
.mockAll({ page }) .mockAll({ page })
.login() .login()
.do(async (thePage) => { .do(async (thePage) => {
await actions.passTermsAndConditionsDialog({ page: thePage }) await actions.passAgreementsDialog({ page: thePage })
await test.expect(actions.locateDriveView(thePage)).toBeVisible() await test.expect(actions.locateDriveView(thePage)).toBeVisible()
await test.expect(actions.locateLoginButton(thePage)).not.toBeVisible() await test.expect(actions.locateLoginButton(thePage)).not.toBeVisible()
}) })

View File

@ -4,7 +4,7 @@ import * as test from '@playwright/test'
import { import {
INVALID_PASSWORD, INVALID_PASSWORD,
mockAll, mockAll,
passTermsAndConditionsDialog, passAgreementsDialog,
TEXT, TEXT,
VALID_EMAIL, VALID_EMAIL,
VALID_PASSWORD, VALID_PASSWORD,
@ -26,7 +26,7 @@ test.test('login screen', ({ page }) =>
// Technically it should not be allowed, but // Technically it should not be allowed, but
.login(VALID_EMAIL, INVALID_PASSWORD) .login(VALID_EMAIL, INVALID_PASSWORD)
.do(async (thePage) => { .do(async (thePage) => {
await passTermsAndConditionsDialog({ page: thePage }) await passAgreementsDialog({ page: thePage })
}) })
.withDriveView(async (driveView) => { .withDriveView(async (driveView) => {
await test.expect(driveView).toBeVisible() await test.expect(driveView).toBeVisible()

View File

@ -80,8 +80,8 @@ import * as errorBoundary from '#/components/ErrorBoundary'
import * as suspense from '#/components/Suspense' import * as suspense from '#/components/Suspense'
import AboutModal from '#/modals/AboutModal' import AboutModal from '#/modals/AboutModal'
import { AgreementsModal } from '#/modals/AgreementsModal'
import * as setOrganizationNameModal from '#/modals/SetOrganizationNameModal' import * as setOrganizationNameModal from '#/modals/SetOrganizationNameModal'
import * as termsOfServiceModal from '#/modals/TermsOfServiceModal'
import LocalBackend from '#/services/LocalBackend' import LocalBackend from '#/services/LocalBackend'
import ProjectManager, * as projectManager from '#/services/ProjectManager' import ProjectManager, * as projectManager from '#/services/ProjectManager'
@ -353,9 +353,10 @@ function AppRouter(props: AppRouterProps) {
}, [localStorage, inputBindingsRaw]) }, [localStorage, inputBindingsRaw])
const mainPageUrl = getMainPageUrl() const mainPageUrl = getMainPageUrl()
// Subscribe to `termsOfService` updates to trigger a rerender when the terms of service // Subscribe to `localStorage` updates to trigger a rerender when the terms of service
// has been accepted. // or privacy policy have been accepted.
localStorageProvider.useLocalStorageState('termsOfService') localStorageProvider.useLocalStorageState('termsOfService')
localStorageProvider.useLocalStorageState('privacyPolicy')
const authService = useInitAuthService(props) const authService = useInitAuthService(props)
const userSession = authService?.cognito.userSession.bind(authService.cognito) ?? null const userSession = authService?.cognito.userSession.bind(authService.cognito) ?? null
@ -430,7 +431,7 @@ function AppRouter(props: AppRouterProps) {
{/* Protected pages are visible to authenticated users. */} {/* Protected pages are visible to authenticated users. */}
<router.Route element={<authProvider.NotDeletedUserLayout />}> <router.Route element={<authProvider.NotDeletedUserLayout />}>
<router.Route element={<authProvider.ProtectedLayout />}> <router.Route element={<authProvider.ProtectedLayout />}>
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}> <router.Route element={<AgreementsModal />}>
<router.Route element={<setOrganizationNameModal.SetOrganizationNameModal />}> <router.Route element={<setOrganizationNameModal.SetOrganizationNameModal />}>
<router.Route element={<InvitedToOrganizationModal />}> <router.Route element={<InvitedToOrganizationModal />}>
<router.Route element={<openAppWatcher.OpenAppWatcher />}> <router.Route element={<openAppWatcher.OpenAppWatcher />}>
@ -467,7 +468,7 @@ function AppRouter(props: AppRouterProps) {
</router.Route> </router.Route>
</router.Route> </router.Route>
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}> <router.Route element={<AgreementsModal />}>
<router.Route element={<authProvider.NotDeletedUserLayout />}> <router.Route element={<authProvider.NotDeletedUserLayout />}>
<router.Route path={appUtils.SETUP_PATH} element={<setup.Setup />} /> <router.Route path={appUtils.SETUP_PATH} element={<setup.Setup />} />
</router.Route> </router.Route>

View File

@ -23,11 +23,24 @@ import {
} from '#/providers/EnsoDevtoolsProvider' } from '#/providers/EnsoDevtoolsProvider'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria' import { Switch } from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents' import {
Button,
ButtonGroup,
DialogTrigger,
Form,
Popover,
Radio,
RadioGroup,
Separator,
Text,
} from '#/components/AriaComponents'
import Portal from '#/components/Portal' import Portal from '#/components/Portal'
import { useLocalStorage } from '#/providers/LocalStorageProvider'
import * as backend from '#/services/Backend' import * as backend from '#/services/Backend'
import LocalStorage from '#/utilities/LocalStorage'
import { unsafeEntries } from 'enso-common/src/utilities/data/object'
/** /**
* Configuration for a paywall feature. * Configuration for a paywall feature.
@ -64,6 +77,7 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
const { authQueryKey, session } = authProvider.useAuth() const { authQueryKey, session } = authProvider.useAuth()
const enableVersionChecker = useEnableVersionChecker() const enableVersionChecker = useEnableVersionChecker()
const setEnableVersionChecker = useSetEnableVersionChecker() const setEnableVersionChecker = useSetEnableVersionChecker()
const { localStorage } = useLocalStorage()
const [features, setFeatures] = React.useState< const [features, setFeatures] = React.useState<
Record<billing.PaywallFeatureName, PaywallDevtoolsFeatureConfiguration> Record<billing.PaywallFeatureName, PaywallDevtoolsFeatureConfiguration>
@ -92,8 +106,8 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
{children} {children}
<Portal> <Portal>
<ariaComponents.DialogTrigger> <DialogTrigger>
<ariaComponents.Button <Button
icon={DevtoolsLogo} icon={DevtoolsLogo}
aria-label={getText('paywallDevtoolsButtonLabel')} aria-label={getText('paywallDevtoolsButtonLabel')}
variant="icon" variant="icon"
@ -103,27 +117,25 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
data-ignore-click-outside data-ignore-click-outside
/> />
<ariaComponents.Popover> <Popover>
<ariaComponents.Text.Heading disableLineHeightCompensation> <Text.Heading disableLineHeightCompensation>
{getText('paywallDevtoolsPopoverHeading')} {getText('paywallDevtoolsPopoverHeading')}
</ariaComponents.Text.Heading> </Text.Heading>
<ariaComponents.Separator orientation="horizontal" className="my-3" /> <Separator orientation="horizontal" className="my-3" />
{session?.type === UserSessionType.full && ( {session?.type === UserSessionType.full && (
<> <>
<ariaComponents.Text variant="subtitle"> <Text variant="subtitle">{getText('paywallDevtoolsPlanSelectSubtitle')}</Text>
{getText('paywallDevtoolsPlanSelectSubtitle')}
</ariaComponents.Text>
<ariaComponents.Form <Form
gap="small" gap="small"
schema={(schema) => schema.object({ plan: schema.string() })} schema={(schema) => schema.object({ plan: schema.string() })}
defaultValues={{ plan: session.user.plan ?? 'free' }} defaultValues={{ plan: session.user.plan ?? 'free' }}
> >
{({ form }) => ( {({ form }) => (
<> <>
<ariaComponents.RadioGroup <RadioGroup
form={form} form={form}
name="plan" name="plan"
onChange={(value) => { onChange={(value) => {
@ -133,16 +145,13 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
}) })
}} }}
> >
<ariaComponents.Radio label={getText('free')} value={'free'} /> <Radio label={getText('free')} value={'free'} />
<ariaComponents.Radio label={getText('solo')} value={backend.Plan.solo} /> <Radio label={getText('solo')} value={backend.Plan.solo} />
<ariaComponents.Radio label={getText('team')} value={backend.Plan.team} /> <Radio label={getText('team')} value={backend.Plan.team} />
<ariaComponents.Radio <Radio label={getText('enterprise')} value={backend.Plan.enterprise} />
label={getText('enterprise')} </RadioGroup>
value={backend.Plan.enterprise}
/>
</ariaComponents.RadioGroup>
<ariaComponents.Button <Button
size="small" size="small"
variant="outline" variant="outline"
onPress={() => onPress={() =>
@ -152,27 +161,27 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
} }
> >
{getText('reset')} {getText('reset')}
</ariaComponents.Button> </Button>
</> </>
)} )}
</ariaComponents.Form> </Form>
<ariaComponents.Separator orientation="horizontal" className="my-3" /> <Separator orientation="horizontal" className="my-3" />
{/* eslint-disable-next-line no-restricted-syntax */} {/* eslint-disable-next-line no-restricted-syntax */}
<ariaComponents.Button variant="link" href={SETUP_PATH + '?__qd-debg__=true'}> <Button variant="link" href={SETUP_PATH + '?__qd-debg__=true'}>
Open setup page Open setup page
</ariaComponents.Button> </Button>
<ariaComponents.Separator orientation="horizontal" className="my-3" /> <Separator orientation="horizontal" className="my-3" />
</> </>
)} )}
<ariaComponents.Text variant="subtitle" className="mb-2"> <Text variant="subtitle" className="mb-2">
{getText('productionOnlyFeatures')} {getText('productionOnlyFeatures')}
</ariaComponents.Text> </Text>
<div className="flex flex-col"> <div className="flex flex-col">
<aria.Switch <Switch
className="group flex items-center gap-1" className="group flex items-center gap-1"
isSelected={enableVersionChecker ?? !IS_DEV_MODE} isSelected={enableVersionChecker ?? !IS_DEV_MODE}
onChange={setEnableVersionChecker} onChange={setEnableVersionChecker}
@ -181,21 +190,45 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
<span className="aspect-square h-full flex-none translate-x-0 transform rounded-full bg-white transition duration-200 ease-in-out group-selected:translate-x-[100%]" /> <span className="aspect-square h-full flex-none translate-x-0 transform rounded-full bg-white transition duration-200 ease-in-out group-selected:translate-x-[100%]" />
</div> </div>
<ariaComponents.Text className="flex-1"> <Text className="flex-1">{getText('enableVersionChecker')}</Text>
{getText('enableVersionChecker')} </Switch>
</ariaComponents.Text>
</aria.Switch>
<ariaComponents.Text variant="body" color="disabled"> <Text variant="body" color="disabled">
{getText('enableVersionCheckerDescription')} {getText('enableVersionCheckerDescription')}
</ariaComponents.Text> </Text>
</div> </div>
<ariaComponents.Separator orientation="horizontal" className="my-3" /> <Separator orientation="horizontal" className="my-3" />
<ariaComponents.Text variant="subtitle" className="mb-2"> <Text variant="subtitle" className="mb-2">
{getText('localStorage')}
</Text>
{unsafeEntries(LocalStorage.keyMetadata).map(([key]) => (
<div className="flex gap-1">
<ButtonGroup className="grow-0">
<Button
size="small"
variant="outline"
onPress={() => {
localStorage.delete(key)
}}
>
{getText('delete')}
</Button>
</ButtonGroup>
<Text variant="body">
{key
.replace(/[A-Z]/g, (m) => ' ' + m.toLowerCase())
.replace(/^./, (m) => m.toUpperCase())}
</Text>
</div>
))}
<Separator orientation="horizontal" className="my-3" />
<Text variant="subtitle" className="mb-2">
{getText('paywallDevtoolsPaywallFeaturesToggles')} {getText('paywallDevtoolsPaywallFeaturesToggles')}
</ariaComponents.Text> </Text>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{Object.entries(features).map(([feature, configuration]) => { {Object.entries(features).map(([feature, configuration]) => {
@ -205,7 +238,7 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
return ( return (
<div key={feature} className="flex flex-col"> <div key={feature} className="flex flex-col">
<aria.Switch <Switch
className="group flex items-center gap-1" className="group flex items-center gap-1"
isSelected={configuration.isForceEnabled ?? true} isSelected={configuration.isForceEnabled ?? true}
onChange={(value) => { onChange={(value) => {
@ -218,18 +251,18 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
<span className="aspect-square h-full flex-none translate-x-0 transform rounded-full bg-white transition duration-200 ease-in-out group-selected:translate-x-[100%]" /> <span className="aspect-square h-full flex-none translate-x-0 transform rounded-full bg-white transition duration-200 ease-in-out group-selected:translate-x-[100%]" />
</div> </div>
<ariaComponents.Text className="flex-1">{getText(label)}</ariaComponents.Text> <Text className="flex-1">{getText(label)}</Text>
</aria.Switch> </Switch>
<ariaComponents.Text variant="body" color="disabled"> <Text variant="body" color="disabled">
{getText(descriptionTextId)} {getText(descriptionTextId)}
</ariaComponents.Text> </Text>
</div> </div>
) )
})} })}
</div> </div>
</ariaComponents.Popover> </Popover>
</ariaComponents.DialogTrigger> </DialogTrigger>
</Portal> </Portal>
</PaywallDevtoolsContext.Provider> </PaywallDevtoolsContext.Provider>
) )

View File

@ -0,0 +1,214 @@
/** @file Modal for accepting the terms of service. */
import { useId } from 'react'
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
import { Outlet } from 'react-router'
import * as z from 'zod'
import { Input } from '#/components/aria'
import { Button, Dialog, Form, Text } from '#/components/AriaComponents'
import { useAuth } from '#/providers/AuthProvider'
import { useLocalStorage } from '#/providers/LocalStorageProvider'
import { useText } from '#/providers/TextProvider'
import LocalStorage from '#/utilities/LocalStorage'
import { omit } from '#/utilities/object'
import { twMerge } from '#/utilities/tailwindMerge'
// =================
// === Constants ===
// =================
const TEN_MINUTES_MS = 600_000
const TOS_SCHEMA = z.object({ versionHash: z.string() })
const PRIVACY_POLICY_SCHEMA = z.object({ versionHash: z.string() })
const TOS_ENDPOINT_SCHEMA = z.object({ hash: z.string() })
const PRIVACY_POLICY_ENDPOINT_SCHEMA = z.object({ hash: z.string() })
export const latestTermsOfServiceQueryOptions = queryOptions({
queryKey: ['termsOfService', 'currentVersion'],
queryFn: async () => {
const response = await fetch(new URL('/eula.json', process.env.ENSO_CLOUD_ENSO_HOST))
if (!response.ok) {
throw new Error('Failed to fetch Terms of Service')
} else {
return TOS_ENDPOINT_SCHEMA.parse(await response.json())
}
},
refetchOnWindowFocus: true,
refetchIntervalInBackground: true,
refetchInterval: TEN_MINUTES_MS,
})
export const latestPrivacyPolicyQueryOptions = queryOptions({
queryKey: ['privacyPolicy', 'currentVersion'],
queryFn: async () => {
const response = await fetch(new URL('/privacy.json', process.env.ENSO_CLOUD_ENSO_HOST))
if (!response.ok) {
throw new Error('Failed to fetch Privacy Policy')
} else {
return PRIVACY_POLICY_ENDPOINT_SCHEMA.parse(await response.json())
}
},
refetchOnWindowFocus: true,
refetchIntervalInBackground: true,
refetchInterval: TEN_MINUTES_MS,
})
// ============================
// === Global configuration ===
// ============================
declare module '#/utilities/LocalStorage' {
/** Metadata containing the version hash of the terms of service that the user has accepted. */
interface LocalStorageData {
readonly termsOfService: z.infer<typeof TOS_SCHEMA>
readonly privacyPolicy: z.infer<typeof PRIVACY_POLICY_SCHEMA>
}
}
LocalStorage.registerKey('termsOfService', { schema: TOS_SCHEMA })
LocalStorage.registerKey('privacyPolicy', { schema: PRIVACY_POLICY_SCHEMA })
// =======================
// === AgreementsModal ===
// =======================
/** Modal for accepting the terms of service. */
export function AgreementsModal() {
const { getText } = useText()
const { localStorage } = useLocalStorage()
const checkboxId = useId()
const { session } = useAuth()
const cachedTosHash = localStorage.get('termsOfService')?.versionHash
const { data: tosHash } = useSuspenseQuery({
...latestTermsOfServiceQueryOptions,
// If the user has already accepted the EULA, we don't need to
// block user interaction with the app while we fetch the latest version.
// We can use the local version hash as the initial data.
// and refetch in the background to check for updates.
...(cachedTosHash != null && {
initialData: { hash: cachedTosHash },
}),
select: (data) => data.hash,
})
const cachedPrivacyPolicyHash = localStorage.get('privacyPolicy')?.versionHash
const { data: privacyPolicyHash } = useSuspenseQuery({
...latestPrivacyPolicyQueryOptions,
...(cachedPrivacyPolicyHash != null && {
initialData: { hash: cachedPrivacyPolicyHash },
}),
select: (data) => data.hash,
})
const isLatest = tosHash === cachedTosHash && privacyPolicyHash === cachedPrivacyPolicyHash
const isAccepted = cachedTosHash != null
const shouldDisplay = !(isAccepted && isLatest)
const formSchema = Form.useFormSchema((schema) =>
schema.object({
// The user must agree to the ToS to proceed.
agreedToTos: schema
.boolean()
// The user must agree to the ToS to proceed.
.refine((value) => value, getText('licenseAgreementCheckboxError')),
agreedToPrivacyPolicy: schema
.boolean()
.refine((value) => value, getText('privacyPolicyCheckboxError')),
}),
)
if (shouldDisplay) {
// Note that this produces warnings about missing a `<Heading slot="title">`, even though
// all `ariaComponents.Dialog`s contain one. This is likely caused by Suspense discarding
// renders, and so it does not seem to be fixable.
return (
<Dialog
title={getText('licenseAgreementTitle')}
isKeyboardDismissDisabled
isDismissable={false}
hideCloseButton
modalProps={{ defaultOpen: true }}
testId="agreements-modal"
id="agreements-modal"
>
<Form
schema={formSchema}
defaultValues={{
agreedToTos: tosHash === cachedTosHash,
agreedToPrivacyPolicy: privacyPolicyHash === cachedPrivacyPolicyHash,
}}
testId="agreements-form"
method="dialog"
onSubmit={() => {
localStorage.set('termsOfService', { versionHash: tosHash })
localStorage.set('privacyPolicy', { versionHash: privacyPolicyHash })
}}
>
{({ register }) => (
<>
<Text>{getText('someAgreementsHaveBeenUpdated')}</Text>
<Form.Field name="agreedToTos">
{({ isInvalid }) => (
<>
<div className="flex w-full items-center gap-1">
<Input
type="checkbox"
className={twMerge(
'flex size-4 cursor-pointer overflow-clip rounded-lg border border-primary outline-primary focus-visible:outline focus-visible:outline-2',
isInvalid && 'border-red-700 text-red-500 outline-red-500',
)}
id={checkboxId}
data-testid="terms-of-service-checkbox"
{...omit(register('agreedToTos'), 'isInvalid')}
/>
<label htmlFor={checkboxId}>
<Text>{getText('licenseAgreementCheckbox')}</Text>
</label>
</div>
<Button variant="link" target="_blank" href="https://ensoanalytics.com/eula">
{getText('viewLicenseAgreement')}
</Button>
</>
)}
</Form.Field>
<Form.Field name="agreedToPrivacyPolicy">
{({ isInvalid }) => (
<>
<label className="flex w-full items-center gap-1">
<Input
type="checkbox"
className={twMerge(
'flex size-4 cursor-pointer overflow-clip rounded-lg border border-primary outline-primary focus-visible:outline focus-visible:outline-2',
isInvalid && 'border-red-700 text-red-500 outline-red-500',
)}
data-testid="privacy-policy-checkbox"
{...omit(register('agreedToPrivacyPolicy'), 'isInvalid')}
/>
<Text>{getText('privacyPolicyCheckbox')}</Text>
</label>
<Button variant="link" target="_blank" href="https://ensoanalytics.com/privacy">
{getText('viewPrivacyPolicy')}
</Button>
</>
)}
</Form.Field>
<Form.FormError />
<Form.Submit fullWidth>{getText('accept')}</Form.Submit>
</>
)}
</Form>
</Dialog>
)
} else {
return <Outlet context={session} />
}
}

View File

@ -1,171 +0,0 @@
/** @file Modal for accepting the terms of service. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as router from 'react-router'
import * as z from 'zod'
import * as authProvider from '#/providers/AuthProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import LocalStorage from '#/utilities/LocalStorage'
import * as object from '#/utilities/object'
import * as tailwindMerge from '#/utilities/tailwindMerge'
// =================
// === Constants ===
// =================
const TEN_MINUTES_MS = 600_000
const TERMS_OF_SERVICE_SCHEMA = z.object({ versionHash: z.string() })
// ============================
// === Global configuration ===
// ============================
declare module '#/utilities/LocalStorage' {
/** Metadata containing the version hash of the terms of service that the user has accepted. */
interface LocalStorageData {
readonly termsOfService: z.infer<typeof TERMS_OF_SERVICE_SCHEMA> | null
}
}
LocalStorage.registerKey('termsOfService', { schema: TERMS_OF_SERVICE_SCHEMA })
export const latestTermsOfService = reactQuery.queryOptions({
queryKey: ['termsOfService', 'currentVersion'],
queryFn: () =>
fetch(new URL('/eula.json', process.env.ENSO_CLOUD_ENSO_HOST))
.then((response) => {
if (!response.ok) {
throw new Error('Failed to fetch terms of service')
} else {
return response.json()
}
})
.then((data) => {
const schema = z.object({ hash: z.string() })
return schema.parse(data)
}),
refetchOnWindowFocus: true,
refetchIntervalInBackground: true,
refetchInterval: TEN_MINUTES_MS,
})
// ===========================
// === TermsOfServiceModal ===
// ===========================
/** Modal for accepting the terms of service. */
export function TermsOfServiceModal() {
const { getText } = textProvider.useText()
const { localStorage } = localStorageProvider.useLocalStorage()
const checkboxId = React.useId()
const { session } = authProvider.useAuth()
const localVersionHash = localStorage.get('termsOfService')?.versionHash
const { data: latestVersionHash } = reactQuery.useSuspenseQuery({
...latestTermsOfService,
// If the user has already accepted EULA, we don't need to
// block user interaction with the app while we fetch the latest version.
// We can use the local version hash as the initial data.
// and refetch in the background to check for updates.
...(localVersionHash != null && {
initialData: { hash: localVersionHash },
}),
select: (data) => data.hash,
})
const isLatest = latestVersionHash === localVersionHash
const isAccepted = localVersionHash != null
const shouldDisplay = !(isAccepted && isLatest)
const formSchema = ariaComponents.Form.useFormSchema((schema) =>
schema.object({
agree: schema
.boolean()
// we accept only true
.refine((value) => value, getText('licenseAgreementCheckboxError')),
hash: schema.string(),
}),
)
if (shouldDisplay) {
// Note that this produces warnings about missing a `<Heading slot="title">`, even though
// all `ariaComponents.Dialog`s contain one. This is likely caused by Suspense discarding
// renders, and so it does not seem to be fixable.
return (
<>
<ariaComponents.Dialog
title={getText('licenseAgreementTitle')}
isKeyboardDismissDisabled
isDismissable={false}
hideCloseButton
modalProps={{ defaultOpen: true }}
testId="terms-of-service-modal"
id="terms-of-service-modal"
>
<ariaComponents.Form
schema={formSchema}
defaultValues={{ agree: false, hash: latestVersionHash }}
testId="terms-of-service-form"
method="dialog"
onSubmit={({ hash }) => {
localStorage.set('termsOfService', { versionHash: hash })
}}
>
{({ register }) => (
<>
<ariaComponents.Form.Field name="agree">
{({ isInvalid }) => (
<>
<div className="flex w-full items-center gap-1">
<aria.Input
type="checkbox"
className={tailwindMerge.twMerge(
'flex size-4 cursor-pointer overflow-clip rounded-lg border border-primary outline-primary focus-visible:outline focus-visible:outline-2',
isInvalid && 'border-red-700 text-red-500 outline-red-500',
)}
id={checkboxId}
data-testid="terms-of-service-checkbox"
{...object.omit(register('agree'), 'isInvalid')}
/>
<label htmlFor={checkboxId}>
<ariaComponents.Text>
{getText('licenseAgreementCheckbox')}
</ariaComponents.Text>
</label>
</div>
<ariaComponents.Button
variant="link"
target="_blank"
href="https://enso.org/eula"
>
{getText('viewLicenseAgreement')}
</ariaComponents.Button>
</>
)}
</ariaComponents.Form.Field>
<ariaComponents.Form.FormError />
<ariaComponents.Form.Submit fullWidth>
{getText('accept')}
</ariaComponents.Form.Submit>
</>
)}
</ariaComponents.Form>
</ariaComponents.Dialog>
</>
)
} else {
return <router.Outlet context={session} />
}
}

View File

@ -9,8 +9,13 @@ import AtIcon from '#/assets/at.svg'
import CreateAccountIcon from '#/assets/create_account.svg' import CreateAccountIcon from '#/assets/create_account.svg'
import GoBackIcon from '#/assets/go_back.svg' import GoBackIcon from '#/assets/go_back.svg'
import LockIcon from '#/assets/lock.svg' import LockIcon from '#/assets/lock.svg'
import { Form, Input, Password } from '#/components/AriaComponents' import { Input as AriaInput } from '#/components/aria'
import { Button, Form, Input, Password, Text } from '#/components/AriaComponents'
import Link from '#/components/Link' import Link from '#/components/Link'
import {
latestPrivacyPolicyQueryOptions,
latestTermsOfServiceQueryOptions,
} from '#/modals/AgreementsModal'
import AuthenticationPage from '#/pages/authentication/AuthenticationPage' import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
import { passwordWithPatternSchema } from '#/pages/authentication/schemas' import { passwordWithPatternSchema } from '#/pages/authentication/schemas'
import { useAuth } from '#/providers/AuthProvider' import { useAuth } from '#/providers/AuthProvider'
@ -18,7 +23,10 @@ import { useLocalBackend } from '#/providers/BackendProvider'
import { useLocalStorage } from '#/providers/LocalStorageProvider' import { useLocalStorage } from '#/providers/LocalStorageProvider'
import { type GetText, useText } from '#/providers/TextProvider' import { type GetText, useText } from '#/providers/TextProvider'
import LocalStorage from '#/utilities/LocalStorage' import LocalStorage from '#/utilities/LocalStorage'
import { twMerge } from '#/utilities/tailwindMerge'
import { PASSWORD_REGEX } from '#/utilities/validation' import { PASSWORD_REGEX } from '#/utilities/validation'
import { useSuspenseQuery } from '@tanstack/react-query'
import { omit } from 'enso-common/src/utilities/data/object'
// ============================ // ============================
// === Global configuration === // === Global configuration ===
@ -43,6 +51,10 @@ function createRegistrationFormSchema(getText: GetText) {
email: z.string().email(getText('invalidEmailValidationError')), email: z.string().email(getText('invalidEmailValidationError')),
password: passwordWithPatternSchema(getText), password: passwordWithPatternSchema(getText),
confirmPassword: z.string(), confirmPassword: z.string(),
agreedToTos: z.boolean().refine((value) => value, getText('licenseAgreementCheckboxError')),
agreedToPrivacyPolicy: z
.boolean()
.refine((value) => value, getText('privacyPolicyCheckboxError')),
}) })
.superRefine((object, context) => { .superRefine((object, context) => {
if (PASSWORD_REGEX.test(object.password) && object.password !== object.confirmPassword) { if (PASSWORD_REGEX.test(object.password) && object.password !== object.confirmPassword) {
@ -74,6 +86,27 @@ export default function Registration() {
const redirectTo = query.get('redirect_to') const redirectTo = query.get('redirect_to')
const [emailInput, setEmailInput] = useState(initialEmail ?? '') const [emailInput, setEmailInput] = useState(initialEmail ?? '')
const cachedTosHash = localStorage.get('termsOfService')?.versionHash
const { data: tosHash } = useSuspenseQuery({
...latestTermsOfServiceQueryOptions,
// If the user has already accepted the EULA, we don't need to
// block user interaction with the app while we fetch the latest version.
// We can use the local version hash as the initial data.
// and refetch in the background to check for updates.
...(cachedTosHash != null && {
initialData: { hash: cachedTosHash },
}),
select: (data) => data.hash,
})
const cachedPrivacyPolicyHash = localStorage.get('privacyPolicy')?.versionHash
const { data: privacyPolicyHash } = useSuspenseQuery({
...latestPrivacyPolicyQueryOptions,
...(cachedPrivacyPolicyHash != null && {
initialData: { hash: cachedPrivacyPolicyHash },
}),
select: (data) => data.hash,
})
useEffect(() => { useEffect(() => {
if (redirectTo != null) { if (redirectTo != null) {
localStorage.set('loginRedirect', redirectTo) localStorage.set('loginRedirect', redirectTo)
@ -85,6 +118,7 @@ export default function Registration() {
return ( return (
<AuthenticationPage <AuthenticationPage
schema={createRegistrationFormSchema(getText)} schema={createRegistrationFormSchema(getText)}
defaultValues={{ agreedToTos: false, agreedToPrivacyPolicy: false }}
title={getText('createANewAccount')} title={getText('createANewAccount')}
supportsOffline={supportsOffline} supportsOffline={supportsOffline}
footer={ footer={
@ -94,8 +128,14 @@ export default function Registration() {
text={getText('alreadyHaveAnAccount')} text={getText('alreadyHaveAnAccount')}
/> />
} }
onSubmit={({ email, password }) => signUp(email, password, organizationId)} onSubmit={async ({ email, password }) => {
await signUp(email, password, organizationId)
localStorage.set('termsOfService', { versionHash: tosHash })
localStorage.set('privacyPolicy', { versionHash: privacyPolicyHash })
}}
> >
{({ register }) => (
<>
<Input <Input
autoFocus autoFocus
required required
@ -131,11 +171,61 @@ export default function Registration() {
placeholder={getText('confirmPasswordPlaceholder')} placeholder={getText('confirmPasswordPlaceholder')}
/> />
<Form.Field name="agreedToTos">
{({ isInvalid }) => (
<>
<label className="flex w-full items-center gap-1">
<AriaInput
type="checkbox"
className={twMerge(
'flex size-4 cursor-pointer overflow-clip rounded-lg border border-primary outline-primary focus-visible:outline focus-visible:outline-2',
isInvalid && 'border-red-700 text-red-500 outline-red-500',
)}
data-testid="terms-of-service-checkbox"
{...omit(register('agreedToTos'), 'isInvalid')}
/>
<Text>{getText('licenseAgreementCheckbox')}</Text>
</label>
<Button variant="link" target="_blank" href="https://ensoanalytics.com/eula">
{getText('viewLicenseAgreement')}
</Button>
</>
)}
</Form.Field>
<Form.Field name="agreedToPrivacyPolicy">
{({ isInvalid }) => (
<>
<label className="flex w-full items-center gap-1">
<AriaInput
type="checkbox"
className={twMerge(
'flex size-4 cursor-pointer overflow-clip rounded-lg border border-primary outline-primary focus-visible:outline focus-visible:outline-2',
isInvalid && 'border-red-700 text-red-500 outline-red-500',
)}
data-testid="privacy-policy-checkbox"
{...omit(register('agreedToPrivacyPolicy'), 'isInvalid')}
/>
<Text>{getText('privacyPolicyCheckbox')}</Text>
</label>
<Button variant="link" target="_blank" href="https://ensoanalytics.com/privacy">
{getText('viewPrivacyPolicy')}
</Button>
</>
)}
</Form.Field>
<Form.Submit size="large" icon={CreateAccountIcon} className="w-full"> <Form.Submit size="large" icon={CreateAccountIcon} className="w-full">
{getText('register')} {getText('register')}
</Form.Submit> </Form.Submit>
<Form.FormError /> <Form.FormError />
</>
)}
</AuthenticationPage> </AuthenticationPage>
) )
} }

View File

@ -430,6 +430,7 @@
"enableVersionCheckerDescription": "Show a dialog if the current version of the desktop app does not match the latest version.", "enableVersionCheckerDescription": "Show a dialog if the current version of the desktop app does not match the latest version.",
"removeTheLocalDirectoryXFromFavorites": "remove the local folder '$0' from your favorites", "removeTheLocalDirectoryXFromFavorites": "remove the local folder '$0' from your favorites",
"changeLocalRootDirectoryInSettings": "Change your root folder in Settings.", "changeLocalRootDirectoryInSettings": "Change your root folder in Settings.",
"localStorage": "Local Storage",
"addLocalDirectory": "Add Folder", "addLocalDirectory": "Add Folder",
"browseForNewLocalRootDirectory": "Browse for new Root Folder", "browseForNewLocalRootDirectory": "Browse for new Root Folder",
"resetLocalRootDirectory": "Reset Root Folder", "resetLocalRootDirectory": "Reset Root Folder",
@ -573,10 +574,14 @@
"downgradeInfo": "Looking for downgrade options?", "downgradeInfo": "Looking for downgrade options?",
"someAgreementsHaveBeenUpdated": "Some agreements have been updated. Please re-read and agree to continue.",
"licenseAgreementTitle": "Enso Terms of Service", "licenseAgreementTitle": "Enso Terms of Service",
"licenseAgreementCheckbox": "I agree to the Enso Terms of Service", "licenseAgreementCheckbox": "I agree to the Enso Terms of Service",
"licenseAgreementCheckboxError": "Please agree to the Enso Terms of Service", "licenseAgreementCheckboxError": "Please agree to the Enso Terms of Service",
"viewLicenseAgreement": "View Terms of Service", "viewLicenseAgreement": "View Terms of Service",
"privacyPolicyCheckbox": "I agree to the Privacy Policy",
"privacyPolicyCheckboxError": "Please agree to the Privacy Policy",
"viewPrivacyPolicy": "View Privacy Policy",
"metaModifier": "Meta", "metaModifier": "Meta",
"shiftModifier": "Shift", "shiftModifier": "Shift",