* 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 b5122348da)
This commit is contained in:
Sergei Garin 2024-09-13 16:00:31 +03:00 committed by James Dunkerley
parent 3e43d62eaa
commit 751551e18c
68 changed files with 2277 additions and 1050 deletions

View File

@ -29,6 +29,7 @@
}, },
"dependencies": { "dependencies": {
"@aws-amplify/auth": "5.6.5", "@aws-amplify/auth": "5.6.5",
"amazon-cognito-identity-js": "6.3.6",
"@aws-amplify/core": "5.8.5", "@aws-amplify/core": "5.8.5",
"@hookform/resolvers": "^3.4.0", "@hookform/resolvers": "^3.4.0",
"@internationalized/date": "^3.5.5", "@internationalized/date": "^3.5.5",
@ -60,7 +61,9 @@
"ts-results": "^3.3.0", "ts-results": "^3.3.0",
"validator": "^13.12.0", "validator": "^13.12.0",
"zod": "^3.23.8", "zod": "^3.23.8",
"zustand": "^4.5.4" "zustand": "^4.5.4",
"input-otp": "1.2.4",
"qrcode.react": "3.1.0"
}, },
"devDependencies": { "devDependencies": {
"@fast-check/vitest": "^0.0.8", "@fast-check/vitest": "^0.0.8",

View File

@ -346,6 +346,7 @@ function AppRouter(props: AppRouterProps) {
}, },
} }
}, [localStorage, inputBindingsRaw]) }, [localStorage, inputBindingsRaw])
const mainPageUrl = getMainPageUrl() const mainPageUrl = getMainPageUrl()
// Subscribe to `localStorage` updates to trigger a rerender when the terms of service // Subscribe to `localStorage` updates to trigger a rerender when the terms of service
@ -354,10 +355,10 @@ function AppRouter(props: AppRouterProps) {
localStorageProvider.useLocalStorageState('privacyPolicy') localStorageProvider.useLocalStorageState('privacyPolicy')
const authService = useInitAuthService(props) const authService = useInitAuthService(props)
const userSession = authService?.cognito.userSession.bind(authService.cognito) ?? null
const refreshUserSession = const userSession = authService.cognito.userSession.bind(authService.cognito)
authService?.cognito.refreshUserSession.bind(authService.cognito) ?? null const refreshUserSession = authService.cognito.refreshUserSession.bind(authService.cognito)
const registerAuthEventListener = authService?.registerAuthEventListener ?? null const registerAuthEventListener = authService.registerAuthEventListener
React.useEffect(() => { React.useEffect(() => {
if ('menuApi' in window) { if ('menuApi' in window) {
@ -490,7 +491,7 @@ function AppRouter(props: AppRouterProps) {
<FeatureFlagsProvider> <FeatureFlagsProvider>
<RouterProvider navigate={navigate}> <RouterProvider navigate={navigate}>
<SessionProvider <SessionProvider
saveAccessToken={authService?.cognito.saveAccessToken.bind(authService.cognito) ?? null} saveAccessToken={authService.cognito.saveAccessToken.bind(authService.cognito)}
mainPageUrl={mainPageUrl} mainPageUrl={mainPageUrl}
userSession={userSession} userSession={userSession}
registerAuthEventListener={registerAuthEventListener} registerAuthEventListener={registerAuthEventListener}

View File

@ -14,6 +14,7 @@ export const LOGIN_PATH = '/login'
export const REGISTRATION_PATH = '/registration' export const REGISTRATION_PATH = '/registration'
/** Path to the confirm registration page. */ /** Path to the confirm registration page. */
export const CONFIRM_REGISTRATION_PATH = '/confirmation' export const CONFIRM_REGISTRATION_PATH = '/confirmation'
export const SETUP_PATH = '/setup' export const SETUP_PATH = '/setup'
/** Path to the page in which a user can restore their account after it has been /** Path to the page in which a user can restore their account after it has been
* marked for deletion. */ * marked for deletion. */

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 6.08122L0.59375 4.15622L1.84314 2.59448L23.4049 19.8439L22.1555 21.4056L18.8887 18.7922C17.2377 20.7536 14.7644 22 12 22C7.02944 22 3 17.9705 3 13V6.08122ZM17.3263 17.5423C16.0424 19.0463 14.1326 20 12 20C8.13401 20 5 16.866 5 13V7.68122L17.3263 17.5423Z" fill="black"/>
<path d="M19 13C19 13.2454 18.9874 13.4879 18.9627 13.7268L20.7416 15.1499C20.9105 14.461 21 13.7409 21 13V5.34595L12 1.40845L6.54694 3.79416L8.31101 5.20541L12 3.59148L19 6.65398V13Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 626 B

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.5 11.75L11 13.25L14.5 9.75M12 3L20 5.75V11.9123C20 16.8848 16 19 12 21.1579C8 19 4 16.8848 4 11.9123V5.75L12 3Z" stroke="black" stroke-width="2" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 13.5L12 11.5M12 11.5L10 9.5M12 11.5L14 9.5M12 11.5L10 13.5M12 3L20 5.75V11.9123C20 16.8848 16 19 12 21.1579C8 19 4 16.8848 4 11.9123V5.75L12 3Z" stroke="black" stroke-width="2" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 5.75L12 3L4 5.75V11.9123C4 16.8848 8 19 12 21.1579C16 19 20 16.8848 20 11.9123V5.75Z" stroke="black" stroke-width="2" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 260 B

View File

@ -283,6 +283,13 @@ export class Cognito {
async refreshUserSession() { async refreshUserSession() {
return Promise.resolve(results.Ok(null)) return Promise.resolve(results.Ok(null))
} }
/**
* Returns MFA preference for the current user.
*/
async getMFAPreference() {
return Promise.resolve(results.Ok('NOMFA'))
}
} }
// =================== // ===================

View File

@ -30,7 +30,7 @@
* `kind` field provides a unique string that can be used to brand the error in place of the * `kind` field provides a unique string that can be used to brand the error in place of the
* `internalCode`, when rethrowing the error. */ * `internalCode`, when rethrowing the error. */
import * as amplify from '@aws-amplify/auth' 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 results from 'ts-results'
import * as detect from 'enso-common/src/detect' import * as detect from 'enso-common/src/detect'
@ -70,6 +70,18 @@ interface UserAttributes {
} }
/* eslint-enable @typescript-eslint/naming-convention */ /* 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}. */ /** User information returned from {@link amplify.Auth.currentUserInfo}. */
interface UserInfo { interface UserInfo {
readonly username: string readonly username: string
@ -214,6 +226,16 @@ export class Cognito {
return userInfo.attributes['custom:organizationId'] ?? null 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. /** Sign up with username and password.
* *
* Does not rely on federated identity providers (e.g., Google or GitHub). */ * 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). */ * Does not rely on external identity providers (e.g., Google or GitHub). */
async signInWithPassword(username: string, password: string) { async signInWithPassword(username: string, password: string) {
const result = await results.Result.wrapAsync(async () => { 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) 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 /** 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 * 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 * 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) return result.mapErr(intoAmplifyErrorOrThrow)
} }
export { CognitoUser } from '@aws-amplify/auth'

View File

@ -116,20 +116,17 @@ export interface AuthService {
* *
* This hook should only be called in a single place, as it performs global configuration of the * This hook should only be called in a single place, as it performs global configuration of the
* Amplify library. */ * Amplify library. */
export function useInitAuthService(authConfig: AuthConfig): AuthService | null { export function useInitAuthService(authConfig: AuthConfig): AuthService {
const { supportsDeepLinks } = authConfig const { supportsDeepLinks } = authConfig
const logger = useLogger() const logger = useLogger()
const navigate = useNavigate() const navigate = useNavigate()
return React.useMemo(() => { return React.useMemo(() => {
const amplifyConfig = loadAmplifyConfig(logger, supportsDeepLinks, navigate) const amplifyConfig = loadAmplifyConfig(logger, supportsDeepLinks, navigate)
const cognito = const cognito = new cognitoModule.Cognito(logger, supportsDeepLinks, amplifyConfig)
amplifyConfig == null ? null : (
new cognitoModule.Cognito(logger, supportsDeepLinks, amplifyConfig)
)
return cognito == null ? null : ( return { cognito, registerAuthEventListener: listen.registerAuthEventListener }
{ cognito, registerAuthEventListener: listen.registerAuthEventListener }
)
}, [logger, navigate, supportsDeepLinks]) }, [logger, navigate, supportsDeepLinks])
} }
@ -138,7 +135,7 @@ function loadAmplifyConfig(
logger: Logger, logger: Logger,
supportsDeepLinks: boolean, supportsDeepLinks: boolean,
navigate: (url: string) => void, navigate: (url: string) => void,
): AmplifyConfig | null { ): AmplifyConfig {
let urlOpener: ((url: string) => void) | null = null let urlOpener: ((url: string) => void) | null = null
let saveAccessToken: ((accessToken: saveAccessTokenModule.AccessToken | null) => void) | null = let saveAccessToken: ((accessToken: saveAccessTokenModule.AccessToken | null) => void) | null =
null null
@ -175,25 +172,18 @@ function loadAmplifyConfig(
/** Load the platform-specific Amplify configuration. */ /** Load the platform-specific Amplify configuration. */
const signInOutRedirect = supportsDeepLinks ? `${common.DEEP_LINK_SCHEME}://auth` : redirectUrl const signInOutRedirect = supportsDeepLinks ? `${common.DEEP_LINK_SCHEME}://auth` : redirectUrl
return ( return {
process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID == null || userPoolId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID,
process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID == null || userPoolWebClientId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID,
process.env.ENSO_CLOUD_COGNITO_DOMAIN == null || domain: process.env.ENSO_CLOUD_COGNITO_DOMAIN,
process.env.ENSO_CLOUD_COGNITO_REGION == null region: process.env.ENSO_CLOUD_COGNITO_REGION,
) ? redirectSignIn: signInOutRedirect,
null redirectSignOut: signInOutRedirect,
: { scope: ['email', 'openid', 'aws.cognito.signin.user.admin'],
userPoolId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID, responseType: 'code',
userPoolWebClientId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID, urlOpener,
domain: process.env.ENSO_CLOUD_COGNITO_DOMAIN, saveAccessToken,
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. /** Set the callback that will be invoked when a deep link to the application is opened.

View File

@ -1,6 +1,8 @@
/** @file Alert component. */ /** @file Alert component. */
import { type ForwardedRef, type HTMLAttributes, type PropsWithChildren } from 'react' import { type ForwardedRef, type HTMLAttributes, type PropsWithChildren } from 'react'
import SvgMask from '#/components/SvgMask'
import { forwardRef } from '#/utilities/react' import { forwardRef } from '#/utilities/react'
import { tv, type VariantProps } from '#/utilities/tailwindVariants' import { tv, type VariantProps } from '#/utilities/tailwindVariants'
@ -9,7 +11,7 @@ import { tv, type VariantProps } from '#/utilities/tailwindVariants'
// ================= // =================
export const ALERT_STYLES = tv({ export const ALERT_STYLES = tv({
base: 'flex flex-col items-stretch', base: 'flex items-stretch gap-2',
variants: { variants: {
fullWidth: { true: 'w-full' }, fullWidth: { true: 'w-full' },
variant: { variant: {
@ -37,6 +39,11 @@ export const ALERT_STYLES = tv({
large: 'px-4 pt-2 pb-2', 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: { defaultVariants: {
fullWidth: true, fullWidth: true,
variant: 'error', variant: 'error',
@ -53,7 +60,12 @@ export const ALERT_STYLES = tv({
export interface AlertProps export interface AlertProps
extends PropsWithChildren, extends PropsWithChildren,
VariantProps<typeof ALERT_STYLES>, VariantProps<typeof ALERT_STYLES>,
HTMLAttributes<HTMLDivElement> {} HTMLAttributes<HTMLDivElement> {
/**
* The icon to display in the Alert
*/
readonly icon?: React.ReactElement | string | null | undefined
}
/** Alert component. */ /** Alert component. */
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
@ -61,20 +73,45 @@ export const Alert = forwardRef(function Alert(
props: AlertProps, props: AlertProps,
ref: ForwardedRef<HTMLDivElement>, ref: ForwardedRef<HTMLDivElement>,
) { ) {
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') { if (variant === 'error') {
containerProps.tabIndex = -1 containerProps.tabIndex = -1
containerProps.role = 'alert' containerProps.role = 'alert'
} }
const classes = variants({
variant,
size,
rounded,
fullWidth,
})
return ( return (
<div <div className={classes.base({ className })} ref={ref} {...containerProps}>
className={ALERT_STYLES({ variant, size, className, rounded, fullWidth })} {icon != null &&
ref={ref} (() => {
{...containerProps} if (typeof icon === 'string') {
> // eslint-disable-next-line no-restricted-syntax
{children} return (
<div className={classes.iconContainer()}>
<SvgMask src={icon} />
</div>
)
}
return <div className={classes.iconContainer()}>{icon}</div>
})()}
<div className={classes.children()}>{children}</div>
</div> </div>
) )
}) })

View File

@ -72,6 +72,7 @@ export const CheckboxGroup = forwardRef(
return ( return (
<Form.Controller <Form.Controller
name={name} name={name}
control={formInstance.control}
{...(defaultValueOverride != null && { defaultValue: defaultValueOverride })} {...(defaultValueOverride != null && { defaultValue: defaultValueOverride })}
render={({ field, fieldState }) => { render={({ field, fieldState }) => {
const defaultValue = defaultValueOverride ?? formInstance.control._defaultValues[name] const defaultValue = defaultValueOverride ?? formInstance.control._defaultValues[name]

View File

@ -53,7 +53,9 @@ const MODAL_STYLES = tv({
}) })
const DIALOG_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: { variants: {
type: { type: {
modal: { modal: {
@ -149,7 +151,7 @@ const DIALOG_STYLES = tv({
* Can be used to display alerts, confirmations, or other content. */ * Can be used to display alerts, confirmations, or other content. */
export function Dialog(props: DialogProps) { export function Dialog(props: DialogProps) {
const { const {
children, children: Children,
title, title,
type = 'modal', type = 'modal',
closeButton = 'normal', closeButton = 'normal',
@ -302,7 +304,9 @@ export function Dialog(props: DialogProps) {
<suspense.Suspense <suspense.Suspense
loaderProps={{ minHeight: type === 'fullscreen' ? 'full' : 'h32' }} loaderProps={{ minHeight: type === 'fullscreen' ? 'full' : 'h32' }}
> >
{typeof children === 'function' ? children(opts) : children} {typeof Children === 'function' ?
<Children {...opts} />
: Children}
</suspense.Suspense> </suspense.Suspense>
</errorBoundary.ErrorBoundary> </errorBoundary.ErrorBoundary>
</div> </div>

View File

@ -1,18 +1,11 @@
/** @file Form component. */ /** @file Form component. */
import * as React from 'react' 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 textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import * as errorUtils from '#/utilities/error' import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { forwardRef } from '#/utilities/react' import { forwardRef } from '#/utilities/react'
import * as dialog from '../Dialog' import * as dialog from '../Dialog'
import * as components from './components' 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. */ * Provides better error handling and form state management and better UX out of the box. */
// There is no way to avoid type casting here // There is no way to avoid type casting here
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
export const Form = forwardRef(function Form<Schema extends components.TSchema>( export const Form = forwardRef(function Form<
props: types.FormProps<Schema>, Schema extends components.TSchema,
ref: React.Ref<HTMLFormElement>, SubmitResult = void,
) { >(props: types.FormProps<Schema, SubmitResult>, ref: React.Ref<HTMLFormElement>) {
/** Input values for this form. */ /** Input values for this form. */
type FieldValues = components.FieldValues<Schema> type FieldValues = components.FieldValues<Schema>
const formId = React.useId() const formId = React.useId()
const { const {
children, children,
onSubmit,
formRef, formRef,
form, form,
formOptions = {}, formOptions,
className, className,
style, style,
onSubmitted = () => {}, onSubmitted = () => {},
onSubmitSuccess = () => {}, onSubmitSuccess = () => {},
onSubmitFailed = () => {}, onSubmitFailed = () => {},
id = formId, id = formId,
testId,
schema, schema,
defaultValues, defaultValues,
gap, gap,
@ -55,78 +46,47 @@ export const Form = forwardRef(function Form<Schema extends components.TSchema>(
const { getText } = textProvider.useText() 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 dialogContext = dialog.useDialogContext()
const formMutation = reactQuery.useMutation({ const onSubmit = useEventCallback(
// We use template literals to make the mutation key more readable in the devtools async (fieldValues: types.FieldValues<Schema>, formInstance: types.UseFormReturn<Schema>) => {
// This mutation exists only for debug purposes - React Query dev tools record the mutation, // This is SAFE because we're passing the result transparently, and it's typed outside
// the result, and the variables(form fields). // eslint-disable-next-line no-restricted-syntax
// In general, prefer using object literals for the mutation key. const result = (await props.onSubmit?.(fieldValues, formInstance)) as SubmitResult
mutationKey: ['Form submission', `testId: ${testId}`, `id: ${id}`],
mutationFn: async (fieldValues: FieldValues) => {
try {
await onSubmit?.(fieldValues, innerForm)
if (method === 'dialog') { if (method === 'dialog') {
dialogContext?.close() 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
} }
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<Schema, SubmitResult>(
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({ const base = styles.FORM_STYLES({
className: typeof className === 'function' ? className(innerForm) : className, className: typeof className === 'function' ? className(innerForm) : className,
gap, gap,
}) })
const { formState, setError } = innerForm const { formState } = innerForm
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
const errors = Object.fromEntries( const errors = Object.fromEntries(
@ -136,39 +96,30 @@ export const Form = forwardRef(function Form<Schema extends components.TSchema>(
}), }),
) as Record<keyof FieldValues, string> ) as Record<keyof FieldValues, string>
return ( const values = components.useWatch({ control: innerForm.control })
<>
<form
id={id}
ref={ref}
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
if (isOffline && !canSubmitOffline) { return (
setError('root.offline', { message: getText('unavailableOffline') }) <form
} else { {...formProps}
void formOnSubmit(event) id={id}
} ref={ref}
}} className={base}
className={base} style={typeof style === 'function' ? style(innerForm) : style}
style={typeof style === 'function' ? style(innerForm) : style} noValidate
noValidate data-testid={testId}
data-testid={testId} onSubmit={innerForm.submit}
{...formProps} >
> <aria.FormValidationContext.Provider value={errors}>
<aria.FormValidationContext.Provider value={errors}> <components.FormProvider form={innerForm}>
<reactHookForm.FormProvider {...innerForm}> {typeof children === 'function' ?
{typeof children === 'function' ? children({ ...innerForm, form: innerForm, values })
children({ ...innerForm, form: innerForm }) : children}
: children} </components.FormProvider>
</reactHookForm.FormProvider> </aria.FormValidationContext.Provider>
</aria.FormValidationContext.Provider> </form>
</form>
</>
) )
}) as unknown as (<Schema extends components.TSchema>( }) as unknown as (<Schema extends components.TSchema, SubmitResult = void>(
props: React.RefAttributes<HTMLFormElement> & types.FormProps<Schema>, props: React.RefAttributes<HTMLFormElement> & types.FormProps<Schema, SubmitResult>,
) => React.JSX.Element) & { ) => React.JSX.Element) & {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
schema: typeof components.schema schema: typeof components.schema
@ -183,7 +134,9 @@ export const Form = forwardRef(function Form<Schema extends components.TSchema>(
FIELD_STYLES: typeof components.FIELD_STYLES FIELD_STYLES: typeof components.FIELD_STYLES
useFormContext: typeof components.useFormContext useFormContext: typeof components.useFormContext
useOptionalFormContext: typeof components.useOptionalFormContext 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 */ /* eslint-enable @typescript-eslint/naming-convention */
} }
@ -198,5 +151,7 @@ Form.useFormContext = components.useFormContext
Form.useOptionalFormContext = components.useOptionalFormContext Form.useOptionalFormContext = components.useOptionalFormContext
Form.Field = components.Field Form.Field = components.Field
Form.Controller = components.Controller Form.Controller = components.Controller
Form.useWatch = reactHookForm.useWatch Form.useWatch = components.useWatch
Form.FIELD_STYLES = components.FIELD_STYLES Form.FIELD_STYLES = components.FIELD_STYLES
Form.useFieldRegister = components.useFieldRegister
Form.useFieldState = components.useFieldState

View File

@ -11,8 +11,8 @@ import { forwardRef } from '#/utilities/react'
import { tv, type VariantProps } from '#/utilities/tailwindVariants' import { tv, type VariantProps } from '#/utilities/tailwindVariants'
import type { Path } from 'react-hook-form' import type { Path } from 'react-hook-form'
import * as text from '../../Text' import * as text from '../../Text'
import { Form } from '../Form'
import type * as types from './types' import type * as types from './types'
import * as formContext from './useFormContext'
/** /**
* Props for Field component * Props for Field component
@ -44,6 +44,7 @@ export interface FieldChildrenRenderProps {
readonly isDirty: boolean readonly isDirty: boolean
readonly isTouched: boolean readonly isTouched: boolean
readonly isValidating: boolean readonly isValidating: boolean
readonly hasError: boolean
readonly error?: string | undefined readonly error?: string | undefined
} }
@ -73,36 +74,29 @@ export const Field = forwardRef(function Field<Schema extends types.TSchema>(
ref: React.ForwardedRef<HTMLFieldSetElement>, ref: React.ForwardedRef<HTMLFieldSetElement>,
) { ) {
const { const {
// eslint-disable-next-line no-restricted-syntax
form = formContext.useFormContext() as unknown as types.FormInstance<Schema>,
isInvalid,
children, children,
className, className,
label, label,
description, description,
fullWidth, fullWidth,
error, error,
name,
isHidden, isHidden,
isInvalid = false,
isRequired = false, isRequired = false,
variants = FIELD_STYLES, variants = FIELD_STYLES,
} = props } = props
const fieldState = form.getFieldState(name)
const labelId = React.useId() const labelId = React.useId()
const descriptionId = React.useId() const descriptionId = React.useId()
const errorId = React.useId() const errorId = React.useId()
const invalid = isInvalid === true || fieldState.invalid const fieldState = Form.useFieldState(props)
const classes = variants({ const invalid = isInvalid || fieldState.hasError
fullWidth,
isInvalid: invalid,
isHidden,
})
const hasError = (error ?? fieldState.error?.message) != null const classes = variants({ fullWidth, isInvalid: invalid, isHidden })
const hasError = (error ?? fieldState.error) != null
return ( return (
<fieldset <fieldset
@ -138,7 +132,8 @@ export const Field = forwardRef(function Field<Schema extends types.TSchema>(
isDirty: fieldState.isDirty, isDirty: fieldState.isDirty,
isTouched: fieldState.isTouched, isTouched: fieldState.isTouched,
isValidating: fieldState.isValidating, isValidating: fieldState.isValidating,
error: fieldState.error?.message, hasError: fieldState.hasError,
error: fieldState.error,
}) })
: children} : children}
</div> </div>
@ -152,7 +147,7 @@ export const Field = forwardRef(function Field<Schema extends types.TSchema>(
{hasError && ( {hasError && (
<span data-testid="error" id={errorId} className={classes.error()}> <span data-testid="error" id={errorId} className={classes.error()}>
{error ?? fieldState.error?.message} {error ?? fieldState.error}
</span> </span>
)} )}
</fieldset> </fieldset>

View File

@ -10,8 +10,8 @@ import * as textProvider from '#/providers/TextProvider'
import * as reactAriaComponents from '#/components/AriaComponents' import * as reactAriaComponents from '#/components/AriaComponents'
import * as formContext from './FormProvider'
import type * as types from './types' import type * as types from './types'
import * as formContext from './useFormContext'
/** /**
* Props for the FormError component. * Props for the FormError component.
@ -26,14 +26,9 @@ export interface FormErrorProps extends Omit<reactAriaComponents.AlertProps, 'ch
* Form error component. * Form error component.
*/ */
export function FormError(props: FormErrorProps) { export function FormError(props: FormErrorProps) {
const { const { size = 'large', variant = 'error', rounded = 'large', ...alertProps } = props
form = formContext.useFormContext(),
size = 'large',
variant = 'error',
rounded = 'large',
...alertProps
} = props
const form = formContext.useFormContext(props.form)
const { formState } = form const { formState } = form
const { errors } = formState const { errors } = formState
const { getText } = textProvider.useText() const { getText } = textProvider.useText()

View File

@ -0,0 +1,72 @@
/**
* @file
*
* Context that injects form instance into the component tree.
*/
import type { PropsWithChildren } from 'react'
import { createContext, useContext } from 'react'
import invariant from 'tiny-invariant'
import type * as types from './types'
import type { FormInstance, FormInstanceValidated } from './types'
/**
* Context type for the form provider.
*/
interface FormContextType<Schema extends types.TSchema> {
readonly form: types.UseFormReturn<Schema>
}
// 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<FormContextType<any> | null>(null)
/**
* Provides the form instance to the component tree.
*/
export function FormProvider<Schema extends types.TSchema>(
props: FormContextType<Schema> & PropsWithChildren,
) {
const { children, form } = props
return (
// eslint-disable-next-line no-restricted-syntax,@typescript-eslint/no-explicit-any
<FormContext.Provider value={{ form: form as types.UseFormReturn<any> }}>
{children}
</FormContext.Provider>
)
}
/**
* Returns the form instance from the context.
*/
export function useFormContext<Schema extends types.TSchema>(
form?: FormInstanceValidated<Schema> | undefined,
): FormInstance<Schema> {
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<Schema>
}
}
/**
* Returns the form instance from the context, or null if the context is not available.
*/
export function useOptionalFormContext<
Form extends FormInstanceValidated<Schema> | undefined,
Schema extends types.TSchema,
>(form?: Form): Form extends undefined ? FormInstance<Schema> | null : FormInstance<Schema> {
try {
return useFormContext<Schema>(form)
} catch {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return null!
}
}

View File

@ -7,8 +7,9 @@ import * as React from 'react'
import * as ariaComponents from '#/components/AriaComponents' import * as ariaComponents from '#/components/AriaComponents'
import { useText } from '#/providers/TextProvider'
import * as formContext from './FormProvider'
import type * as types from './types' import type * as types from './types'
import * as formContext from './useFormContext'
/** /**
* Props for the Reset component. * Props for the Reset component.
@ -29,14 +30,16 @@ export interface ResetProps extends Omit<ariaComponents.ButtonProps, 'loading'>
* Reset button for forms. * Reset button for forms.
*/ */
export function Reset(props: ResetProps): React.JSX.Element { export function Reset(props: ResetProps): React.JSX.Element {
const { getText } = useText()
const { const {
form = formContext.useFormContext(), variant = 'ghost-fading',
variant = 'cancel',
size = 'medium', size = 'medium',
testId = 'form-reset-button', testId = 'form-reset-button',
children = getText('reset'),
...buttonProps ...buttonProps
} = props } = props
const { formState } = form
const { formState } = formContext.useFormContext(props.form)
return ( return (
<ariaComponents.Button <ariaComponents.Button
@ -48,6 +51,7 @@ export function Reset(props: ResetProps): React.JSX.Element {
size={size} size={size}
isDisabled={formState.isSubmitting || !formState.isDirty} isDisabled={formState.isSubmitting || !formState.isDirty}
testId={testId} testId={testId}
children={children}
/> />
) )
} }

View File

@ -10,8 +10,8 @@ import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents' import * as ariaComponents from '#/components/AriaComponents'
import * as formContext from './FormProvider'
import type * as types from './types' import type * as types from './types'
import * as formContext from './useFormContext'
/** /**
* Additional props for the Submit component. * Additional props for the Submit component.
@ -48,22 +48,23 @@ export type SubmitProps = Omit<ariaComponents.ButtonProps, 'href' | 'variant'> &
* Manages the form state and displays a loading spinner when the form is submitting. * Manages the form state and displays a loading spinner when the form is submitting.
*/ */
export function Submit(props: SubmitProps): React.JSX.Element { export function Submit(props: SubmitProps): React.JSX.Element {
const { getText } = textProvider.useText()
const { const {
form = formContext.useFormContext(),
variant = 'submit',
size = 'medium', size = 'medium',
testId = 'form-submit-button',
formnovalidate = false, formnovalidate = false,
loading = false, loading = false,
children, children = formnovalidate ? getText('cancel') : getText('submit'),
variant = formnovalidate ? 'ghost-fading' : 'submit',
testId = formnovalidate ? 'form-cancel-button' : 'form-submit-button',
...buttonProps ...buttonProps
} = props } = props
const { getText } = textProvider.useText()
const dialogContext = ariaComponents.useDialogContext() const dialogContext = ariaComponents.useDialogContext()
const form = formContext.useFormContext(props.form)
const { formState } = form const { formState } = form
const isLoading = loading || formState.isSubmitting const isLoading = formnovalidate ? false : loading || formState.isSubmitting
const type = formnovalidate || isLoading ? 'button' : 'submit' const type = formnovalidate || isLoading ? 'button' : 'submit'
return ( return (
@ -82,7 +83,7 @@ export function Submit(props: SubmitProps): React.JSX.Element {
} }
}} }}
> >
{children ?? getText('submit')} {children}
</ariaComponents.Button> </ariaComponents.Button>
) )
} }

View File

@ -3,14 +3,16 @@
* *
* Barrel file for form components. * Barrel file for form components.
*/ */
export { Controller } from 'react-hook-form' export { Controller, useWatch } from 'react-hook-form'
export * from './Field' export * from './Field'
export * from './FormError' export * from './FormError'
export * from './FormProvider'
export * from './Reset' export * from './Reset'
export * from './schema' export * from './schema'
export * from './Submit' export * from './Submit'
export * from './types' export * from './types'
export * from './useField' export * from './useField'
export * from './useFieldRegister'
export * from './useFieldState'
export * from './useForm' export * from './useForm'
export * from './useFormContext'
export * from './useFormSchema' export * from './useFormSchema'

View File

@ -7,6 +7,7 @@ import type * as React from 'react'
import type * as reactHookForm from 'react-hook-form' import type * as reactHookForm from 'react-hook-form'
import type * as z from 'zod' import type * as z from 'zod'
import type { FormEvent } from 'react'
import type * as schemaModule from './schema' import type * as schemaModule from './schema'
/** The type of the inputs to the form, used for UI inputs. */ /** The type of the inputs to the form, used for UI inputs. */
@ -31,15 +32,61 @@ export type TSchema =
| z.ZodEffects<z.AnyZodObject> | z.ZodEffects<z.AnyZodObject>
| z.ZodEffects<z.ZodEffects<z.AnyZodObject>> | z.ZodEffects<z.ZodEffects<z.AnyZodObject>>
/**
* OnSubmitCallbacks type.
*/
export interface OnSubmitCallbacks<Schema extends TSchema, SubmitResult = void> {
readonly onSubmit?:
| ((
values: FieldValues<Schema>,
form: UseFormReturn<Schema>,
) => Promise<SubmitResult> | SubmitResult)
| undefined
readonly onSubmitFailed?:
| ((
error: unknown,
values: FieldValues<Schema>,
form: UseFormReturn<Schema>,
) => Promise<void> | void)
| undefined
readonly onSubmitSuccess?:
| ((
data: SubmitResult,
values: FieldValues<Schema>,
form: UseFormReturn<Schema>,
) => Promise<void> | void)
| undefined
readonly onSubmitted?:
| ((
data: SubmitResult | undefined,
error: unknown,
values: FieldValues<Schema>,
form: UseFormReturn<Schema>,
) => Promise<void> | void)
| undefined
}
/** /**
* Props for the useForm hook. * Props for the useForm hook.
*/ */
export interface UseFormProps<Schema extends TSchema> export interface UseFormProps<Schema extends TSchema, SubmitResult = void>
extends Omit< extends Omit<
reactHookForm.UseFormProps<FieldValues<Schema>>, reactHookForm.UseFormProps<FieldValues<Schema>>,
'handleSubmit' | 'resetOptions' | 'resolver' 'handleSubmit' | 'resetOptions' | 'resolver'
> { >,
OnSubmitCallbacks<Schema, SubmitResult> {
readonly schema: Schema | ((schema: typeof schemaModule.schema) => Schema) 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<Schema extends TSchema> = <
>( >(
name: TFieldName, name: TFieldName,
options?: reactHookForm.RegisterOptions<FieldValues<Schema>, TFieldName>, options?: reactHookForm.RegisterOptions<FieldValues<Schema>, TFieldName>,
// eslint-disable-next-line no-restricted-syntax
) => UseFormRegisterReturn<Schema, TFieldName> ) => UseFormRegisterReturn<Schema, TFieldName>
/** /**
@ -64,9 +110,12 @@ export interface UseFormRegisterReturn<
readonly onChange: <Value>(value: Value) => Promise<boolean | void> readonly onChange: <Value>(value: Value) => Promise<boolean | void>
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
readonly onBlur: <Value>(value: Value) => Promise<boolean | void> readonly onBlur: <Value>(value: Value) => Promise<boolean | void>
readonly isDisabled?: boolean readonly isDisabled: boolean
readonly isRequired?: boolean readonly isRequired: boolean
readonly isInvalid?: boolean readonly isInvalid: boolean
readonly disabled: boolean
readonly required: boolean
readonly invalid: boolean
} }
/** /**
@ -74,8 +123,14 @@ export interface UseFormRegisterReturn<
* @alias reactHookForm.UseFormReturn * @alias reactHookForm.UseFormReturn
*/ */
export interface UseFormReturn<Schema extends TSchema> export interface UseFormReturn<Schema extends TSchema>
extends reactHookForm.UseFormReturn<FieldValues<Schema>, unknown, TransformedValues<Schema>> { extends Omit<
reactHookForm.UseFormReturn<FieldValues<Schema>, unknown, TransformedValues<Schema>>,
'onSubmit' | 'resetOptions' | 'resolver'
> {
readonly register: UseFormRegister<Schema> readonly register: UseFormRegister<Schema>
readonly submit: (event?: FormEvent<HTMLFormElement> | null | undefined) => Promise<void>
readonly schema: Schema
readonly setFormError: (error: string) => void
} }
/** /**
@ -113,6 +168,16 @@ export interface FormWithValueValidation<
| undefined | 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<Schema> | (any[] & {})
/** /**
* Props for the Field component. * Props for the Field component.
*/ */
@ -148,3 +213,36 @@ export interface FieldProps {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
'aria-details'?: string | undefined 'aria-details'?: string | undefined
} }
/**
* Base Props for a Form Field.
* @private
*/
export interface FormFieldProps<
BaseValueType,
Schema extends TSchema,
TFieldName extends FieldPath<Schema>,
> extends FormWithValueValidation<BaseValueType, Schema, TFieldName> {
readonly name: TFieldName
readonly value?: BaseValueType extends FieldValues<Schema> ? FieldValues<Schema>[TFieldName]
: never
readonly defaultValue?: FieldValues<Schema>[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<Schema>,
> = FormFieldProps<BaseProps['value'], Schema, TFieldName> & {
// to avoid conflicts with the FormFieldProps we need to omit the FormFieldProps from the BaseProps
[K in keyof Omit<
BaseProps,
keyof FormFieldProps<BaseProps['value'], Schema, TFieldName>
>]: BaseProps[K]
}

View File

@ -5,8 +5,8 @@
*/ */
import * as reactHookForm from 'react-hook-form' import * as reactHookForm from 'react-hook-form'
import * as formContext from './FormProvider'
import type * as types from './types' import type * as types from './types'
import * as formContext from './useFormContext'
/** /**
* Options for {@link useField} hook. * Options for {@link useField} hook.
@ -29,24 +29,16 @@ export function useField<
Schema extends types.TSchema, Schema extends types.TSchema,
TFieldName extends types.FieldPath<Schema>, TFieldName extends types.FieldPath<Schema>,
>(options: UseFieldOptions<BaseValueType, Schema, TFieldName>) { >(options: UseFieldOptions<BaseValueType, Schema, TFieldName>) {
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. const formInstance = formContext.useFormContext(options.form)
// 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<Schema>
const { field, fieldState, formState } = reactHookForm.useController({ const { field, fieldState, formState } = reactHookForm.useController({
name, name,
disabled: isDisabled, disabled: isDisabled,
control: formInstance.control,
...(defaultValue != null ? { defaultValue } : {}), ...(defaultValue != null ? { defaultValue } : {}),
}) })
return { return { field, fieldState, formState, formInstance } as const
field,
fieldState,
formState,
formInstance,
} as const
} }

View File

@ -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<Schema>,
> = Omit<FormFieldProps<BaseValueType, Schema, TFieldName>, 'form'> & {
name: TFieldName
form?: FormInstanceValidated<Schema> | undefined
defaultValue?: FieldValues<Schema>[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<Schema>,
>(options: UseFieldRegisterOptions<BaseValueType, Schema, TFieldName>) {
const { name, min, max, minLength, maxLength, isRequired, isDisabled, form, setValueAs } = options
const formInstance = useFormContext(form)
const extractedValidationDetails = unsafe__extractValidationDetailsFromSchema<Schema, TFieldName>(
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: 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
}
}

View File

@ -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<Schema>,
> {
readonly name: TFieldName
readonly form?: FormInstanceValidated<Schema> | undefined
}
/**
* Hook to get the state of a field.
*/
export function useFieldState<Schema extends TSchema, TFieldName extends FieldPath<Schema>>(
options: UseFieldStateOptions<Schema, TFieldName>,
) {
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
}

View File

@ -3,13 +3,18 @@
* *
* A hook that returns a form instance. * A hook that returns a form instance.
*/ */
import * as sentry from '@sentry/react'
import * as React from 'react' import * as React from 'react'
import * as zodResolver from '@hookform/resolvers/zod' import * as zodResolver from '@hookform/resolvers/zod'
import * as reactHookForm from 'react-hook-form' import * as reactHookForm from 'react-hook-form'
import invariant from 'tiny-invariant' import invariant from 'tiny-invariant'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useOffline, useOfflineChange } from '#/hooks/offlineHooks'
import { useText } from '#/providers/TextProvider' import { useText } from '#/providers/TextProvider'
import * as errorUtils from '#/utilities/error'
import { useMutation } from '@tanstack/react-query'
import * as schemaModule from './schema' import * as schemaModule from './schema'
import type * as types from './types' 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. * But be careful, You should not switch between the two types of arguments.
* Otherwise you'll be fired * Otherwise you'll be fired
*/ */
export function useForm<Schema extends types.TSchema>( export function useForm<Schema extends types.TSchema, SubmitResult = void>(
optionsOrFormInstance: types.UseFormProps<Schema> | types.UseFormReturn<Schema>, optionsOrFormInstance: types.UseFormProps<Schema, SubmitResult> | types.UseFormReturn<Schema>,
): types.UseFormReturn<Schema> { ): types.UseFormReturn<Schema> {
const { getText } = useText() const { getText } = useText()
const initialTypePassed = React.useRef(getArgsType(optionsOrFormInstance)) const [initialTypePassed] = React.useState(() => getArgsType(optionsOrFormInstance))
const argsType = getArgsType(optionsOrFormInstance) const argsType = getArgsType(optionsOrFormInstance)
invariant( 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 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) { if ('formState' in optionsOrFormInstance) {
return optionsOrFormInstance return optionsOrFormInstance
} else { } 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 computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema
const formInstance = reactHookForm.useForm< const formInstance = reactHookForm.useForm({
types.FieldValues<Schema>,
unknown,
types.TransformedValues<Schema>
>({
...options, ...options,
resolver: zodResolver.zodResolver(computedSchema, { resolver: zodResolver.zodResolver(
async: true, computedSchema,
errorMap: (issue) => { {
switch (issue.code) { async: true,
case 'too_small': errorMap: (issue) => {
if (issue.minimum === 0) { switch (issue.code) {
return { case 'too_small':
message: getText('arbitraryFieldRequired'), if (issue.minimum === 0) {
return {
message: getText('arbitraryFieldRequired'),
}
} else {
return {
message: getText('arbitraryFieldTooSmall', issue.minimum.toString()),
}
} }
} else { case 'too_big':
return { return {
message: getText('arbitraryFieldTooSmall', issue.minimum.toString()), message: getText('arbitraryFieldTooLarge', issue.maximum.toString()),
} }
} case 'invalid_type':
case 'too_big': return {
return { message: getText('arbitraryFieldInvalid'),
message: getText('arbitraryFieldTooLarge', issue.maximum.toString()), }
} default:
case 'invalid_type': return {
return { message: getText('arbitraryFieldInvalid'),
message: getText('arbitraryFieldInvalid'), }
} }
default: },
return {
message: getText('arbitraryFieldInvalid'),
}
}
}, },
}), { mode: 'async' },
),
}) })
const register: types.UseFormRegister<Schema> = (name, opts) => { const register: types.UseFormRegister<Schema> = (name, opts) => {
@ -110,9 +124,12 @@ export function useForm<Schema extends types.TSchema>(
const result: types.UseFormRegisterReturn<Schema, typeof name> = { const result: types.UseFormRegisterReturn<Schema, typeof name> = {
...registered, ...registered,
...(registered.disabled != null ? { isDisabled: registered.disabled } : {}), disabled: registered.disabled ?? false,
...(registered.required != null ? { isRequired: registered.required } : {}), isDisabled: registered.disabled ?? false,
invalid: !!formInstance.formState.errors[name],
isInvalid: !!formInstance.formState.errors[name], isInvalid: !!formInstance.formState.errors[name],
required: registered.required ?? false,
isRequired: registered.required ?? false,
onChange, onChange,
onBlur, onBlur,
} }
@ -120,19 +137,106 @@ export function useForm<Schema extends types.TSchema>(
return result 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<Schema>) => {
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<HTMLFormElement> | 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<Schema> = {
...formInstance, ...formInstance,
submit,
control: { ...formInstance.control, register }, control: { ...formInstance.control, register },
register, register,
} satisfies types.UseFormReturn<Schema> schema: computedSchema,
setFormError,
handleSubmit: formInstance.handleSubmit,
}
return form
} }
} }
/** /**
* Get the type of arguments passed to the useForm hook * Get the type of arguments passed to the useForm hook
*/ */
function getArgsType<Schema extends types.TSchema>( function getArgsType<Schema extends types.TSchema, SubmitResult = void>(
args: types.UseFormProps<Schema> | types.UseFormReturn<Schema>, args: types.UseFormProps<Schema, SubmitResult>,
) { ) {
return 'formState' in args ? 'formInstance' : 'formOptions' return 'formState' in args ? ('formInstance' as const) : ('formOptions' as const)
} }

View File

@ -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
}
}

View File

@ -3,8 +3,8 @@ import * as React from 'react'
import * as callbackEventHooks from '#/hooks/eventCallbackHooks' import * as callbackEventHooks from '#/hooks/eventCallbackHooks'
import * as schemaComponent from '#/components/AriaComponents/Form/components/schema' import * as schemaComponent from './schema'
import type * as types from '#/components/AriaComponents/Form/components/types' import type * as types from './types'
// ===================== // =====================
// === useFormSchema === // === useFormSchema ===

View File

@ -7,6 +7,8 @@ import type * as React from 'react'
import type * as reactHookForm from 'react-hook-form' 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 components from './components'
import type * as styles from './styles' import type * as styles from './styles'
@ -15,8 +17,11 @@ export type * from './components'
/** /**
* Props for the Form component * Props for the Form component
*/ */
export type FormProps<Schema extends components.TSchema> = BaseFormProps<Schema> & export type FormProps<
(FormPropsWithOptions<Schema> | FormPropsWithParentForm<Schema>) Schema extends components.TSchema,
SubmitResult = void,
> = BaseFormProps<Schema> &
(FormPropsWithOptions<Schema, SubmitResult> | FormPropsWithParentForm<Schema>)
/** /**
* Base props for the Form component. * Base props for the Form component.
@ -26,20 +31,8 @@ interface BaseFormProps<Schema extends components.TSchema>
React.HTMLProps<HTMLFormElement>, React.HTMLProps<HTMLFormElement>,
'children' | 'className' | 'form' | 'onSubmit' | 'onSubmitCapture' | 'style' 'children' | 'className' | 'form' | 'onSubmit' | 'onSubmitCapture' | 'style'
>, >,
styles.FormStyleProps { Omit<styles.FormStyleProps, 'class' | 'className'>,
/** TestIdProps {
* 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<Schema>['defaultValues']
readonly onSubmit?: (
values: components.TransformedValues<Schema>,
form: components.UseFormReturn<Schema>,
) => unknown
readonly style?: readonly style?:
| React.CSSProperties | React.CSSProperties
| ((props: components.UseFormReturn<Schema>) => React.CSSProperties) | ((props: components.UseFormReturn<Schema>) => React.CSSProperties)
@ -48,17 +41,13 @@ interface BaseFormProps<Schema extends components.TSchema>
| (( | ((
props: components.UseFormReturn<Schema> & { props: components.UseFormReturn<Schema> & {
readonly form: components.UseFormReturn<Schema> readonly form: components.UseFormReturn<Schema>
readonly values: DeepPartialSkipArrayKey<components.FieldValues<Schema>>
}, },
) => React.ReactNode) ) => React.ReactNode)
readonly formRef?: React.MutableRefObject<components.UseFormReturn<Schema>> readonly formRef?: React.MutableRefObject<components.UseFormReturn<Schema>>
readonly className?: string | ((props: components.UseFormReturn<Schema>) => string) readonly className?: string | ((props: components.UseFormReturn<Schema>) => string)
readonly onSubmitFailed?: (error: unknown) => Promise<void> | void
readonly onSubmitSuccess?: () => Promise<void> | void
readonly onSubmitted?: () => Promise<void> | void
readonly testId?: string
/** /**
* When set to `dialog`, form submission will close the parent dialog on successful submission. * When set to `dialog`, form submission will close the parent dialog on successful submission.
*/ */
@ -76,16 +65,33 @@ interface FormPropsWithParentForm<Schema extends components.TSchema> {
readonly form: components.UseFormReturn<Schema> readonly form: components.UseFormReturn<Schema>
readonly schema?: never readonly schema?: never
readonly formOptions?: 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. * Props for the Form component with schema and form options.
* Creates a new form instance. This is the default way to use the form. * Creates a new form instance. This is the default way to use the form.
*/ */
interface FormPropsWithOptions<Schema extends components.TSchema> { interface FormPropsWithOptions<Schema extends components.TSchema, SubmitResult = void>
extends components.OnSubmitCallbacks<Schema, SubmitResult> {
readonly schema: Schema | ((schema: typeof components.schema) => Schema) readonly schema: Schema | ((schema: typeof components.schema) => Schema)
readonly formOptions?: Omit<
components.UseFormProps<Schema, SubmitResult>,
'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<Schema>['defaultValues']
readonly form?: never readonly form?: never
readonly formOptions?: Omit<components.UseFormProps<Schema>, 'resolver' | 'schema'>
} }
/** /**
@ -134,38 +140,3 @@ export type FormStateRenderProps<Schema extends components.TSchema> = Pick<
/** The form instance. */ /** The form instance. */
readonly form: components.FormInstance<Schema> readonly form: components.FormInstance<Schema>
} }
/**
* Base Props for a Form Field.
* @private
*/
interface FormFieldProps<
BaseValueType,
Schema extends components.TSchema,
TFieldName extends components.FieldPath<Schema>,
> extends components.FormWithValueValidation<BaseValueType, Schema, TFieldName> {
readonly name: TFieldName
readonly value?: BaseValueType extends components.FieldValues<Schema>[TFieldName] ?
components.FieldValues<Schema>[TFieldName]
: never
readonly defaultValue?: components.FieldValues<Schema>[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<Schema>,
> = FormFieldProps<BaseProps['value'], Schema, TFieldName> & {
// to avoid conflicts with the FormFieldProps we need to omit the FormFieldProps from the BaseProps
[K in keyof Omit<
BaseProps,
keyof FormFieldProps<BaseProps['value'], Schema, TFieldName>
>]: BaseProps[K]
}

View File

@ -37,8 +37,8 @@ import {
} from '#/components/AriaComponents' } from '#/components/AriaComponents'
import { useText } from '#/providers/TextProvider' import { useText } from '#/providers/TextProvider'
import { forwardRef } from '#/utilities/react' import { forwardRef } from '#/utilities/react'
import { Controller } from 'react-hook-form' import type { VariantProps } from '#/utilities/tailwindVariants'
import { tv, type VariantProps } from 'tailwind-variants' import { tv } from '#/utilities/tailwindVariants'
const DATE_PICKER_STYLES = tv({ const DATE_PICKER_STYLES = tv({
base: '', base: '',
@ -135,7 +135,7 @@ export const DatePicker = forwardRef(function DatePicker<
ref={ref} ref={ref}
style={props.style} style={props.style}
> >
<Controller <Form.Controller
control={formInstance.control} control={formInstance.control}
name={name} name={name}
render={(renderProps) => { render={(renderProps) => {

View File

@ -30,6 +30,7 @@ import SvgMask from '#/components/SvgMask'
import { mergeRefs } from '#/utilities/mergeRefs' import { mergeRefs } from '#/utilities/mergeRefs'
import { forwardRef } from '#/utilities/react' import { forwardRef } from '#/utilities/react'
import type { ExtractFunction } from '#/utilities/tailwindVariants' import type { ExtractFunction } from '#/utilities/tailwindVariants'
import { omit } from 'enso-common/src/utilities/data/object'
import { INPUT_STYLES } from '../variants' import { INPUT_STYLES } from '../variants'
/** /**
@ -62,24 +63,18 @@ export const Input = forwardRef(function Input<
>(props: InputProps<Schema, TFieldName>, ref: ForwardedRef<HTMLFieldSetElement>) { >(props: InputProps<Schema, TFieldName>, ref: ForwardedRef<HTMLFieldSetElement>) {
const { const {
name, name,
isDisabled = false,
form,
defaultValue,
description, description,
inputRef, inputRef,
addonStart, addonStart,
addonEnd, addonEnd,
label,
size, size,
rounded, rounded,
isRequired = false,
min,
max,
icon, icon,
type = 'text', type = 'text',
variant, variant,
variants = INPUT_STYLES, variants = INPUT_STYLES,
fieldVariants, fieldVariants,
form,
...inputProps ...inputProps
} = props } = props
@ -87,32 +82,14 @@ export const Input = forwardRef(function Input<
const privateInputRef = useRef<HTMLInputElement>(null) const privateInputRef = useRef<HTMLInputElement>(null)
const { fieldState, formInstance } = Form.useField({ const { fieldProps, formInstance } = Form.useFieldRegister<
name, Omit<aria.InputProps, 'children' | 'size'>,
isDisabled, Schema,
TFieldName
>({
...props,
form, form,
defaultValue, setValueAs: (value: unknown) => {
})
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) => {
if (typeof value === 'string') { if (typeof value === 'string') {
if (type === 'number') { if (type === 'number') {
return Number(value) 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 ( return (
<Form.Field <Form.Field
data-testid={testId} {...aria.mergeProps<FieldComponentProps<Schema>>()(inputProps, omit(fieldProps), {
form={formInstance} isHidden: props.hidden,
name={name} fullWidth: true,
fullWidth variants: fieldVariants,
isHidden={inputProps.hidden} form: formInstance,
label={label} })}
aria-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
aria-describedby={props['aria-describedby']}
isRequired={field.required}
isInvalid={fieldState.invalid}
aria-details={props['aria-details']}
ref={ref} ref={ref}
style={props.style} name={props.name}
className={props.className} data-testid={testId}
variants={fieldVariants}
> >
<div <div
className={classes.base()} className={classes.base()}
@ -158,12 +137,12 @@ export const Input = forwardRef(function Input<
<div className={classes.inputContainer()}> <div className={classes.inputContainer()}>
<aria.Input <aria.Input
ref={mergeRefs(inputRef, privateInputRef, fieldRef)}
{...aria.mergeProps<aria.InputProps>()( {...aria.mergeProps<aria.InputProps>()(
{ className: classes.textArea(), type, name, min, max },
inputProps, inputProps,
field, { className: classes.textArea(), type, name },
omit(fieldProps, 'isInvalid', 'isRequired', 'isDisabled', 'invalid'),
)} )}
ref={mergeRefs(inputRef, privateInputRef, fieldProps.ref)}
/> />
</div> </div>

View File

@ -22,7 +22,6 @@ import { mergeRefs } from '#/utilities/mergeRefs'
import { forwardRef } from '#/utilities/react' import { forwardRef } from '#/utilities/react'
import { tv } from '#/utilities/tailwindVariants' import { tv } from '#/utilities/tailwindVariants'
import { omit, unsafeRemoveUndefined } from 'enso-common/src/utilities/data/object' import { omit, unsafeRemoveUndefined } from 'enso-common/src/utilities/data/object'
import { Controller } from 'react-hook-form'
import { MultiSelectorOption } from './MultiSelectorOption' import { MultiSelectorOption } from './MultiSelectorOption'
/** * Props for the MultiSelector component. */ /** * Props for the MultiSelector component. */
@ -141,7 +140,7 @@ export const MultiSelector = forwardRef(function MultiSelector<
className={classes.base()} className={classes.base()}
onClick={() => privateInputRef.current?.focus({ preventScroll: true })} onClick={() => privateInputRef.current?.focus({ preventScroll: true })}
> >
<Controller <Form.Controller
control={formInstance.control} control={formInstance.control}
name={name} name={name}
render={(renderProps) => { render={(renderProps) => {

View File

@ -1,9 +1,9 @@
/** @file An option in a selector. */ /** @file An option in a selector. */
import { ListBoxItem, type ListBoxItemProps } from '#/components/aria' import { ListBoxItem, type ListBoxItemProps } from '#/components/aria'
import { forwardRef } from '#/utilities/react' import { forwardRef } from '#/utilities/react'
import type { VariantProps } from '#/utilities/tailwindVariants'
import { tv } from '#/utilities/tailwindVariants' import { tv } from '#/utilities/tailwindVariants'
import * as React from 'react' import * as React from 'react'
import type { VariantProps } from 'tailwind-variants'
import { TEXT_STYLE } from '../../Text' import { TEXT_STYLE } from '../../Text'
/** Props for a {@link MultiSelectorOption}. */ /** Props for a {@link MultiSelectorOption}. */

View File

@ -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<Schema extends TSchema, TFieldName extends FieldPath<Schema>>
extends FieldStateProps<Omit<OTPInputProps, 'children' | 'render'>, Schema, TFieldName>,
FieldProps,
FieldVariantProps,
Omit<VariantProps<typeof STYLES>, 'disabled' | 'invalid'>,
TestIdProps {
readonly inputRef?: Ref<HTMLInputElement>
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<Schema>,
>(props: OtpInputProps<Schema, TFieldName>, ref: ForwardedRef<HTMLFieldSetElement>) {
const {
maxLength,
variants = STYLES,
className,
name,
fieldVariants,
inputRef,
submitOnComplete = true,
onComplete,
form,
...inputProps
} = props
const innerOtpInputRef = useRef<HTMLInputElement>(null)
const classes = variants({ className })
const { fieldProps, formInstance } = Form.useFieldRegister({
...props,
form,
})
return (
<Form.Field
{...mergeProps<FieldComponentProps<Schema>>()(inputProps, omit(fieldProps), {
isHidden: props.hidden,
fullWidth: true,
variants: fieldVariants,
form: formInstance,
})}
ref={ref}
name={props.name}
>
<BaseOTPInput
{...mergeProps<OTPInputProps>()(
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 (
<div role="presentation" className="flex w-full items-center gap-2">
{sections.map((section, idx) => (
<>
<div key={idx} className={classes.slotsContainer()}>
{section.map((slot, key) => (
<Slot isInvalid={fieldProps.isInvalid} key={key} {...slot} />
))}
</div>
{idx < sections.length - 1 && (
<Separator
key={idx + 'separator'}
orientation="horizontal"
className="w-3"
size="medium"
/>
)}
</>
))}
</div>
)
}}
/>
</Form.Field>
)
})
/**
* Props for a single {@link Slot}.
*/
interface SlotProps extends Omit<OTPInputSlotProps, 'isActive'>, VariantProps<typeof SLOT_STYLES> {}
/**
* 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 (
<div className={classes.base()}>
{char != null && <div className={classes.char()}>{char}</div>}
{hasFakeCaret && <div role="presentation" className={classes.fakeCaret()} />}
</div>
)
}

View File

@ -0,0 +1,6 @@
/**
* @file
*
* Barrel export file for OTPInput
*/
export * from './OTPInput'

View File

@ -7,6 +7,7 @@ import EyeCrossedIcon from '#/assets/eye_crossed.svg'
import { import {
Button, Button,
Input, Input,
type FieldPath,
type FieldValues, type FieldValues,
type InputProps, type InputProps,
type TSchema, type TSchema,
@ -17,7 +18,7 @@ import {
// ================ // ================
/** Props for a {@link Password}. */ /** Props for a {@link Password}. */
export interface PasswordProps<Schema extends TSchema, TFieldName extends Path<FieldValues<Schema>>> export interface PasswordProps<Schema extends TSchema, TFieldName extends FieldPath<Schema>>
extends Omit<InputProps<Schema, TFieldName>, 'type'> {} extends Omit<InputProps<Schema, TFieldName>, 'type'> {}
/** A component wrapping {@link Input} with the ability to show and hide password. */ /** A component wrapping {@link Input} with the ability to show and hide password. */

View File

@ -35,8 +35,10 @@ export interface ResizableContentEditableInputProps<
VariantProps<typeof INPUT_STYLES>, VariantProps<typeof INPUT_STYLES>,
'disabled' | 'invalid' | 'rounded' | 'size' | 'variant' 'disabled' | 'invalid' | 'rounded' | 'size' | 'variant'
>, >,
FieldVariantProps,
Omit<FieldProps, 'variant'>, Omit<FieldProps, 'variant'>,
FieldVariantProps, FieldVariantProps,
Pick<VariantProps<typeof INPUT_STYLES>, 'rounded' | 'size' | 'variant'>,
Omit< Omit<
VariantProps<typeof CONTENT_EDITABLE_STYLES>, VariantProps<typeof CONTENT_EDITABLE_STYLES>,
'disabled' | 'invalid' | 'rounded' | 'size' | 'variant' 'disabled' | 'invalid' | 'rounded' | 'size' | 'variant'

View File

@ -4,12 +4,14 @@ import * as React from 'react'
import type * as twv from 'tailwind-variants' import type * as twv from 'tailwind-variants'
import { mergeProps, type RadioGroupProps } from '#/components/aria' import { mergeProps, type RadioGroupProps } from '#/components/aria'
import type { FieldComponentProps } from '#/components/AriaComponents'
import { import {
Form,
type FieldPath, type FieldPath,
type FieldProps, type FieldProps,
type FieldStateProps, type FieldStateProps,
type FieldValues, type FieldValues,
Form, type FieldVariantProps,
type TSchema, type TSchema,
} from '#/components/AriaComponents' } from '#/components/AriaComponents'
@ -18,7 +20,6 @@ import RadioGroup from '#/components/styled/RadioGroup'
import { mergeRefs } from '#/utilities/mergeRefs' import { mergeRefs } from '#/utilities/mergeRefs'
import { forwardRef } from '#/utilities/react' import { forwardRef } from '#/utilities/react'
import { tv } from '#/utilities/tailwindVariants' import { tv } from '#/utilities/tailwindVariants'
import { Controller } from 'react-hook-form'
import { SelectorOption } from './SelectorOption' import { SelectorOption } from './SelectorOption'
/** * Props for the Selector component. */ /** * Props for the Selector component. */
@ -29,7 +30,8 @@ export interface SelectorProps<Schema extends TSchema, TFieldName extends FieldP
TFieldName TFieldName
>, >,
FieldProps, FieldProps,
Omit<twv.VariantProps<typeof SELECTOR_STYLES>, 'disabled' | 'invalid'> { Omit<twv.VariantProps<typeof SELECTOR_STYLES>, 'disabled' | 'invalid'>,
FieldVariantProps {
readonly items: readonly FieldValues<Schema>[TFieldName][] readonly items: readonly FieldValues<Schema>[TFieldName][]
readonly children?: (item: FieldValues<Schema>[TFieldName]) => string readonly children?: (item: FieldValues<Schema>[TFieldName]) => string
readonly columns?: number readonly columns?: number
@ -90,23 +92,20 @@ export const Selector = forwardRef(function Selector<
isDisabled = false, isDisabled = false,
columns, columns,
form, form,
defaultValue,
inputRef, inputRef,
label, label,
size, size,
rounded, rounded,
isRequired = false, isRequired = false,
isInvalid = false,
fieldVariants,
defaultValue,
...inputProps ...inputProps
} = props } = props
const privateInputRef = React.useRef<HTMLDivElement>(null) const privateInputRef = React.useRef<HTMLDivElement>(null)
const { fieldState, formInstance } = Form.useField({ const formInstance = Form.useFormContext(form)
name,
isDisabled,
form,
...(defaultValue != null ? { defaultValue } : {}),
})
const classes = SELECTOR_STYLES({ const classes = SELECTOR_STYLES({
size, size,
@ -116,51 +115,49 @@ export const Selector = forwardRef(function Selector<
}) })
return ( return (
<Form.Field <Form.Controller
form={formInstance} control={formInstance.control}
name={name} name={name}
fullWidth render={(renderProps) => {
label={label} const { value } = renderProps.field
aria-label={props['aria-label']} return (
aria-labelledby={props['aria-labelledby']} <Form.Field
aria-describedby={props['aria-describedby']} {...mergeProps<FieldComponentProps<Schema>>()(inputProps, renderProps.field, {
isRequired={isRequired} fullWidth: true,
isInvalid={fieldState.invalid} variants: fieldVariants,
aria-details={props['aria-details']} form: formInstance,
ref={ref} label,
style={props.style} isRequired,
className={props.className} })}
> name={props.name}
<div ref={ref}
className={classes.base()} >
onClick={() => privateInputRef.current?.focus({ preventScroll: true })} <div
> className={classes.base()}
<Controller onClick={() => privateInputRef.current?.focus({ preventScroll: true })}
control={formInstance.control} >
name={name}
render={(renderProps) => {
const { ref: fieldRef, value, onChange, ...field } = renderProps.field
return (
<RadioGroup <RadioGroup
ref={mergeRefs(inputRef, privateInputRef, fieldRef)}
{...mergeProps<RadioGroupProps>()( {...mergeProps<RadioGroupProps>()(
{ {
className: classes.radioGroup(), className: classes.radioGroup(),
name, name,
isRequired, isRequired,
isDisabled, isDisabled,
isInvalid,
style: style:
columns != null ? { gridTemplateColumns: `repeat(${columns}, 1fr)` } : {}, columns != null ? { gridTemplateColumns: `repeat(${columns}, 1fr)` } : {},
...(defaultValue != null ? { defaultValue } : {}),
}, },
inputProps, inputProps,
field, renderProps.field,
)} )}
ref={mergeRefs(inputRef, privateInputRef, renderProps.field.ref)}
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
aria-label={props['aria-label'] ?? (typeof label === 'string' ? label : '')} aria-label={props['aria-label'] ?? (typeof label === 'string' ? label : '')}
value={String(items.indexOf(value))} value={String(items.indexOf(value))}
onChange={(newValue) => { onChange={(newValue) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return // eslint-disable-next-line @typescript-eslint/no-unsafe-return
onChange(items[Number(newValue)]) renderProps.field.onChange(items[Number(newValue)])
}} }}
> >
<AnimatedBackground value={String(items.indexOf(value))}> <AnimatedBackground value={String(items.indexOf(value))}>
@ -169,10 +166,10 @@ export const Selector = forwardRef(function Selector<
))} ))}
</AnimatedBackground> </AnimatedBackground>
</RadioGroup> </RadioGroup>
) </div>
}} </Form.Field>
/> )
</div> }}
</Form.Field> />
) )
}) })

View File

@ -2,9 +2,9 @@
import { AnimatedBackground } from '#/components/AnimatedBackground' import { AnimatedBackground } from '#/components/AnimatedBackground'
import { Radio, type RadioProps } from '#/components/aria' import { Radio, type RadioProps } from '#/components/aria'
import { forwardRef } from '#/utilities/react' import { forwardRef } from '#/utilities/react'
import type { VariantProps } from '#/utilities/tailwindVariants'
import { tv } from '#/utilities/tailwindVariants' import { tv } from '#/utilities/tailwindVariants'
import * as React from 'react' import * as React from 'react'
import type { VariantProps } from 'tailwind-variants'
import { TEXT_STYLE } from '../../Text' import { TEXT_STYLE } from '../../Text'
/** Props for a {@link SelectorOption}. */ /** Props for a {@link SelectorOption}. */
@ -99,9 +99,18 @@ export const SelectorOption = forwardRef(function SelectorOption(
props: SelectorOptionProps, props: SelectorOptionProps,
ref: React.ForwardedRef<HTMLLabelElement>, ref: React.ForwardedRef<HTMLLabelElement>,
) { ) {
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 ( return (
<AnimatedBackground.Item <AnimatedBackground.Item

View File

@ -8,6 +8,7 @@ export * from './DatePicker'
export * from './Dropdown' export * from './Dropdown'
export * from './Input' export * from './Input'
export * from './MultiSelector' export * from './MultiSelector'
export * from './OTPInput'
export * from './Password' export * from './Password'
export * from './ResizableInput' export * from './ResizableInput'
export * from './Selector' export * from './Selector'

View File

@ -39,7 +39,7 @@ export const INPUT_STYLES = tv({
variant: { variant: {
custom: {}, custom: {},
outline: { outline: {
base: 'border-[0.5px] border-primary/20 outline-offset-2 focus-within:border-primary/50 focus-within:outline focus-within:outline-2 focus-within:outline-offset-[0.5px] focus-within:outline-primary', base: 'border-[0.5px] border-primary/20 outline-offset-2 focus-within:border-primary/50 focus-within:outline focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-primary',
textArea: 'border-transparent focus-within:border-transparent', textArea: 'border-transparent focus-within:border-transparent',
}, },
}, },

View File

@ -127,6 +127,8 @@ export const Switch = forwardRef(function Switch<
{...mergeProps<AriaSwitchProps>()(ariaSwitchProps, fieldProps, { {...mergeProps<AriaSwitchProps>()(ariaSwitchProps, fieldProps, {
defaultSelected: field.value, defaultSelected: field.value,
className: switchStyles(), className: switchStyles(),
onChange: field.onChange,
onBlur: field.onBlur,
})} })}
> >
<div className={background()} role="presentation"> <div className={background()} role="presentation">

View File

@ -212,6 +212,8 @@ export const Text = forwardRef(function Text(props: TextProps, ref: React.Ref<HT
}) as unknown as React.FC<React.RefAttributes<HTMLSpanElement> & TextProps> & { }) as unknown as React.FC<React.RefAttributes<HTMLSpanElement> & TextProps> & {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
Heading: typeof Heading Heading: typeof Heading
// eslint-disable-next-line @typescript-eslint/naming-convention
Group: React.FC<React.PropsWithChildren>
} }
/** /**
@ -234,3 +236,14 @@ const Heading = forwardRef(function Heading(
return <Text ref={ref} elementType={`h${level}`} variant="h1" balance {...textProps} /> return <Text ref={ref} elementType={`h${level}`} variant="h1" balance {...textProps} />
}) })
Text.Heading = Heading Text.Heading = Heading
/**
* Text group component. It's used to visually group text elements together
*/
Text.Group = function TextGroup(props: React.PropsWithChildren) {
return (
<textProvider.TextProvider value={{ isInsideTextComponent: true }}>
{props.children}
</textProvider.TextProvider>
)
}

View File

@ -168,8 +168,8 @@ export function EnsoDevtools() {
<ariaComponents.Form <ariaComponents.Form
gap="small" gap="small"
formOptions={{ mode: 'onChange' }}
schema={FEATURE_FLAGS_SCHEMA} schema={FEATURE_FLAGS_SCHEMA}
formOptions={{ mode: 'onChange' }}
defaultValues={{ defaultValues={{
enableMultitabs: featureFlags.enableMultitabs, enableMultitabs: featureFlags.enableMultitabs,
enableAssetsTableBackgroundRefresh: featureFlags.enableAssetsTableBackgroundRefresh, enableAssetsTableBackgroundRefresh: featureFlags.enableAssetsTableBackgroundRefresh,

View File

@ -0,0 +1,199 @@
/**
* @file Step component.
* A step component is used to represent a single step in a stepper component.
*/
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 ariaComponents from '#/components/AriaComponents'
import SvgMask from '#/components/SvgMask'
import * as stepperProvider from './StepperProvider'
import type { RenderStepProps } from './types'
import type * as stepperState from './useStepperState'
/** A prop with the given type, or a function to produce a value of the given type. */
type StepProp<T> = T | ((props: RenderStepProps) => T)
/**
* Props for {@link Step} component.
*/
export interface StepProps extends RenderStepProps {
readonly className?: StepProp<string | null | undefined>
readonly icon?: StepProp<React.ReactElement | string | null | undefined>
readonly completeIcon?: StepProp<React.ReactElement | string | null | undefined>
readonly title?: StepProp<React.ReactElement | string | null | undefined>
readonly description?: StepProp<React.ReactElement | string | null | undefined>
readonly children?: StepProp<React.ReactNode>
}
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 = (
<ariaComponents.Text variant="subtitle" color="current" aria-hidden>
{index + 1}
</ariaComponents.Text>
),
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 (
<div className={styles.base()}>
<AnimatePresence initial={false} mode="sync" custom={state.direction}>
<motion.div
key={isCompleted ? 'done' : 'icon'}
className={styles.icon()}
initial="enter"
animate="center"
exit="exit"
variants={{
enter: {
rotate:
state.direction === 'forward' ? -stepAnimationRotation : stepAnimationRotation,
scale: stepAnimationScale,
opacity: 0,
position: 'absolute',
top: 0,
},
center: {
rotate: 0,
scale: 1,
opacity: 1,
position: 'static',
},
exit: (direction: stepperState.StepperState['direction']) => ({
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 <SvgMask src={renderIconElement} />
} else {
return renderIconElement
}
})()}
</motion.div>
</AnimatePresence>
<div className={styles.titleContainer()}>
{titleElement != null && (
<div>
{typeof titleElement === 'string' ?
<ariaComponents.Text nowrap color="current">
{titleElement}
</ariaComponents.Text>
: titleElement}
</div>
)}
{descriptionElement != null && (
<div>
{typeof descriptionElement === 'string' ?
<ariaComponents.Text variant="body" color="current" truncate="2">
{descriptionElement}
</ariaComponents.Text>
: descriptionElement}
</div>
)}
</div>
<div className={styles.content()}>
{typeof children === 'function' ? children(renderStepProps) : children}
</div>
</div>
)
}

View File

@ -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
}
}

View File

@ -8,62 +8,17 @@ import * as React from 'react'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import * as tvw from 'tailwind-variants' import * as tvw from 'tailwind-variants'
import DoneIcon from '#/assets/check_mark.svg'
import * as eventCallback from '#/hooks/eventCallbackHooks' import * as eventCallback from '#/hooks/eventCallbackHooks'
import * as ariaComponents from '#/components/AriaComponents'
import { ErrorBoundary } from '#/components/ErrorBoundary' import { ErrorBoundary } from '#/components/ErrorBoundary'
import { Suspense } from '#/components/Suspense' import { Suspense } from '#/components/Suspense'
import SvgMask from '#/components/SvgMask'
import { Step } from './Step'
import { StepContent } from './StepContent'
import * as stepperProvider from './StepperProvider' import * as stepperProvider from './StepperProvider'
import type { BaseRenderProps, RenderChildrenProps, RenderStepProps } from './types'
import * as stepperState from './useStepperState' 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. * 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> = T | ((props: RenderStepProps) => T)
/**
* Props for {@link Step} component.
*/
export interface StepProps extends RenderStepProps {
readonly className?: StepProp<string | null | undefined>
readonly icon?: StepProp<React.ReactElement | string | null | undefined>
readonly completeIcon?: StepProp<React.ReactElement | string | null | undefined>
readonly title?: StepProp<React.ReactElement | string | null | undefined>
readonly description?: StepProp<React.ReactElement | string | null | undefined>
readonly children?: StepProp<React.ReactNode>
}
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 = (
<ariaComponents.Text variant="subtitle" color="current" aria-hidden>
{index + 1}
</ariaComponents.Text>
),
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 (
<div className={styles.base()}>
<AnimatePresence initial={false} mode="sync" custom={state.direction}>
<motion.div
key={isCompleted ? 'done' : 'icon'}
className={styles.icon()}
initial="enter"
animate="center"
exit="exit"
variants={{
enter: {
rotate:
state.direction === 'forward' ? -stepAnimationRotation : stepAnimationRotation,
scale: stepAnimationScale,
opacity: 0,
position: 'absolute',
top: 0,
},
center: {
rotate: 0,
scale: 1,
opacity: 1,
position: 'static',
},
exit: (direction: stepperState.StepperState['direction']) => ({
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 <SvgMask src={renderIconElement} />
} else {
return renderIconElement
}
})()}
</motion.div>
</AnimatePresence>
<div className={styles.titleContainer()}>
{titleElement != null && (
<div>
{typeof titleElement === 'string' ?
<ariaComponents.Text nowrap color="current">
{titleElement}
</ariaComponents.Text>
: titleElement}
</div>
)}
{descriptionElement != null && (
<div>
{typeof descriptionElement === 'string' ?
<ariaComponents.Text variant="body" color="current" truncate="2">
{descriptionElement}
</ariaComponents.Text>
: descriptionElement}
</div>
)}
</div>
<div className={styles.content()}>
{typeof children === 'function' ? children(renderStepProps) : children}
</div>
</div>
)
}
Stepper.Step = Step Stepper.Step = Step
Stepper.StepContent = StepContent
Stepper.useStepperState = stepperState.useStepperState Stepper.useStepperState = stepperState.useStepperState

View File

@ -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
}

View File

@ -81,19 +81,28 @@ export function useStepperState(props: StepperStateProps): UseStepperStateResult
const setCurrentStep = eventCallbackHooks.useEventCallback( const setCurrentStep = eventCallbackHooks.useEventCallback(
(step: number | ((current: number) => number)) => { (step: number | ((current: number) => number)) => {
privateSetCurrentStep((current) => { React.startTransition(() => {
const nextStep = typeof step === 'function' ? step(current.current) : step privateSetCurrentStep((current) => {
const direction = nextStep > current.current ? 'forward' : 'back' const nextStep = typeof step === 'function' ? step(current.current) : step
const direction = nextStep > current.current ? 'forward' : 'back'
if (nextStep < 0) { if (nextStep < 0) {
return { current: 0, direction: 'back-none' } return {
} else if (nextStep > steps - 1) { current: 0,
onCompletedStableCallback() direction: 'back-none',
return { current: steps - 1, direction: 'forward-none' } }
} else { } else if (nextStep > steps - 1) {
onStepChangeStableCallback(nextStep, direction) onCompletedStableCallback()
return { current: nextStep, direction } return {
} current: steps - 1,
direction: 'forward-none',
}
} else {
onStepChangeStableCallback(nextStep, direction)
return { current: nextStep, direction }
}
})
}) })
}, },
) )

View File

@ -331,7 +331,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
break break
} }
default: { default: {
return break
} }
} }
} else { } else {
@ -549,18 +549,18 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
} }
case AssetEventType.deleteLabel: { case AssetEventType.deleteLabel: {
setAsset((oldAsset) => { setAsset((oldAsset) => {
// The IIFE is required to prevent TypeScript from narrowing this value. const oldLabels = oldAsset.labels ?? []
let found = (() => false)() const labels: backendModule.LabelName[] = []
const labels =
oldAsset.labels?.filter((label) => { for (const label of oldLabels) {
if (label === event.labelName) { if (label !== event.labelName) {
found = true labels.push(label)
return false }
} else { }
return true
} return oldLabels.length !== labels.length ?
}) ?? null object.merge(oldAsset, { labels })
return found ? object.merge(oldAsset, { labels }) : oldAsset : oldAsset
}) })
break break
} }

View File

@ -6,10 +6,9 @@ import type * as jsonSchemaInput from '#/components/JSONSchemaInput'
import JSONSchemaInput from '#/components/JSONSchemaInput' import JSONSchemaInput from '#/components/JSONSchemaInput'
import { FieldError } from '#/components/aria' import { FieldError } from '#/components/aria'
import type { FieldValues, FormInstance, TSchema } from '#/components/AriaComponents' import type { FieldPath, FormInstance, TSchema } from '#/components/AriaComponents'
import { useFormContext } from '#/components/AriaComponents/Form/components/useFormContext' import { Form } from '#/components/AriaComponents'
import * as error from '#/utilities/error' import * as error from '#/utilities/error'
import { Controller, type FieldPath } from 'react-hook-form'
// ================= // =================
// === Constants === // === Constants ===
@ -52,17 +51,17 @@ export default function DatalinkInput(props: DatalinkInputProps) {
export interface DatalinkFormInputProps<Schema extends TSchema> export interface DatalinkFormInputProps<Schema extends TSchema>
extends Omit<DatalinkInputProps, 'onChange' | 'value'> { extends Omit<DatalinkInputProps, 'onChange' | 'value'> {
readonly form?: FormInstance<Schema> readonly form?: FormInstance<Schema>
readonly name: FieldPath<FieldValues<Schema>> readonly name: FieldPath<Schema>
} }
/** A dynamic wizard for creating an arbitrary type of Datalink. */ /** A dynamic wizard for creating an arbitrary type of Datalink. */
export function DatalinkFormInput<Schema extends TSchema>(props: DatalinkFormInputProps<Schema>) { export function DatalinkFormInput<Schema extends TSchema>(props: DatalinkFormInputProps<Schema>) {
const fallbackForm = useFormContext() const { name, ...inputProps } = props
// eslint-disable-next-line no-restricted-syntax
const { form = fallbackForm as unknown as FormInstance<Schema>, name, ...inputProps } = props const form = Form.useFormContext(props.form)
return ( return (
<Controller <Form.Controller
control={form.control} control={form.control}
name={name} name={name}
render={({ field, fieldState }) => { render={({ field, fieldState }) => {

View File

@ -203,13 +203,13 @@ declare global {
// @ts-expect-error The index signature is intentional to disallow unknown env vars. // @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_CLOUD_STRIPE_KEY?: string readonly ENSO_CLOUD_STRIPE_KEY?: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars. // @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. // @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. // @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. // @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. // @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_CLOUD_GOOGLE_ANALYTICS_TAG?: string readonly ENSO_CLOUD_GOOGLE_ANALYTICS_TAG?: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars. // @ts-expect-error The index signature is intentional to disallow unknown env vars.

View File

@ -183,7 +183,7 @@ export default function Settings() {
}, [isQueryBlank, doesEntryMatchQuery, getText, isMatch, effectiveTab]) }, [isQueryBlank, doesEntryMatchQuery, getText, isMatch, effectiveTab])
return ( return (
<div className="flex flex-1 flex-col gap-4 overflow-hidden px-page-x pt-4"> <div className="flex flex-1 flex-col gap-4 overflow-hidden pl-page-x pt-4">
<aria.Heading level={1} className="flex items-center px-heading-x"> <aria.Heading level={1} className="flex items-center px-heading-x">
<aria.MenuTrigger isOpen={isSidebarPopoverOpen} onOpenChange={setIsSidebarPopoverOpen}> <aria.MenuTrigger isOpen={isSidebarPopoverOpen} onOpenChange={setIsSidebarPopoverOpen}>
<Button image={BurgerMenuIcon} buttonClassName="mr-3 sm:hidden" onPress={() => {}} /> <Button image={BurgerMenuIcon} buttonClassName="mr-3 sm:hidden" onPress={() => {}} />
@ -208,13 +208,12 @@ export default function Settings() {
<ariaComponents.Text <ariaComponents.Text
variant="h1" variant="h1"
truncate="1" truncate="1"
className="ml-2.5 max-w-lg rounded-full bg-white px-2.5 font-bold" className="ml-2.5 mr-8 max-w-lg rounded-full bg-white px-2.5 font-bold"
aria-hidden aria-hidden
> >
{data.organizationOnly === true ? organization?.name ?? 'your organization' : user.name} {data.organizationOnly === true ? organization?.name ?? 'your organization' : user.name}
</ariaComponents.Text> </ariaComponents.Text>
</aria.Heading>
<div className="flex sm:ml-[222px]">
<SearchBar <SearchBar
data-testid="settings-search-bar" data-testid="settings-search-bar"
query={query} query={query}
@ -222,8 +221,9 @@ export default function Settings() {
label={getText('settingsSearchBarLabel')} label={getText('settingsSearchBarLabel')}
placeholder={getText('settingsSearchBarPlaceholder')} placeholder={getText('settingsSearchBarPlaceholder')}
/> />
</div> </aria.Heading>
<div className="flex flex-1 gap-6 overflow-hidden pr-0.5"> <div className="flex sm:ml-[222px]" />
<div className="flex flex-1 gap-4 overflow-hidden">
<aside className="hidden h-full shrink-0 basis-[206px] flex-col overflow-y-auto overflow-x-hidden pb-12 sm:flex"> <aside className="hidden h-full shrink-0 basis-[206px] flex-col overflow-y-auto overflow-x-hidden pb-12 sm:flex">
<SettingsSidebar <SettingsSidebar
context={context} context={context}
@ -232,15 +232,17 @@ export default function Settings() {
setTab={setTab} setTab={setTab}
/> />
</aside> </aside>
<SettingsTab <main className="flex flex-1 flex-col overflow-y-auto pb-12 pl-1 scrollbar-gutter-stable">
context={context} <SettingsTab
data={data} context={context}
onInteracted={() => { data={data}
if (effectiveTab !== tab) { onInteracted={() => {
setTab(effectiveTab) if (effectiveTab !== tab) {
} setTab(effectiveTab)
}} }
/> }}
/>
</main>
</div> </div>
</div> </div>
) )

View File

@ -67,20 +67,20 @@ export default function SettingsTab(props: SettingsTabProps) {
} else { } else {
const content = const content =
columns.length === 1 ? columns.length === 1 ?
<div className="flex grow flex-col gap-settings-subsection overflow-auto" {...contentProps}> <div className="flex grow flex-col gap-settings-subsection" {...contentProps}>
{sections.map((section) => ( {sections.map((section) => (
<SettingsSection key={section.nameId} context={context} data={section} /> <SettingsSection key={section.nameId} context={context} data={section} />
))} ))}
</div> </div>
: <div : <div
className="flex min-h-full grow flex-col gap-settings-section overflow-auto lg:h-auto lg:flex-row" className="flex min-h-full grow flex-col gap-settings-section lg:h-auto lg:flex-row"
{...contentProps} {...contentProps}
> >
{columns.map((sectionsInColumn, i) => ( {columns.map((sectionsInColumn, i) => (
<div <div
key={i} key={i}
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
'flex flex-1 flex-col gap-settings-subsection', 'flex h-fit w-0 flex-1 flex-col gap-settings-subsection pb-12',
classes[i], classes[i],
)} )}
> >

View File

@ -0,0 +1,252 @@
/**
* @file
*
* 2FA Setup Settings Section. Allows users to setup, disable, and change their 2FA method.
*/
import ShieldCheck from '#/assets/shield_check.svg'
import ShieldCrossed from '#/assets/shield_crossed.svg'
import type { MfaType } from '#/authentication/cognito'
import {
Alert,
Button,
ButtonGroup,
CopyBlock,
Dialog,
DialogTrigger,
Form,
OTPInput,
Selector,
Switch,
Text,
} from '#/components/AriaComponents'
import { ErrorBoundary } from '#/components/ErrorBoundary'
import { Suspense } from '#/components/Suspense'
import { useAuth } from '#/providers/AuthProvider'
import { useText } from '#/providers/TextProvider'
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { lazy } from 'react'
const LazyQRCode = lazy(() =>
// eslint-disable-next-line @typescript-eslint/naming-convention,@typescript-eslint/no-unsafe-assignment
import('qrcode.react').then(({ QRCodeCanvas }) => ({ default: QRCodeCanvas })),
)
/**
* 2FA Setup Settings Section.
*
* Allows users to setup, disable, and change their 2FA method.
*/
export function SetupTwoFaForm() {
const { getText } = useText()
const { cognito } = useAuth()
const { data } = useSuspenseQuery({
queryKey: ['twoFaPreference'],
queryFn: () =>
cognito.getMFAPreference().then((res) => {
if (res.err) {
throw res.val
} else {
return res.unwrap()
}
}),
})
const MFAEnabled = data !== 'NOMFA'
const updateMFAPreferenceMutation = useMutation({
mutationFn: (preference: MfaType) =>
cognito.updateMFAPreference(preference).then((res) => {
if (res.err) {
throw res.val
} else {
return res.unwrap()
}
}),
meta: { invalidates: [['twoFaPreference']] },
})
if (MFAEnabled) {
return (
<div className="flex w-full flex-col gap-4">
<Alert variant="neutral" icon={ShieldCheck}>
<Text.Group>
<Text variant="subtitle" weight="bold">
{getText('2FAEnabled')}
</Text>
<Text>{getText('2FAEnabledDescription')}</Text>
</Text.Group>
</Alert>
<div className="flex w-full flex-col">
<Text variant="subtitle" weight="bold">
{getText('disable2FA')}
</Text>
<Text color="disabled" className="mb-4">
{getText('disable2FADescription')}
</Text>
<DialogTrigger>
<Button variant="delete" className="self-start" icon={ShieldCrossed}>
{getText('disable2FA')}
</Button>
<Dialog title={getText('disable2FA')}>
<Form
/* eslint-disable-next-line @typescript-eslint/no-magic-numbers */
schema={(z) => 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
}
})
}
>
<Text>{getText('disable2FAWarning')}</Text>
<OTPInput autoFocus name="otp" maxLength={6} label={getText('verificationCode')} />
<ButtonGroup>
<Form.Submit variant="delete">{getText('disable')}</Form.Submit>
<Form.Submit formnovalidate>{getText('cancel')}</Form.Submit>
</ButtonGroup>
<Form.FormError />
</Form>
</Dialog>
</DialogTrigger>
</div>
</div>
)
} else {
return (
<Form
schema={(z) =>
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 }) => (
<>
<Switch
name="enabled"
description={getText('enable2FADescription')}
label={getText('enable2FA')}
/>
<ErrorBoundary>
<Suspense>{values.enabled === true && <TwoFa />}</Suspense>
</ErrorBoundary>
</>
)}
</Form>
)
}
}
/**
* 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 (
<>
<div className="flex w-full flex-col gap-4">
<Selector name="display" items={['qr', 'text']} aria-label={getText('display')} />
{field.value === 'qr' && (
<>
<Alert variant="neutral" icon={ShieldCheck}>
<Text.Group>
<Text variant="subtitle" weight="bold">
{getText('scanQR')}
</Text>
<Text>{getText('scanQRDescription')}</Text>
</Text.Group>
</Alert>
<div className="self-center">
<LazyQRCode
value={data.url}
bgColor="transparent"
fgColor="rgb(0 0 0 / 60%)"
size={192}
className="rounded-2xl border-0.5 border-primary p-4"
/>
</div>
</>
)}
{field.value === 'text' && (
<>
<Alert variant="neutral" icon={ShieldCheck}>
<Text.Group>
<Text variant="subtitle" weight="bold">
{getText('copyLink')}
</Text>
<Text>{getText('copyLinkDescription')}</Text>
</Text.Group>
</Alert>
<CopyBlock copyText={data.url} />
</>
)}
<OTPInput
className="max-w-96"
label={getText('verificationCode')}
name="otp"
maxLength={6}
description={getText('verificationCodePlaceholder')}
/>
</div>
<ButtonGroup>
<Form.Submit>{getText('enable')}</Form.Submit>
<Form.Reset>{getText('cancel')}</Form.Reset>
</ButtonGroup>
<Form.FormError />
</>
)
}

View File

@ -42,6 +42,7 @@ import type RemoteBackend from '#/services/RemoteBackend'
import { normalizePath } from '#/utilities/fileInfo' import { normalizePath } from '#/utilities/fileInfo'
import * as object from '#/utilities/object' import * as object from '#/utilities/object'
import { SetupTwoFaForm } from './SetupTwoFaForm'
// ========================= // =========================
// === SettingsEntryType === // === SettingsEntryType ===
@ -124,6 +125,23 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
}, },
], ],
}, },
{
nameId: 'setup2FASettingsSection',
entries: [
{
type: SettingsEntryType.custom,
render: SetupTwoFaForm,
getVisible: (context) => {
// 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', nameId: 'deleteUserAccountSettingsSection',
heading: false, heading: false,

View File

@ -1,18 +1,8 @@
/** @file Modal for confirming delete of any type of asset. */ /** @file Modal for confirming delete of any type of asset. */
import * as z from 'zod' import { ButtonGroup, Dialog, Form, Input, Password } from '#/components/AriaComponents'
import { Button, ButtonGroup, Dialog, Form, Input, Password } from '#/components/AriaComponents'
import { useText } from '#/providers/TextProvider' import { useText } from '#/providers/TextProvider'
import type { SecretId } from '#/services/Backend' 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 === // === UpsertSecretModal ===
// ========================= // =========================
@ -34,49 +24,43 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) {
return ( return (
<Dialog title={isCreatingSecret ? getText('newSecret') : getText('editSecret')}> <Dialog title={isCreatingSecret ? getText('newSecret') : getText('editSecret')}>
{({ close }) => ( <Form
<Form testId="upsert-secret-modal"
data-testid="upsert-secret-modal" method="dialog"
method="dialog" schema={(z) => z.object({ name: z.string().min(1), value: z.string() })}
schema={createUpsertSecretSchema()} defaultValues={{ name: nameRaw ?? '', value: '' }}
onSubmit={async ({ name, value }) => { onSubmit={async ({ name, value }) => {
await doCreate(name, value) await doCreate(name, value)
}} }}
> >
{({ form }) => ( {({ form }) => (
<> <>
<Input <Input
form={form} form={form}
name="name" name="name"
autoFocus autoFocus
autoComplete="off" autoComplete="off"
disabled={!isNameEditable} disabled={!isNameEditable}
label={getText('name')} label={getText('name')}
placeholder={getText('secretNamePlaceholder')} placeholder={getText('secretNamePlaceholder')}
defaultValue={nameRaw ?? undefined} />
/> <Password
<Password form={form}
form={form} name="value"
name="value" autoFocus={!isNameEditable}
autoFocus={!isNameEditable} autoComplete="off"
autoComplete="off" label={getText('value')}
label={getText('value')} placeholder={
placeholder={ isNameEditable ? getText('secretValuePlaceholder') : getText('secretValueHidden')
isNameEditable ? getText('secretValuePlaceholder') : getText('secretValueHidden') }
} />
/> <ButtonGroup>
<ButtonGroup className="relative"> <Form.Submit>{isCreatingSecret ? getText('create') : getText('update')}</Form.Submit>
<Form.Submit> <Form.Submit formnovalidate />
{isCreatingSecret ? getText('create') : getText('update')} </ButtonGroup>
</Form.Submit> </>
<Button variant="outline" onPress={close}> )}
{getText('cancel')} </Form>
</Button>
</ButtonGroup>
</>
)}
</Form>
)}
</Dialog> </Dialog>
) )
} }

View File

@ -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
}
})
}
},
})
}

View File

@ -3,4 +3,5 @@
* *
* Barrel file for payments api * Barrel file for payments api
*/ */
export * from './createPaymentMethod'
export * from './useSubscriptionPrice' export * from './useSubscriptionPrice'

View File

@ -7,11 +7,11 @@ import * as React from 'react'
import * as stripeReact from '@stripe/react-stripe-js' import * as stripeReact from '@stripe/react-stripe-js'
import type * as stripeJs from '@stripe/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 text from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents' import * as ariaComponents from '#/components/AriaComponents'
import { useCreatePaymentMethodMutation } from '../api/createPaymentMethod'
/** /**
* Props for {@link AddPaymentMethodForm}. * Props for {@link AddPaymentMethodForm}.
@ -44,6 +44,8 @@ export const ADD_PAYMENT_METHOD_FORM_SCHEMA = ariaComponents.Form.schema.object(
(data) => data?.error == null, (data) => data?.error == null,
(data) => ({ message: data?.error?.message ?? 'This field is required' }), (data) => ({ message: data?.error?.message ?? 'This field is required' }),
), ),
cardElement: ariaComponents.Form.schema.custom<stripeJs.StripeCardElement | null>(),
stripeInstance: ariaComponents.Form.schema.custom<stripeJs.Stripe>(),
}) })
/** /**
@ -52,54 +54,33 @@ export const ADD_PAYMENT_METHOD_FORM_SCHEMA = ariaComponents.Form.schema.object(
export function AddPaymentMethodForm< export function AddPaymentMethodForm<
Schema extends typeof ADD_PAYMENT_METHOD_FORM_SCHEMA = typeof ADD_PAYMENT_METHOD_FORM_SCHEMA, Schema extends typeof ADD_PAYMENT_METHOD_FORM_SCHEMA = typeof ADD_PAYMENT_METHOD_FORM_SCHEMA,
>(props: AddPaymentMethodFormProps<Schema>) { >(props: AddPaymentMethodFormProps<Schema>) {
const { stripeInstance, elements, onSubmit, submitText, form } = props const { stripeInstance, onSubmit, submitText, form } = props
const { getText } = text.useText() const { getText } = text.useText()
const [cardElement, setCardElement] = React.useState<stripeJs.StripeCardElement | null>(() =>
elements.getElement(stripeReact.CardElement),
)
const dialogContext = ariaComponents.useDialogContext() const dialogContext = ariaComponents.useDialogContext()
const createPaymentMethodMutation = reactQuery.useMutation({ const createPaymentMethodMutation = useCreatePaymentMethodMutation()
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
}
})
}
},
})
// No idea if it's safe or not, but outside of the function everything is fine // 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) // 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( const formInstance = ariaComponents.Form.useForm(
// eslint-disable-next-line no-restricted-syntax form ?? {
(form as ariaComponents.FormInstance<typeof ADD_PAYMENT_METHOD_FORM_SCHEMA> | undefined) ?? {
schema: ADD_PAYMENT_METHOD_FORM_SCHEMA, schema: ADD_PAYMENT_METHOD_FORM_SCHEMA,
onSubmit: ({ cardElement }) =>
createPaymentMethodMutation.mutateAsync({ stripeInstance, cardElement }),
onSubmitSuccess: ({ paymentMethod }) => onSubmit?.(paymentMethod.id),
}, },
) ) as unknown as ariaComponents.FormInstance<typeof ADD_PAYMENT_METHOD_FORM_SCHEMA>
const cardElement = ariaComponents.Form.useWatch({
control: formInstance.control,
name: 'cardElement',
})
return ( return (
<ariaComponents.Form <ariaComponents.Form method="dialog" form={formInstance}>
method="dialog"
form={formInstance}
onSubmit={() =>
createPaymentMethodMutation.mutateAsync().then(async ({ paymentMethod }) => {
cardElement?.clear()
await onSubmit?.(paymentMethod.id)
})
}
>
<ariaComponents.Form.Field name="card" fullWidth label={getText('bankCardLabel')}> <ariaComponents.Form.Field name="card" fullWidth label={getText('bankCardLabel')}>
<stripeReact.CardElement <stripeReact.CardElement
options={{ options={{
@ -112,7 +93,8 @@ export function AddPaymentMethodForm<
}} }}
onEscape={() => dialogContext?.close()} onEscape={() => dialogContext?.close()}
onReady={(element) => { onReady={(element) => {
setCardElement(element) formInstance.setValue('cardElement', element)
formInstance.setValue('stripeInstance', stripeInstance)
}} }}
onChange={(event) => { onChange={(event) => {
if (event.error?.message != null) { if (event.error?.message != null) {

View File

@ -27,7 +27,7 @@ import type { Plan } from '#/services/Backend'
import { twMerge } from '#/utilities/tailwindMerge' import { twMerge } from '#/utilities/tailwindMerge'
import { createSubscriptionPriceQuery } from '../../../api' import { createSubscriptionPriceQuery, useCreatePaymentMethodMutation } from '../../../api'
import { import {
MAX_SEATS_BY_PLAN, MAX_SEATS_BY_PLAN,
PRICE_BY_PLAN, PRICE_BY_PLAN,
@ -77,6 +77,8 @@ export function PlanSelectorDialog(props: PlanSelectorDialogProps) {
const price = PRICE_BY_PLAN[plan] const price = PRICE_BY_PLAN[plan]
const maxSeats = MAX_SEATS_BY_PLAN[plan] const maxSeats = MAX_SEATS_BY_PLAN[plan]
const createPaymentMethodMutation = useCreatePaymentMethodMutation()
const form = Form.useForm({ const form = Form.useForm({
schema: (z) => schema: (z) =>
ADD_PAYMENT_METHOD_FORM_SCHEMA.extend({ ADD_PAYMENT_METHOD_FORM_SCHEMA.extend({
@ -95,10 +97,18 @@ export function PlanSelectorDialog(props: PlanSelectorDialogProps) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers // eslint-disable-next-line @typescript-eslint/no-magic-numbers
defaultValues: { seats: 1, period: 12, agree: [] }, defaultValues: { seats: 1, period: 12, agree: [] },
mode: 'onChange', 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 seats = Form.useWatch({ name: 'seats', control: form.control })
const period = form.watch('period') const period = Form.useWatch({ name: 'period', control: form.control })
const formatter = React.useMemo( const formatter = React.useMemo(
() => new Intl.NumberFormat(locale, { style: 'currency', currency: PRICE_CURRENCY }), () => new Intl.NumberFormat(locale, { style: 'currency', currency: PRICE_CURRENCY }),

View File

@ -36,8 +36,8 @@ export default function AuthenticationPage<Schema extends TSchema>(
props: AuthenticationPageProps<Schema>, props: AuthenticationPageProps<Schema>,
) { ) {
const { title, children, footer, supportsOffline = false, ...formProps } = props const { title, children, footer, supportsOffline = false, ...formProps } = props
const { form, schema, onSubmit } = formProps const { form, schema } = formProps
const isForm = onSubmit != null && (form != null || schema != null) const isForm = schema != null || form != null
const { getText } = useText() const { getText } = useText()
const { isOffline } = useOffline() const { isOffline } = useOffline()
@ -88,7 +88,7 @@ export default function AuthenticationPage<Schema extends TSchema>(
: <Form : <Form
// This is SAFE, as the props type of this type extends `FormProps`. // This is SAFE, as the props type of this type extends `FormProps`.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
{...(formProps as FormProps<Schema>)} {...(form ? { form } : (formProps as FormProps<Schema>))}
className={containerClasses} className={containerClasses}
> >
{(innerProps) => ( {(innerProps) => (

View File

@ -3,15 +3,17 @@ import * as router from 'react-router-dom'
import { CLOUD_DASHBOARD_DOMAIN } from 'enso-common' 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 ArrowRightIcon from '#/assets/arrow_right.svg'
import AtIcon from '#/assets/at.svg' import AtIcon from '#/assets/at.svg'
import CreateAccountIcon from '#/assets/create_account.svg' import CreateAccountIcon from '#/assets/create_account.svg'
import GithubIcon from '#/assets/github_color.svg' import GithubIcon from '#/assets/github_color.svg'
import GoogleIcon from '#/assets/google_color.svg' import GoogleIcon from '#/assets/google_color.svg'
import LockIcon from '#/assets/lock.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 Link from '#/components/Link'
import { Stepper } from '#/components/Stepper'
import AuthenticationPage from '#/pages/authentication/AuthenticationPage' import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
import { passwordSchema } from '#/pages/authentication/schemas' import { passwordSchema } from '#/pages/authentication/schemas'
import { useAuth } from '#/providers/AuthProvider' import { useAuth } from '#/providers/AuthProvider'
@ -26,14 +28,52 @@ import { useState } from 'react'
/** A form for users to log in. */ /** A form for users to log in. */
export default function Login() { export default function Login() {
const location = router.useLocation() const location = router.useLocation()
const { signInWithGoogle, signInWithGitHub, signInWithPassword } = useAuth() const navigate = router.useNavigate()
const { signInWithGoogle, signInWithGitHub, signInWithPassword, cognito } = useAuth()
const { getText } = useText() const { getText } = useText()
const query = new URLSearchParams(location.search) const query = new URLSearchParams(location.search)
const initialEmail = query.get('email') ?? '' 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 [emailInput, setEmailInput] = useState(initialEmail)
const [user, setUser] = useState<CognitoUser | null>(null)
const localBackend = useLocalBackend() const localBackend = useLocalBackend()
const supportsOffline = localBackend != null const supportsOffline = localBackend != null
const { nextStep, stepperState, previousStep } = Stepper.useStepperState({
steps: 2,
defaultStep: 0,
})
return ( return (
<AuthenticationPage <AuthenticationPage
title={getText('loginToYourAccount')} title={getText('loginToYourAccount')}
@ -52,85 +92,126 @@ export default function Login() {
/> />
} }
> >
<div className="flex flex-col gap-auth"> <Stepper state={stepperState} renderStep={() => null}>
<Button <Stepper.StepContent index={0}>
size="large" {() => (
variant="outline" <div className="flex flex-col gap-auth">
icon={<img src={GoogleIcon} />} <Button
onPress={async () => { size="large"
await signInWithGoogle() variant="outline"
}} icon={GoogleIcon}
> onPress={async () => {
{getText('signUpOrLoginWithGoogle')} await signInWithGoogle()
</Button> }}
<Button >
size="large" {getText('signUpOrLoginWithGoogle')}
variant="outline" </Button>
icon={<img src={GithubIcon} />} <Button
onPress={async () => { size="large"
await signInWithGitHub() variant="outline"
}} icon={GithubIcon}
> onPress={async () => {
{getText('signUpOrLoginWithGitHub')} await signInWithGitHub()
</Button> }}
</div> >
{getText('signUpOrLoginWithGitHub')}
</Button>
<Form <Form form={form} gap="medium">
schema={(z) => <Input
z.object({ autoFocus
email: z required
.string() data-testid="email-input"
.min(1, getText('arbitraryFieldRequired')) name="email"
.email(getText('invalidEmailValidationError')), label={getText('email')}
password: passwordSchema(getText), type="email"
}) autoComplete="email"
} icon={AtIcon}
gap="medium" placeholder={getText('emailPlaceholder')}
defaultValues={{ email: initialEmail }} onChange={(event) => {
onSubmit={({ email, password }) => signInWithPassword(email, password)} setEmailInput(event.currentTarget.value)
> }}
<Input />
autoFocus
required
data-testid="email-input"
name="email"
label={getText('email')}
type="email"
autoComplete="email"
icon={AtIcon}
placeholder={getText('emailPlaceholder')}
onChange={(event) => {
setEmailInput(event.currentTarget.value)
}}
/>
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
<Password <Password
required required
data-testid="password-input" data-testid="password-input"
name="password" name="password"
label={getText('password')} label={getText('password')}
autoComplete="current-password" autoComplete="current-password"
icon={LockIcon} icon={LockIcon}
placeholder={getText('passwordPlaceholder')} placeholder={getText('passwordPlaceholder')}
/> />
<Button <Button
variant="link" variant="link"
href={`${FORGOT_PASSWORD_PATH}?${new URLSearchParams({ email: emailInput }).toString()}`} href={`${FORGOT_PASSWORD_PATH}?${new URLSearchParams({ email: emailInput }).toString()}`}
size="small" size="small"
className="self-end" className="self-end"
> >
{getText('forgotYourPassword')} {getText('forgotYourPassword')}
</Button> </Button>
</div> </div>
<Form.Submit size="large" icon={ArrowRightIcon} iconPosition="end" fullWidth> <Form.Submit size="large" icon={ArrowRightIcon} iconPosition="end" fullWidth>
{getText('login')} {getText('login')}
</Form.Submit> </Form.Submit>
<Form.FormError /> <Form.FormError />
</Form> </Form>
</div>
)}
</Stepper.StepContent>
<Stepper.StepContent index={1}>
{() => (
<Form
/* eslint-disable-next-line @typescript-eslint/no-magic-numbers */
schema={(z) => 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
}
}
}
}}
>
<Text>{getText('enterTotp')}</Text>
<OTPInput
autoFocus
required
testId="otp-input"
name="otp"
label={getText('totp')}
maxLength={6}
/>
<Form.Submit size="large" icon={ArrowRightIcon} iconPosition="end" fullWidth>
{getText('login')}
</Form.Submit>
<Form.FormError />
</Form>
)}
</Stepper.StepContent>
</Stepper>
</AuthenticationPage> </AuthenticationPage>
) )
} }

View File

@ -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 }) const { stepperState } = useStepperState({ steps: 2, defaultStep: 0 })
@ -161,24 +169,14 @@ export default function Registration() {
{getText('createANewAccount')} {getText('createANewAccount')}
</Text.Heading> </Text.Heading>
<Form <Form form={signupForm}>
form={signupForm}
onSubmit={async ({ email, password }) => {
localStorage.set('termsOfService', { versionHash: tosHash })
localStorage.set('privacyPolicy', { versionHash: privacyPolicyHash })
await signUp(email, password, organizationId)
stepperState.nextStep()
}}
>
{({ form }) => ( {({ form }) => (
<> <>
<Input <Input
form={form} form={form}
autoFocus autoFocus
required required
data-testid="email-input" testId="email-input"
name="email" name="email"
label={getText('emailLabel')} label={getText('emailLabel')}
type="email" type="email"
@ -193,7 +191,7 @@ export default function Registration() {
<Password <Password
form={form} form={form}
required required
data-testid="password-input" testId="password-input"
name="password" name="password"
label={getText('passwordLabel')} label={getText('passwordLabel')}
autoComplete="new-password" autoComplete="new-password"
@ -205,7 +203,7 @@ export default function Registration() {
<Password <Password
form={form} form={form}
required required
data-testid="confirm-password-input" testId="confirm-password-input"
name="confirmPassword" name="confirmPassword"
label={getText('confirmPasswordLabel')} label={getText('confirmPasswordLabel')}
autoComplete="new-password" autoComplete="new-password"

View File

@ -92,7 +92,13 @@ interface AuthContextType {
readonly setUsername: (username: string) => Promise<boolean> readonly setUsername: (username: string) => Promise<boolean>
readonly signInWithGoogle: () => Promise<boolean> readonly signInWithGoogle: () => Promise<boolean>
readonly signInWithGitHub: () => Promise<boolean> readonly signInWithGitHub: () => Promise<boolean>
readonly signInWithPassword: (email: string, password: string) => Promise<void> readonly signInWithPassword: (
email: string,
password: string,
) => Promise<{
readonly challenge: cognitoModule.UserSessionChallenge
readonly user: cognitoModule.CognitoUser
}>
readonly forgotPassword: (email: string) => Promise<void> readonly forgotPassword: (email: string) => Promise<void>
readonly changePassword: (oldPassword: string, newPassword: string) => Promise<boolean> readonly changePassword: (oldPassword: string, newPassword: string) => Promise<boolean>
readonly resetPassword: (email: string, code: string, password: string) => Promise<void> readonly resetPassword: (email: string, code: string, password: string) => Promise<void>
@ -116,6 +122,7 @@ interface AuthContextType {
readonly isUserDeleted: () => boolean readonly isUserDeleted: () => boolean
/** Return `true` if the user is soft deleted. */ /** Return `true` if the user is soft deleted. */
readonly isUserSoftDeleted: () => boolean readonly isUserSoftDeleted: () => boolean
readonly cognito: cognitoModule.Cognito
} }
const AuthContext = React.createContext<AuthContextType | null>(null) const AuthContext = React.createContext<AuthContextType | null>(null)
@ -163,7 +170,7 @@ function createUsersMeQuery(
/** Props for an {@link AuthProvider}. */ /** Props for an {@link AuthProvider}. */
export interface AuthProviderProps { export interface AuthProviderProps {
readonly shouldStartInOfflineMode: boolean readonly shouldStartInOfflineMode: boolean
readonly authService: authServiceModule.AuthService | null readonly authService: authServiceModule.AuthService
/** Callback to execute once the user has authenticated successfully. */ /** Callback to execute once the user has authenticated successfully. */
readonly onAuthenticated: (accessToken: string | null) => void readonly onAuthenticated: (accessToken: string | null) => void
readonly children: React.ReactNode readonly children: React.ReactNode
@ -174,7 +181,7 @@ export default function AuthProvider(props: AuthProviderProps) {
const { authService, onAuthenticated } = props const { authService, onAuthenticated } = props
const { children } = props const { children } = props
const remoteBackend = backendProvider.useRemoteBackendStrict() const remoteBackend = backendProvider.useRemoteBackendStrict()
const { cognito } = authService ?? {} const { cognito } = authService
const { session, sessionQueryKey } = sessionProvider.useSession() const { session, sessionQueryKey } = sessionProvider.useSession()
const { localStorage } = localStorageProvider.useLocalStorage() const { localStorage } = localStorageProvider.useLocalStorage()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
@ -194,23 +201,19 @@ export default function AuthProvider(props: AuthProviderProps) {
}, []) }, [])
const performLogout = async () => { const performLogout = async () => {
if (cognito != null) { await cognito.signOut()
await cognito.signOut()
const parentDomain = location.hostname.replace(/^[^.]*\./, '') const parentDomain = location.hostname.replace(/^[^.]*\./, '')
document.cookie = `logged_in=no;max-age=0;domain=${parentDomain}` document.cookie = `logged_in=no;max-age=0;domain=${parentDomain}`
gtagEvent('cloud_sign_out') gtagEvent('cloud_sign_out')
cognito.saveAccessToken(null) cognito.saveAccessToken(null)
localStorage.clearUserSpecificEntries() localStorage.clearUserSpecificEntries()
sentry.setUser(null) sentry.setUser(null)
await queryClient.invalidateQueries({ queryKey: sessionQueryKey }) await queryClient.invalidateQueries({ queryKey: sessionQueryKey })
await queryClient.clearWithPersister() await queryClient.clearWithPersister()
return Promise.resolve() return Promise.resolve()
} else {
return Promise.reject()
}
} }
const logoutMutation = reactQuery.useMutation({ const logoutMutation = reactQuery.useMutation({
@ -289,100 +292,91 @@ export default function AuthProvider(props: AuthProviderProps) {
const signUp = useEventCallback( const signUp = useEventCallback(
async (username: string, password: string, organizationId: string | null) => { async (username: string, password: string, organizationId: string | null) => {
if (cognito != null) { gtagEvent('cloud_sign_up')
gtagEvent('cloud_sign_up') const result = await cognito.signUp(username, password, organizationId)
const result = await cognito.signUp(username, password, organizationId)
if (result.err) { if (result.err) {
throw new Error(result.val.message) throw new Error(result.val.message)
} else { } else {
return return
}
} }
}, },
) )
const confirmSignUp = useEventCallback(async (email: string, code: string) => { const confirmSignUp = useEventCallback(async (email: string, code: string) => {
if (cognito == null) { gtagEvent('cloud_confirm_sign_up')
throw new Error(getText('confirmSignUpError')) const result = await cognito.confirmSignUp(email, code)
} else {
gtagEvent('cloud_confirm_sign_up')
const result = await cognito.confirmSignUp(email, code)
if (result.err) { if (result.err) {
switch (result.val.type) { switch (result.val.type) {
case cognitoModule.CognitoErrorType.userAlreadyConfirmed: case cognitoModule.CognitoErrorType.userAlreadyConfirmed:
case cognitoModule.CognitoErrorType.userNotFound: { case cognitoModule.CognitoErrorType.userNotFound: {
return return
} }
default: { default: {
throw new errorModule.UnreachableCaseError(result.val.type) throw new errorModule.UnreachableCaseError(result.val.type)
}
} }
} }
} }
}) })
const signInWithPassword = useEventCallback(async (email: string, password: string) => { const signInWithPassword = useEventCallback(async (email: string, password: string) => {
if (cognito != null) { gtagEvent('cloud_sign_in', { provider: 'Email' })
gtagEvent('cloud_sign_in', { provider: 'Email' })
const result = await cognito.signInWithPassword(email, password) const result = await cognito.signInWithPassword(email, password)
if (result.ok) {
void queryClient.invalidateQueries({ queryKey: sessionQueryKey }) if (result.ok) {
navigate(appUtils.DASHBOARD_PATH) const user = result.unwrap()
return
} else { const challenge = user.challengeName ?? 'NO_CHALLENGE'
throw new Error(result.val.message)
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) => { const setUsername = useEventCallback(async (username: string) => {
if (cognito == null) { gtagEvent('cloud_user_created')
return false
if (userData?.type === UserSessionType.full) {
await updateUserMutation.mutateAsync({ username: username })
} else { } else {
gtagEvent('cloud_user_created') const organizationId = await cognito.organizationId()
const email = session?.email ?? ''
if (userData?.type === UserSessionType.full) { await createUserMutation.mutateAsync({
await updateUserMutation.mutateAsync({ username: username }) userName: username,
} else { userEmail: backendModule.EmailAddress(email),
const organizationId = await cognito.organizationId() organizationId:
const email = session?.email ?? '' organizationId != null ? backendModule.OrganizationId(organizationId) : null,
})
await createUserMutation.mutateAsync({
userName: username,
userEmail: backendModule.EmailAddress(email),
organizationId:
organizationId != null ? backendModule.OrganizationId(organizationId) : null,
})
}
return true
} }
return true
}) })
const deleteUser = useEventCallback(async () => { const deleteUser = useEventCallback(async () => {
if (cognito == null) { await deleteUserMutation.mutateAsync()
return false
} else {
await deleteUserMutation.mutateAsync()
toastSuccess(getText('deleteUserSuccess')) toastSuccess(getText('deleteUserSuccess'))
return true return true
}
}) })
const restoreUser = useEventCallback(async () => { const restoreUser = useEventCallback(async () => {
if (cognito == null) { await restoreUserMutation.mutateAsync()
return false
} else {
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) => { const forgotPassword = useEventCallback(async (email: string) => {
if (cognito != null) { const result = await cognito.forgotPassword(email)
const result = await cognito.forgotPassword(email) if (result.ok) {
if (result.ok) { navigate(appUtils.LOGIN_PATH)
navigate(appUtils.LOGIN_PATH) return
return } else {
} else { throw new Error(result.val.message)
throw new Error(result.val.message)
}
} }
}) })
const resetPassword = useEventCallback(async (email: string, code: string, password: string) => { const resetPassword = useEventCallback(async (email: string, code: string, password: string) => {
if (cognito != null) { const result = await cognito.forgotPasswordSubmit(email, code, password)
const result = await cognito.forgotPasswordSubmit(email, code, password)
if (result.ok) { if (result.ok) {
navigate(appUtils.LOGIN_PATH) navigate(appUtils.LOGIN_PATH)
return return
} else { } else {
throw new Error(result.val.message) throw new Error(result.val.message)
}
} }
}) })
const changePassword = useEventCallback(async (oldPassword: string, newPassword: string) => { const changePassword = useEventCallback(async (oldPassword: string, newPassword: string) => {
if (cognito == null) { const result = await cognito.changePassword(oldPassword, newPassword)
return false
if (result.ok) {
toastSuccess(getText('changePasswordSuccess'))
} else { } else {
const result = await cognito.changePassword(oldPassword, newPassword) toastError(result.val.message)
if (result.ok) {
toastSuccess(getText('changePasswordSuccess'))
} else {
toastError(result.val.message)
}
return result.ok
} }
return result.ok
}) })
const isUserMarkedForDeletion = useEventCallback( const isUserMarkedForDeletion = useEventCallback(
@ -503,33 +492,28 @@ export default function AuthProvider(props: AuthProviderProps) {
isUserSoftDeleted, isUserSoftDeleted,
restoreUser, restoreUser,
deleteUser, deleteUser,
cognito,
signInWithGoogle: useEventCallback(() => { signInWithGoogle: useEventCallback(() => {
if (cognito == null) { gtagEvent('cloud_sign_in', { provider: 'Google' })
return Promise.resolve(false)
} else { return cognito
gtagEvent('cloud_sign_in', { provider: 'Google' }) .signInWithGoogle()
return cognito .then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey }))
.signInWithGoogle() .then(
.then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey })) () => true,
.then( () => false,
() => true, )
() => false,
)
}
}), }),
signInWithGitHub: useEventCallback(() => { signInWithGitHub: useEventCallback(() => {
if (cognito == null) { gtagEvent('cloud_sign_in', { provider: 'GitHub' })
return Promise.resolve(false)
} else { return cognito
gtagEvent('cloud_sign_in', { provider: 'GitHub' }) .signInWithGitHub()
return cognito .then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey }))
.signInWithGitHub() .then(
.then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey })) () => true,
.then( () => false,
() => true, )
() => false,
)
}
}), }),
signInWithPassword, signInWithPassword,
forgotPassword, forgotPassword,

View File

@ -25,6 +25,9 @@ export type TVWithoutExtends<T> = ExtractFunction<T> & Omit<T, 'extend'>
* Props for a component that uses `tailwind-variants`. * Props for a component that uses `tailwind-variants`.
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export type VariantProps<T extends (...args: any) => any> = TvVariantProps<T> & { export type VariantProps<T extends (...args: any) => any> = Omit<
TvVariantProps<T>,
'class' | 'className'
> & {
variants?: ExtractFunction<T> | undefined variants?: ExtractFunction<T> | undefined
} }

View File

@ -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`, inset 0 -36px 51px -51px #00000014`,
}, },
animation: { 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', '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', 'appear-delayed': 'appear-delayed 0.5s ease-in-out',
}, },
@ -420,6 +421,10 @@ inset 0 -36px 51px -51px #00000014`,
'99%': { opacity: '0' }, '99%': { opacity: '0' },
'100%': { opacity: '1' }, '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 }) => { plugin(({ addVariant, addUtilities, matchUtilities, addComponents, theme }) => {
addVariant('group-hover-2', ['.group:where([data-hovered]) &', '.group:where(:hover) &']) addVariant('group-hover-2', ['.group:where([data-hovered]) &', '.group:where(:hover) &'])
addUtilities({
'.scrollbar-gutter-stable': {
scrollbarGutter: 'stable',
},
})
addUtilities( addUtilities(
{ {
'.container-size': { '.container-size': {

View File

@ -216,12 +216,21 @@
"likes": "Likes", "likes": "Likes",
"shortcuts": "Shortcuts", "shortcuts": "Shortcuts",
"download": "Download", "download": "Download",
"disable": "Disable",
"enable": "Enable",
"email": "Email", "email": "Email",
"intro": "Intro", "intro": "Intro",
"emailIsRequired": "Email is required", "emailIsRequired": "Email is required",
"emailIsInvalid": "Email is invalid", "emailIsInvalid": "Email is invalid",
"emailAlreadyExists": "Email already exists", "emailAlreadyExists": "Email already exists",
"emailAlreadyAdded": "Email already added", "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", "password": "Password",
"reset": "Reset", "reset": "Reset",
"members": "Members", "members": "Members",
@ -473,6 +482,14 @@
"enableAssetsTableBackgroundRefreshInterval": "Refresh interval", "enableAssetsTableBackgroundRefreshInterval": "Refresh interval",
"enableAssetsTableBackgroundRefreshIntervalDescription": "Set the interval in ms for the assets table to refresh in the background.", "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'", "deleteLabelActionText": "delete the label '$0'",
"deleteSelectedAssetActionText": "delete '$0'", "deleteSelectedAssetActionText": "delete '$0'",
"deleteSelectedAssetsActionText": "delete $0 selected items", "deleteSelectedAssetsActionText": "delete $0 selected items",
@ -814,6 +831,24 @@
"userNameSettingsInput": "Name", "userNameSettingsInput": "Name",
"userEmailSettingsInput": "Email", "userEmailSettingsInput": "Email",
"changePasswordSettingsSection": "Change Password", "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", "changePasswordSettingsCustomEntryAliases": "current password\nnew password\nconfirm new password",
"deleteUserAccountSettingsSection": "Delete User Account", "deleteUserAccountSettingsSection": "Delete User Account",
"deleteUserAccountSettingsCustomEntryAliases": "danger zone\ndelete this user account", "deleteUserAccountSettingsCustomEntryAliases": "danger zone\ndelete this user account",

View File

@ -79,6 +79,9 @@ importers:
ajv: ajv:
specifier: ^8.12.0 specifier: ^8.12.0
version: 8.16.0 version: 8.16.0
amazon-cognito-identity-js:
specifier: 6.3.6
version: 6.3.6
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
@ -88,12 +91,18 @@ importers:
framer-motion: framer-motion:
specifier: 11.3.0 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) 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: is-network-error:
specifier: ^1.0.1 specifier: ^1.0.1
version: 1.1.0 version: 1.1.0
monaco-editor: monaco-editor:
specifier: 0.48.0 specifier: 0.48.0
version: 0.48.0 version: 0.48.0
qrcode.react:
specifier: 3.1.0
version: 3.1.0(react@18.3.1)
react: react:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1 version: 18.3.1
@ -5155,6 +5164,12 @@ packages:
ini@1.3.8: ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} 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: install@0.13.0:
resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==} resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@ -6495,6 +6510,11 @@ packages:
pure-rand@6.1.0: pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} 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: qs@6.11.0:
resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
@ -13351,6 +13371,11 @@ snapshots:
ini@1.3.8: {} 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: {} install@0.13.0: {}
internal-slot@1.0.7: internal-slot@1.0.7:
@ -14601,6 +14626,10 @@ snapshots:
pure-rand@6.1.0: {} pure-rand@6.1.0: {}
qrcode.react@3.1.0(react@18.3.1):
dependencies:
react: 18.3.1
qs@6.11.0: qs@6.11.0:
dependencies: dependencies:
side-channel: 1.0.6 side-channel: 1.0.6