New Terms of Service Dialog (#9975)

#### Tl;dr
Closes: enso-org/cloud-v2#1228
This PR adds a new DIalog that requires user to submit terms and conditions

![CleanShot 2024-05-16 at 16 44 52@2x](https://github.com/enso-org/enso/assets/61194245/02814557-e7b3-4e4a-9148-2f8be52c0858)


<details><summary>Demo Presentation</summary>
<p>
This commit is contained in:
Sergei Garin 2024-05-23 10:53:55 +03:00 committed by GitHub
parent a0a6f8c302
commit 5ed5c71e93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 408 additions and 84 deletions

View File

@ -1,3 +1,5 @@
ENSO_CLOUD_ENSO_HOST=https://enso.org
ENSO_CLOUD_REDIRECT=http://localhost:8080
ENSO_CLOUD_ENVIRONMENT=production
ENSO_CLOUD_API_URL=https://aaaaaaaaaa.execute-api.mars.amazonaws.com
ENSO_CLOUD_CHAT_URL=wss://chat.example.com

View File

@ -120,6 +120,9 @@ export function getDefines() {
'process.env.ENSO_CLOUD_DASHBOARD_COMMIT_HASH': stringify(
process.env.ENSO_CLOUD_DASHBOARD_COMMIT_HASH
),
'process.env.ENSO_CLOUD_ENSO_HOST': stringify(
process.env.ENSO_CLOUD_ENSO_HOST ?? 'https://enso.org'
),
/* eslint-enable @typescript-eslint/naming-convention */
}
}

View File

@ -752,6 +752,7 @@ export async function login(
await locatePasswordInput(page).fill(password)
await locateLoginButton(page).click()
await locateToastCloseButton(page).click()
await passTermsAndConditionsDialog({ page })
}
// ================
@ -787,6 +788,23 @@ async function mockDate({ page }: MockParams) {
}`)
}
/**
* Passes Terms and conditions dialog
*/
export async function passTermsAndConditionsDialog({ page }: MockParams) {
// wait for terms and conditions dialog to appear
// but don't fail if it doesn't appear
try {
// wait for terms and conditions dialog to appear
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
await page.waitForSelector('#terms-of-service-modal', { timeout: 500 })
await page.getByRole('checkbox').click()
await page.getByRole('button', { name: 'Accept' }).click()
} catch (error) {
// do nothing
}
}
// ========================
// === mockIDEContainer ===
// ========================
@ -836,8 +854,12 @@ export async function mockAll({ page }: MockParams) {
export async function mockAllAndLogin({ page }: MockParams) {
const mocks = await mockAll({ page })
await login({ page })
await passTermsAndConditionsDialog({ page })
// This MUST run after login, otherwise the element's styles are reset when the browser
// is navigated to another page.
await mockIDEContainer({ page })
return mocks
}

View File

@ -19,11 +19,15 @@ test.test('sign up flow', async ({ page }) => {
await actions.locateEmailInput(page).fill(email)
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateLoginButton(page).click()
await actions.passTermsAndConditionsDialog({ page })
await test.expect(actions.locateSetUsernamePanel(page)).toBeVisible()
// Logged in, but account disabled
await actions.locateUsernameInput(page).fill(name)
await actions.locateSetUsernameButton(page).click()
await test.expect(actions.locateUpgradeButton(page)).toBeVisible()
await test.expect(actions.locateDriveView(page)).not.toBeVisible()

View File

@ -25,11 +25,15 @@ test.test('sign up with organization id', async ({ page }) => {
await actions.locateConfirmPasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateRegisterButton(page).click()
await actions.passTermsAndConditionsDialog({ page })
// Log in
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateLoginButton(page).click()
await actions.passTermsAndConditionsDialog({ page })
// Set username
await actions.locateUsernameInput(page).fill('arbitrary username')
await actions.locateSetUsernameButton(page).click()

View File

@ -20,11 +20,15 @@ test.test('sign up without organization id', async ({ page }) => {
await actions.locateConfirmPasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateRegisterButton(page).click()
await actions.passTermsAndConditionsDialog({ page })
// Log in
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateLoginButton(page).click()
await actions.passTermsAndConditionsDialog({ page })
// Set username
await actions.locateUsernameInput(page).fill('arbitrary username')
await actions.locateSetUsernameButton(page).click()

View File

@ -42,7 +42,6 @@ import * as toastify from 'react-toastify'
import * as detect from 'enso-common/src/detect'
import * as appUtils from '#/appUtils'
import * as reactQueryClientModule from '#/reactQueryClient'
import * as inputBindingsModule from '#/configurations/inputBindings'
@ -58,9 +57,7 @@ import SessionProvider from '#/providers/SessionProvider'
import SupportsLocalBackendProvider from '#/providers/SupportsLocalBackendProvider'
import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration'
import ErrorScreen from '#/pages/authentication/ErrorScreen'
import ForgotPassword from '#/pages/authentication/ForgotPassword'
import LoadingScreen from '#/pages/authentication/LoadingScreen'
import Login from '#/pages/authentication/Login'
import Registration from '#/pages/authentication/Registration'
import ResetPassword from '#/pages/authentication/ResetPassword'
@ -76,6 +73,7 @@ import * as rootComponent from '#/components/Root'
import AboutModal from '#/modals/AboutModal'
import * as setOrganizationNameModal from '#/modals/SetOrganizationNameModal'
import * as termsOfServiceModal from '#/modals/TermsOfServiceModal'
import type Backend from '#/services/Backend'
import LocalBackend from '#/services/LocalBackend'
@ -159,44 +157,40 @@ export interface AppProps {
export default function App(props: AppProps) {
const { supportsLocalBackend } = props
const queryClient = React.useMemo(() => reactQueryClientModule.createReactQueryClient(), [])
const [rootDirectoryPath, setRootDirectoryPath] = React.useState<projectManager.Path | null>(null)
const [error, setError] = React.useState<unknown>(null)
const isLoading = supportsLocalBackend && rootDirectoryPath == null
const { data: rootDirectoryPath } = reactQuery.useSuspenseQuery({
queryKey: ['root-directory', supportsLocalBackend],
queryFn: async () => {
if (supportsLocalBackend) {
const response = await fetch(`${appBaseUrl.APP_BASE_URL}/api/root-directory`)
const text = await response.text()
return projectManager.Path(text)
} else {
return null
}
},
})
React.useEffect(() => {
if (supportsLocalBackend) {
void (async () => {
try {
const response = await fetch(`${appBaseUrl.APP_BASE_URL}/api/root-directory`)
const text = await response.text()
setRootDirectoryPath(projectManager.Path(text))
} catch (innerError) {
setError(innerError)
}
})()
}
}, [supportsLocalBackend])
const routerFuture: Partial<router.FutureConfig> = {
/* we want to use startTransition to enable concurrent rendering */
/* eslint-disable-next-line @typescript-eslint/naming-convention */
v7_startTransition: true,
}
// Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`.
// Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider`
// will redirect the user between the login/register pages and the dashboard.
return error != null ? (
<ErrorScreen error={error} />
) : isLoading ? (
<LoadingScreen />
) : (
<reactQuery.QueryClientProvider client={queryClient}>
return (
<>
<toastify.ToastContainer
position="top-center"
theme="light"
closeOnClick={false}
draggable={false}
toastClassName="text-sm leading-cozy bg-selected-frame rounded-default backdrop-blur-default"
toastClassName="text-sm leading-cozy bg-selected-frame rounded-lg backdrop-blur-default"
transition={toastify.Zoom}
limit={3}
/>
<router.BrowserRouter basename={getMainPageUrl().pathname}>
<router.BrowserRouter basename={getMainPageUrl().pathname} future={routerFuture}>
<LocalStorageProvider>
<ModalProvider>
<AppRouter {...props} projectManagerRootDirectory={rootDirectoryPath} />
@ -205,7 +199,7 @@ export default function App(props: AppProps) {
</router.BrowserRouter>
<reactQueryDevtools.ReactQueryDevtools />
</reactQuery.QueryClientProvider>
</>
)
}
@ -393,22 +387,24 @@ function AppRouter(props: AppRouterProps) {
{/* Protected pages are visible to authenticated users. */}
<router.Route element={<authProvider.NotDeletedUserLayout />}>
<router.Route element={<authProvider.ProtectedLayout />}>
<router.Route element={<setOrganizationNameModal.SetOrganizationNameModal />}>
<router.Route
path={appUtils.DASHBOARD_PATH}
element={shouldShowDashboard && <Dashboard {...props} />}
/>
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
<router.Route element={<setOrganizationNameModal.SetOrganizationNameModal />}>
<router.Route
path={appUtils.DASHBOARD_PATH}
element={shouldShowDashboard && <Dashboard {...props} />}
/>
<router.Route
path={appUtils.SUBSCRIBE_PATH}
element={
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<loader.Loader />}>
<subscribe.Subscribe />
</React.Suspense>
</errorBoundary.ErrorBoundary>
}
/>
<router.Route
path={appUtils.SUBSCRIBE_PATH}
element={
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<loader.Loader />}>
<subscribe.Subscribe />
</React.Suspense>
</errorBoundary.ErrorBoundary>
}
/>
</router.Route>
</router.Route>
<router.Route
@ -424,10 +420,12 @@ function AppRouter(props: AppRouterProps) {
</router.Route>
</router.Route>
{/* Semi-protected pages are visible to users currently registering. */}
<router.Route element={<authProvider.NotDeletedUserLayout />}>
<router.Route element={<authProvider.SemiProtectedLayout />}>
<router.Route path={appUtils.SET_USERNAME_PATH} element={<SetUsername />} />
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
{/* Semi-protected pages are visible to users currently registering. */}
<router.Route element={<authProvider.NotDeletedUserLayout />}>
<router.Route element={<authProvider.SemiProtectedLayout />}>
<router.Route path={appUtils.SET_USERNAME_PATH} element={<SetUsername />} />
</router.Route>
</router.Route>
</router.Route>
@ -447,7 +445,9 @@ function AppRouter(props: AppRouterProps) {
<router.Route path="*" element={<router.Navigate to="/" replace />} />
</router.Routes>
)
let result = routes
result = (
<SupportsLocalBackendProvider supportsLocalBackend={supportsLocalBackend}>
{result}

View File

@ -52,6 +52,8 @@ export interface BaseButtonProps extends Omit<twv.VariantProps<typeof BUTTON_STY
* If the handler returns a promise, the button will be in a loading state until the promise resolves.
*/
readonly onPress?: (event: aria.PressEvent) => Promise<void> | void
readonly testId?: string
}
export const BUTTON_STYLES = twv.tv({
@ -152,6 +154,7 @@ export const Button = React.forwardRef(function Button(
fullWidth,
rounded,
tooltip,
testId,
onPress = () => {},
...ariaProps
} = props
@ -163,7 +166,9 @@ export const Button = React.forwardRef(function Button(
const Tag = isLink ? aria.Link : aria.Button
const goodDefaults = isLink ? { rel: 'noopener noreferrer' } : { type: 'button' }
const goodDefaults = isLink
? { rel: 'noopener noreferrer', 'data-testid': testId ?? 'link' }
: { type: 'button', 'data-testid': testId ?? 'button' }
const isIconOnly = (children == null || children === '' || children === false) && icon != null
const shouldShowTooltip = isIconOnly && tooltip !== false
const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null

View File

@ -9,6 +9,8 @@ import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as portal from '#/components/Portal'
import * as mergeRefs from '#/utilities/mergeRefs'
import type * as types from './types'
import * as variants from './variants'
@ -60,6 +62,7 @@ export function Dialog(props: types.DialogProps) {
className,
onOpenChange = () => {},
modalProps = {},
testId = 'dialog',
...ariaDialogProps
} = props
const dialogRef = React.useRef<HTMLDivElement>(null)
@ -79,7 +82,7 @@ export function Dialog(props: types.DialogProps) {
dialogRef.current?.animate(
[{ transform: 'scale(1)' }, { transform: 'scale(1.015)' }, { transform: 'scale(1)' }],
{ duration, iterations: 2, direction: 'alternate' }
{ duration, iterations: 1, direction: 'alternate' }
)
}
},
@ -106,20 +109,38 @@ export function Dialog(props: types.DialogProps) {
onOpenChange={onOpenChange}
{...modalProps}
>
<aria.Dialog ref={dialogRef} className={dialogSlots.base()} {...ariaDialogProps}>
<aria.Dialog
ref={mergeRefs.mergeRefs(dialogRef, element => {
if (element) {
// This is a workaround for the `data-testid` attribute not being
// supported by the 'react-aria-components' library.
// We need to set the `data-testid` attribute on the dialog element
// so that we can use it in our tests.
// This is a temporary solution until we refactor the Dialog component
// to use `useDialog` hook from the 'react-aria-components' library.
// this will allow us to set the `data-testid` attribute on the dialog
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
element.dataset.testId = testId
}
})}
className={dialogSlots.base()}
{...ariaDialogProps}
>
{opts => (
<>
{shouldRenderTitle && (
<aria.Header className={dialogSlots.header()}>
<ariaComponents.CloseButton
className={clsx('mr-auto mt-0.5', { hidden: hideCloseButton })}
className={clsx('col-start-1 col-end-1 mr-auto mt-0.5', {
hidden: hideCloseButton,
})}
onPress={opts.close}
/>
<aria.Heading
slot="title"
level={2}
className="my-0 text-base font-semibold leading-6"
className="col-start-2 col-end-2 my-0 text-base font-semibold leading-6"
>
{title}
</aria.Heading>

View File

@ -15,6 +15,8 @@ export interface DialogProps extends aria.DialogProps {
readonly onOpenChange?: (isOpen: boolean) => void
readonly isKeyboardDismissDisabled?: boolean
readonly modalProps?: Pick<aria.ModalOverlayProps, 'className' | 'defaultOpen' | 'isOpen'>
readonly testId?: string
}
/** The props for the DialogTrigger component. */

View File

@ -43,6 +43,7 @@ export const Form = React.forwardRef(function Form<
onSubmitSuccess = () => {},
onSubmitFailed = () => {},
id = formId,
testId,
schema,
...formProps
} = props
@ -59,7 +60,7 @@ export const Form = React.forwardRef(function Form<
React.useImperativeHandle(formRef, () => innerForm, [innerForm])
const formMutation = reactQuery.useMutation({
mutationKey: ['FormSubmit', id],
mutationKey: ['FormSubmit', testId, id],
mutationFn: async (fieldValues: TFieldValues) => {
try {
await onSubmit(fieldValues, innerForm)
@ -82,11 +83,16 @@ export const Form = React.forwardRef(function Form<
// There is no way to avoid type casting here
// eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax,@typescript-eslint/no-unsafe-argument
const formOnSubmit = innerForm.handleSubmit(formMutation.mutateAsync as any)
const { formState, clearErrors, getValues, setValue, setError, register, unregister } = innerForm
const formStateRenderProps = {
formState: innerForm.formState,
register: innerForm.register,
unregister: innerForm.unregister,
const formStateRenderProps: types.FormStateRenderProps<TFieldValues> = {
formState,
register,
unregister,
setError,
clearErrors,
getValues,
setValue,
}
return (
@ -97,6 +103,7 @@ export const Form = React.forwardRef(function Form<
className={typeof className === 'function' ? className(formStateRenderProps) : className}
style={typeof style === 'function' ? style(formStateRenderProps) : style}
noValidate
data-testid={testId}
{...formProps}
>
<reactHookForm.FormProvider {...innerForm}>

View File

@ -34,6 +34,7 @@ export function Reset(props: ResetProps): React.JSX.Element {
return (
<ariaComponents.Button
{...props}
type="reset"
variant={variant}
size={size}
isDisabled={formState.isSubmitting}

View File

@ -38,7 +38,12 @@ export type SubmitProps = Omit<ariaComponents.ButtonProps, 'loading' | 'variant'
* Manages the form state and displays a loading spinner when the form is submitting.
*/
export function Submit(props: SubmitProps): React.JSX.Element {
const { form = reactHookForm.useFormContext(), variant = 'submit', size = 'medium' } = props
const {
form = reactHookForm.useFormContext(),
variant = 'submit',
size = 'medium',
testId = 'form-submit-button',
} = props
const { formState } = form
return (
@ -48,6 +53,7 @@ export function Submit(props: SubmitProps): React.JSX.Element {
variant={variant}
size={size}
loading={formState.isSubmitting}
testId={testId}
/>
)
}

View File

@ -51,7 +51,7 @@ export function useForm<
return reactHookForm.useForm({
...options,
...(schema ? { resolver: zodResolver.zodResolver(schema) } : {}),
...(schema ? { resolver: zodResolver.zodResolver(schema, { async: true }) } : {}),
})
}
}

View File

@ -3,6 +3,8 @@
* Types for the Form component.
*/
import type * as React from 'react'
import type * as reactHookForm from 'react-hook-form'
import type * as z from 'zod'
@ -51,6 +53,8 @@ interface BaseFormProps<
readonly onSubmitFailed?: (error: unknown) => Promise<void> | void
readonly onSubmitSuccess?: () => Promise<void> | void
readonly onSubmitted?: () => Promise<void> | void
readonly testId?: string
}
/**
@ -63,7 +67,7 @@ interface FormPropsWithParentForm<
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues | undefined = undefined,
> {
readonly form: components.UseFormReturn<TFieldValues, TTransformedValues>
readonly form?: components.UseFormReturn<TFieldValues, TTransformedValues>
readonly schema?: never
readonly formOptions?: never
}
@ -75,7 +79,7 @@ interface FormPropsWithParentForm<
interface FormPropsWithOptions<TFieldValues extends components.FieldValues> {
readonly form?: never
readonly schema?: z.ZodObject<TFieldValues>
readonly formOptions: Omit<components.UseFormProps<TFieldValues>, 'resolver'>
readonly formOptions?: Omit<components.UseFormProps<TFieldValues>, 'resolver'>
}
/**
@ -96,4 +100,8 @@ export interface FormStateRenderProps<TFieldValues extends components.FieldValue
* Removes a field from the form state.
*/
readonly unregister: reactHookForm.UseFormUnregister<TFieldValues>
readonly setValue: reactHookForm.UseFormSetValue<TFieldValues>
readonly getValues: reactHookForm.UseFormGetValues<TFieldValues>
readonly setError: reactHookForm.UseFormSetError<TFieldValues>
readonly clearErrors: reactHookForm.UseFormClearErrors<TFieldValues>
}

View File

@ -5,6 +5,8 @@
*/
import * as React from 'react'
import * as sentry from '@sentry/react'
import * as reactQuery from '@tanstack/react-query'
import * as errorBoundary from 'react-error-boundary'
import * as textProvider from '#/providers/TextProvider'
@ -37,12 +39,22 @@ export function ErrorBoundary(props: ErrorBoundaryProps) {
...rest
} = props
return (
<errorBoundary.ErrorBoundary
FallbackComponent={FallbackComponent}
onError={onError}
onReset={onReset}
{...rest}
/>
<reactQuery.QueryErrorResetBoundary>
{({ reset }) => (
<errorBoundary.ErrorBoundary
FallbackComponent={FallbackComponent}
onError={(error, info) => {
sentry.captureException(error, { extra: { info } })
onError(error, info)
}}
onReset={details => {
reset()
onReset(details)
}}
{...rest}
/>
)}
</reactQuery.QueryErrorResetBoundary>
)
}

View File

@ -57,6 +57,12 @@ export function useCopy(props: UseCopyProps) {
successToastMessage === true ? getText('copiedToClipboard') : successToastMessage,
{ toastId, closeOnClick: true, hideProgressBar: true, position: 'bottom-right' }
)
// If user closes the toast, reset the button state
toastify.toast.onChange(toast => {
if (toast.id === toastId && toast.status === 'removed') {
copyQuery.reset()
}
})
}
// Reset the button to its original state after a timeout.

View File

@ -4,6 +4,7 @@
import * as React from 'react'
import * as sentry from '@sentry/react'
import * as reactQuery from '@tanstack/react-query'
import * as reactDOM from 'react-dom/client'
import * as reactRouter from 'react-router-dom'
@ -11,8 +12,11 @@ import * as detect from 'enso-common/src/detect'
import type * as app from '#/App'
import App from '#/App'
import * as reactQueryClientModule from '#/reactQueryClient'
import * as loader from '#/components/Loader'
import LoadingScreen from '#/pages/authentication/LoadingScreen'
import * as errorBoundary from '#/components/ErrorBoundary'
// =================
// === Constants ===
@ -79,17 +83,21 @@ function run(props: app.AppProps) {
// `supportsDeepLinks` will be incorrect when accessing the installed Electron app's pages
// via the browser.
const actuallySupportsDeepLinks = supportsDeepLinks && detect.isOnElectron()
const queryClient = reactQueryClientModule.createReactQueryClient()
reactDOM.createRoot(root).render(
<React.StrictMode>
<sentry.ErrorBoundary>
<React.Suspense fallback={<loader.Loader size={64} />}>
{detect.IS_DEV_MODE ? (
<App {...props} />
) : (
<App {...props} supportsDeepLinks={actuallySupportsDeepLinks} />
)}
</React.Suspense>
</sentry.ErrorBoundary>
<reactQuery.QueryClientProvider client={queryClient}>
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<LoadingScreen />}>
{detect.IS_DEV_MODE ? (
<App {...props} />
) : (
<App {...props} supportsDeepLinks={actuallySupportsDeepLinks} />
)}
</React.Suspense>
</errorBoundary.ErrorBoundary>
</reactQuery.QueryClientProvider>
</React.StrictMode>
)
}

View File

@ -0,0 +1,158 @@
/**
* @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 twMerge from 'tailwind-merge'
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'
declare module '#/utilities/LocalStorage' {
/**
* Contains the latest terms of service version hash that the user has accepted.
*/
interface LocalStorageData {
readonly termsOfService: z.infer<typeof TERMS_OF_SERVICE_SCHEMA> | null
}
}
const TERMS_OF_SERVICE_SCHEMA = z.object({ versionHash: z.string() })
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,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
refetchInterval: 1000 * 60 * 10, // 10 minutes
})
/**
* 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 eula = reactQuery.useSuspenseQuery(latestTermsOfService)
const latestVersionHash = eula.data.hash
const localVersionHash = localStorage.get('termsOfService')?.versionHash
const isLatest = latestVersionHash === localVersionHash
const isAccepted = localVersionHash != null
const shouldDisplay = !(isAccepted && isLatest)
if (shouldDisplay) {
return (
<>
<ariaComponents.Dialog
title={getText('licenseAgreementTitle')}
isKeyboardDismissDisabled
isDismissable={false}
hideCloseButton
modalProps={{ isOpen: true }}
testId="terms-of-service-modal"
id="terms-of-service-modal"
>
<ariaComponents.Form
testId="terms-of-service-form"
schema={ariaComponents.Form.schema.object({
agree: ariaComponents.Form.schema
.boolean()
// we accept only true
.refine(value => value, getText('licenseAgreementCheckboxError')),
})}
onSubmit={() => {
localStorage.set('termsOfService', { versionHash: latestVersionHash })
}}
>
{({ register, formState }) => {
const agreeError = formState.errors.agree
const hasError = formState.errors.agree != null
const checkboxRegister = register('agree')
return (
<>
<div className="pb-6 pt-2">
<div className="mb-1">
<div className="flex items-center gap-1.5 text-sm">
<div className="mt-0">
<aria.Input
type="checkbox"
className={twMerge.twMerge(
`flex size-4 cursor-pointer overflow-clip rounded-lg border border-primary outline-primary focus-visible:outline focus-visible:outline-2 ${hasError ? 'border-red-700 text-red-500 outline-red-500' : ''}`
)}
id={checkboxId}
aria-invalid={hasError}
{...checkboxRegister}
onInput={event => {
void checkboxRegister.onChange(event)
}}
data-testid="terms-of-service-checkbox"
/>
</div>
<aria.Label htmlFor={checkboxId} className="text-sm">
{getText('licenseAgreementCheckbox')}
</aria.Label>
</div>
{agreeError && (
<p className="m-0 text-xs text-red-700" role="alert">
{agreeError.message}
</p>
)}
</div>
<ariaComponents.Button
variant="link"
target="_blank"
href="https://enso.org/eula"
>
{getText('viewLicenseAgreement')}
</ariaComponents.Button>
</div>
<ariaComponents.Form.FormError />
<ariaComponents.Form.Submit>{getText('accept')}</ariaComponents.Form.Submit>
</>
)
}}
</ariaComponents.Form>
</ariaComponents.Dialog>
</>
)
} else {
return <router.Outlet context={session} />
}
}

View File

@ -2,6 +2,8 @@
* via the shared React context. */
import * as React from 'react'
import * as refreshHooks from '#/hooks/refreshHooks'
import LocalStorage from '#/utilities/LocalStorage'
// ===========================
@ -27,7 +29,15 @@ export interface LocalStorageProviderProps extends Readonly<React.PropsWithChild
/** A React Provider that lets components get the shortcut registry. */
export default function LocalStorageProvider(props: LocalStorageProviderProps) {
const { children } = props
const localStorage = React.useMemo(() => new LocalStorage(), [])
const [, doRefresh] = refreshHooks.useRefresh()
const localStorage = React.useMemo(
() =>
new LocalStorage(() => {
doRefresh()
}),
[doRefresh]
)
return (
<LocalStorageContext.Provider value={{ localStorage }}>{children}</LocalStorageContext.Provider>

View File

@ -183,6 +183,8 @@
"reset": "Reset",
"members": "Members",
"drop": "Drop",
"accept": "Accept",
"reject": "Reject",
"clearTrash": "Clear Trash",
"sharedWith": "Shared with",
"editSecret": "Edit Secret",
@ -457,6 +459,11 @@
"subscribeSuccessTitle": "Success",
"subscribeSuccessSubtitle": "We received your payment and now you on $0 plan",
"licenseAgreementTitle": "Enso Terms of Service",
"licenseAgreementCheckbox": "I agree to the Enso Terms of Service",
"licenseAgreementCheckboxError": "You must agree to the Enso Terms of Service",
"viewLicenseAgreement": "View Terms of Service",
"metaModifier": "Meta",
"shiftModifier": "Shift",
"altModifier": "Alt",

View File

@ -1,4 +1,6 @@
/** @file A LocalStorage data manager. */
import type * as z from 'zod'
import * as common from 'enso-common'
import * as object from '#/utilities/object'
@ -8,10 +10,36 @@ import * as object from '#/utilities/object'
// ====================
/** Metadata describing runtime behavior associated with a {@link LocalStorageKey}. */
export interface LocalStorageKeyMetadata<K extends LocalStorageKey> {
export type LocalStorageKeyMetadata<K extends LocalStorageKey> =
| LocalStorageKeyMetadataWithParseFunction<K>
| LocalStorageKeyMetadataWithSchema<K>
/**
* A {@link LocalStorageKeyMetadata} with a `tryParse` function.
*/
interface LocalStorageKeyMetadataWithParseFunction<K extends LocalStorageKey> {
readonly isUserSpecific?: boolean
/** A type-safe way to deserialize a value from `localStorage`. */
/**
* A function to parse a value from the stored data.
* If this is provided, the value will be parsed using this function.
* If this is not provided, the value will be parsed using the `schema`.
*/
readonly tryParse: (value: unknown) => LocalStorageData[K] | null
readonly schema?: never
}
/**
* A {@link LocalStorageKeyMetadata} with a `schema`.
*/
interface LocalStorageKeyMetadataWithSchema<K extends LocalStorageKey> {
readonly isUserSpecific?: boolean
/**
* The Zod schema to validate the value.
* If this is provided, the value will be parsed using this schema.
* If this is not provided, the value will be parsed using the `tryParse` function.
*/
readonly schema: z.ZodType<LocalStorageData[K]>
readonly tryParse?: never
}
/** The data that can be stored in a {@link LocalStorage}.
@ -31,15 +59,18 @@ export default class LocalStorage {
protected values: Partial<LocalStorageData>
/** Create a {@link LocalStorage}. */
constructor() {
constructor(private readonly triggerRerender: () => void) {
const savedValues: unknown = JSON.parse(localStorage.getItem(this.localStorageKey) ?? '{}')
this.values = {}
if (typeof savedValues === 'object' && savedValues != null) {
for (const [key, metadata] of object.unsafeEntries(LocalStorage.keyMetadata)) {
if (key in savedValues) {
// This is SAFE, as it is guarded by the `key in savedValues` check.
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, no-restricted-syntax, @typescript-eslint/no-explicit-any
const value = metadata.tryParse((savedValues as any)[key])
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, no-restricted-syntax, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
const savedValue = (savedValues as any)[key]
const value = metadata.schema
? metadata.schema.safeParse(savedValue).data
: metadata.tryParse(savedValue)
if (value != null) {
// This is SAFE, as the `tryParse` function is required by definition to
// return a value of the correct type.
@ -89,5 +120,6 @@ export default class LocalStorage {
/** Save the current value of the stored data.. */
protected save() {
localStorage.setItem(this.localStorageKey, JSON.stringify(this.values))
this.triggerRerender()
}
}

View File

@ -199,6 +199,8 @@ declare global {
readonly ENSO_CLOUD_DASHBOARD_COMMIT_HASH?: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_SUPPORTS_VIBRANCY?: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_CLOUD_ENSO_HOST?: string
// === Electron watch script variables ===