diff --git a/app/ide-desktop/.example.env b/app/ide-desktop/.example.env index ba37cdfe52..e48f33487a 100644 --- a/app/ide-desktop/.example.env +++ b/app/ide-desktop/.example.env @@ -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 diff --git a/app/ide-desktop/lib/common/src/appConfig.js b/app/ide-desktop/lib/common/src/appConfig.js index 617ae81690..f980ca1d9c 100644 --- a/app/ide-desktop/lib/common/src/appConfig.js +++ b/app/ide-desktop/lib/common/src/appConfig.js @@ -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 */ } } diff --git a/app/ide-desktop/lib/dashboard/e2e/actions.ts b/app/ide-desktop/lib/dashboard/e2e/actions.ts index 1b6ec39642..a723c956b0 100644 --- a/app/ide-desktop/lib/dashboard/e2e/actions.ts +++ b/app/ide-desktop/lib/dashboard/e2e/actions.ts @@ -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 } diff --git a/app/ide-desktop/lib/dashboard/e2e/signUpFlow.spec.ts b/app/ide-desktop/lib/dashboard/e2e/signUpFlow.spec.ts index 508f9a035d..8278819cbf 100644 --- a/app/ide-desktop/lib/dashboard/e2e/signUpFlow.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/signUpFlow.spec.ts @@ -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() diff --git a/app/ide-desktop/lib/dashboard/e2e/signUpWithOrganizationId.spec.ts b/app/ide-desktop/lib/dashboard/e2e/signUpWithOrganizationId.spec.ts index c70f50c084..2a8d4e2c65 100644 --- a/app/ide-desktop/lib/dashboard/e2e/signUpWithOrganizationId.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/signUpWithOrganizationId.spec.ts @@ -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() diff --git a/app/ide-desktop/lib/dashboard/e2e/signUpWithoutOrganizationId.spec.ts b/app/ide-desktop/lib/dashboard/e2e/signUpWithoutOrganizationId.spec.ts index acc5bb696d..288ea5a642 100644 --- a/app/ide-desktop/lib/dashboard/e2e/signUpWithoutOrganizationId.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/signUpWithoutOrganizationId.spec.ts @@ -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() diff --git a/app/ide-desktop/lib/dashboard/src/App.tsx b/app/ide-desktop/lib/dashboard/src/App.tsx index 6789044c79..b3722d40dd 100644 --- a/app/ide-desktop/lib/dashboard/src/App.tsx +++ b/app/ide-desktop/lib/dashboard/src/App.tsx @@ -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(null) - const [error, setError] = React.useState(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 = { + /* 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 ? ( - - ) : isLoading ? ( - - ) : ( - + return ( + <> - + @@ -205,7 +199,7 @@ export default function App(props: AppProps) { - + ) } @@ -393,22 +387,24 @@ function AppRouter(props: AppRouterProps) { {/* Protected pages are visible to authenticated users. */} }> }> - }> - } - /> + }> + }> + } + /> - - }> - - - - } - /> + + }> + + + + } + /> + - {/* Semi-protected pages are visible to users currently registering. */} - }> - }> - } /> + }> + {/* Semi-protected pages are visible to users currently registering. */} + }> + }> + } /> + @@ -447,7 +445,9 @@ function AppRouter(props: AppRouterProps) { } /> ) + let result = routes + result = ( {result} diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx index 0281c6fc29..17b6619401 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Button/Button.tsx @@ -52,6 +52,8 @@ export interface BaseButtonProps extends Omit Promise | 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 diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx index e92440c239..dce023b8b5 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx @@ -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(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} > - + { + 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 && ( {title} diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/types.ts b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/types.ts index f80d4da047..88232b727d 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/types.ts +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Dialog/types.ts @@ -15,6 +15,8 @@ export interface DialogProps extends aria.DialogProps { readonly onOpenChange?: (isOpen: boolean) => void readonly isKeyboardDismissDisabled?: boolean readonly modalProps?: Pick + + readonly testId?: string } /** The props for the DialogTrigger component. */ diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/Form.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/Form.tsx index 83f53eeff0..a256639115 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/Form.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/Form.tsx @@ -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 = { + 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} > diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/Reset.tsx b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/Reset.tsx index 4477d4ca3e..7c62017153 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/Reset.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/Reset.tsx @@ -34,6 +34,7 @@ export function Reset(props: ResetProps): React.JSX.Element { return ( ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/useForm.ts b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/useForm.ts index c4df19007f..091627f634 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/useForm.ts +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/components/useForm.ts @@ -51,7 +51,7 @@ export function useForm< return reactHookForm.useForm({ ...options, - ...(schema ? { resolver: zodResolver.zodResolver(schema) } : {}), + ...(schema ? { resolver: zodResolver.zodResolver(schema, { async: true }) } : {}), }) } } diff --git a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/types.ts b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/types.ts index 3030ea3269..387a7d75f4 100644 --- a/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/types.ts +++ b/app/ide-desktop/lib/dashboard/src/components/AriaComponents/Form/types.ts @@ -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 readonly onSubmitSuccess?: () => Promise | void readonly onSubmitted?: () => Promise | 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 + readonly form?: components.UseFormReturn readonly schema?: never readonly formOptions?: never } @@ -75,7 +79,7 @@ interface FormPropsWithParentForm< interface FormPropsWithOptions { readonly form?: never readonly schema?: z.ZodObject - readonly formOptions: Omit, 'resolver'> + readonly formOptions?: Omit, 'resolver'> } /** @@ -96,4 +100,8 @@ export interface FormStateRenderProps + readonly setValue: reactHookForm.UseFormSetValue + readonly getValues: reactHookForm.UseFormGetValues + readonly setError: reactHookForm.UseFormSetError + readonly clearErrors: reactHookForm.UseFormClearErrors } diff --git a/app/ide-desktop/lib/dashboard/src/components/ErrorBoundary.tsx b/app/ide-desktop/lib/dashboard/src/components/ErrorBoundary.tsx index 33ce6cd96d..6e44e128d7 100644 --- a/app/ide-desktop/lib/dashboard/src/components/ErrorBoundary.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/ErrorBoundary.tsx @@ -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 ( - + + {({ reset }) => ( + { + sentry.captureException(error, { extra: { info } }) + onError(error, info) + }} + onReset={details => { + reset() + onReset(details) + }} + {...rest} + /> + )} + ) } diff --git a/app/ide-desktop/lib/dashboard/src/hooks/copyHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/copyHooks.ts index 25d95bc820..e3c7b89e35 100644 --- a/app/ide-desktop/lib/dashboard/src/hooks/copyHooks.ts +++ b/app/ide-desktop/lib/dashboard/src/hooks/copyHooks.ts @@ -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. diff --git a/app/ide-desktop/lib/dashboard/src/index.tsx b/app/ide-desktop/lib/dashboard/src/index.tsx index f988875ff5..70fafb8056 100644 --- a/app/ide-desktop/lib/dashboard/src/index.tsx +++ b/app/ide-desktop/lib/dashboard/src/index.tsx @@ -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( - - }> - {detect.IS_DEV_MODE ? ( - - ) : ( - - )} - - + + + }> + {detect.IS_DEV_MODE ? ( + + ) : ( + + )} + + + ) } diff --git a/app/ide-desktop/lib/dashboard/src/modals/TermsOfServiceModal.tsx b/app/ide-desktop/lib/dashboard/src/modals/TermsOfServiceModal.tsx new file mode 100644 index 0000000000..121275a6fb --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/modals/TermsOfServiceModal.tsx @@ -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 | 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 ( + <> + + 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 ( + <> +
+
+
+
+ { + void checkboxRegister.onChange(event) + }} + data-testid="terms-of-service-checkbox" + /> +
+ + + {getText('licenseAgreementCheckbox')} + +
+ + {agreeError && ( +

+ {agreeError.message} +

+ )} +
+ + + {getText('viewLicenseAgreement')} + +
+ + + + {getText('accept')} + + ) + }} +
+
+ + ) + } else { + return + } +} diff --git a/app/ide-desktop/lib/dashboard/src/providers/LocalStorageProvider.tsx b/app/ide-desktop/lib/dashboard/src/providers/LocalStorageProvider.tsx index 872822fbfc..2e8187bc7f 100644 --- a/app/ide-desktop/lib/dashboard/src/providers/LocalStorageProvider.tsx +++ b/app/ide-desktop/lib/dashboard/src/providers/LocalStorageProvider.tsx @@ -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 new LocalStorage(), []) + const [, doRefresh] = refreshHooks.useRefresh() + + const localStorage = React.useMemo( + () => + new LocalStorage(() => { + doRefresh() + }), + [doRefresh] + ) return ( {children} diff --git a/app/ide-desktop/lib/dashboard/src/text/english.json b/app/ide-desktop/lib/dashboard/src/text/english.json index 737d1749c6..6e425b3859 100644 --- a/app/ide-desktop/lib/dashboard/src/text/english.json +++ b/app/ide-desktop/lib/dashboard/src/text/english.json @@ -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", diff --git a/app/ide-desktop/lib/dashboard/src/utilities/LocalStorage.ts b/app/ide-desktop/lib/dashboard/src/utilities/LocalStorage.ts index 6d31efa702..b60ff1d60b 100644 --- a/app/ide-desktop/lib/dashboard/src/utilities/LocalStorage.ts +++ b/app/ide-desktop/lib/dashboard/src/utilities/LocalStorage.ts @@ -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 { +export type LocalStorageKeyMetadata = + | LocalStorageKeyMetadataWithParseFunction + | LocalStorageKeyMetadataWithSchema + +/** + * A {@link LocalStorageKeyMetadata} with a `tryParse` function. + */ +interface LocalStorageKeyMetadataWithParseFunction { 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 { + 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 + readonly tryParse?: never } /** The data that can be stored in a {@link LocalStorage}. @@ -31,15 +59,18 @@ export default class LocalStorage { protected values: Partial /** 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() } } diff --git a/app/ide-desktop/lib/types/globals.d.ts b/app/ide-desktop/lib/types/globals.d.ts index 5ddd42d2c2..279de11580 100644 --- a/app/ide-desktop/lib/types/globals.d.ts +++ b/app/ide-desktop/lib/types/globals.d.ts @@ -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 ===