From 751551e18cdb894ade4bf82ee6ffbbdc51ffeb1b Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Fri, 13 Sep 2024 16:00:31 +0300 Subject: [PATCH] MFA (#10875) * Draft checkbox component * Fixes in Setup Page * Invite Users * Add usergroup setup after subscription * Fix comments * Refetch Interval + Feature Toggles * Fix lint * Address issues * Fix Dialog * Assign users to the user group * Use transitions to navigate between steps * Small fixes * Improve styling for scrollbars * Fix typescript * OTP input * Fix setup logic * Show Setup dialog only for admins * Add otp * OTP input * 2FA settings section * Small improvements * Fixes * Small fixes * Remove w-full * TOTP at login * Fixes in 2FA * Fixes in types * Fix totp * Merge fixes * Merge fixes x2 * Merge fixes x2 * Fix types * Fix types * Fix cancel button * Fix reset button * Fix types * Fix prettier * Fix prettier * Fix lint * Fix lint * Fix control * Address prettier * Fix MFA mock * Fix sign in message * Fix tests * Address CR * Fix types (cherry picked from commit b5122348da9c22c41b3e4af22a3f323b894dbb78) --- app/dashboard/package.json | 5 +- app/dashboard/src/App.tsx | 11 +- app/dashboard/src/appUtils.tsx | 1 + app/dashboard/src/assets/shield_break.svg | 4 + app/dashboard/src/assets/shield_check.svg | 3 + app/dashboard/src/assets/shield_crossed.svg | 3 + app/dashboard/src/assets/un_fa.svg | 3 + .../src/authentication/cognito.mock.ts | 7 + app/dashboard/src/authentication/cognito.ts | 146 +++++++++- app/dashboard/src/authentication/service.ts | 46 ++-- .../components/AriaComponents/Alert/Alert.tsx | 55 +++- .../AriaComponents/Checkbox/CheckboxGroup.tsx | 1 + .../AriaComponents/Dialog/Dialog.tsx | 10 +- .../components/AriaComponents/Form/Form.tsx | 171 +++++------- .../AriaComponents/Form/components/Field.tsx | 27 +- .../Form/components/FormError.tsx | 11 +- .../Form/components/FormProvider.tsx | 72 +++++ .../AriaComponents/Form/components/Reset.tsx | 12 +- .../AriaComponents/Form/components/Submit.tsx | 17 +- .../AriaComponents/Form/components/index.ts | 6 +- .../AriaComponents/Form/components/types.ts | 116 +++++++- .../Form/components/useField.ts | 18 +- .../Form/components/useFieldRegister.ts | 99 +++++++ .../Form/components/useFieldState.ts | 47 ++++ .../AriaComponents/Form/components/useForm.ts | 190 ++++++++++--- .../Form/components/useFormContext.tsx | 24 -- .../Form/components/useFormSchema.tsx | 4 +- .../components/AriaComponents/Form/types.ts | 87 ++---- .../Inputs/DatePicker/DatePicker.tsx | 6 +- .../AriaComponents/Inputs/Input/Input.tsx | 79 ++---- .../Inputs/MultiSelector/MultiSelector.tsx | 3 +- .../MultiSelector/MultiSelectorOption.tsx | 2 +- .../Inputs/OTPInput/OTPInput.tsx | 218 +++++++++++++++ .../AriaComponents/Inputs/OTPInput/index.ts | 6 + .../Inputs/Password/Password.tsx | 3 +- .../ResizableContentEditableInput.tsx | 2 + .../Inputs/Selector/Selector.tsx | 81 +++--- .../Inputs/Selector/SelectorOption.tsx | 15 +- .../components/AriaComponents/Inputs/index.ts | 1 + .../AriaComponents/Inputs/variants.ts | 2 +- .../AriaComponents/Switch/Switch.tsx | 2 + .../components/AriaComponents/Text/Text.tsx | 13 + .../src/components/Devtools/EnsoDevtools.tsx | 2 +- app/dashboard/src/components/Stepper/Step.tsx | 199 ++++++++++++++ .../src/components/Stepper/StepContent.tsx | 42 +++ .../src/components/Stepper/Stepper.tsx | 234 +--------------- app/dashboard/src/components/Stepper/types.ts | 39 +++ .../src/components/Stepper/useStepperState.ts | 33 ++- .../src/components/dashboard/AssetRow.tsx | 26 +- .../components/dashboard/DatalinkInput.tsx | 15 +- app/dashboard/src/globals.d.ts | 8 +- app/dashboard/src/layouts/Settings.tsx | 32 +-- .../src/layouts/Settings/SettingsTab.tsx | 6 +- .../src/layouts/Settings/SetupTwoFaForm.tsx | 252 ++++++++++++++++++ .../src/layouts/Settings/settingsData.tsx | 18 ++ .../src/modals/UpsertSecretModal.tsx | 92 +++---- .../payments/api/createPaymentMethod.ts | 38 +++ .../src/modules/payments/api/index.ts | 1 + .../components/AddPaymentMethodForm.tsx | 56 ++-- .../components/PlanSelectorDialog.tsx | 16 +- .../authentication/AuthenticationPage.tsx | 6 +- .../src/pages/authentication/Login.tsx | 235 ++++++++++------ .../src/pages/authentication/Registration.tsx | 26 +- app/dashboard/src/providers/AuthProvider.tsx | 242 ++++++++--------- .../src/utilities/tailwindVariants.ts | 5 +- app/dashboard/tailwind.config.js | 11 + app/ide-desktop/common/src/text/english.json | 35 +++ pnpm-lock.yaml | 29 ++ 68 files changed, 2277 insertions(+), 1050 deletions(-) create mode 100644 app/dashboard/src/assets/shield_break.svg create mode 100644 app/dashboard/src/assets/shield_check.svg create mode 100644 app/dashboard/src/assets/shield_crossed.svg create mode 100644 app/dashboard/src/assets/un_fa.svg create mode 100644 app/dashboard/src/components/AriaComponents/Form/components/FormProvider.tsx create mode 100644 app/dashboard/src/components/AriaComponents/Form/components/useFieldRegister.ts create mode 100644 app/dashboard/src/components/AriaComponents/Form/components/useFieldState.ts delete mode 100644 app/dashboard/src/components/AriaComponents/Form/components/useFormContext.tsx create mode 100644 app/dashboard/src/components/AriaComponents/Inputs/OTPInput/OTPInput.tsx create mode 100644 app/dashboard/src/components/AriaComponents/Inputs/OTPInput/index.ts create mode 100644 app/dashboard/src/components/Stepper/Step.tsx create mode 100644 app/dashboard/src/components/Stepper/StepContent.tsx create mode 100644 app/dashboard/src/components/Stepper/types.ts create mode 100644 app/dashboard/src/layouts/Settings/SetupTwoFaForm.tsx create mode 100644 app/dashboard/src/modules/payments/api/createPaymentMethod.ts diff --git a/app/dashboard/package.json b/app/dashboard/package.json index 03d89cebc9..3e0073e871 100644 --- a/app/dashboard/package.json +++ b/app/dashboard/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@aws-amplify/auth": "5.6.5", + "amazon-cognito-identity-js": "6.3.6", "@aws-amplify/core": "5.8.5", "@hookform/resolvers": "^3.4.0", "@internationalized/date": "^3.5.5", @@ -60,7 +61,9 @@ "ts-results": "^3.3.0", "validator": "^13.12.0", "zod": "^3.23.8", - "zustand": "^4.5.4" + "zustand": "^4.5.4", + "input-otp": "1.2.4", + "qrcode.react": "3.1.0" }, "devDependencies": { "@fast-check/vitest": "^0.0.8", diff --git a/app/dashboard/src/App.tsx b/app/dashboard/src/App.tsx index 1c72ad17f9..bf09c66b14 100644 --- a/app/dashboard/src/App.tsx +++ b/app/dashboard/src/App.tsx @@ -346,6 +346,7 @@ function AppRouter(props: AppRouterProps) { }, } }, [localStorage, inputBindingsRaw]) + const mainPageUrl = getMainPageUrl() // Subscribe to `localStorage` updates to trigger a rerender when the terms of service @@ -354,10 +355,10 @@ function AppRouter(props: AppRouterProps) { localStorageProvider.useLocalStorageState('privacyPolicy') const authService = useInitAuthService(props) - const userSession = authService?.cognito.userSession.bind(authService.cognito) ?? null - const refreshUserSession = - authService?.cognito.refreshUserSession.bind(authService.cognito) ?? null - const registerAuthEventListener = authService?.registerAuthEventListener ?? null + + const userSession = authService.cognito.userSession.bind(authService.cognito) + const refreshUserSession = authService.cognito.refreshUserSession.bind(authService.cognito) + const registerAuthEventListener = authService.registerAuthEventListener React.useEffect(() => { if ('menuApi' in window) { @@ -490,7 +491,7 @@ function AppRouter(props: AppRouterProps) { + + + diff --git a/app/dashboard/src/assets/shield_check.svg b/app/dashboard/src/assets/shield_check.svg new file mode 100644 index 0000000000..dcc2e7d24a --- /dev/null +++ b/app/dashboard/src/assets/shield_check.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/dashboard/src/assets/shield_crossed.svg b/app/dashboard/src/assets/shield_crossed.svg new file mode 100644 index 0000000000..03270c29cb --- /dev/null +++ b/app/dashboard/src/assets/shield_crossed.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/dashboard/src/assets/un_fa.svg b/app/dashboard/src/assets/un_fa.svg new file mode 100644 index 0000000000..73b74efe84 --- /dev/null +++ b/app/dashboard/src/assets/un_fa.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/dashboard/src/authentication/cognito.mock.ts b/app/dashboard/src/authentication/cognito.mock.ts index 9f379103c9..f293b78d9b 100644 --- a/app/dashboard/src/authentication/cognito.mock.ts +++ b/app/dashboard/src/authentication/cognito.mock.ts @@ -283,6 +283,13 @@ export class Cognito { async refreshUserSession() { return Promise.resolve(results.Ok(null)) } + + /** + * Returns MFA preference for the current user. + */ + async getMFAPreference() { + return Promise.resolve(results.Ok('NOMFA')) + } } // =================== diff --git a/app/dashboard/src/authentication/cognito.ts b/app/dashboard/src/authentication/cognito.ts index 989e61dedd..595c6355e1 100644 --- a/app/dashboard/src/authentication/cognito.ts +++ b/app/dashboard/src/authentication/cognito.ts @@ -30,7 +30,7 @@ * `kind` field provides a unique string that can be used to brand the error in place of the * `internalCode`, when rethrowing the error. */ import * as amplify from '@aws-amplify/auth' -import type * as cognito from 'amazon-cognito-identity-js' +import * as cognito from 'amazon-cognito-identity-js' import * as results from 'ts-results' import * as detect from 'enso-common/src/detect' @@ -70,6 +70,18 @@ interface UserAttributes { } /* eslint-enable @typescript-eslint/naming-convention */ +/** + * The type of multi-factor authentication (MFA) that the user has set up. + */ +export type MfaType = 'NOMFA' | 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA' | 'TOTP' + +/** + * The type of challenge that the user is currently facing after signing in. + * + * The `NO_CHALLENGE` value is used when the user is not currently facing any challenge. + */ +export type UserSessionChallenge = cognito.ChallengeName | 'NO_CHALLENGE' + /** User information returned from {@link amplify.Auth.currentUserInfo}. */ interface UserInfo { readonly username: string @@ -214,6 +226,16 @@ export class Cognito { return userInfo.attributes['custom:organizationId'] ?? null } + /** + * Gets user email from cognito + */ + async email() { + // This `any` comes from a third-party API and cannot be avoided. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const userInfo: UserInfo = await amplify.Auth.currentUserInfo() + return userInfo.attributes.email + } + /** Sign up with username and password. * * Does not rely on federated identity providers (e.g., Google or GitHub). */ @@ -268,7 +290,20 @@ export class Cognito { * Does not rely on external identity providers (e.g., Google or GitHub). */ async signInWithPassword(username: string, password: string) { const result = await results.Result.wrapAsync(async () => { - await amplify.Auth.signIn(username, password) + // This `any` comes from a third-party API and cannot be avoided. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const maybeUser = await amplify.Auth.signIn(username, password) + + if (maybeUser instanceof cognito.CognitoUser) { + return maybeUser + } else { + // eslint-disable-next-line no-restricted-properties + console.error( + 'Unknown result from signIn, expected CognitoUser, got ' + typeof maybeUser, + JSON.stringify(maybeUser), + ) + throw new Error('Unknown response from the server, please try again later ') + } }) return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignInWithPasswordErrorOrThrow) @@ -363,6 +398,112 @@ export class Cognito { } } + /** + * Start the TOTP setup process. Returns the secret and the URL to scan the QR code. + */ + async setupTOTP() { + const email = await this.email() + const cognitoUserResult = await currentAuthenticatedUser() + if (cognitoUserResult.ok) { + const cognitoUser = cognitoUserResult.unwrap() + + const result = ( + await results.Result.wrapAsync(() => amplify.Auth.setupTOTP(cognitoUser)) + ).map((data) => { + const str = 'otpauth://totp/AWSCognito:' + email + '?secret=' + data + '&issuer=' + 'Enso' + + return { secret: data, url: str } as const + }) + + return result.mapErr(intoAmplifyErrorOrThrow) + } else { + return results.Err(cognitoUserResult.val) + } + } + + /** + * Verify the TOTP token during the setup process. + * Use it *only* during the setup process. + */ + async verifyTotpSetup(totpToken: string) { + const cognitoUserResult = await currentAuthenticatedUser() + if (cognitoUserResult.ok) { + const cognitoUser = cognitoUserResult.unwrap() + const result = await results.Result.wrapAsync(async () => { + await amplify.Auth.verifyTotpToken(cognitoUser, totpToken) + }) + return result.mapErr(intoAmplifyErrorOrThrow) + } else { + return results.Err(cognitoUserResult.val) + } + } + + /** + * Set the user's preferred MFA method. + */ + async updateMFAPreference(mfaMethod: MfaType) { + const cognitoUserResult = await currentAuthenticatedUser() + if (cognitoUserResult.ok) { + const cognitoUser = cognitoUserResult.unwrap() + const result = await results.Result.wrapAsync( + async () => await amplify.Auth.setPreferredMFA(cognitoUser, mfaMethod), + ) + return result.mapErr(intoAmplifyErrorOrThrow) + } else { + return results.Err(cognitoUserResult.val) + } + } + + /** + * Get the user's preferred MFA method. + */ + async getMFAPreference() { + const cognitoUserResult = await currentAuthenticatedUser() + if (cognitoUserResult.ok) { + const cognitoUser = cognitoUserResult.unwrap() + const result = await results.Result.wrapAsync(async () => { + // eslint-disable-next-line no-restricted-syntax + return (await amplify.Auth.getPreferredMFA(cognitoUser)) as MfaType + }) + return result.mapErr(intoAmplifyErrorOrThrow) + } else { + return results.Err(cognitoUserResult.val) + } + } + + /** + * Verify the TOTP token. + * Returns the user session if the token is valid. + */ + async verifyTotpToken(totpToken: string) { + const cognitoUserResult = await currentAuthenticatedUser() + + if (cognitoUserResult.ok) { + const cognitoUser = cognitoUserResult.unwrap() + + return ( + await results.Result.wrapAsync(() => amplify.Auth.verifyTotpToken(cognitoUser, totpToken)) + ).mapErr(intoAmplifyErrorOrThrow) + } else { + return results.Err(cognitoUserResult.val) + } + } + + /** + * Confirm the sign in with the MFA token. + */ + async confirmSignIn( + user: amplify.CognitoUser, + confirmationCode: string, + mfaType: 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA', + ) { + const result = await results.Result.wrapAsync(() => + amplify.Auth.confirmSignIn(user, confirmationCode, mfaType), + ) + + return result.mapErr(intoAmplifyErrorOrThrow) + } + /** We want to signal to Amplify to fire a "custom state change" event when the user is * redirected back to the application after signing in via an external identity provider. This * is done so we get a chance to fix the location history. The location history is the history @@ -707,3 +848,4 @@ async function currentAuthenticatedUser() { ) return result.mapErr(intoAmplifyErrorOrThrow) } +export { CognitoUser } from '@aws-amplify/auth' diff --git a/app/dashboard/src/authentication/service.ts b/app/dashboard/src/authentication/service.ts index a72f2b3d83..e4caeafab3 100644 --- a/app/dashboard/src/authentication/service.ts +++ b/app/dashboard/src/authentication/service.ts @@ -116,20 +116,17 @@ export interface AuthService { * * This hook should only be called in a single place, as it performs global configuration of the * Amplify library. */ -export function useInitAuthService(authConfig: AuthConfig): AuthService | null { +export function useInitAuthService(authConfig: AuthConfig): AuthService { const { supportsDeepLinks } = authConfig + const logger = useLogger() const navigate = useNavigate() + return React.useMemo(() => { const amplifyConfig = loadAmplifyConfig(logger, supportsDeepLinks, navigate) - const cognito = - amplifyConfig == null ? null : ( - new cognitoModule.Cognito(logger, supportsDeepLinks, amplifyConfig) - ) + const cognito = new cognitoModule.Cognito(logger, supportsDeepLinks, amplifyConfig) - return cognito == null ? null : ( - { cognito, registerAuthEventListener: listen.registerAuthEventListener } - ) + return { cognito, registerAuthEventListener: listen.registerAuthEventListener } }, [logger, navigate, supportsDeepLinks]) } @@ -138,7 +135,7 @@ function loadAmplifyConfig( logger: Logger, supportsDeepLinks: boolean, navigate: (url: string) => void, -): AmplifyConfig | null { +): AmplifyConfig { let urlOpener: ((url: string) => void) | null = null let saveAccessToken: ((accessToken: saveAccessTokenModule.AccessToken | null) => void) | null = null @@ -175,25 +172,18 @@ function loadAmplifyConfig( /** Load the platform-specific Amplify configuration. */ const signInOutRedirect = supportsDeepLinks ? `${common.DEEP_LINK_SCHEME}://auth` : redirectUrl - return ( - process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID == null || - process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID == null || - process.env.ENSO_CLOUD_COGNITO_DOMAIN == null || - process.env.ENSO_CLOUD_COGNITO_REGION == null - ) ? - null - : { - userPoolId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID, - userPoolWebClientId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID, - domain: process.env.ENSO_CLOUD_COGNITO_DOMAIN, - region: process.env.ENSO_CLOUD_COGNITO_REGION, - redirectSignIn: signInOutRedirect, - redirectSignOut: signInOutRedirect, - scope: ['email', 'openid', 'aws.cognito.signin.user.admin'], - responseType: 'code', - urlOpener, - saveAccessToken, - } + return { + userPoolId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID, + userPoolWebClientId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID, + domain: process.env.ENSO_CLOUD_COGNITO_DOMAIN, + region: process.env.ENSO_CLOUD_COGNITO_REGION, + redirectSignIn: signInOutRedirect, + redirectSignOut: signInOutRedirect, + scope: ['email', 'openid', 'aws.cognito.signin.user.admin'], + responseType: 'code', + urlOpener, + saveAccessToken, + } } /** Set the callback that will be invoked when a deep link to the application is opened. diff --git a/app/dashboard/src/components/AriaComponents/Alert/Alert.tsx b/app/dashboard/src/components/AriaComponents/Alert/Alert.tsx index 000b705fe4..40c06b1736 100644 --- a/app/dashboard/src/components/AriaComponents/Alert/Alert.tsx +++ b/app/dashboard/src/components/AriaComponents/Alert/Alert.tsx @@ -1,6 +1,8 @@ /** @file Alert component. */ import { type ForwardedRef, type HTMLAttributes, type PropsWithChildren } from 'react' +import SvgMask from '#/components/SvgMask' + import { forwardRef } from '#/utilities/react' import { tv, type VariantProps } from '#/utilities/tailwindVariants' @@ -9,7 +11,7 @@ import { tv, type VariantProps } from '#/utilities/tailwindVariants' // ================= export const ALERT_STYLES = tv({ - base: 'flex flex-col items-stretch', + base: 'flex items-stretch gap-2', variants: { fullWidth: { true: 'w-full' }, variant: { @@ -37,6 +39,11 @@ export const ALERT_STYLES = tv({ large: 'px-4 pt-2 pb-2', }, }, + slots: { + iconContainer: 'flex items-center justify-center w-6 h-6', + children: 'flex flex-col items-stretch', + icon: 'flex items-center justify-center w-6 h-6 mr-2', + }, defaultVariants: { fullWidth: true, variant: 'error', @@ -53,7 +60,12 @@ export const ALERT_STYLES = tv({ export interface AlertProps extends PropsWithChildren, VariantProps, - HTMLAttributes {} + HTMLAttributes { + /** + * The icon to display in the Alert + */ + readonly icon?: React.ReactElement | string | null | undefined +} /** Alert component. */ // eslint-disable-next-line no-restricted-syntax @@ -61,20 +73,45 @@ export const Alert = forwardRef(function Alert( props: AlertProps, ref: ForwardedRef, ) { - const { children, className, variant, size, rounded, fullWidth, ...containerProps } = props + const { + children, + className, + variant, + size, + rounded, + fullWidth, + icon, + variants = ALERT_STYLES, + ...containerProps + } = props if (variant === 'error') { containerProps.tabIndex = -1 containerProps.role = 'alert' } + const classes = variants({ + variant, + size, + rounded, + fullWidth, + }) + return ( -
- {children} +
+ {icon != null && + (() => { + if (typeof icon === 'string') { + // eslint-disable-next-line no-restricted-syntax + return ( +
+ +
+ ) + } + return
{icon}
+ })()} +
{children}
) }) diff --git a/app/dashboard/src/components/AriaComponents/Checkbox/CheckboxGroup.tsx b/app/dashboard/src/components/AriaComponents/Checkbox/CheckboxGroup.tsx index c8859460ed..710cfba957 100644 --- a/app/dashboard/src/components/AriaComponents/Checkbox/CheckboxGroup.tsx +++ b/app/dashboard/src/components/AriaComponents/Checkbox/CheckboxGroup.tsx @@ -72,6 +72,7 @@ export const CheckboxGroup = forwardRef( return ( { const defaultValue = defaultValueOverride ?? formInstance.control._defaultValues[name] diff --git a/app/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx b/app/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx index b03965a3ef..ad8342db1e 100644 --- a/app/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx +++ b/app/dashboard/src/components/AriaComponents/Dialog/Dialog.tsx @@ -53,7 +53,9 @@ const MODAL_STYLES = tv({ }) const DIALOG_STYLES = tv({ - base: DIALOG_BACKGROUND({ className: 'w-full flex flex-col text-left align-middle shadow-xl' }), + base: DIALOG_BACKGROUND({ + className: 'w-full max-w-full flex flex-col text-left align-middle shadow-xl', + }), variants: { type: { modal: { @@ -149,7 +151,7 @@ const DIALOG_STYLES = tv({ * Can be used to display alerts, confirmations, or other content. */ export function Dialog(props: DialogProps) { const { - children, + children: Children, title, type = 'modal', closeButton = 'normal', @@ -302,7 +304,9 @@ export function Dialog(props: DialogProps) { - {typeof children === 'function' ? children(opts) : children} + {typeof Children === 'function' ? + + : Children}
diff --git a/app/dashboard/src/components/AriaComponents/Form/Form.tsx b/app/dashboard/src/components/AriaComponents/Form/Form.tsx index ef2ebfb91e..babc312641 100644 --- a/app/dashboard/src/components/AriaComponents/Form/Form.tsx +++ b/app/dashboard/src/components/AriaComponents/Form/Form.tsx @@ -1,18 +1,11 @@ /** @file Form component. */ import * as React from 'react' -import * as sentry from '@sentry/react' -import * as reactQuery from '@tanstack/react-query' -import * as reactHookForm from 'react-hook-form' - -import * as offlineHooks from '#/hooks/offlineHooks' - import * as textProvider from '#/providers/TextProvider' import * as aria from '#/components/aria' -import * as errorUtils from '#/utilities/error' - +import { useEventCallback } from '#/hooks/eventCallbackHooks' import { forwardRef } from '#/utilities/react' import * as dialog from '../Dialog' import * as components from './components' @@ -24,27 +17,25 @@ import type * as types from './types' * Provides better error handling and form state management and better UX out of the box. */ // There is no way to avoid type casting here // eslint-disable-next-line no-restricted-syntax -export const Form = forwardRef(function Form( - props: types.FormProps, - ref: React.Ref, -) { +export const Form = forwardRef(function Form< + Schema extends components.TSchema, + SubmitResult = void, +>(props: types.FormProps, ref: React.Ref) { /** Input values for this form. */ type FieldValues = components.FieldValues const formId = React.useId() const { children, - onSubmit, formRef, form, - formOptions = {}, + formOptions, className, style, onSubmitted = () => {}, onSubmitSuccess = () => {}, onSubmitFailed = () => {}, id = formId, - testId, schema, defaultValues, gap, @@ -55,78 +46,47 @@ export const Form = forwardRef(function Form( const { getText } = textProvider.useText() - if (defaultValues) { - formOptions.defaultValues = defaultValues - } - - const innerForm = components.useForm(form ?? { shouldFocusError: true, schema, ...formOptions }) - - React.useImperativeHandle(formRef, () => innerForm, [innerForm]) - const dialogContext = dialog.useDialogContext() - const formMutation = reactQuery.useMutation({ - // We use template literals to make the mutation key more readable in the devtools - // This mutation exists only for debug purposes - React Query dev tools record the mutation, - // the result, and the variables(form fields). - // In general, prefer using object literals for the mutation key. - mutationKey: ['Form submission', `testId: ${testId}`, `id: ${id}`], - mutationFn: async (fieldValues: FieldValues) => { - try { - await onSubmit?.(fieldValues, innerForm) + const onSubmit = useEventCallback( + async (fieldValues: types.FieldValues, formInstance: types.UseFormReturn) => { + // This is SAFE because we're passing the result transparently, and it's typed outside + // eslint-disable-next-line no-restricted-syntax + const result = (await props.onSubmit?.(fieldValues, formInstance)) as SubmitResult - if (method === 'dialog') { - dialogContext?.close() - } - } catch (error) { - const isJSError = errorUtils.isJSError(error) - - if (isJSError) { - sentry.captureException(error, { - contexts: { form: { values: fieldValues } }, - }) - } - - const message = - isJSError ? - getText('arbitraryFormErrorMessage') - : errorUtils.tryGetMessage(error, getText('arbitraryFormErrorMessage')) - - innerForm.setError('root.submit', { message }) - - // We need to throw the error to make the mutation fail - // eslint-disable-next-line no-restricted-syntax - throw error + if (method === 'dialog') { + dialogContext?.close() } + + return result }, - onError: onSubmitFailed, - onSuccess: onSubmitSuccess, - onSettled: onSubmitted, - }) - - // 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 { isOffline } = offlineHooks.useOffline() - - offlineHooks.useOfflineChange( - (offline) => { - if (offline) { - innerForm.setError('root.offline', { message: getText('unavailableOffline') }) - } else { - innerForm.clearErrors('root.offline') - } - }, - { isDisabled: canSubmitOffline }, ) + const testId = props['testId'] ?? props['data-testid'] ?? 'form' + + const innerForm = components.useForm( + form ?? { + ...formOptions, + ...(defaultValues ? { defaultValues } : {}), + schema, + canSubmitOffline, + onSubmit, + onSubmitFailed, + onSubmitSuccess, + onSubmitted, + shouldFocusError: true, + debugName: `Form ${testId} id: ${id}`, + }, + ) + + React.useImperativeHandle(formRef, () => innerForm, [innerForm]) + const base = styles.FORM_STYLES({ className: typeof className === 'function' ? className(innerForm) : className, gap, }) - const { formState, setError } = innerForm + const { formState } = innerForm // eslint-disable-next-line no-restricted-syntax const errors = Object.fromEntries( @@ -136,39 +96,30 @@ export const Form = forwardRef(function Form( }), ) as Record - return ( - <> -
{ - event.preventDefault() - event.stopPropagation() + const values = components.useWatch({ control: innerForm.control }) - if (isOffline && !canSubmitOffline) { - setError('root.offline', { message: getText('unavailableOffline') }) - } else { - void formOnSubmit(event) - } - }} - className={base} - style={typeof style === 'function' ? style(innerForm) : style} - noValidate - data-testid={testId} - {...formProps} - > - - - {typeof children === 'function' ? - children({ ...innerForm, form: innerForm }) - : children} - - -
- + return ( +
+ + + {typeof children === 'function' ? + children({ ...innerForm, form: innerForm, values }) + : children} + + +
) -}) as unknown as (( - props: React.RefAttributes & types.FormProps, +}) as unknown as (( + props: React.RefAttributes & types.FormProps, ) => React.JSX.Element) & { /* eslint-disable @typescript-eslint/naming-convention */ schema: typeof components.schema @@ -183,7 +134,9 @@ export const Form = forwardRef(function Form( FIELD_STYLES: typeof components.FIELD_STYLES useFormContext: typeof components.useFormContext useOptionalFormContext: typeof components.useOptionalFormContext - useWatch: typeof reactHookForm.useWatch + useWatch: typeof components.useWatch + useFieldRegister: typeof components.useFieldRegister + useFieldState: typeof components.useFieldState /* eslint-enable @typescript-eslint/naming-convention */ } @@ -198,5 +151,7 @@ Form.useFormContext = components.useFormContext Form.useOptionalFormContext = components.useOptionalFormContext Form.Field = components.Field Form.Controller = components.Controller -Form.useWatch = reactHookForm.useWatch +Form.useWatch = components.useWatch Form.FIELD_STYLES = components.FIELD_STYLES +Form.useFieldRegister = components.useFieldRegister +Form.useFieldState = components.useFieldState diff --git a/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx b/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx index eeeb958e44..f2a52394d4 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx +++ b/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx @@ -11,8 +11,8 @@ import { forwardRef } from '#/utilities/react' import { tv, type VariantProps } from '#/utilities/tailwindVariants' import type { Path } from 'react-hook-form' import * as text from '../../Text' +import { Form } from '../Form' import type * as types from './types' -import * as formContext from './useFormContext' /** * Props for Field component @@ -44,6 +44,7 @@ export interface FieldChildrenRenderProps { readonly isDirty: boolean readonly isTouched: boolean readonly isValidating: boolean + readonly hasError: boolean readonly error?: string | undefined } @@ -73,36 +74,29 @@ export const Field = forwardRef(function Field( ref: React.ForwardedRef, ) { const { - // eslint-disable-next-line no-restricted-syntax - form = formContext.useFormContext() as unknown as types.FormInstance, - isInvalid, children, className, label, description, fullWidth, error, - name, isHidden, + isInvalid = false, isRequired = false, variants = FIELD_STYLES, } = props - const fieldState = form.getFieldState(name) - const labelId = React.useId() const descriptionId = React.useId() const errorId = React.useId() - const invalid = isInvalid === true || fieldState.invalid + const fieldState = Form.useFieldState(props) - const classes = variants({ - fullWidth, - isInvalid: invalid, - isHidden, - }) + const invalid = isInvalid || fieldState.hasError - const hasError = (error ?? fieldState.error?.message) != null + const classes = variants({ fullWidth, isInvalid: invalid, isHidden }) + + const hasError = (error ?? fieldState.error) != null return (
( isDirty: fieldState.isDirty, isTouched: fieldState.isTouched, isValidating: fieldState.isValidating, - error: fieldState.error?.message, + hasError: fieldState.hasError, + error: fieldState.error, }) : children} @@ -152,7 +147,7 @@ export const Field = forwardRef(function Field( {hasError && ( - {error ?? fieldState.error?.message} + {error ?? fieldState.error} )}
diff --git a/app/dashboard/src/components/AriaComponents/Form/components/FormError.tsx b/app/dashboard/src/components/AriaComponents/Form/components/FormError.tsx index 77f845153a..9bc1719640 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/FormError.tsx +++ b/app/dashboard/src/components/AriaComponents/Form/components/FormError.tsx @@ -10,8 +10,8 @@ import * as textProvider from '#/providers/TextProvider' import * as reactAriaComponents from '#/components/AriaComponents' +import * as formContext from './FormProvider' import type * as types from './types' -import * as formContext from './useFormContext' /** * Props for the FormError component. @@ -26,14 +26,9 @@ export interface FormErrorProps extends Omit { + readonly form: types.UseFormReturn +} + +// at this moment, we don't know the type of the form context +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const FormContext = createContext | null>(null) + +/** + * Provides the form instance to the component tree. + */ +export function FormProvider( + props: FormContextType & PropsWithChildren, +) { + const { children, form } = props + + return ( + // eslint-disable-next-line no-restricted-syntax,@typescript-eslint/no-explicit-any + }}> + {children} + + ) +} + +/** + * Returns the form instance from the context. + */ +export function useFormContext( + form?: FormInstanceValidated | undefined, +): FormInstance { + if (form != null && 'control' in form) { + return form + } else { + // eslint-disable-next-line react-hooks/rules-of-hooks + const ctx = useContext(FormContext) + + invariant(ctx, 'FormContext not found') + + // This is safe, as it's we pass the value transparently and it's typed outside + // eslint-disable-next-line no-restricted-syntax + return ctx.form as unknown as types.UseFormReturn + } +} + +/** + * Returns the form instance from the context, or null if the context is not available. + */ +export function useOptionalFormContext< + Form extends FormInstanceValidated | undefined, + Schema extends types.TSchema, +>(form?: Form): Form extends undefined ? FormInstance | null : FormInstance { + try { + return useFormContext(form) + } catch { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return null! + } +} diff --git a/app/dashboard/src/components/AriaComponents/Form/components/Reset.tsx b/app/dashboard/src/components/AriaComponents/Form/components/Reset.tsx index 05d10e6e80..67f91a82be 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/Reset.tsx +++ b/app/dashboard/src/components/AriaComponents/Form/components/Reset.tsx @@ -7,8 +7,9 @@ import * as React from 'react' import * as ariaComponents from '#/components/AriaComponents' +import { useText } from '#/providers/TextProvider' +import * as formContext from './FormProvider' import type * as types from './types' -import * as formContext from './useFormContext' /** * Props for the Reset component. @@ -29,14 +30,16 @@ export interface ResetProps extends Omit * Reset button for forms. */ export function Reset(props: ResetProps): React.JSX.Element { + const { getText } = useText() const { - form = formContext.useFormContext(), - variant = 'cancel', + variant = 'ghost-fading', size = 'medium', testId = 'form-reset-button', + children = getText('reset'), ...buttonProps } = props - const { formState } = form + + const { formState } = formContext.useFormContext(props.form) return ( ) } diff --git a/app/dashboard/src/components/AriaComponents/Form/components/Submit.tsx b/app/dashboard/src/components/AriaComponents/Form/components/Submit.tsx index a6505d85d9..b87b2f1ffe 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/Submit.tsx +++ b/app/dashboard/src/components/AriaComponents/Form/components/Submit.tsx @@ -10,8 +10,8 @@ import * as textProvider from '#/providers/TextProvider' import * as ariaComponents from '#/components/AriaComponents' +import * as formContext from './FormProvider' import type * as types from './types' -import * as formContext from './useFormContext' /** * Additional props for the Submit component. @@ -48,22 +48,23 @@ export type SubmitProps = Omit & * Manages the form state and displays a loading spinner when the form is submitting. */ export function Submit(props: SubmitProps): React.JSX.Element { + const { getText } = textProvider.useText() + const { - form = formContext.useFormContext(), - variant = 'submit', size = 'medium', - testId = 'form-submit-button', formnovalidate = false, loading = false, - children, + children = formnovalidate ? getText('cancel') : getText('submit'), + variant = formnovalidate ? 'ghost-fading' : 'submit', + testId = formnovalidate ? 'form-cancel-button' : 'form-submit-button', ...buttonProps } = props - const { getText } = textProvider.useText() const dialogContext = ariaComponents.useDialogContext() + const form = formContext.useFormContext(props.form) const { formState } = form - const isLoading = loading || formState.isSubmitting + const isLoading = formnovalidate ? false : loading || formState.isSubmitting const type = formnovalidate || isLoading ? 'button' : 'submit' return ( @@ -82,7 +83,7 @@ export function Submit(props: SubmitProps): React.JSX.Element { } }} > - {children ?? getText('submit')} + {children} ) } diff --git a/app/dashboard/src/components/AriaComponents/Form/components/index.ts b/app/dashboard/src/components/AriaComponents/Form/components/index.ts index 3bd2ded7df..2ccfc60f65 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/index.ts +++ b/app/dashboard/src/components/AriaComponents/Form/components/index.ts @@ -3,14 +3,16 @@ * * Barrel file for form components. */ -export { Controller } from 'react-hook-form' +export { Controller, useWatch } from 'react-hook-form' export * from './Field' export * from './FormError' +export * from './FormProvider' export * from './Reset' export * from './schema' export * from './Submit' export * from './types' export * from './useField' +export * from './useFieldRegister' +export * from './useFieldState' export * from './useForm' -export * from './useFormContext' export * from './useFormSchema' diff --git a/app/dashboard/src/components/AriaComponents/Form/components/types.ts b/app/dashboard/src/components/AriaComponents/Form/components/types.ts index 13ebc22299..f840595fae 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/types.ts +++ b/app/dashboard/src/components/AriaComponents/Form/components/types.ts @@ -7,6 +7,7 @@ import type * as React from 'react' import type * as reactHookForm from 'react-hook-form' import type * as z from 'zod' +import type { FormEvent } from 'react' import type * as schemaModule from './schema' /** The type of the inputs to the form, used for UI inputs. */ @@ -31,15 +32,61 @@ export type TSchema = | z.ZodEffects | z.ZodEffects> +/** + * OnSubmitCallbacks type. + */ +export interface OnSubmitCallbacks { + readonly onSubmit?: + | (( + values: FieldValues, + form: UseFormReturn, + ) => Promise | SubmitResult) + | undefined + + readonly onSubmitFailed?: + | (( + error: unknown, + values: FieldValues, + form: UseFormReturn, + ) => Promise | void) + | undefined + readonly onSubmitSuccess?: + | (( + data: SubmitResult, + values: FieldValues, + form: UseFormReturn, + ) => Promise | void) + | undefined + readonly onSubmitted?: + | (( + data: SubmitResult | undefined, + error: unknown, + values: FieldValues, + form: UseFormReturn, + ) => Promise | void) + | undefined +} + /** * Props for the useForm hook. */ -export interface UseFormProps +export interface UseFormProps extends Omit< - reactHookForm.UseFormProps>, - 'handleSubmit' | 'resetOptions' | 'resolver' - > { + reactHookForm.UseFormProps>, + 'handleSubmit' | 'resetOptions' | 'resolver' + >, + OnSubmitCallbacks { readonly schema: Schema | ((schema: typeof schemaModule.schema) => Schema) + /** + * Whether the form can submit offline. + * @default false + */ + readonly canSubmitOffline?: boolean + + /** + * Debug name for the form. Use it to identify the form in the tanstack query devtools. + */ + readonly debugName?: string } /** @@ -50,7 +97,6 @@ export type UseFormRegister = < >( name: TFieldName, options?: reactHookForm.RegisterOptions, TFieldName>, - // eslint-disable-next-line no-restricted-syntax ) => UseFormRegisterReturn /** @@ -64,9 +110,12 @@ export interface UseFormRegisterReturn< readonly onChange: (value: Value) => Promise // eslint-disable-next-line @typescript-eslint/no-invalid-void-type readonly onBlur: (value: Value) => Promise - readonly isDisabled?: boolean - readonly isRequired?: boolean - readonly isInvalid?: boolean + readonly isDisabled: boolean + readonly isRequired: boolean + readonly isInvalid: boolean + readonly disabled: boolean + readonly required: boolean + readonly invalid: boolean } /** @@ -74,8 +123,14 @@ export interface UseFormRegisterReturn< * @alias reactHookForm.UseFormReturn */ export interface UseFormReturn - extends reactHookForm.UseFormReturn, unknown, TransformedValues> { + extends Omit< + reactHookForm.UseFormReturn, unknown, TransformedValues>, + 'onSubmit' | 'resetOptions' | 'resolver' + > { readonly register: UseFormRegister + readonly submit: (event?: FormEvent | null | undefined) => Promise + readonly schema: Schema + readonly setFormError: (error: string) => void } /** @@ -113,6 +168,16 @@ export interface FormWithValueValidation< | undefined } +/** + * Form instance type that has been validated. + * Cast validatable form type to FormInstance + */ +export type FormInstanceValidated< + Schema extends TSchema, + // We use any here because we want to bypass the type check for Error type as it won't be a case here + // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/ban-types +> = FormInstance | (any[] & {}) + /** * Props for the Field component. */ @@ -148,3 +213,36 @@ export interface FieldProps { // eslint-disable-next-line @typescript-eslint/naming-convention 'aria-details'?: string | undefined } +/** + * Base Props for a Form Field. + * @private + */ +export interface FormFieldProps< + BaseValueType, + Schema extends TSchema, + TFieldName extends FieldPath, +> extends FormWithValueValidation { + readonly name: TFieldName + readonly value?: BaseValueType extends FieldValues ? FieldValues[TFieldName] + : never + readonly defaultValue?: FieldValues[TFieldName] | undefined + readonly isDisabled?: boolean | undefined + readonly isRequired?: boolean | undefined + readonly isInvalid?: boolean | undefined +} + +/** + * Field State Props + */ +export type FieldStateProps< + // eslint-disable-next-line no-restricted-syntax + BaseProps extends { value?: unknown }, + Schema extends TSchema, + TFieldName extends FieldPath, +> = FormFieldProps & { + // to avoid conflicts with the FormFieldProps we need to omit the FormFieldProps from the BaseProps + [K in keyof Omit< + BaseProps, + keyof FormFieldProps + >]: BaseProps[K] +} diff --git a/app/dashboard/src/components/AriaComponents/Form/components/useField.ts b/app/dashboard/src/components/AriaComponents/Form/components/useField.ts index f3bfb69d17..a6c8e7ced1 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/useField.ts +++ b/app/dashboard/src/components/AriaComponents/Form/components/useField.ts @@ -5,8 +5,8 @@ */ import * as reactHookForm from 'react-hook-form' +import * as formContext from './FormProvider' import type * as types from './types' -import * as formContext from './useFormContext' /** * Options for {@link useField} hook. @@ -29,24 +29,16 @@ export function useField< Schema extends types.TSchema, TFieldName extends types.FieldPath, >(options: UseFieldOptions) { - const { form = formContext.useFormContext(), name, defaultValue, isDisabled = false } = options + const { name, defaultValue, isDisabled = false } = options - // This is safe, because the form is always passed either via the options or via the context. - // The assertion is needed because we use additional type validation for form instance and throw - // ts error if form does not pass the validation. - // eslint-disable-next-line no-restricted-syntax - const formInstance = form as types.FormInstance + const formInstance = formContext.useFormContext(options.form) const { field, fieldState, formState } = reactHookForm.useController({ name, disabled: isDisabled, + control: formInstance.control, ...(defaultValue != null ? { defaultValue } : {}), }) - return { - field, - fieldState, - formState, - formInstance, - } as const + return { field, fieldState, formState, formInstance } as const } diff --git a/app/dashboard/src/components/AriaComponents/Form/components/useFieldRegister.ts b/app/dashboard/src/components/AriaComponents/Form/components/useFieldRegister.ts new file mode 100644 index 0000000000..13413e2e81 --- /dev/null +++ b/app/dashboard/src/components/AriaComponents/Form/components/useFieldRegister.ts @@ -0,0 +1,99 @@ +/** + * @file + * + * Form field registration hook. + * Use this hook to register a field in the form. + */ +import { useFormContext } from './FormProvider' +import type { + FieldPath, + FieldValues, + FormFieldProps, + FormInstanceValidated, + TSchema, +} from './types' + +/** + * Options for the useFieldRegister hook. + */ +export type UseFieldRegisterOptions< + BaseValueType extends { value?: unknown }, + Schema extends TSchema, + TFieldName extends FieldPath, +> = Omit, 'form'> & { + name: TFieldName + form?: FormInstanceValidated | undefined + defaultValue?: FieldValues[TFieldName] | undefined + min?: number | string | undefined + max?: number | string | undefined + minLength?: number | undefined + maxLength?: number | undefined + setValueAs?: ((value: unknown) => unknown) | undefined +} + +/** + * Registers a field in the form. + */ +export function useFieldRegister< + BaseValueType extends { value?: unknown }, + Schema extends TSchema, + TFieldName extends FieldPath, +>(options: UseFieldRegisterOptions) { + const { name, min, max, minLength, maxLength, isRequired, isDisabled, form, setValueAs } = options + + const formInstance = useFormContext(form) + + const extractedValidationDetails = unsafe__extractValidationDetailsFromSchema( + formInstance.schema, + name, + ) + + const fieldProps = formInstance.register(name, { + disabled: isDisabled ?? false, + required: isRequired ?? extractedValidationDetails?.required ?? false, + ...(setValueAs != null ? { setValueAs } : {}), + ...(extractedValidationDetails?.min != null ? { min: extractedValidationDetails.min } : {}), + ...(extractedValidationDetails?.max != null ? { min: extractedValidationDetails.max } : {}), + ...(min != null ? { min } : {}), + ...(max != null ? { max } : {}), + ...(minLength != null ? { minLength } : {}), + ...(maxLength != null ? { maxLength } : {}), + }) + + return { fieldProps, formInstance } as const +} +/** + * Tried to extract validation details from the schema. + */ +// This name is intentional to highlight that this function is unsafe and should be used with caution. +// eslint-disable-next-line @typescript-eslint/naming-convention +function unsafe__extractValidationDetailsFromSchema< + Schema extends TSchema, + TFieldName extends FieldPath, +>(schema: Schema, name: TFieldName) { + try { + if ('shape' in schema) { + if (name in schema.shape) { + // THIS is 100% unsafe, so we need to be very careful here + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment + const fieldShape = schema.shape[name] + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + const min: number | null = fieldShape.minLength + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment + const max: number | null = fieldShape.maxLength + const required = min != null && min > 0 + + // eslint-disable-next-line no-restricted-syntax + return { required, min, max } as const + } + + // eslint-disable-next-line no-restricted-syntax + return null + } + // eslint-disable-next-line no-restricted-syntax + return null + } catch { + // eslint-disable-next-line no-restricted-syntax + return null + } +} diff --git a/app/dashboard/src/components/AriaComponents/Form/components/useFieldState.ts b/app/dashboard/src/components/AriaComponents/Form/components/useFieldState.ts new file mode 100644 index 0000000000..e1e2e74b22 --- /dev/null +++ b/app/dashboard/src/components/AriaComponents/Form/components/useFieldState.ts @@ -0,0 +1,47 @@ +/** + * @file + * + * Hook to get the state of a field. + */ +import { useFormState } from 'react-hook-form' +import { useFormContext } from './FormProvider' +import type { FieldPath, FormInstanceValidated, TSchema } from './types' + +/** + * Options for the `useFieldState` hook. + */ +export interface UseFieldStateOptions< + Schema extends TSchema, + TFieldName extends FieldPath, +> { + readonly name: TFieldName + readonly form?: FormInstanceValidated | undefined +} + +/** + * Hook to get the state of a field. + */ +export function useFieldState>( + options: UseFieldStateOptions, +) { + const { name } = options + + const form = useFormContext(options.form) + + const { errors, dirtyFields, isValidating, touchedFields } = useFormState({ + control: form.control, + name, + }) + + const isDirty = name in dirtyFields + const isTouched = name in touchedFields + const error = errors[name]?.message?.toString() + + return { + error, + isDirty, + isTouched, + isValidating, + hasError: error != null, + } as const +} diff --git a/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts b/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts index 5197a3b9f2..f1f55e94e8 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts +++ b/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts @@ -3,13 +3,18 @@ * * A hook that returns a form instance. */ +import * as sentry from '@sentry/react' import * as React from 'react' import * as zodResolver from '@hookform/resolvers/zod' import * as reactHookForm from 'react-hook-form' import invariant from 'tiny-invariant' +import { useEventCallback } from '#/hooks/eventCallbackHooks' +import { useOffline, useOfflineChange } from '#/hooks/offlineHooks' import { useText } from '#/providers/TextProvider' +import * as errorUtils from '#/utilities/error' +import { useMutation } from '@tanstack/react-query' import * as schemaModule from './schema' import type * as types from './types' @@ -39,64 +44,73 @@ function mapValueOnEvent(value: unknown) { * But be careful, You should not switch between the two types of arguments. * Otherwise you'll be fired */ -export function useForm( - optionsOrFormInstance: types.UseFormProps | types.UseFormReturn, +export function useForm( + optionsOrFormInstance: types.UseFormProps | types.UseFormReturn, ): types.UseFormReturn { const { getText } = useText() - const initialTypePassed = React.useRef(getArgsType(optionsOrFormInstance)) + const [initialTypePassed] = React.useState(() => getArgsType(optionsOrFormInstance)) const argsType = getArgsType(optionsOrFormInstance) invariant( - initialTypePassed.current === argsType, + initialTypePassed === argsType, ` Found a switch between form options and form instance. This is not allowed. Please use either form options or form instance and stick to it.\n\n - Initially passed: ${initialTypePassed.current}, Currently passed: ${argsType}. + Initially passed: ${initialTypePassed}, Currently passed: ${argsType}. `, ) if ('formState' in optionsOrFormInstance) { return optionsOrFormInstance } else { - const { schema, ...options } = optionsOrFormInstance + const { + schema, + onSubmit, + canSubmitOffline = false, + onSubmitFailed, + onSubmitted, + onSubmitSuccess, + debugName, + ...options + } = optionsOrFormInstance const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema - const formInstance = reactHookForm.useForm< - types.FieldValues, - unknown, - types.TransformedValues - >({ + const formInstance = reactHookForm.useForm({ ...options, - resolver: zodResolver.zodResolver(computedSchema, { - async: true, - errorMap: (issue) => { - switch (issue.code) { - case 'too_small': - if (issue.minimum === 0) { - return { - message: getText('arbitraryFieldRequired'), + resolver: zodResolver.zodResolver( + computedSchema, + { + async: true, + errorMap: (issue) => { + switch (issue.code) { + case 'too_small': + if (issue.minimum === 0) { + return { + message: getText('arbitraryFieldRequired'), + } + } else { + return { + message: getText('arbitraryFieldTooSmall', issue.minimum.toString()), + } } - } else { + case 'too_big': return { - message: getText('arbitraryFieldTooSmall', issue.minimum.toString()), + message: getText('arbitraryFieldTooLarge', issue.maximum.toString()), } - } - case 'too_big': - return { - message: getText('arbitraryFieldTooLarge', issue.maximum.toString()), - } - case 'invalid_type': - return { - message: getText('arbitraryFieldInvalid'), - } - default: - return { - message: getText('arbitraryFieldInvalid'), - } - } + case 'invalid_type': + return { + message: getText('arbitraryFieldInvalid'), + } + default: + return { + message: getText('arbitraryFieldInvalid'), + } + } + }, }, - }), + { mode: 'async' }, + ), }) const register: types.UseFormRegister = (name, opts) => { @@ -110,9 +124,12 @@ export function useForm( const result: types.UseFormRegisterReturn = { ...registered, - ...(registered.disabled != null ? { isDisabled: registered.disabled } : {}), - ...(registered.required != null ? { isRequired: registered.required } : {}), + disabled: registered.disabled ?? false, + isDisabled: registered.disabled ?? false, + invalid: !!formInstance.formState.errors[name], isInvalid: !!formInstance.formState.errors[name], + required: registered.required ?? false, + isRequired: registered.required ?? false, onChange, onBlur, } @@ -120,19 +137,106 @@ export function useForm( return result } - return { + // eslint-disable-next-line react-hooks/rules-of-hooks + const formMutation = useMutation({ + // We use template literals to make the mutation key more readable in the devtools + // This mutation exists only for debug purposes - React Query dev tools record the mutation, + // the result, and the variables(form fields). + // In general, prefer using object literals for the mutation key. + mutationKey: ['Form submission', `debugName: ${debugName}`], + mutationFn: async (fieldValues: types.FieldValues) => { + try { + // This is safe, because we transparently passing the result of the onSubmit function, + // and the type of the result is the same as the type of the SubmitResult. + // eslint-disable-next-line no-restricted-syntax + return (await onSubmit?.(fieldValues, form)) as SubmitResult + } catch (error) { + const isJSError = errorUtils.isJSError(error) + + if (isJSError) { + sentry.captureException(error, { + contexts: { form: { values: fieldValues } }, + }) + } + + const message = + isJSError ? + getText('arbitraryFormErrorMessage') + : errorUtils.tryGetMessage(error, getText('arbitraryFormErrorMessage')) + + setFormError(message) + // We need to throw the error to make the mutation fail + // eslint-disable-next-line no-restricted-syntax + throw error + } + }, + onError: (error, values) => onSubmitFailed?.(error, values, form), + onSuccess: (data, values) => onSubmitSuccess?.(data, values, form), + onSettled: (data, error, values) => onSubmitted?.(data, error, values, 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 = formInstance.handleSubmit(formMutation.mutateAsync as any) + + // eslint-disable-next-line react-hooks/rules-of-hooks + const { isOffline } = useOffline() + + // eslint-disable-next-line react-hooks/rules-of-hooks + useOfflineChange( + (offline) => { + if (offline) { + formInstance.setError('root.offline', { message: getText('unavailableOffline') }) + } else { + formInstance.clearErrors('root.offline') + } + }, + { isDisabled: canSubmitOffline }, + ) + + // eslint-disable-next-line react-hooks/rules-of-hooks + const submit = useEventCallback( + (event: React.FormEvent | null | undefined) => { + event?.preventDefault() + event?.stopPropagation() + + if (isOffline && !canSubmitOffline) { + formInstance.setError('root.offline', { message: getText('unavailableOffline') }) + return Promise.resolve() + } else { + if (event) { + return formOnSubmit(event) + } else { + return formOnSubmit() + } + } + }, + ) + + // eslint-disable-next-line react-hooks/rules-of-hooks + const setFormError = useEventCallback((error: string) => { + formInstance.setError('root.submit', { message: error }) + }) + + const form: types.UseFormReturn = { ...formInstance, + submit, control: { ...formInstance.control, register }, register, - } satisfies types.UseFormReturn + schema: computedSchema, + setFormError, + handleSubmit: formInstance.handleSubmit, + } + + return form } } /** * Get the type of arguments passed to the useForm hook */ -function getArgsType( - args: types.UseFormProps | types.UseFormReturn, +function getArgsType( + args: types.UseFormProps, ) { - return 'formState' in args ? 'formInstance' : 'formOptions' + return 'formState' in args ? ('formInstance' as const) : ('formOptions' as const) } diff --git a/app/dashboard/src/components/AriaComponents/Form/components/useFormContext.tsx b/app/dashboard/src/components/AriaComponents/Form/components/useFormContext.tsx deleted file mode 100644 index 490f7ebe73..0000000000 --- a/app/dashboard/src/components/AriaComponents/Form/components/useFormContext.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @file - * - * This file is a wrapper around the react-hook-form useFormContext hook. - */ -import * as reactHookForm from 'react-hook-form' - -/** - * Returns the form instance from the context. - */ -export function useFormContext() { - return reactHookForm.useFormContext() -} - -/** - * Returns the form instance from the context, or null if the context is not available. - */ -export function useOptionalFormContext() { - try { - return useFormContext() - } catch { - return null - } -} diff --git a/app/dashboard/src/components/AriaComponents/Form/components/useFormSchema.tsx b/app/dashboard/src/components/AriaComponents/Form/components/useFormSchema.tsx index ef5f2b47b6..ede8698dfe 100644 --- a/app/dashboard/src/components/AriaComponents/Form/components/useFormSchema.tsx +++ b/app/dashboard/src/components/AriaComponents/Form/components/useFormSchema.tsx @@ -3,8 +3,8 @@ import * as React from 'react' import * as callbackEventHooks from '#/hooks/eventCallbackHooks' -import * as schemaComponent from '#/components/AriaComponents/Form/components/schema' -import type * as types from '#/components/AriaComponents/Form/components/types' +import * as schemaComponent from './schema' +import type * as types from './types' // ===================== // === useFormSchema === diff --git a/app/dashboard/src/components/AriaComponents/Form/types.ts b/app/dashboard/src/components/AriaComponents/Form/types.ts index 28fe075b09..30b767e3a0 100644 --- a/app/dashboard/src/components/AriaComponents/Form/types.ts +++ b/app/dashboard/src/components/AriaComponents/Form/types.ts @@ -7,6 +7,8 @@ import type * as React from 'react' import type * as reactHookForm from 'react-hook-form' +import type { DeepPartialSkipArrayKey } from 'react-hook-form' +import type { TestIdProps } from '../types' import type * as components from './components' import type * as styles from './styles' @@ -15,8 +17,11 @@ export type * from './components' /** * Props for the Form component */ -export type FormProps = BaseFormProps & - (FormPropsWithOptions | FormPropsWithParentForm) +export type FormProps< + Schema extends components.TSchema, + SubmitResult = void, +> = BaseFormProps & + (FormPropsWithOptions | FormPropsWithParentForm) /** * Base props for the Form component. @@ -26,20 +31,8 @@ interface BaseFormProps React.HTMLProps, 'children' | 'className' | 'form' | 'onSubmit' | 'onSubmitCapture' | 'style' >, - styles.FormStyleProps { - /** - * The default values for the form fields - * - * __Note:__ Even though this is optional, - * it is recommended to provide default values and specify all fields defined in the schema. - * Otherwise Typescript fails to infer the correct type for the form values. - * This is a known limitation and we are working on a solution. - */ - readonly defaultValues?: components.UseFormProps['defaultValues'] - readonly onSubmit?: ( - values: components.TransformedValues, - form: components.UseFormReturn, - ) => unknown + Omit, + TestIdProps { readonly style?: | React.CSSProperties | ((props: components.UseFormReturn) => React.CSSProperties) @@ -48,17 +41,13 @@ interface BaseFormProps | (( props: components.UseFormReturn & { readonly form: components.UseFormReturn + readonly values: DeepPartialSkipArrayKey> }, ) => React.ReactNode) readonly formRef?: React.MutableRefObject> readonly className?: string | ((props: components.UseFormReturn) => string) - readonly onSubmitFailed?: (error: unknown) => Promise | void - readonly onSubmitSuccess?: () => Promise | void - readonly onSubmitted?: () => Promise | void - - readonly testId?: string /** * When set to `dialog`, form submission will close the parent dialog on successful submission. */ @@ -76,16 +65,33 @@ interface FormPropsWithParentForm { readonly form: components.UseFormReturn readonly schema?: never readonly formOptions?: never + readonly defaultValues?: never + readonly onSubmit?: never + readonly onSubmitSuccess?: never + readonly onSubmitFailed?: never + readonly onSubmitted?: never } /** * Props for the Form component with schema and form options. * Creates a new form instance. This is the default way to use the form. */ -interface FormPropsWithOptions { +interface FormPropsWithOptions + extends components.OnSubmitCallbacks { readonly schema: Schema | ((schema: typeof components.schema) => Schema) + readonly formOptions?: Omit< + components.UseFormProps, + 'defaultValues' | 'onSubmit' | 'onSubmitFailed' | 'onSubmitSuccess' | 'onSubmitted' | 'schema' + > + /** + * The default values for the form fields + * + * __Note:__ Even though this is optional, + * it is recommended to provide default values and specify all fields defined in the schema. + * Otherwise Typescript fails to infer the correct type for the form values. + */ + readonly defaultValues?: components.UseFormProps['defaultValues'] readonly form?: never - readonly formOptions?: Omit, 'resolver' | 'schema'> } /** @@ -134,38 +140,3 @@ export type FormStateRenderProps = Pick< /** The form instance. */ readonly form: components.FormInstance } - -/** - * Base Props for a Form Field. - * @private - */ -interface FormFieldProps< - BaseValueType, - Schema extends components.TSchema, - TFieldName extends components.FieldPath, -> extends components.FormWithValueValidation { - readonly name: TFieldName - readonly value?: BaseValueType extends components.FieldValues[TFieldName] ? - components.FieldValues[TFieldName] - : never - readonly defaultValue?: components.FieldValues[TFieldName] | undefined - readonly isDisabled?: boolean - readonly isRequired?: boolean - readonly isInvalid?: boolean -} - -/** - * Field State Props - */ -export type FieldStateProps< - // eslint-disable-next-line no-restricted-syntax - BaseProps extends { value?: unknown }, - Schema extends components.TSchema, - TFieldName extends components.FieldPath, -> = FormFieldProps & { - // to avoid conflicts with the FormFieldProps we need to omit the FormFieldProps from the BaseProps - [K in keyof Omit< - BaseProps, - keyof FormFieldProps - >]: BaseProps[K] -} diff --git a/app/dashboard/src/components/AriaComponents/Inputs/DatePicker/DatePicker.tsx b/app/dashboard/src/components/AriaComponents/Inputs/DatePicker/DatePicker.tsx index dccfc52462..1cb94ec557 100644 --- a/app/dashboard/src/components/AriaComponents/Inputs/DatePicker/DatePicker.tsx +++ b/app/dashboard/src/components/AriaComponents/Inputs/DatePicker/DatePicker.tsx @@ -37,8 +37,8 @@ import { } from '#/components/AriaComponents' import { useText } from '#/providers/TextProvider' import { forwardRef } from '#/utilities/react' -import { Controller } from 'react-hook-form' -import { tv, type VariantProps } from 'tailwind-variants' +import type { VariantProps } from '#/utilities/tailwindVariants' +import { tv } from '#/utilities/tailwindVariants' const DATE_PICKER_STYLES = tv({ base: '', @@ -135,7 +135,7 @@ export const DatePicker = forwardRef(function DatePicker< ref={ref} style={props.style} > - { diff --git a/app/dashboard/src/components/AriaComponents/Inputs/Input/Input.tsx b/app/dashboard/src/components/AriaComponents/Inputs/Input/Input.tsx index bd747805a0..1baac80b31 100644 --- a/app/dashboard/src/components/AriaComponents/Inputs/Input/Input.tsx +++ b/app/dashboard/src/components/AriaComponents/Inputs/Input/Input.tsx @@ -30,6 +30,7 @@ import SvgMask from '#/components/SvgMask' import { mergeRefs } from '#/utilities/mergeRefs' import { forwardRef } from '#/utilities/react' import type { ExtractFunction } from '#/utilities/tailwindVariants' +import { omit } from 'enso-common/src/utilities/data/object' import { INPUT_STYLES } from '../variants' /** @@ -62,24 +63,18 @@ export const Input = forwardRef(function Input< >(props: InputProps, ref: ForwardedRef) { const { name, - isDisabled = false, - form, - defaultValue, description, inputRef, addonStart, addonEnd, - label, size, rounded, - isRequired = false, - min, - max, icon, type = 'text', variant, variants = INPUT_STYLES, fieldVariants, + form, ...inputProps } = props @@ -87,32 +82,14 @@ export const Input = forwardRef(function Input< const privateInputRef = useRef(null) - const { fieldState, formInstance } = Form.useField({ - name, - isDisabled, + const { fieldProps, formInstance } = Form.useFieldRegister< + Omit, + Schema, + TFieldName + >({ + ...props, form, - defaultValue, - }) - - const classes = variants({ - variant, - size, - rounded, - invalid: fieldState.invalid, - readOnly: inputProps.readOnly, - disabled: isDisabled || formInstance.formState.isSubmitting, - }) - - const { ref: fieldRef, ...field } = formInstance.register(name, { - disabled: isDisabled, - required: isRequired, - ...(inputProps.onBlur && { onBlur: inputProps.onBlur }), - ...(inputProps.onChange && { onChange: inputProps.onChange }), - ...(inputProps.minLength != null ? { minLength: inputProps.minLength } : {}), - ...(inputProps.maxLength != null ? { maxLength: inputProps.maxLength } : {}), - ...(min != null ? { min } : {}), - ...(max != null ? { max } : {}), - setValueAs: (value) => { + setValueAs: (value: unknown) => { if (typeof value === 'string') { if (type === 'number') { return Number(value) @@ -128,24 +105,26 @@ export const Input = forwardRef(function Input< }, }) + const classes = variants({ + variant, + size, + rounded, + invalid: fieldProps.isInvalid, + readOnly: inputProps.readOnly, + disabled: fieldProps.disabled || formInstance.formState.isSubmitting, + }) + return ( >()(inputProps, omit(fieldProps), { + isHidden: props.hidden, + fullWidth: true, + variants: fieldVariants, + form: formInstance, + })} ref={ref} - style={props.style} - className={props.className} - variants={fieldVariants} + name={props.name} + data-testid={testId} >
()( - { className: classes.textArea(), type, name, min, max }, inputProps, - field, + { className: classes.textArea(), type, name }, + omit(fieldProps, 'isInvalid', 'isRequired', 'isDisabled', 'invalid'), )} + ref={mergeRefs(inputRef, privateInputRef, fieldProps.ref)} />
diff --git a/app/dashboard/src/components/AriaComponents/Inputs/MultiSelector/MultiSelector.tsx b/app/dashboard/src/components/AriaComponents/Inputs/MultiSelector/MultiSelector.tsx index bf46f380d7..82711703cb 100644 --- a/app/dashboard/src/components/AriaComponents/Inputs/MultiSelector/MultiSelector.tsx +++ b/app/dashboard/src/components/AriaComponents/Inputs/MultiSelector/MultiSelector.tsx @@ -22,7 +22,6 @@ import { mergeRefs } from '#/utilities/mergeRefs' import { forwardRef } from '#/utilities/react' import { tv } from '#/utilities/tailwindVariants' import { omit, unsafeRemoveUndefined } from 'enso-common/src/utilities/data/object' -import { Controller } from 'react-hook-form' import { MultiSelectorOption } from './MultiSelectorOption' /** * Props for the MultiSelector component. */ @@ -141,7 +140,7 @@ export const MultiSelector = forwardRef(function MultiSelector< className={classes.base()} onClick={() => privateInputRef.current?.focus({ preventScroll: true })} > - { diff --git a/app/dashboard/src/components/AriaComponents/Inputs/MultiSelector/MultiSelectorOption.tsx b/app/dashboard/src/components/AriaComponents/Inputs/MultiSelector/MultiSelectorOption.tsx index 76aa7fe4e5..b84fac5db0 100644 --- a/app/dashboard/src/components/AriaComponents/Inputs/MultiSelector/MultiSelectorOption.tsx +++ b/app/dashboard/src/components/AriaComponents/Inputs/MultiSelector/MultiSelectorOption.tsx @@ -1,9 +1,9 @@ /** @file An option in a selector. */ import { ListBoxItem, type ListBoxItemProps } from '#/components/aria' import { forwardRef } from '#/utilities/react' +import type { VariantProps } from '#/utilities/tailwindVariants' import { tv } from '#/utilities/tailwindVariants' import * as React from 'react' -import type { VariantProps } from 'tailwind-variants' import { TEXT_STYLE } from '../../Text' /** Props for a {@link MultiSelectorOption}. */ diff --git a/app/dashboard/src/components/AriaComponents/Inputs/OTPInput/OTPInput.tsx b/app/dashboard/src/components/AriaComponents/Inputs/OTPInput/OTPInput.tsx new file mode 100644 index 0000000000..7024e6fc08 --- /dev/null +++ b/app/dashboard/src/components/AriaComponents/Inputs/OTPInput/OTPInput.tsx @@ -0,0 +1,218 @@ +/** + * @file + */ +import { mergeProps } from '#/components/aria' +import { mergeRefs } from '#/utilities/mergeRefs' +import type { VariantProps } from '#/utilities/tailwindVariants' +import { tv } from '#/utilities/tailwindVariants' +import { omit } from 'enso-common/src/utilities/data/object' +import type { OTPInputProps } from 'input-otp' +import { OTPInput as BaseOTPInput, type SlotProps as OTPInputSlotProps } from 'input-otp' +import type { ForwardedRef, Ref } from 'react' +import { forwardRef, useRef } from 'react' +import type { + FieldComponentProps, + FieldPath, + FieldProps, + FieldStateProps, + FieldVariantProps, + TSchema, +} from '../../Form' +import { Form } from '../../Form' +import { Separator } from '../../Separator' +import { TEXT_STYLE } from '../../Text' +import type { TestIdProps } from '../../types' + +/** + * Props for an {@link OTPInput}. + */ +export interface OtpInputProps> + extends FieldStateProps, Schema, TFieldName>, + FieldProps, + FieldVariantProps, + Omit, 'disabled' | 'invalid'>, + TestIdProps { + readonly inputRef?: Ref + readonly maxLength: number + readonly className?: string + /** + * Whether to submit the form when the OTP is filled. + * @default true + */ + readonly submitOnComplete?: boolean + /** + * Callback when the OTP is filled. + */ + readonly onComplete?: () => void +} + +const STYLES = tv({ + base: 'group flex overflow-hidden p-1 w-[calc(100%+8px)] -m-1 flex-1', + slots: { + slotsContainer: 'flex items-center justify-center flex-1 w-full gap-1', + }, +}) + +const SLOT_STYLES = tv({ + base: [ + 'flex-1 h-10 min-w-8 flex items-center justify-center', + 'border border-primary rounded-xl', + 'outline outline-1 outline-transparent -outline-offset-2', + 'transition-[outline-offset] duration-200', + ], + variants: { + isActive: { true: 'relative outline-offset-0 outline-2 outline-primary' }, + isInvalid: { true: { base: 'border-danger', char: 'text-danger' } }, + }, + slots: { + char: TEXT_STYLE({ + variant: 'body', + weight: 'bold', + color: 'current', + }), + fakeCaret: + 'absolute pointer-events-none inset-0 flex items-center justify-center animate-caret-blink before:w-px before:h-5 before:bg-primary', + }, + compoundVariants: [ + { + isActive: true, + isInvalid: true, + class: { base: 'outline-danger' }, + }, + ], +}) + +/** + * Accessible one-time password component with copy paste functionality. + */ +export const OTPInput = forwardRef(function OTPInput< + Schema extends TSchema, + TFieldName extends FieldPath, +>(props: OtpInputProps, ref: ForwardedRef) { + const { + maxLength, + variants = STYLES, + className, + name, + fieldVariants, + inputRef, + submitOnComplete = true, + onComplete, + form, + ...inputProps + } = props + + const innerOtpInputRef = useRef(null) + const classes = variants({ className }) + + const { fieldProps, formInstance } = Form.useFieldRegister({ + ...props, + form, + }) + + return ( + >()(inputProps, omit(fieldProps), { + isHidden: props.hidden, + fullWidth: true, + variants: fieldVariants, + form: formInstance, + })} + ref={ref} + name={props.name} + > + ()( + inputProps, + omit(fieldProps, 'isInvalid', 'isRequired', 'isDisabled', 'invalid'), + { + name, + maxLength, + noScriptCSSFallback: null, + containerClassName: classes.base(), + onClick: () => { + if (innerOtpInputRef.current) { + // Check if the input is not already focused + if (document.activeElement !== innerOtpInputRef.current) { + innerOtpInputRef.current.focus() + } + } + }, + onComplete: () => { + onComplete?.() + + if (submitOnComplete) { + void formInstance.trigger(name).then(() => formInstance.submit()) + } + }, + }, + )} + ref={mergeRefs(fieldProps.ref, inputRef, innerOtpInputRef)} + render={({ slots }) => { + const sections = (() => { + const items = [] + const remainingSlots = slots.length % 3 + + const sectionsCount = Math.floor(slots.length / 3) + (remainingSlots > 0 ? 1 : 0) + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + if (slots.length < 6) { + items.push(slots) + } else { + for (let i = 0; i < sectionsCount; i++) { + const section = slots.slice(i * 3, (i + 1) * 3) + items.push(section) + } + } + + return items + })() + + return ( +
+ {sections.map((section, idx) => ( + <> +
+ {section.map((slot, key) => ( + + ))} +
+ + {idx < sections.length - 1 && ( + + )} + + ))} +
+ ) + }} + /> +
+ ) +}) + +/** + * Props for a single {@link Slot}. + */ +interface SlotProps extends Omit, VariantProps {} + +/** + * Slot is a component that represents a single char in the OTP input. + * @internal + */ +function Slot(props: SlotProps) { + const { char, isActive, hasFakeCaret, variants = SLOT_STYLES, isInvalid } = props + const classes = variants({ isActive, isInvalid }) + + return ( +
+ {char != null &&
{char}
} + {hasFakeCaret &&
} +
+ ) +} diff --git a/app/dashboard/src/components/AriaComponents/Inputs/OTPInput/index.ts b/app/dashboard/src/components/AriaComponents/Inputs/OTPInput/index.ts new file mode 100644 index 0000000000..f623bee2a1 --- /dev/null +++ b/app/dashboard/src/components/AriaComponents/Inputs/OTPInput/index.ts @@ -0,0 +1,6 @@ +/** + * @file + * + * Barrel export file for OTPInput + */ +export * from './OTPInput' diff --git a/app/dashboard/src/components/AriaComponents/Inputs/Password/Password.tsx b/app/dashboard/src/components/AriaComponents/Inputs/Password/Password.tsx index f841135281..f5e81b5f0a 100644 --- a/app/dashboard/src/components/AriaComponents/Inputs/Password/Password.tsx +++ b/app/dashboard/src/components/AriaComponents/Inputs/Password/Password.tsx @@ -7,6 +7,7 @@ import EyeCrossedIcon from '#/assets/eye_crossed.svg' import { Button, Input, + type FieldPath, type FieldValues, type InputProps, type TSchema, @@ -17,7 +18,7 @@ import { // ================ /** Props for a {@link Password}. */ -export interface PasswordProps>> +export interface PasswordProps> extends Omit, 'type'> {} /** A component wrapping {@link Input} with the ability to show and hide password. */ diff --git a/app/dashboard/src/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx b/app/dashboard/src/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx index 91c2d9da3e..7cf417c0d6 100644 --- a/app/dashboard/src/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx +++ b/app/dashboard/src/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx @@ -35,8 +35,10 @@ export interface ResizableContentEditableInputProps< VariantProps, 'disabled' | 'invalid' | 'rounded' | 'size' | 'variant' >, + FieldVariantProps, Omit, FieldVariantProps, + Pick, 'rounded' | 'size' | 'variant'>, Omit< VariantProps, 'disabled' | 'invalid' | 'rounded' | 'size' | 'variant' diff --git a/app/dashboard/src/components/AriaComponents/Inputs/Selector/Selector.tsx b/app/dashboard/src/components/AriaComponents/Inputs/Selector/Selector.tsx index 569ea018c4..9c5e1cb8d3 100644 --- a/app/dashboard/src/components/AriaComponents/Inputs/Selector/Selector.tsx +++ b/app/dashboard/src/components/AriaComponents/Inputs/Selector/Selector.tsx @@ -4,12 +4,14 @@ import * as React from 'react' import type * as twv from 'tailwind-variants' import { mergeProps, type RadioGroupProps } from '#/components/aria' +import type { FieldComponentProps } from '#/components/AriaComponents' import { + Form, type FieldPath, type FieldProps, type FieldStateProps, type FieldValues, - Form, + type FieldVariantProps, type TSchema, } from '#/components/AriaComponents' @@ -18,7 +20,6 @@ import RadioGroup from '#/components/styled/RadioGroup' import { mergeRefs } from '#/utilities/mergeRefs' import { forwardRef } from '#/utilities/react' import { tv } from '#/utilities/tailwindVariants' -import { Controller } from 'react-hook-form' import { SelectorOption } from './SelectorOption' /** * Props for the Selector component. */ @@ -29,7 +30,8 @@ export interface SelectorProps, FieldProps, - Omit, 'disabled' | 'invalid'> { + Omit, 'disabled' | 'invalid'>, + FieldVariantProps { readonly items: readonly FieldValues[TFieldName][] readonly children?: (item: FieldValues[TFieldName]) => string readonly columns?: number @@ -90,23 +92,20 @@ export const Selector = forwardRef(function Selector< isDisabled = false, columns, form, - defaultValue, inputRef, label, size, rounded, isRequired = false, + isInvalid = false, + fieldVariants, + defaultValue, ...inputProps } = props const privateInputRef = React.useRef(null) - const { fieldState, formInstance } = Form.useField({ - name, - isDisabled, - form, - ...(defaultValue != null ? { defaultValue } : {}), - }) + const formInstance = Form.useFormContext(form) const classes = SELECTOR_STYLES({ size, @@ -116,51 +115,49 @@ export const Selector = forwardRef(function Selector< }) return ( - -
privateInputRef.current?.focus({ preventScroll: true })} - > - { - const { ref: fieldRef, value, onChange, ...field } = renderProps.field - return ( + render={(renderProps) => { + const { value } = renderProps.field + return ( + >()(inputProps, renderProps.field, { + fullWidth: true, + variants: fieldVariants, + form: formInstance, + label, + isRequired, + })} + name={props.name} + ref={ref} + > +
privateInputRef.current?.focus({ preventScroll: true })} + > ()( { className: classes.radioGroup(), name, isRequired, isDisabled, + isInvalid, style: columns != null ? { gridTemplateColumns: `repeat(${columns}, 1fr)` } : {}, + ...(defaultValue != null ? { defaultValue } : {}), }, inputProps, - field, + renderProps.field, )} + ref={mergeRefs(inputRef, privateInputRef, renderProps.field.ref)} // eslint-disable-next-line no-restricted-syntax aria-label={props['aria-label'] ?? (typeof label === 'string' ? label : '')} value={String(items.indexOf(value))} onChange={(newValue) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-return - onChange(items[Number(newValue)]) + renderProps.field.onChange(items[Number(newValue)]) }} > @@ -169,10 +166,10 @@ export const Selector = forwardRef(function Selector< ))} - ) - }} - /> -
-
+
+
+ ) + }} + /> ) }) diff --git a/app/dashboard/src/components/AriaComponents/Inputs/Selector/SelectorOption.tsx b/app/dashboard/src/components/AriaComponents/Inputs/Selector/SelectorOption.tsx index 9298ff2ba9..77225f3298 100644 --- a/app/dashboard/src/components/AriaComponents/Inputs/Selector/SelectorOption.tsx +++ b/app/dashboard/src/components/AriaComponents/Inputs/Selector/SelectorOption.tsx @@ -2,9 +2,9 @@ import { AnimatedBackground } from '#/components/AnimatedBackground' import { Radio, type RadioProps } from '#/components/aria' import { forwardRef } from '#/utilities/react' +import type { VariantProps } from '#/utilities/tailwindVariants' import { tv } from '#/utilities/tailwindVariants' import * as React from 'react' -import type { VariantProps } from 'tailwind-variants' import { TEXT_STYLE } from '../../Text' /** Props for a {@link SelectorOption}. */ @@ -99,9 +99,18 @@ export const SelectorOption = forwardRef(function SelectorOption( props: SelectorOptionProps, ref: React.ForwardedRef, ) { - const { label, value, size, rounded, variant, className, ...radioProps } = props + const { + label, + value, + size, + rounded, + variant, + className, + variants = SELECTOR_OPTION_STYLES, + ...radioProps + } = props - const styles = SELECTOR_OPTION_STYLES({ size, rounded, variant }) + const styles = variants({ size, rounded, variant }) return ( ()(ariaSwitchProps, fieldProps, { defaultSelected: field.value, className: switchStyles(), + onChange: field.onChange, + onBlur: field.onBlur, })} >
diff --git a/app/dashboard/src/components/AriaComponents/Text/Text.tsx b/app/dashboard/src/components/AriaComponents/Text/Text.tsx index 7475aa92cd..2e7f249540 100644 --- a/app/dashboard/src/components/AriaComponents/Text/Text.tsx +++ b/app/dashboard/src/components/AriaComponents/Text/Text.tsx @@ -212,6 +212,8 @@ export const Text = forwardRef(function Text(props: TextProps, ref: React.Ref & TextProps> & { // eslint-disable-next-line @typescript-eslint/naming-convention Heading: typeof Heading + // eslint-disable-next-line @typescript-eslint/naming-convention + Group: React.FC } /** @@ -234,3 +236,14 @@ const Heading = forwardRef(function Heading( return }) Text.Heading = Heading + +/** + * Text group component. It's used to visually group text elements together + */ +Text.Group = function TextGroup(props: React.PropsWithChildren) { + return ( + + {props.children} + + ) +} diff --git a/app/dashboard/src/components/Devtools/EnsoDevtools.tsx b/app/dashboard/src/components/Devtools/EnsoDevtools.tsx index b1430c93e7..9d10f73aff 100644 --- a/app/dashboard/src/components/Devtools/EnsoDevtools.tsx +++ b/app/dashboard/src/components/Devtools/EnsoDevtools.tsx @@ -168,8 +168,8 @@ export function EnsoDevtools() { = T | ((props: RenderStepProps) => T) + +/** + * Props for {@link Step} component. + */ +export interface StepProps extends RenderStepProps { + readonly className?: StepProp + readonly icon?: StepProp + readonly completeIcon?: StepProp + readonly title?: StepProp + readonly description?: StepProp + readonly children?: StepProp +} + +const STEP_STYLES = tvw.tv({ + base: 'relative flex items-center gap-2 select-none', + slots: { + icon: 'w-6 h-6 border-0.5 flex-none border-current rounded-full flex items-center justify-center transition-colors duration-200', + titleContainer: '-mt-1 flex flex-col items-start justify-start transition-colors duration-200', + content: 'flex-1', + }, + variants: { + position: { first: 'rounded-l-full', last: 'rounded-r-full' }, + status: { + completed: { + base: 'text-primary', + icon: 'bg-primary border-transparent text-invert', + content: 'text-primary', + }, + current: { base: 'text-primary', content: 'text-primary/30' }, + next: { base: 'text-primary/30', content: 'text-primary/30' }, + }, + }, +}) + +/** + * A step component is used to represent a single step in a stepper component. + */ +export function Step(props: StepProps) { + const { + index, + title, + description, + isCompleted, + goToStep, + nextStep, + previousStep, + totalSteps, + currentStep, + isCurrent, + isLast, + isFirst, + isDisabled, + className, + children, + icon = ( + + {index + 1} + + ), + completeIcon = DoneIcon, + } = props + + const { state } = stepperProvider.useStepperContext() + + const renderStepProps = { + isCompleted, + goToStep, + nextStep, + previousStep, + totalSteps, + currentStep, + isCurrent, + isLast, + isFirst, + isDisabled, + index, + } satisfies RenderStepProps + + const classes = typeof className === 'function' ? className(renderStepProps) : className + const descriptionElement = + typeof description === 'function' ? description(renderStepProps) : description + const titleElement = typeof title === 'function' ? title(renderStepProps) : title + const iconElement = typeof icon === 'function' ? icon(renderStepProps) : icon + const doneIconElement = + typeof completeIcon === 'function' ? completeIcon(renderStepProps) : completeIcon + + const styles = STEP_STYLES({ + className: classes, + position: + isFirst ? 'first' + : isLast ? 'last' + : undefined, + status: + isCompleted ? 'completed' + : isCurrent ? 'current' + : 'next', + }) + + const stepAnimationRotation = 30 + const stepAnimationScale = 0.5 + + return ( +
+ + ({ + rotate: direction === 'back' ? -stepAnimationRotation : stepAnimationRotation, + scale: stepAnimationScale, + opacity: 0, + position: 'absolute', + top: 0, + }), + }} + transition={{ + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + rotate: { type: 'spring', stiffness: 500, damping: 100, bounce: 0, duration: 0.2 }, + }} + > + {(() => { + const renderIconElement = isCompleted ? doneIconElement : iconElement + + if (renderIconElement == null) { + return null + } else if (typeof renderIconElement === 'string') { + return + } else { + return renderIconElement + } + })()} + + + +
+ {titleElement != null && ( +
+ {typeof titleElement === 'string' ? + + {titleElement} + + : titleElement} +
+ )} + + {descriptionElement != null && ( +
+ {typeof descriptionElement === 'string' ? + + {descriptionElement} + + : descriptionElement} +
+ )} +
+
+ {typeof children === 'function' ? children(renderStepProps) : children} +
+
+ ) +} diff --git a/app/dashboard/src/components/Stepper/StepContent.tsx b/app/dashboard/src/components/Stepper/StepContent.tsx new file mode 100644 index 0000000000..3a4c98395d --- /dev/null +++ b/app/dashboard/src/components/Stepper/StepContent.tsx @@ -0,0 +1,42 @@ +/** + * @file + * Component to render the step content. + */ +import type { ReactElement, ReactNode } from 'react' +import { useStepperContext } from './StepperProvider' +import type { RenderChildrenProps } from './types' + +/** + * Props for {@link StepContent} component. + */ +export interface StepContentProps { + readonly index: number + readonly children: ReactNode | ((props: RenderChildrenProps) => ReactNode) + readonly forceRender?: boolean +} + +/** + * Step content component. Renders the step content if the step is current or if `forceRender` is true. + */ +export function StepContent(props: StepContentProps): ReactElement | null { + const { index, children, forceRender = false } = props + const { currentStep, goToStep, nextStep, previousStep, totalSteps } = useStepperContext() + + const isCurrent = currentStep === index + + const renderProps = { + currentStep, + totalSteps, + isFirst: currentStep === 0, + isLast: currentStep === totalSteps - 1, + goToStep, + nextStep, + previousStep, + } satisfies RenderChildrenProps + + if (isCurrent || forceRender) { + return <>{typeof children === 'function' ? children(renderProps) : children} + } else { + return null + } +} diff --git a/app/dashboard/src/components/Stepper/Stepper.tsx b/app/dashboard/src/components/Stepper/Stepper.tsx index ad3198055d..c1b657d6aa 100644 --- a/app/dashboard/src/components/Stepper/Stepper.tsx +++ b/app/dashboard/src/components/Stepper/Stepper.tsx @@ -8,62 +8,17 @@ import * as React from 'react' import { AnimatePresence, motion } from 'framer-motion' import * as tvw from 'tailwind-variants' -import DoneIcon from '#/assets/check_mark.svg' - import * as eventCallback from '#/hooks/eventCallbackHooks' -import * as ariaComponents from '#/components/AriaComponents' import { ErrorBoundary } from '#/components/ErrorBoundary' import { Suspense } from '#/components/Suspense' -import SvgMask from '#/components/SvgMask' +import { Step } from './Step' +import { StepContent } from './StepContent' import * as stepperProvider from './StepperProvider' +import type { BaseRenderProps, RenderChildrenProps, RenderStepProps } from './types' import * as stepperState from './useStepperState' -/** - * Render props for the stepper component. - */ -export interface BaseRenderProps { - readonly goToStep: (step: number) => void - readonly nextStep: () => void - readonly previousStep: () => void - readonly currentStep: number - readonly totalSteps: number -} - -/** - * Render props for rendering children of the stepper component. - */ -export interface RenderChildrenProps extends BaseRenderProps { - readonly isFirst: boolean - readonly isLast: boolean -} - -/** - * Render props for lazy rendering of steps. - */ -export interface RenderStepProps extends BaseRenderProps { - /** - * The index of the step, starting from 0. - */ - readonly index: number - readonly isCurrent: boolean - readonly isCompleted: boolean - readonly isFirst: boolean - readonly isLast: boolean - readonly isDisabled: boolean -} - -/** - * Render props for styling the stepper component. - */ -export interface RenderStepperProps { - readonly currentStep: number - readonly totalSteps: number - readonly isFirst: boolean - readonly isLast: boolean -} - /** * Props for {@link Stepper} component. */ @@ -228,187 +183,6 @@ export function Stepper(props: StepperProps) { ) } -/** A prop with the given type, or a function to produce a value of the given type. */ -type StepProp = T | ((props: RenderStepProps) => T) - -/** - * Props for {@link Step} component. - */ -export interface StepProps extends RenderStepProps { - readonly className?: StepProp - readonly icon?: StepProp - readonly completeIcon?: StepProp - readonly title?: StepProp - readonly description?: StepProp - readonly children?: StepProp -} - -const STEP_STYLES = tvw.tv({ - base: 'relative flex items-center gap-2 select-none', - slots: { - icon: 'w-6 h-6 border-0.5 flex-none border-current rounded-full flex items-center justify-center transition-colors duration-200', - titleContainer: '-mt-1 flex flex-col items-start justify-start transition-colors duration-200', - content: 'flex-1', - }, - variants: { - position: { first: 'rounded-l-full', last: 'rounded-r-full' }, - status: { - completed: { - base: 'text-primary', - icon: 'bg-primary border-transparent text-invert', - content: 'text-primary', - }, - current: { base: 'text-primary', content: 'text-primary/30' }, - next: { base: 'text-primary/30', content: 'text-primary/30' }, - }, - }, -}) - -/** - * A step component is used to represent a single step in a stepper component. - */ -function Step(props: StepProps) { - const { - index, - title, - description, - isCompleted, - goToStep, - nextStep, - previousStep, - totalSteps, - currentStep, - isCurrent, - isLast, - isFirst, - isDisabled, - className, - children, - icon = ( - - {index + 1} - - ), - completeIcon = DoneIcon, - } = props - - const { state } = stepperProvider.useStepperContext() - - const renderStepProps = { - isCompleted, - goToStep, - nextStep, - previousStep, - totalSteps, - currentStep, - isCurrent, - isLast, - isFirst, - isDisabled, - index, - } satisfies RenderStepProps - - const classes = typeof className === 'function' ? className(renderStepProps) : className - const descriptionElement = - typeof description === 'function' ? description(renderStepProps) : description - const titleElement = typeof title === 'function' ? title(renderStepProps) : title - const iconElement = typeof icon === 'function' ? icon(renderStepProps) : icon - const doneIconElement = - typeof completeIcon === 'function' ? completeIcon(renderStepProps) : completeIcon - - const styles = STEP_STYLES({ - className: classes, - position: - isFirst ? 'first' - : isLast ? 'last' - : undefined, - status: - isCompleted ? 'completed' - : isCurrent ? 'current' - : 'next', - }) - - const stepAnimationRotation = 30 - const stepAnimationScale = 0.5 - - return ( -
- - ({ - rotate: direction === 'back' ? -stepAnimationRotation : stepAnimationRotation, - scale: stepAnimationScale, - opacity: 0, - position: 'absolute', - top: 0, - }), - }} - transition={{ - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - rotate: { type: 'spring', stiffness: 500, damping: 100, bounce: 0, duration: 0.2 }, - }} - > - {(() => { - const renderIconElement = isCompleted ? doneIconElement : iconElement - - if (renderIconElement == null) { - return null - } else if (typeof renderIconElement === 'string') { - return - } else { - return renderIconElement - } - })()} - - - -
- {titleElement != null && ( -
- {typeof titleElement === 'string' ? - - {titleElement} - - : titleElement} -
- )} - - {descriptionElement != null && ( -
- {typeof descriptionElement === 'string' ? - - {descriptionElement} - - : descriptionElement} -
- )} -
-
- {typeof children === 'function' ? children(renderStepProps) : children} -
-
- ) -} - Stepper.Step = Step +Stepper.StepContent = StepContent Stepper.useStepperState = stepperState.useStepperState diff --git a/app/dashboard/src/components/Stepper/types.ts b/app/dashboard/src/components/Stepper/types.ts new file mode 100644 index 0000000000..3873571959 --- /dev/null +++ b/app/dashboard/src/components/Stepper/types.ts @@ -0,0 +1,39 @@ +/** + * @file + * + * Types for the stepper component. + */ + +/** + * Render props for the stepper component. + */ +export interface BaseRenderProps { + readonly goToStep: (step: number) => void + readonly nextStep: () => void + readonly previousStep: () => void + readonly currentStep: number + readonly totalSteps: number +} + +/** + * Render props for rendering children of the stepper component. + */ +export interface RenderChildrenProps extends BaseRenderProps { + readonly isFirst: boolean + readonly isLast: boolean +} + +/** + * Render props for lazy rendering of steps. + */ +export interface RenderStepProps extends BaseRenderProps { + /** + * The index of the step, starting from 0. + */ + readonly index: number + readonly isCurrent: boolean + readonly isCompleted: boolean + readonly isFirst: boolean + readonly isLast: boolean + readonly isDisabled: boolean +} diff --git a/app/dashboard/src/components/Stepper/useStepperState.ts b/app/dashboard/src/components/Stepper/useStepperState.ts index 8828b28026..72ff5412c2 100644 --- a/app/dashboard/src/components/Stepper/useStepperState.ts +++ b/app/dashboard/src/components/Stepper/useStepperState.ts @@ -81,19 +81,28 @@ export function useStepperState(props: StepperStateProps): UseStepperStateResult const setCurrentStep = eventCallbackHooks.useEventCallback( (step: number | ((current: number) => number)) => { - privateSetCurrentStep((current) => { - const nextStep = typeof step === 'function' ? step(current.current) : step - const direction = nextStep > current.current ? 'forward' : 'back' + React.startTransition(() => { + privateSetCurrentStep((current) => { + const nextStep = typeof step === 'function' ? step(current.current) : step + const direction = nextStep > current.current ? 'forward' : 'back' - if (nextStep < 0) { - return { current: 0, direction: 'back-none' } - } else if (nextStep > steps - 1) { - onCompletedStableCallback() - return { current: steps - 1, direction: 'forward-none' } - } else { - onStepChangeStableCallback(nextStep, direction) - return { current: nextStep, direction } - } + if (nextStep < 0) { + return { + current: 0, + direction: 'back-none', + } + } else if (nextStep > steps - 1) { + onCompletedStableCallback() + return { + current: steps - 1, + direction: 'forward-none', + } + } else { + onStepChangeStableCallback(nextStep, direction) + + return { current: nextStep, direction } + } + }) }) }, ) diff --git a/app/dashboard/src/components/dashboard/AssetRow.tsx b/app/dashboard/src/components/dashboard/AssetRow.tsx index d6656d850e..85bd94f980 100644 --- a/app/dashboard/src/components/dashboard/AssetRow.tsx +++ b/app/dashboard/src/components/dashboard/AssetRow.tsx @@ -331,7 +331,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { break } default: { - return + break } } } else { @@ -549,18 +549,18 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { } case AssetEventType.deleteLabel: { setAsset((oldAsset) => { - // The IIFE is required to prevent TypeScript from narrowing this value. - let found = (() => false)() - const labels = - oldAsset.labels?.filter((label) => { - if (label === event.labelName) { - found = true - return false - } else { - return true - } - }) ?? null - return found ? object.merge(oldAsset, { labels }) : oldAsset + const oldLabels = oldAsset.labels ?? [] + const labels: backendModule.LabelName[] = [] + + for (const label of oldLabels) { + if (label !== event.labelName) { + labels.push(label) + } + } + + return oldLabels.length !== labels.length ? + object.merge(oldAsset, { labels }) + : oldAsset }) break } diff --git a/app/dashboard/src/components/dashboard/DatalinkInput.tsx b/app/dashboard/src/components/dashboard/DatalinkInput.tsx index de2363dfff..f9653c30fa 100644 --- a/app/dashboard/src/components/dashboard/DatalinkInput.tsx +++ b/app/dashboard/src/components/dashboard/DatalinkInput.tsx @@ -6,10 +6,9 @@ import type * as jsonSchemaInput from '#/components/JSONSchemaInput' import JSONSchemaInput from '#/components/JSONSchemaInput' import { FieldError } from '#/components/aria' -import type { FieldValues, FormInstance, TSchema } from '#/components/AriaComponents' -import { useFormContext } from '#/components/AriaComponents/Form/components/useFormContext' +import type { FieldPath, FormInstance, TSchema } from '#/components/AriaComponents' +import { Form } from '#/components/AriaComponents' import * as error from '#/utilities/error' -import { Controller, type FieldPath } from 'react-hook-form' // ================= // === Constants === @@ -52,17 +51,17 @@ export default function DatalinkInput(props: DatalinkInputProps) { export interface DatalinkFormInputProps extends Omit { readonly form?: FormInstance - readonly name: FieldPath> + readonly name: FieldPath } /** A dynamic wizard for creating an arbitrary type of Datalink. */ export function DatalinkFormInput(props: DatalinkFormInputProps) { - const fallbackForm = useFormContext() - // eslint-disable-next-line no-restricted-syntax - const { form = fallbackForm as unknown as FormInstance, name, ...inputProps } = props + const { name, ...inputProps } = props + + const form = Form.useFormContext(props.form) return ( - { diff --git a/app/dashboard/src/globals.d.ts b/app/dashboard/src/globals.d.ts index 6a03d16d5e..6c399e31ff 100644 --- a/app/dashboard/src/globals.d.ts +++ b/app/dashboard/src/globals.d.ts @@ -203,13 +203,13 @@ declare global { // @ts-expect-error The index signature is intentional to disallow unknown env vars. readonly ENSO_CLOUD_STRIPE_KEY?: string // @ts-expect-error The index signature is intentional to disallow unknown env vars. - readonly ENSO_CLOUD_COGNITO_USER_POOL_ID?: string + readonly ENSO_CLOUD_COGNITO_USER_POOL_ID: string // @ts-expect-error The index signature is intentional to disallow unknown env vars. - readonly ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID?: string + readonly ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: string // @ts-expect-error The index signature is intentional to disallow unknown env vars. - readonly ENSO_CLOUD_COGNITO_DOMAIN?: string + readonly ENSO_CLOUD_COGNITO_DOMAIN: string // @ts-expect-error The index signature is intentional to disallow unknown env vars. - readonly ENSO_CLOUD_COGNITO_REGION?: string + readonly ENSO_CLOUD_COGNITO_REGION: string // @ts-expect-error The index signature is intentional to disallow unknown env vars. readonly ENSO_CLOUD_GOOGLE_ANALYTICS_TAG?: string // @ts-expect-error The index signature is intentional to disallow unknown env vars. diff --git a/app/dashboard/src/layouts/Settings.tsx b/app/dashboard/src/layouts/Settings.tsx index 65e15f7e0b..ef406b1bbe 100644 --- a/app/dashboard/src/layouts/Settings.tsx +++ b/app/dashboard/src/layouts/Settings.tsx @@ -183,7 +183,7 @@ export default function Settings() { }, [isQueryBlank, doesEntryMatchQuery, getText, isMatch, effectiveTab]) return ( -
+
+ + +
z.object({ otp: z.string().min(6).max(6) })} + formOptions={{ mode: 'onSubmit' }} + method="dialog" + onSubmit={({ otp }) => + cognito.verifyTotpToken(otp).then((res) => { + if (res.ok) { + return updateMFAPreferenceMutation.mutateAsync('NOMFA') + } else { + throw res.val + } + }) + } + > + {getText('disable2FAWarning')} + + + + + {getText('disable')} + {getText('cancel')} + + + + +
+ +
+
+ ) + } else { + return ( +
+ z.object({ + enabled: z.boolean(), + display: z.string(), + /* eslint-disable-next-line @typescript-eslint/no-magic-numbers */ + otp: z.string().min(6).max(6), + }) + } + defaultValues={{ enabled: false, display: 'qr' }} + onSubmit={async ({ enabled, otp }) => { + if (enabled) { + return cognito.verifyTotpToken(otp).then((res) => { + if (res.ok) { + return updateMFAPreferenceMutation.mutateAsync('TOTP') + } else { + throw res.val + } + }) + } + }} + > + {({ values }) => ( + <> + + + + {values.enabled === true && } + + + )} + + ) + } +} + +/** + * Two Factor Authentication Setup Form. + */ +function TwoFa() { + const { cognito } = useAuth() + const { getText } = useText() + + const { data } = useSuspenseQuery({ + queryKey: ['setupTOTP'], + queryFn: () => + cognito.setupTOTP().then((res) => { + if (res.err) { + throw res.val + } else { + return res.unwrap() + } + }), + }) + + const { field } = Form.useField({ name: 'display' }) + + return ( + <> +
+ + + {field.value === 'qr' && ( + <> + + + + {getText('scanQR')} + + + {getText('scanQRDescription')} + + + +
+ +
+ + )} + + {field.value === 'text' && ( + <> + + + + {getText('copyLink')} + + {getText('copyLinkDescription')} + + + + + + )} + + +
+ + + {getText('enable')} + + {getText('cancel')} + + + + + ) +} diff --git a/app/dashboard/src/layouts/Settings/settingsData.tsx b/app/dashboard/src/layouts/Settings/settingsData.tsx index a73ef2d8ee..a6b7faaf4b 100644 --- a/app/dashboard/src/layouts/Settings/settingsData.tsx +++ b/app/dashboard/src/layouts/Settings/settingsData.tsx @@ -42,6 +42,7 @@ import type RemoteBackend from '#/services/RemoteBackend' import { normalizePath } from '#/utilities/fileInfo' import * as object from '#/utilities/object' +import { SetupTwoFaForm } from './SetupTwoFaForm' // ========================= // === SettingsEntryType === @@ -124,6 +125,23 @@ export const SETTINGS_TAB_DATA: Readonly { + // The shape of the JWT payload is statically known. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const username: string | null = + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-non-null-assertion + JSON.parse(atob(context.accessToken.split('.')[1]!)).username + return username != null ? !/^Github_|^Google_/.test(username) : false + }, + }, + ], + }, { nameId: 'deleteUserAccountSettingsSection', heading: false, diff --git a/app/dashboard/src/modals/UpsertSecretModal.tsx b/app/dashboard/src/modals/UpsertSecretModal.tsx index bbb3ec1e76..ffaafdbc48 100644 --- a/app/dashboard/src/modals/UpsertSecretModal.tsx +++ b/app/dashboard/src/modals/UpsertSecretModal.tsx @@ -1,18 +1,8 @@ /** @file Modal for confirming delete of any type of asset. */ -import * as z from 'zod' - -import { Button, ButtonGroup, Dialog, Form, Input, Password } from '#/components/AriaComponents' +import { ButtonGroup, Dialog, Form, Input, Password } from '#/components/AriaComponents' import { useText } from '#/providers/TextProvider' import type { SecretId } from '#/services/Backend' -/** Create the schema for this form. */ -function createUpsertSecretSchema() { - return z.object({ - name: z.string().min(1), - value: z.string(), - }) -} - // ========================= // === UpsertSecretModal === // ========================= @@ -34,49 +24,43 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) { return ( - {({ close }) => ( -
{ - await doCreate(name, value) - }} - > - {({ form }) => ( - <> - - - - - {isCreatingSecret ? getText('create') : getText('update')} - - - - - )} - - )} +
z.object({ name: z.string().min(1), value: z.string() })} + defaultValues={{ name: nameRaw ?? '', value: '' }} + onSubmit={async ({ name, value }) => { + await doCreate(name, value) + }} + > + {({ form }) => ( + <> + + + + {isCreatingSecret ? getText('create') : getText('update')} + + + + )} +
) } diff --git a/app/dashboard/src/modules/payments/api/createPaymentMethod.ts b/app/dashboard/src/modules/payments/api/createPaymentMethod.ts new file mode 100644 index 0000000000..bdda11e118 --- /dev/null +++ b/app/dashboard/src/modules/payments/api/createPaymentMethod.ts @@ -0,0 +1,38 @@ +/** + * @file + * + * API for creating a payment method. + */ +import type { Stripe, StripeCardElement } from '@stripe/stripe-js' +import { useMutation } from '@tanstack/react-query' + +/** + * Parameters for the `createPaymentMethod` mutation. + */ +export interface CreatePaymentMethodMutationParams { + readonly cardElement?: StripeCardElement | null | undefined + readonly stripeInstance: Stripe +} + +/** + * Hook for creating a payment method. + */ +export function useCreatePaymentMethodMutation() { + return useMutation({ + mutationFn: async (params: CreatePaymentMethodMutationParams) => { + if (!params.cardElement) { + throw new Error('Unexpected error') + } else { + return params.stripeInstance + .createPaymentMethod({ type: 'card', card: params.cardElement }) + .then((result) => { + if (result.error) { + throw new Error(result.error.message) + } else { + return result + } + }) + } + }, + }) +} diff --git a/app/dashboard/src/modules/payments/api/index.ts b/app/dashboard/src/modules/payments/api/index.ts index 010307ebb8..335941e523 100644 --- a/app/dashboard/src/modules/payments/api/index.ts +++ b/app/dashboard/src/modules/payments/api/index.ts @@ -3,4 +3,5 @@ * * Barrel file for payments api */ +export * from './createPaymentMethod' export * from './useSubscriptionPrice' diff --git a/app/dashboard/src/modules/payments/components/AddPaymentMethodForm.tsx b/app/dashboard/src/modules/payments/components/AddPaymentMethodForm.tsx index cf5a51d451..0f4024abb5 100644 --- a/app/dashboard/src/modules/payments/components/AddPaymentMethodForm.tsx +++ b/app/dashboard/src/modules/payments/components/AddPaymentMethodForm.tsx @@ -7,11 +7,11 @@ import * as React from 'react' import * as stripeReact from '@stripe/react-stripe-js' import type * as stripeJs from '@stripe/stripe-js' -import * as reactQuery from '@tanstack/react-query' import * as text from '#/providers/TextProvider' import * as ariaComponents from '#/components/AriaComponents' +import { useCreatePaymentMethodMutation } from '../api/createPaymentMethod' /** * Props for {@link AddPaymentMethodForm}. @@ -44,6 +44,8 @@ export const ADD_PAYMENT_METHOD_FORM_SCHEMA = ariaComponents.Form.schema.object( (data) => data?.error == null, (data) => ({ message: data?.error?.message ?? 'This field is required' }), ), + cardElement: ariaComponents.Form.schema.custom(), + stripeInstance: ariaComponents.Form.schema.custom(), }) /** @@ -52,54 +54,33 @@ export const ADD_PAYMENT_METHOD_FORM_SCHEMA = ariaComponents.Form.schema.object( export function AddPaymentMethodForm< Schema extends typeof ADD_PAYMENT_METHOD_FORM_SCHEMA = typeof ADD_PAYMENT_METHOD_FORM_SCHEMA, >(props: AddPaymentMethodFormProps) { - const { stripeInstance, elements, onSubmit, submitText, form } = props + const { stripeInstance, onSubmit, submitText, form } = props const { getText } = text.useText() - const [cardElement, setCardElement] = React.useState(() => - elements.getElement(stripeReact.CardElement), - ) - const dialogContext = ariaComponents.useDialogContext() - const createPaymentMethodMutation = reactQuery.useMutation({ - mutationFn: async () => { - if (!cardElement) { - throw new Error('Unexpected error') - } else { - return stripeInstance - .createPaymentMethod({ type: 'card', card: cardElement }) - .then((result) => { - if (result.error) { - throw new Error(result.error.message) - } else { - return result - } - }) - } - }, - }) + const createPaymentMethodMutation = useCreatePaymentMethodMutation() // No idea if it's safe or not, but outside of the function everything is fine // but for some reason TypeScript fails to infer the `card` field from the schema (it should always be there) + // eslint-disable-next-line no-restricted-syntax const formInstance = ariaComponents.Form.useForm( - // eslint-disable-next-line no-restricted-syntax - (form as ariaComponents.FormInstance | undefined) ?? { + form ?? { schema: ADD_PAYMENT_METHOD_FORM_SCHEMA, + onSubmit: ({ cardElement }) => + createPaymentMethodMutation.mutateAsync({ stripeInstance, cardElement }), + onSubmitSuccess: ({ paymentMethod }) => onSubmit?.(paymentMethod.id), }, - ) + ) as unknown as ariaComponents.FormInstance + + const cardElement = ariaComponents.Form.useWatch({ + control: formInstance.control, + name: 'cardElement', + }) return ( - - createPaymentMethodMutation.mutateAsync().then(async ({ paymentMethod }) => { - cardElement?.clear() - await onSubmit?.(paymentMethod.id) - }) - } - > + dialogContext?.close()} onReady={(element) => { - setCardElement(element) + formInstance.setValue('cardElement', element) + formInstance.setValue('stripeInstance', stripeInstance) }} onChange={(event) => { if (event.error?.message != null) { diff --git a/app/dashboard/src/modules/payments/components/PlanSelector/components/PlanSelectorDialog.tsx b/app/dashboard/src/modules/payments/components/PlanSelector/components/PlanSelectorDialog.tsx index 2aeeff7f81..0663e5d186 100644 --- a/app/dashboard/src/modules/payments/components/PlanSelector/components/PlanSelectorDialog.tsx +++ b/app/dashboard/src/modules/payments/components/PlanSelector/components/PlanSelectorDialog.tsx @@ -27,7 +27,7 @@ import type { Plan } from '#/services/Backend' import { twMerge } from '#/utilities/tailwindMerge' -import { createSubscriptionPriceQuery } from '../../../api' +import { createSubscriptionPriceQuery, useCreatePaymentMethodMutation } from '../../../api' import { MAX_SEATS_BY_PLAN, PRICE_BY_PLAN, @@ -77,6 +77,8 @@ export function PlanSelectorDialog(props: PlanSelectorDialogProps) { const price = PRICE_BY_PLAN[plan] const maxSeats = MAX_SEATS_BY_PLAN[plan] + const createPaymentMethodMutation = useCreatePaymentMethodMutation() + const form = Form.useForm({ schema: (z) => ADD_PAYMENT_METHOD_FORM_SCHEMA.extend({ @@ -95,10 +97,18 @@ export function PlanSelectorDialog(props: PlanSelectorDialogProps) { // eslint-disable-next-line @typescript-eslint/no-magic-numbers defaultValues: { seats: 1, period: 12, agree: [] }, mode: 'onChange', + onSubmit: async ({ cardElement, stripeInstance, seats, period }) => { + const res = await createPaymentMethodMutation.mutateAsync({ + cardElement, + stripeInstance, + }) + + return onSubmit?.(res.paymentMethod.id, seats, period) + }, }) - const seats = form.watch('seats') - const period = form.watch('period') + const seats = Form.useWatch({ name: 'seats', control: form.control }) + const period = Form.useWatch({ name: 'period', control: form.control }) const formatter = React.useMemo( () => new Intl.NumberFormat(locale, { style: 'currency', currency: PRICE_CURRENCY }), diff --git a/app/dashboard/src/pages/authentication/AuthenticationPage.tsx b/app/dashboard/src/pages/authentication/AuthenticationPage.tsx index da254c48e7..23be2348e2 100644 --- a/app/dashboard/src/pages/authentication/AuthenticationPage.tsx +++ b/app/dashboard/src/pages/authentication/AuthenticationPage.tsx @@ -36,8 +36,8 @@ export default function AuthenticationPage( props: AuthenticationPageProps, ) { const { title, children, footer, supportsOffline = false, ...formProps } = props - const { form, schema, onSubmit } = formProps - const isForm = onSubmit != null && (form != null || schema != null) + const { form, schema } = formProps + const isForm = schema != null || form != null const { getText } = useText() const { isOffline } = useOffline() @@ -88,7 +88,7 @@ export default function AuthenticationPage( :
)} + {...(form ? { form } : (formProps as FormProps))} className={containerClasses} > {(innerProps) => ( diff --git a/app/dashboard/src/pages/authentication/Login.tsx b/app/dashboard/src/pages/authentication/Login.tsx index adb3e505e4..bc6b589d11 100644 --- a/app/dashboard/src/pages/authentication/Login.tsx +++ b/app/dashboard/src/pages/authentication/Login.tsx @@ -3,15 +3,17 @@ import * as router from 'react-router-dom' import { CLOUD_DASHBOARD_DOMAIN } from 'enso-common' -import { FORGOT_PASSWORD_PATH, REGISTRATION_PATH } from '#/appUtils' +import { DASHBOARD_PATH, FORGOT_PASSWORD_PATH, REGISTRATION_PATH } from '#/appUtils' import ArrowRightIcon from '#/assets/arrow_right.svg' import AtIcon from '#/assets/at.svg' import CreateAccountIcon from '#/assets/create_account.svg' import GithubIcon from '#/assets/github_color.svg' import GoogleIcon from '#/assets/google_color.svg' import LockIcon from '#/assets/lock.svg' -import { Button, Form, Input, Password } from '#/components/AriaComponents' +import type { CognitoUser } from '#/authentication/cognito' +import { Button, Form, Input, OTPInput, Password, Text } from '#/components/AriaComponents' import Link from '#/components/Link' +import { Stepper } from '#/components/Stepper' import AuthenticationPage from '#/pages/authentication/AuthenticationPage' import { passwordSchema } from '#/pages/authentication/schemas' import { useAuth } from '#/providers/AuthProvider' @@ -26,14 +28,52 @@ import { useState } from 'react' /** A form for users to log in. */ export default function Login() { const location = router.useLocation() - const { signInWithGoogle, signInWithGitHub, signInWithPassword } = useAuth() + const navigate = router.useNavigate() + const { signInWithGoogle, signInWithGitHub, signInWithPassword, cognito } = useAuth() const { getText } = useText() + const query = new URLSearchParams(location.search) const initialEmail = query.get('email') ?? '' + + const form = Form.useForm({ + schema: (z) => + z.object({ + email: z + .string() + .min(1, getText('arbitraryFieldRequired')) + .email(getText('invalidEmailValidationError')), + password: passwordSchema(getText), + }), + defaultValues: { email: initialEmail }, + onSubmit: async ({ email, password }) => { + const res = await signInWithPassword(email, password) + + switch (res.challenge) { + case 'NO_CHALLENGE': + navigate(DASHBOARD_PATH) + break + case 'SMS_MFA': + case 'SOFTWARE_TOKEN_MFA': + setUser(res.user) + nextStep() + break + default: + throw new Error('Unsupported challenge') + } + }, + }) + const [emailInput, setEmailInput] = useState(initialEmail) + + const [user, setUser] = useState(null) const localBackend = useLocalBackend() const supportsOffline = localBackend != null + const { nextStep, stepperState, previousStep } = Stepper.useStepperState({ + steps: 2, + defaultStep: 0, + }) + return ( } > -
- - -
+ null}> + + {() => ( +
+ + - - z.object({ - email: z - .string() - .min(1, getText('arbitraryFieldRequired')) - .email(getText('invalidEmailValidationError')), - password: passwordSchema(getText), - }) - } - gap="medium" - defaultValues={{ email: initialEmail }} - onSubmit={({ email, password }) => signInWithPassword(email, password)} - > - { - setEmailInput(event.currentTarget.value) - }} - /> + + { + setEmailInput(event.currentTarget.value) + }} + /> -
- +
+ - -
+ +
- - {getText('login')} - + + {getText('login')} + - - + + +
+ )} +
+ + + {() => ( +
z.object({ otp: z.string().min(6).max(6) })} + onSubmit={async ({ otp }, formInstance) => { + if (user) { + const res = await cognito.confirmSignIn(user, otp, 'SOFTWARE_TOKEN_MFA') + + if (res.ok) { + navigate(DASHBOARD_PATH) + } else { + switch (res.val.code) { + case 'NotAuthorizedException': + previousStep() + form.setFormError(res.val.message) + setUser(null) + break + case 'CodeMismatchException': + formInstance.setError('otp', { message: res.val.message }) + break + default: + throw res.val + } + } + } + }} + > + {getText('enterTotp')} + + + + + {getText('login')} + + + + + )} +
+
) } diff --git a/app/dashboard/src/pages/authentication/Registration.tsx b/app/dashboard/src/pages/authentication/Registration.tsx index b614c9d813..118275d4ef 100644 --- a/app/dashboard/src/pages/authentication/Registration.tsx +++ b/app/dashboard/src/pages/authentication/Registration.tsx @@ -89,6 +89,14 @@ export default function Registration() { }) } }), + onSubmit: async ({ email, password }) => { + localStorage.set('termsOfService', { versionHash: tosHash }) + localStorage.set('privacyPolicy', { versionHash: privacyPolicyHash }) + + await signUp(email, password, organizationId) + + stepperState.nextStep() + }, }) const { stepperState } = useStepperState({ steps: 2, defaultStep: 0 }) @@ -161,24 +169,14 @@ export default function Registration() { {getText('createANewAccount')} -
{ - localStorage.set('termsOfService', { versionHash: tosHash }) - localStorage.set('privacyPolicy', { versionHash: privacyPolicyHash }) - - await signUp(email, password, organizationId) - - stepperState.nextStep() - }} - > + {({ form }) => ( <> Promise readonly signInWithGoogle: () => Promise readonly signInWithGitHub: () => Promise - readonly signInWithPassword: (email: string, password: string) => Promise + readonly signInWithPassword: ( + email: string, + password: string, + ) => Promise<{ + readonly challenge: cognitoModule.UserSessionChallenge + readonly user: cognitoModule.CognitoUser + }> readonly forgotPassword: (email: string) => Promise readonly changePassword: (oldPassword: string, newPassword: string) => Promise readonly resetPassword: (email: string, code: string, password: string) => Promise @@ -116,6 +122,7 @@ interface AuthContextType { readonly isUserDeleted: () => boolean /** Return `true` if the user is soft deleted. */ readonly isUserSoftDeleted: () => boolean + readonly cognito: cognitoModule.Cognito } const AuthContext = React.createContext(null) @@ -163,7 +170,7 @@ function createUsersMeQuery( /** Props for an {@link AuthProvider}. */ export interface AuthProviderProps { readonly shouldStartInOfflineMode: boolean - readonly authService: authServiceModule.AuthService | null + readonly authService: authServiceModule.AuthService /** Callback to execute once the user has authenticated successfully. */ readonly onAuthenticated: (accessToken: string | null) => void readonly children: React.ReactNode @@ -174,7 +181,7 @@ export default function AuthProvider(props: AuthProviderProps) { const { authService, onAuthenticated } = props const { children } = props const remoteBackend = backendProvider.useRemoteBackendStrict() - const { cognito } = authService ?? {} + const { cognito } = authService const { session, sessionQueryKey } = sessionProvider.useSession() const { localStorage } = localStorageProvider.useLocalStorage() const { getText } = textProvider.useText() @@ -194,23 +201,19 @@ export default function AuthProvider(props: AuthProviderProps) { }, []) const performLogout = async () => { - if (cognito != null) { - await cognito.signOut() + await cognito.signOut() - const parentDomain = location.hostname.replace(/^[^.]*\./, '') - document.cookie = `logged_in=no;max-age=0;domain=${parentDomain}` - gtagEvent('cloud_sign_out') - cognito.saveAccessToken(null) - localStorage.clearUserSpecificEntries() - sentry.setUser(null) + const parentDomain = location.hostname.replace(/^[^.]*\./, '') + document.cookie = `logged_in=no;max-age=0;domain=${parentDomain}` + gtagEvent('cloud_sign_out') + cognito.saveAccessToken(null) + localStorage.clearUserSpecificEntries() + sentry.setUser(null) - await queryClient.invalidateQueries({ queryKey: sessionQueryKey }) - await queryClient.clearWithPersister() + await queryClient.invalidateQueries({ queryKey: sessionQueryKey }) + await queryClient.clearWithPersister() - return Promise.resolve() - } else { - return Promise.reject() - } + return Promise.resolve() } const logoutMutation = reactQuery.useMutation({ @@ -289,100 +292,91 @@ export default function AuthProvider(props: AuthProviderProps) { const signUp = useEventCallback( async (username: string, password: string, organizationId: string | null) => { - if (cognito != null) { - gtagEvent('cloud_sign_up') - const result = await cognito.signUp(username, password, organizationId) + gtagEvent('cloud_sign_up') + const result = await cognito.signUp(username, password, organizationId) - if (result.err) { - throw new Error(result.val.message) - } else { - return - } + if (result.err) { + throw new Error(result.val.message) + } else { + return } }, ) const confirmSignUp = useEventCallback(async (email: string, code: string) => { - if (cognito == null) { - throw new Error(getText('confirmSignUpError')) - } else { - gtagEvent('cloud_confirm_sign_up') - const result = await cognito.confirmSignUp(email, code) + gtagEvent('cloud_confirm_sign_up') + const result = await cognito.confirmSignUp(email, code) - if (result.err) { - switch (result.val.type) { - case cognitoModule.CognitoErrorType.userAlreadyConfirmed: - case cognitoModule.CognitoErrorType.userNotFound: { - return - } - default: { - throw new errorModule.UnreachableCaseError(result.val.type) - } + if (result.err) { + switch (result.val.type) { + case cognitoModule.CognitoErrorType.userAlreadyConfirmed: + case cognitoModule.CognitoErrorType.userNotFound: { + return + } + default: { + throw new errorModule.UnreachableCaseError(result.val.type) } } } }) const signInWithPassword = useEventCallback(async (email: string, password: string) => { - if (cognito != null) { - gtagEvent('cloud_sign_in', { provider: 'Email' }) - const result = await cognito.signInWithPassword(email, password) - if (result.ok) { - void queryClient.invalidateQueries({ queryKey: sessionQueryKey }) - navigate(appUtils.DASHBOARD_PATH) - return - } else { - throw new Error(result.val.message) + gtagEvent('cloud_sign_in', { provider: 'Email' }) + + const result = await cognito.signInWithPassword(email, password) + + if (result.ok) { + const user = result.unwrap() + + const challenge = user.challengeName ?? 'NO_CHALLENGE' + + if (['SMS_MFA', 'SOFTWARE_TOKEN_MFA'].includes(challenge)) { + // eslint-disable-next-line no-restricted-syntax + return { challenge, user } as const } + + return queryClient + .invalidateQueries({ queryKey: sessionQueryKey }) + .then(() => ({ challenge, user }) as const) + } else { + throw new Error(result.val.message) } }) const setUsername = useEventCallback(async (username: string) => { - if (cognito == null) { - return false + gtagEvent('cloud_user_created') + + if (userData?.type === UserSessionType.full) { + await updateUserMutation.mutateAsync({ username: username }) } else { - gtagEvent('cloud_user_created') + const organizationId = await cognito.organizationId() + const email = session?.email ?? '' - if (userData?.type === UserSessionType.full) { - await updateUserMutation.mutateAsync({ username: username }) - } else { - const organizationId = await cognito.organizationId() - const email = session?.email ?? '' - - await createUserMutation.mutateAsync({ - userName: username, - userEmail: backendModule.EmailAddress(email), - organizationId: - organizationId != null ? backendModule.OrganizationId(organizationId) : null, - }) - } - - return true + await createUserMutation.mutateAsync({ + userName: username, + userEmail: backendModule.EmailAddress(email), + organizationId: + organizationId != null ? backendModule.OrganizationId(organizationId) : null, + }) } + + return true }) const deleteUser = useEventCallback(async () => { - if (cognito == null) { - return false - } else { - await deleteUserMutation.mutateAsync() + await deleteUserMutation.mutateAsync() - toastSuccess(getText('deleteUserSuccess')) + toastSuccess(getText('deleteUserSuccess')) - return true - } + return true }) const restoreUser = useEventCallback(async () => { - if (cognito == null) { - return false - } else { - await restoreUserMutation.mutateAsync() + await restoreUserMutation.mutateAsync() - toastSuccess(getText('restoreUserSuccess')) + toastSuccess(getText('restoreUserSuccess')) - return true - } + return true }) /** @@ -402,41 +396,36 @@ export default function AuthProvider(props: AuthProviderProps) { }) const forgotPassword = useEventCallback(async (email: string) => { - if (cognito != null) { - const result = await cognito.forgotPassword(email) - if (result.ok) { - navigate(appUtils.LOGIN_PATH) - return - } else { - throw new Error(result.val.message) - } + const result = await cognito.forgotPassword(email) + if (result.ok) { + navigate(appUtils.LOGIN_PATH) + return + } else { + throw new Error(result.val.message) } }) const resetPassword = useEventCallback(async (email: string, code: string, password: string) => { - if (cognito != null) { - const result = await cognito.forgotPasswordSubmit(email, code, password) - if (result.ok) { - navigate(appUtils.LOGIN_PATH) - return - } else { - throw new Error(result.val.message) - } + const result = await cognito.forgotPasswordSubmit(email, code, password) + + if (result.ok) { + navigate(appUtils.LOGIN_PATH) + return + } else { + throw new Error(result.val.message) } }) const changePassword = useEventCallback(async (oldPassword: string, newPassword: string) => { - if (cognito == null) { - return false + const result = await cognito.changePassword(oldPassword, newPassword) + + if (result.ok) { + toastSuccess(getText('changePasswordSuccess')) } else { - const result = await cognito.changePassword(oldPassword, newPassword) - if (result.ok) { - toastSuccess(getText('changePasswordSuccess')) - } else { - toastError(result.val.message) - } - return result.ok + toastError(result.val.message) } + + return result.ok }) const isUserMarkedForDeletion = useEventCallback( @@ -503,33 +492,28 @@ export default function AuthProvider(props: AuthProviderProps) { isUserSoftDeleted, restoreUser, deleteUser, + cognito, signInWithGoogle: useEventCallback(() => { - if (cognito == null) { - return Promise.resolve(false) - } else { - gtagEvent('cloud_sign_in', { provider: 'Google' }) - return cognito - .signInWithGoogle() - .then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey })) - .then( - () => true, - () => false, - ) - } + gtagEvent('cloud_sign_in', { provider: 'Google' }) + + return cognito + .signInWithGoogle() + .then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey })) + .then( + () => true, + () => false, + ) }), signInWithGitHub: useEventCallback(() => { - if (cognito == null) { - return Promise.resolve(false) - } else { - gtagEvent('cloud_sign_in', { provider: 'GitHub' }) - return cognito - .signInWithGitHub() - .then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey })) - .then( - () => true, - () => false, - ) - } + gtagEvent('cloud_sign_in', { provider: 'GitHub' }) + + return cognito + .signInWithGitHub() + .then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey })) + .then( + () => true, + () => false, + ) }), signInWithPassword, forgotPassword, diff --git a/app/dashboard/src/utilities/tailwindVariants.ts b/app/dashboard/src/utilities/tailwindVariants.ts index 6f5df7cee4..aaec9a397b 100644 --- a/app/dashboard/src/utilities/tailwindVariants.ts +++ b/app/dashboard/src/utilities/tailwindVariants.ts @@ -25,6 +25,9 @@ export type TVWithoutExtends = ExtractFunction & Omit * Props for a component that uses `tailwind-variants`. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type VariantProps any> = TvVariantProps & { +export type VariantProps any> = Omit< + TvVariantProps, + 'class' | 'className' +> & { variants?: ExtractFunction | undefined } diff --git a/app/dashboard/tailwind.config.js b/app/dashboard/tailwind.config.js index 0bbf3cf7d9..abfb20f988 100644 --- a/app/dashboard/tailwind.config.js +++ b/app/dashboard/tailwind.config.js @@ -379,6 +379,7 @@ inset 0 -8px 11.4px -11.4px #00000005, inset 0 -15px 21.3px -21.3px #00000006, \ inset 0 -36px 51px -51px #00000014`, }, animation: { + 'caret-blink': 'caret-blink 1.5s ease-out infinite', 'spin-ease': 'spin cubic-bezier(0.67, 0.33, 0.33, 0.67) 1.5s infinite', 'appear-delayed': 'appear-delayed 0.5s ease-in-out', }, @@ -420,6 +421,10 @@ inset 0 -36px 51px -51px #00000014`, '99%': { opacity: '0' }, '100%': { opacity: '1' }, }, + 'caret-blink': { + '0%,70%,100%': { opacity: '1' }, + '20%,50%': { opacity: '0' }, + }, }, }, }, @@ -429,6 +434,12 @@ inset 0 -36px 51px -51px #00000014`, plugin(({ addVariant, addUtilities, matchUtilities, addComponents, theme }) => { addVariant('group-hover-2', ['.group:where([data-hovered]) &', '.group:where(:hover) &']) + addUtilities({ + '.scrollbar-gutter-stable': { + scrollbarGutter: 'stable', + }, + }) + addUtilities( { '.container-size': { diff --git a/app/ide-desktop/common/src/text/english.json b/app/ide-desktop/common/src/text/english.json index cee9d6942b..a4d9ba561a 100644 --- a/app/ide-desktop/common/src/text/english.json +++ b/app/ide-desktop/common/src/text/english.json @@ -216,12 +216,21 @@ "likes": "Likes", "shortcuts": "Shortcuts", "download": "Download", + "disable": "Disable", + "enable": "Enable", "email": "Email", "intro": "Intro", "emailIsRequired": "Email is required", "emailIsInvalid": "Email is invalid", "emailAlreadyExists": "Email already exists", "emailAlreadyAdded": "Email already added", + "otp": "OTP", + "totp": "TOTP", + "display": "Display", + "invalidOtp": "Invalid OTP", + "invalidTotp": "Invalid TOTP", + "enterOtp": "To continue, please enter the 6-digit verification code generated by your authenticator app.", + "enterTotp": "To continue, please enter the 6-digit verification code generated by your authenticator app.", "password": "Password", "reset": "Reset", "members": "Members", @@ -473,6 +482,14 @@ "enableAssetsTableBackgroundRefreshInterval": "Refresh interval", "enableAssetsTableBackgroundRefreshIntervalDescription": "Set the interval in ms for the assets table to refresh in the background.", + "enableMultitabs": "Enable Multi-Tabs", + "enableMultitabsDescription": "Open multiple projects at the same time.", + + "enableAssetsTableBackgroundRefresh": "Enable Assets Table Background Refresh", + "enableAssetsTableBackgroundRefreshDescription": "Automatically refresh the assets table in the background.", + "enableAssetsTableBackgroundRefreshInterval": "Refresh interval", + "enableAssetsTableBackgroundRefreshIntervalDescription": "Set the interval in ms for the assets table to refresh in the background.", + "deleteLabelActionText": "delete the label '$0'", "deleteSelectedAssetActionText": "delete '$0'", "deleteSelectedAssetsActionText": "delete $0 selected items", @@ -814,6 +831,24 @@ "userNameSettingsInput": "Name", "userEmailSettingsInput": "Email", "changePasswordSettingsSection": "Change Password", + "setup2FASettingsSection": "Two-Factor Authentication (2FA)", + "setup2FASettingsCustomEntryAliases": "two-factor authentication\n2fa", + "setup2FASettingsDescription": "Add an extra layer of security to your account by enabling two-factor authentication.", + "enable2FA": "Enable 2FA", + "enable2FADescription": "Require a code from your authenticator app to log in.", + "scanQR": "Scan this QR code", + "scanQRDescription": "Use an authenticator app like Google Authenticator or Authy to scan this QR code and set up 2FA.", + "copyLink": "Copy this link", + "copyLinkDescription": "If you can't scan the QR code, you can copy this link and paste it into your authenticator app.", + "enterCode": "Enter the code", + "enterCodeDescription": "Enter the code from your authenticator app to verify 2FA.", + "verificationCode": "Verification code", + "verificationCodePlaceholder": "Enter the verification code", + "2FAEnabled": "2FA is enabled", + "2FAEnabledDescription": "Your account is currently protected with two-factor authentication.", + "disable2FA": "Disable 2FA", + "disable2FADescription": "Turning off two-factor authentication will make your account less secure.", + "disable2FAWarning": "Are you sure you want to disable two-factor authentication? Turning off two-factor authentication will make your account less secure. You will still need to enter a verification code to disable 2FA.", "changePasswordSettingsCustomEntryAliases": "current password\nnew password\nconfirm new password", "deleteUserAccountSettingsSection": "Delete User Account", "deleteUserAccountSettingsCustomEntryAliases": "danger zone\ndelete this user account", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0dd3261e3..611a28e73b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,6 +79,9 @@ importers: ajv: specifier: ^8.12.0 version: 8.16.0 + amazon-cognito-identity-js: + specifier: 6.3.6 + version: 6.3.6 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -88,12 +91,18 @@ importers: framer-motion: specifier: 11.3.0 version: 11.3.0(@emotion/is-prop-valid@1.3.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + input-otp: + specifier: 1.2.4 + version: 1.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) is-network-error: specifier: ^1.0.1 version: 1.1.0 monaco-editor: specifier: 0.48.0 version: 0.48.0 + qrcode.react: + specifier: 3.1.0 + version: 3.1.0(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -5155,6 +5164,12 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + input-otp@1.2.4: + resolution: {integrity: sha512-md6rhmD+zmMnUh5crQNSQxq3keBRYvE3odbr4Qb9g2NWzQv9azi+t1a3X4TBTbh98fsGHgEEJlzbe1q860uGCA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + install@0.13.0: resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==} engines: {node: '>= 0.10'} @@ -6495,6 +6510,11 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qrcode.react@3.1.0: + resolution: {integrity: sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + qs@6.11.0: resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} engines: {node: '>=0.6'} @@ -13351,6 +13371,11 @@ snapshots: ini@1.3.8: {} + input-otp@1.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + install@0.13.0: {} internal-slot@1.0.7: @@ -14601,6 +14626,10 @@ snapshots: pure-rand@6.1.0: {} + qrcode.react@3.1.0(react@18.3.1): + dependencies: + react: 18.3.1 + qs@6.11.0: dependencies: side-channel: 1.0.6