mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 10:42:05 +03:00
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:
parent
50d919d952
commit
0ef074b8e9
@ -765,7 +765,7 @@ export async function login(
|
||||
await locateLoginButton(page).click()
|
||||
await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible()
|
||||
if (first) {
|
||||
await passTermsAndConditionsDialog({ page, setupAPI })
|
||||
await passAgreementsDialog({ page, setupAPI })
|
||||
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. */
|
||||
export async function passTermsAndConditionsDialog({ page }: MockParams) {
|
||||
/** Pass the Agreements dialog. */
|
||||
export async function passAgreementsDialog({ page }: MockParams) {
|
||||
await test.test.step('Accept Terms and Conditions', async () => {
|
||||
await page.waitForSelector('#terms-of-service-modal')
|
||||
await page.getByRole('checkbox').click()
|
||||
await page.waitForSelector('#agreements-modal')
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
@ -83,6 +83,14 @@ export default class RegisterPageActions extends BaseActions {
|
||||
await this.page.getByPlaceholder(TEXT.emailPlaceholder).fill(email)
|
||||
await this.page.getByPlaceholder(TEXT.passwordPlaceholder).fill(password)
|
||||
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
|
||||
.getByRole('button', { name: TEXT.register, exact: true })
|
||||
.getByText(TEXT.register)
|
||||
|
@ -65,7 +65,7 @@ test.test('asset panel contents', ({ page }) =>
|
||||
})
|
||||
.login()
|
||||
.do(async (thePage) => {
|
||||
await actions.passTermsAndConditionsDialog({ page: thePage })
|
||||
await actions.passAgreementsDialog({ page: thePage })
|
||||
})
|
||||
.driveTable.clickRow(0)
|
||||
.toggleAssetPanel()
|
||||
|
@ -12,7 +12,7 @@ test.test('login and logout', ({ page }) =>
|
||||
.mockAll({ page })
|
||||
.login()
|
||||
.do(async (thePage) => {
|
||||
await actions.passTermsAndConditionsDialog({ page: thePage })
|
||||
await actions.passAgreementsDialog({ page: thePage })
|
||||
await test.expect(actions.locateDriveView(thePage)).toBeVisible()
|
||||
await test.expect(actions.locateLoginButton(thePage)).not.toBeVisible()
|
||||
})
|
||||
|
@ -4,7 +4,7 @@ import * as test from '@playwright/test'
|
||||
import {
|
||||
INVALID_PASSWORD,
|
||||
mockAll,
|
||||
passTermsAndConditionsDialog,
|
||||
passAgreementsDialog,
|
||||
TEXT,
|
||||
VALID_EMAIL,
|
||||
VALID_PASSWORD,
|
||||
@ -26,7 +26,7 @@ test.test('login screen', ({ page }) =>
|
||||
// Technically it should not be allowed, but
|
||||
.login(VALID_EMAIL, INVALID_PASSWORD)
|
||||
.do(async (thePage) => {
|
||||
await passTermsAndConditionsDialog({ page: thePage })
|
||||
await passAgreementsDialog({ page: thePage })
|
||||
})
|
||||
.withDriveView(async (driveView) => {
|
||||
await test.expect(driveView).toBeVisible()
|
||||
|
@ -80,8 +80,8 @@ import * as errorBoundary from '#/components/ErrorBoundary'
|
||||
import * as suspense from '#/components/Suspense'
|
||||
|
||||
import AboutModal from '#/modals/AboutModal'
|
||||
import { AgreementsModal } from '#/modals/AgreementsModal'
|
||||
import * as setOrganizationNameModal from '#/modals/SetOrganizationNameModal'
|
||||
import * as termsOfServiceModal from '#/modals/TermsOfServiceModal'
|
||||
|
||||
import LocalBackend from '#/services/LocalBackend'
|
||||
import ProjectManager, * as projectManager from '#/services/ProjectManager'
|
||||
@ -353,9 +353,10 @@ function AppRouter(props: AppRouterProps) {
|
||||
}, [localStorage, inputBindingsRaw])
|
||||
const mainPageUrl = getMainPageUrl()
|
||||
|
||||
// Subscribe to `termsOfService` updates to trigger a rerender when the terms of service
|
||||
// has been accepted.
|
||||
// Subscribe to `localStorage` updates to trigger a rerender when the terms of service
|
||||
// or privacy policy have been accepted.
|
||||
localStorageProvider.useLocalStorageState('termsOfService')
|
||||
localStorageProvider.useLocalStorageState('privacyPolicy')
|
||||
|
||||
const authService = useInitAuthService(props)
|
||||
const userSession = authService?.cognito.userSession.bind(authService.cognito) ?? null
|
||||
@ -430,7 +431,7 @@ function AppRouter(props: AppRouterProps) {
|
||||
{/* Protected pages are visible to authenticated users. */}
|
||||
<router.Route element={<authProvider.NotDeletedUserLayout />}>
|
||||
<router.Route element={<authProvider.ProtectedLayout />}>
|
||||
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
|
||||
<router.Route element={<AgreementsModal />}>
|
||||
<router.Route element={<setOrganizationNameModal.SetOrganizationNameModal />}>
|
||||
<router.Route element={<InvitedToOrganizationModal />}>
|
||||
<router.Route element={<openAppWatcher.OpenAppWatcher />}>
|
||||
@ -467,7 +468,7 @@ function AppRouter(props: AppRouterProps) {
|
||||
</router.Route>
|
||||
</router.Route>
|
||||
|
||||
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
|
||||
<router.Route element={<AgreementsModal />}>
|
||||
<router.Route element={<authProvider.NotDeletedUserLayout />}>
|
||||
<router.Route path={appUtils.SETUP_PATH} element={<setup.Setup />} />
|
||||
</router.Route>
|
||||
|
@ -23,11 +23,24 @@ import {
|
||||
} from '#/providers/EnsoDevtoolsProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import { Switch } from '#/components/aria'
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
DialogTrigger,
|
||||
Form,
|
||||
Popover,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Separator,
|
||||
Text,
|
||||
} from '#/components/AriaComponents'
|
||||
import Portal from '#/components/Portal'
|
||||
|
||||
import { useLocalStorage } from '#/providers/LocalStorageProvider'
|
||||
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.
|
||||
@ -64,6 +77,7 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
|
||||
const { authQueryKey, session } = authProvider.useAuth()
|
||||
const enableVersionChecker = useEnableVersionChecker()
|
||||
const setEnableVersionChecker = useSetEnableVersionChecker()
|
||||
const { localStorage } = useLocalStorage()
|
||||
|
||||
const [features, setFeatures] = React.useState<
|
||||
Record<billing.PaywallFeatureName, PaywallDevtoolsFeatureConfiguration>
|
||||
@ -92,8 +106,8 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
|
||||
{children}
|
||||
|
||||
<Portal>
|
||||
<ariaComponents.DialogTrigger>
|
||||
<ariaComponents.Button
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
icon={DevtoolsLogo}
|
||||
aria-label={getText('paywallDevtoolsButtonLabel')}
|
||||
variant="icon"
|
||||
@ -103,27 +117,25 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
|
||||
data-ignore-click-outside
|
||||
/>
|
||||
|
||||
<ariaComponents.Popover>
|
||||
<ariaComponents.Text.Heading disableLineHeightCompensation>
|
||||
<Popover>
|
||||
<Text.Heading disableLineHeightCompensation>
|
||||
{getText('paywallDevtoolsPopoverHeading')}
|
||||
</ariaComponents.Text.Heading>
|
||||
</Text.Heading>
|
||||
|
||||
<ariaComponents.Separator orientation="horizontal" className="my-3" />
|
||||
<Separator orientation="horizontal" className="my-3" />
|
||||
|
||||
{session?.type === UserSessionType.full && (
|
||||
<>
|
||||
<ariaComponents.Text variant="subtitle">
|
||||
{getText('paywallDevtoolsPlanSelectSubtitle')}
|
||||
</ariaComponents.Text>
|
||||
<Text variant="subtitle">{getText('paywallDevtoolsPlanSelectSubtitle')}</Text>
|
||||
|
||||
<ariaComponents.Form
|
||||
<Form
|
||||
gap="small"
|
||||
schema={(schema) => schema.object({ plan: schema.string() })}
|
||||
defaultValues={{ plan: session.user.plan ?? 'free' }}
|
||||
>
|
||||
{({ form }) => (
|
||||
<>
|
||||
<ariaComponents.RadioGroup
|
||||
<RadioGroup
|
||||
form={form}
|
||||
name="plan"
|
||||
onChange={(value) => {
|
||||
@ -133,16 +145,13 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
|
||||
})
|
||||
}}
|
||||
>
|
||||
<ariaComponents.Radio label={getText('free')} value={'free'} />
|
||||
<ariaComponents.Radio label={getText('solo')} value={backend.Plan.solo} />
|
||||
<ariaComponents.Radio label={getText('team')} value={backend.Plan.team} />
|
||||
<ariaComponents.Radio
|
||||
label={getText('enterprise')}
|
||||
value={backend.Plan.enterprise}
|
||||
/>
|
||||
</ariaComponents.RadioGroup>
|
||||
<Radio label={getText('free')} value={'free'} />
|
||||
<Radio label={getText('solo')} value={backend.Plan.solo} />
|
||||
<Radio label={getText('team')} value={backend.Plan.team} />
|
||||
<Radio label={getText('enterprise')} value={backend.Plan.enterprise} />
|
||||
</RadioGroup>
|
||||
|
||||
<ariaComponents.Button
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
onPress={() =>
|
||||
@ -152,27 +161,27 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
|
||||
}
|
||||
>
|
||||
{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 */}
|
||||
<ariaComponents.Button variant="link" href={SETUP_PATH + '?__qd-debg__=true'}>
|
||||
<Button variant="link" href={SETUP_PATH + '?__qd-debg__=true'}>
|
||||
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')}
|
||||
</ariaComponents.Text>
|
||||
</Text>
|
||||
<div className="flex flex-col">
|
||||
<aria.Switch
|
||||
<Switch
|
||||
className="group flex items-center gap-1"
|
||||
isSelected={enableVersionChecker ?? !IS_DEV_MODE}
|
||||
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%]" />
|
||||
</div>
|
||||
|
||||
<ariaComponents.Text className="flex-1">
|
||||
{getText('enableVersionChecker')}
|
||||
</ariaComponents.Text>
|
||||
</aria.Switch>
|
||||
<Text className="flex-1">{getText('enableVersionChecker')}</Text>
|
||||
</Switch>
|
||||
|
||||
<ariaComponents.Text variant="body" color="disabled">
|
||||
<Text variant="body" color="disabled">
|
||||
{getText('enableVersionCheckerDescription')}
|
||||
</ariaComponents.Text>
|
||||
</Text>
|
||||
</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')}
|
||||
</ariaComponents.Text>
|
||||
</Text>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
{Object.entries(features).map(([feature, configuration]) => {
|
||||
@ -205,7 +238,7 @@ export function EnsoDevtools(props: EnsoDevtoolsProps) {
|
||||
|
||||
return (
|
||||
<div key={feature} className="flex flex-col">
|
||||
<aria.Switch
|
||||
<Switch
|
||||
className="group flex items-center gap-1"
|
||||
isSelected={configuration.isForceEnabled ?? true}
|
||||
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%]" />
|
||||
</div>
|
||||
|
||||
<ariaComponents.Text className="flex-1">{getText(label)}</ariaComponents.Text>
|
||||
</aria.Switch>
|
||||
<Text className="flex-1">{getText(label)}</Text>
|
||||
</Switch>
|
||||
|
||||
<ariaComponents.Text variant="body" color="disabled">
|
||||
<Text variant="body" color="disabled">
|
||||
{getText(descriptionTextId)}
|
||||
</ariaComponents.Text>
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ariaComponents.Popover>
|
||||
</ariaComponents.DialogTrigger>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
</Portal>
|
||||
</PaywallDevtoolsContext.Provider>
|
||||
)
|
||||
|
214
app/dashboard/src/modals/AgreementsModal.tsx
Normal file
214
app/dashboard/src/modals/AgreementsModal.tsx
Normal 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} />
|
||||
}
|
||||
}
|
@ -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} />
|
||||
}
|
||||
}
|
@ -9,8 +9,13 @@ import AtIcon from '#/assets/at.svg'
|
||||
import CreateAccountIcon from '#/assets/create_account.svg'
|
||||
import GoBackIcon from '#/assets/go_back.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 {
|
||||
latestPrivacyPolicyQueryOptions,
|
||||
latestTermsOfServiceQueryOptions,
|
||||
} from '#/modals/AgreementsModal'
|
||||
import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
|
||||
import { passwordWithPatternSchema } from '#/pages/authentication/schemas'
|
||||
import { useAuth } from '#/providers/AuthProvider'
|
||||
@ -18,7 +23,10 @@ import { useLocalBackend } from '#/providers/BackendProvider'
|
||||
import { useLocalStorage } from '#/providers/LocalStorageProvider'
|
||||
import { type GetText, useText } from '#/providers/TextProvider'
|
||||
import LocalStorage from '#/utilities/LocalStorage'
|
||||
import { twMerge } from '#/utilities/tailwindMerge'
|
||||
import { PASSWORD_REGEX } from '#/utilities/validation'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { omit } from 'enso-common/src/utilities/data/object'
|
||||
|
||||
// ============================
|
||||
// === Global configuration ===
|
||||
@ -43,6 +51,10 @@ function createRegistrationFormSchema(getText: GetText) {
|
||||
email: z.string().email(getText('invalidEmailValidationError')),
|
||||
password: passwordWithPatternSchema(getText),
|
||||
confirmPassword: z.string(),
|
||||
agreedToTos: z.boolean().refine((value) => value, getText('licenseAgreementCheckboxError')),
|
||||
agreedToPrivacyPolicy: z
|
||||
.boolean()
|
||||
.refine((value) => value, getText('privacyPolicyCheckboxError')),
|
||||
})
|
||||
.superRefine((object, context) => {
|
||||
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 [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(() => {
|
||||
if (redirectTo != null) {
|
||||
localStorage.set('loginRedirect', redirectTo)
|
||||
@ -85,6 +118,7 @@ export default function Registration() {
|
||||
return (
|
||||
<AuthenticationPage
|
||||
schema={createRegistrationFormSchema(getText)}
|
||||
defaultValues={{ agreedToTos: false, agreedToPrivacyPolicy: false }}
|
||||
title={getText('createANewAccount')}
|
||||
supportsOffline={supportsOffline}
|
||||
footer={
|
||||
@ -94,48 +128,104 @@ export default function Registration() {
|
||||
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 })
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
required
|
||||
data-testid="email-input"
|
||||
name="email"
|
||||
label={getText('emailLabel')}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
icon={AtIcon}
|
||||
placeholder={getText('emailPlaceholder')}
|
||||
defaultValue={initialEmail ?? undefined}
|
||||
onChange={(event) => {
|
||||
setEmailInput(event.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
<Password
|
||||
required
|
||||
data-testid="password-input"
|
||||
name="password"
|
||||
label={getText('passwordLabel')}
|
||||
autoComplete="new-password"
|
||||
icon={LockIcon}
|
||||
placeholder={getText('passwordPlaceholder')}
|
||||
description={getText('passwordValidationMessage')}
|
||||
/>
|
||||
<Password
|
||||
required
|
||||
data-testid="confirm-password-input"
|
||||
name="confirmPassword"
|
||||
label={getText('confirmPasswordLabel')}
|
||||
autoComplete="new-password"
|
||||
icon={LockIcon}
|
||||
placeholder={getText('confirmPasswordPlaceholder')}
|
||||
/>
|
||||
{({ register }) => (
|
||||
<>
|
||||
<Input
|
||||
autoFocus
|
||||
required
|
||||
data-testid="email-input"
|
||||
name="email"
|
||||
label={getText('emailLabel')}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
icon={AtIcon}
|
||||
placeholder={getText('emailPlaceholder')}
|
||||
defaultValue={initialEmail ?? undefined}
|
||||
onChange={(event) => {
|
||||
setEmailInput(event.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
<Password
|
||||
required
|
||||
data-testid="password-input"
|
||||
name="password"
|
||||
label={getText('passwordLabel')}
|
||||
autoComplete="new-password"
|
||||
icon={LockIcon}
|
||||
placeholder={getText('passwordPlaceholder')}
|
||||
description={getText('passwordValidationMessage')}
|
||||
/>
|
||||
<Password
|
||||
required
|
||||
data-testid="confirm-password-input"
|
||||
name="confirmPassword"
|
||||
label={getText('confirmPasswordLabel')}
|
||||
autoComplete="new-password"
|
||||
icon={LockIcon}
|
||||
placeholder={getText('confirmPasswordPlaceholder')}
|
||||
/>
|
||||
|
||||
<Form.Submit size="large" icon={CreateAccountIcon} className="w-full">
|
||||
{getText('register')}
|
||||
</Form.Submit>
|
||||
<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')}
|
||||
/>
|
||||
|
||||
<Form.FormError />
|
||||
<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">
|
||||
{getText('register')}
|
||||
</Form.Submit>
|
||||
|
||||
<Form.FormError />
|
||||
</>
|
||||
)}
|
||||
</AuthenticationPage>
|
||||
)
|
||||
}
|
||||
|
@ -430,6 +430,7 @@
|
||||
"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",
|
||||
"changeLocalRootDirectoryInSettings": "Change your root folder in Settings.",
|
||||
"localStorage": "Local Storage",
|
||||
"addLocalDirectory": "Add Folder",
|
||||
"browseForNewLocalRootDirectory": "Browse for new Root Folder",
|
||||
"resetLocalRootDirectory": "Reset Root Folder",
|
||||
@ -573,10 +574,14 @@
|
||||
|
||||
"downgradeInfo": "Looking for downgrade options?",
|
||||
|
||||
"someAgreementsHaveBeenUpdated": "Some agreements have been updated. Please re-read and agree to continue.",
|
||||
"licenseAgreementTitle": "Enso Terms of Service",
|
||||
"licenseAgreementCheckbox": "I agree to the Enso Terms of Service",
|
||||
"licenseAgreementCheckboxError": "Please agree to the Enso 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",
|
||||
"shiftModifier": "Shift",
|
||||
|
Loading…
Reference in New Issue
Block a user