mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 11:52:59 +03:00
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:
parent
a0a6f8c302
commit
5ed5c71e93
@ -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
|
||||
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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. */
|
||||
|
@ -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}>
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -51,7 +51,7 @@ export function useForm<
|
||||
|
||||
return reactHookForm.useForm({
|
||||
...options,
|
||||
...(schema ? { resolver: zodResolver.zodResolver(schema) } : {}),
|
||||
...(schema ? { resolver: zodResolver.zodResolver(schema, { async: true }) } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
158
app/ide-desktop/lib/dashboard/src/modals/TermsOfServiceModal.tsx
Normal file
158
app/ide-desktop/lib/dashboard/src/modals/TermsOfServiceModal.tsx
Normal 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} />
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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",
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
2
app/ide-desktop/lib/types/globals.d.ts
vendored
2
app/ide-desktop/lib/types/globals.d.ts
vendored
@ -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 ===
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user