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

View File

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

View File

@ -14,6 +14,7 @@ export const LOGIN_PATH = '/login'
export const REGISTRATION_PATH = '/registration'
/** Path to the confirm registration page. */
export const CONFIRM_REGISTRATION_PATH = '/confirmation'
export const SETUP_PATH = '/setup'
/** Path to the page in which a user can restore their account after it has been
* 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() {
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
* `internalCode`, when rethrowing the error. */
import * as amplify from '@aws-amplify/auth'
import type * as cognito from 'amazon-cognito-identity-js'
import * as cognito from 'amazon-cognito-identity-js'
import * as results from 'ts-results'
import * as detect from 'enso-common/src/detect'
@ -70,6 +70,18 @@ interface UserAttributes {
}
/* eslint-enable @typescript-eslint/naming-convention */
/**
* The type of multi-factor authentication (MFA) that the user has set up.
*/
export type MfaType = 'NOMFA' | 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA' | 'TOTP'
/**
* The type of challenge that the user is currently facing after signing in.
*
* The `NO_CHALLENGE` value is used when the user is not currently facing any challenge.
*/
export type UserSessionChallenge = cognito.ChallengeName | 'NO_CHALLENGE'
/** User information returned from {@link amplify.Auth.currentUserInfo}. */
interface UserInfo {
readonly username: string
@ -214,6 +226,16 @@ export class Cognito {
return userInfo.attributes['custom:organizationId'] ?? null
}
/**
* Gets user email from cognito
*/
async email() {
// This `any` comes from a third-party API and cannot be avoided.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const userInfo: UserInfo = await amplify.Auth.currentUserInfo()
return userInfo.attributes.email
}
/** Sign up with username and password.
*
* Does not rely on federated identity providers (e.g., Google or GitHub). */
@ -268,7 +290,20 @@ export class Cognito {
* Does not rely on external identity providers (e.g., Google or GitHub). */
async signInWithPassword(username: string, password: string) {
const result = await results.Result.wrapAsync(async () => {
await amplify.Auth.signIn(username, password)
// This `any` comes from a third-party API and cannot be avoided.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const maybeUser = await amplify.Auth.signIn(username, password)
if (maybeUser instanceof cognito.CognitoUser) {
return maybeUser
} else {
// eslint-disable-next-line no-restricted-properties
console.error(
'Unknown result from signIn, expected CognitoUser, got ' + typeof maybeUser,
JSON.stringify(maybeUser),
)
throw new Error('Unknown response from the server, please try again later ')
}
})
return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignInWithPasswordErrorOrThrow)
@ -363,6 +398,112 @@ export class Cognito {
}
}
/**
* Start the TOTP setup process. Returns the secret and the URL to scan the QR code.
*/
async setupTOTP() {
const email = await this.email()
const cognitoUserResult = await currentAuthenticatedUser()
if (cognitoUserResult.ok) {
const cognitoUser = cognitoUserResult.unwrap()
const result = (
await results.Result.wrapAsync(() => amplify.Auth.setupTOTP(cognitoUser))
).map((data) => {
const str = 'otpauth://totp/AWSCognito:' + email + '?secret=' + data + '&issuer=' + 'Enso'
return { secret: data, url: str } as const
})
return result.mapErr(intoAmplifyErrorOrThrow)
} else {
return results.Err(cognitoUserResult.val)
}
}
/**
* Verify the TOTP token during the setup process.
* Use it *only* during the setup process.
*/
async verifyTotpSetup(totpToken: string) {
const cognitoUserResult = await currentAuthenticatedUser()
if (cognitoUserResult.ok) {
const cognitoUser = cognitoUserResult.unwrap()
const result = await results.Result.wrapAsync(async () => {
await amplify.Auth.verifyTotpToken(cognitoUser, totpToken)
})
return result.mapErr(intoAmplifyErrorOrThrow)
} else {
return results.Err(cognitoUserResult.val)
}
}
/**
* Set the user's preferred MFA method.
*/
async updateMFAPreference(mfaMethod: MfaType) {
const cognitoUserResult = await currentAuthenticatedUser()
if (cognitoUserResult.ok) {
const cognitoUser = cognitoUserResult.unwrap()
const result = await results.Result.wrapAsync(
async () => await amplify.Auth.setPreferredMFA(cognitoUser, mfaMethod),
)
return result.mapErr(intoAmplifyErrorOrThrow)
} else {
return results.Err(cognitoUserResult.val)
}
}
/**
* Get the user's preferred MFA method.
*/
async getMFAPreference() {
const cognitoUserResult = await currentAuthenticatedUser()
if (cognitoUserResult.ok) {
const cognitoUser = cognitoUserResult.unwrap()
const result = await results.Result.wrapAsync(async () => {
// eslint-disable-next-line no-restricted-syntax
return (await amplify.Auth.getPreferredMFA(cognitoUser)) as MfaType
})
return result.mapErr(intoAmplifyErrorOrThrow)
} else {
return results.Err(cognitoUserResult.val)
}
}
/**
* Verify the TOTP token.
* Returns the user session if the token is valid.
*/
async verifyTotpToken(totpToken: string) {
const cognitoUserResult = await currentAuthenticatedUser()
if (cognitoUserResult.ok) {
const cognitoUser = cognitoUserResult.unwrap()
return (
await results.Result.wrapAsync(() => amplify.Auth.verifyTotpToken(cognitoUser, totpToken))
).mapErr(intoAmplifyErrorOrThrow)
} else {
return results.Err(cognitoUserResult.val)
}
}
/**
* Confirm the sign in with the MFA token.
*/
async confirmSignIn(
user: amplify.CognitoUser,
confirmationCode: string,
mfaType: 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA',
) {
const result = await results.Result.wrapAsync(() =>
amplify.Auth.confirmSignIn(user, confirmationCode, mfaType),
)
return result.mapErr(intoAmplifyErrorOrThrow)
}
/** We want to signal to Amplify to fire a "custom state change" event when the user is
* redirected back to the application after signing in via an external identity provider. This
* is done so we get a chance to fix the location history. The location history is the history
@ -707,3 +848,4 @@ async function currentAuthenticatedUser() {
)
return result.mapErr(intoAmplifyErrorOrThrow)
}
export { CognitoUser } from '@aws-amplify/auth'

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
* Amplify library. */
export function useInitAuthService(authConfig: AuthConfig): AuthService | null {
export function useInitAuthService(authConfig: AuthConfig): AuthService {
const { supportsDeepLinks } = authConfig
const logger = useLogger()
const navigate = useNavigate()
return React.useMemo(() => {
const amplifyConfig = loadAmplifyConfig(logger, supportsDeepLinks, navigate)
const cognito =
amplifyConfig == null ? null : (
new cognitoModule.Cognito(logger, supportsDeepLinks, amplifyConfig)
)
const cognito = new cognitoModule.Cognito(logger, supportsDeepLinks, amplifyConfig)
return cognito == null ? null : (
{ cognito, registerAuthEventListener: listen.registerAuthEventListener }
)
return { cognito, registerAuthEventListener: listen.registerAuthEventListener }
}, [logger, navigate, supportsDeepLinks])
}
@ -138,7 +135,7 @@ function loadAmplifyConfig(
logger: Logger,
supportsDeepLinks: boolean,
navigate: (url: string) => void,
): AmplifyConfig | null {
): AmplifyConfig {
let urlOpener: ((url: string) => void) | null = null
let saveAccessToken: ((accessToken: saveAccessTokenModule.AccessToken | null) => void) | null =
null
@ -175,14 +172,7 @@ function loadAmplifyConfig(
/** Load the platform-specific Amplify configuration. */
const signInOutRedirect = supportsDeepLinks ? `${common.DEEP_LINK_SCHEME}://auth` : redirectUrl
return (
process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID == null ||
process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID == null ||
process.env.ENSO_CLOUD_COGNITO_DOMAIN == null ||
process.env.ENSO_CLOUD_COGNITO_REGION == null
) ?
null
: {
return {
userPoolId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID,
userPoolWebClientId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID,
domain: process.env.ENSO_CLOUD_COGNITO_DOMAIN,

View File

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

View File

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

View File

@ -53,7 +53,9 @@ const MODAL_STYLES = tv({
})
const DIALOG_STYLES = tv({
base: DIALOG_BACKGROUND({ className: 'w-full flex flex-col text-left align-middle shadow-xl' }),
base: DIALOG_BACKGROUND({
className: 'w-full max-w-full flex flex-col text-left align-middle shadow-xl',
}),
variants: {
type: {
modal: {
@ -149,7 +151,7 @@ const DIALOG_STYLES = tv({
* Can be used to display alerts, confirmations, or other content. */
export function Dialog(props: DialogProps) {
const {
children,
children: Children,
title,
type = 'modal',
closeButton = 'normal',
@ -302,7 +304,9 @@ export function Dialog(props: DialogProps) {
<suspense.Suspense
loaderProps={{ minHeight: type === 'fullscreen' ? 'full' : 'h32' }}
>
{typeof children === 'function' ? children(opts) : children}
{typeof Children === 'function' ?
<Children {...opts} />
: Children}
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import type * as React from 'react'
import type * as reactHookForm from 'react-hook-form'
import type * as z from 'zod'
import type { FormEvent } from 'react'
import type * as schemaModule from './schema'
/** The type of the inputs to the form, used for UI inputs. */
@ -31,15 +32,61 @@ export type TSchema =
| z.ZodEffects<z.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.
*/
export interface UseFormProps<Schema extends TSchema>
export interface UseFormProps<Schema extends TSchema, SubmitResult = void>
extends Omit<
reactHookForm.UseFormProps<FieldValues<Schema>>,
'handleSubmit' | 'resetOptions' | 'resolver'
> {
>,
OnSubmitCallbacks<Schema, SubmitResult> {
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,
options?: reactHookForm.RegisterOptions<FieldValues<Schema>, TFieldName>,
// eslint-disable-next-line no-restricted-syntax
) => UseFormRegisterReturn<Schema, TFieldName>
/**
@ -64,9 +110,12 @@ export interface UseFormRegisterReturn<
readonly onChange: <Value>(value: Value) => Promise<boolean | void>
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
readonly onBlur: <Value>(value: Value) => Promise<boolean | void>
readonly isDisabled?: boolean
readonly isRequired?: boolean
readonly isInvalid?: boolean
readonly isDisabled: boolean
readonly isRequired: boolean
readonly isInvalid: boolean
readonly disabled: boolean
readonly required: boolean
readonly invalid: boolean
}
/**
@ -74,8 +123,14 @@ export interface UseFormRegisterReturn<
* @alias reactHookForm.UseFormReturn
*/
export interface UseFormReturn<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 submit: (event?: FormEvent<HTMLFormElement> | null | undefined) => Promise<void>
readonly schema: Schema
readonly setFormError: (error: string) => void
}
/**
@ -113,6 +168,16 @@ export interface FormWithValueValidation<
| undefined
}
/**
* Form instance type that has been validated.
* Cast validatable form type to FormInstance
*/
export type FormInstanceValidated<
Schema extends TSchema,
// We use any here because we want to bypass the type check for Error type as it won't be a case here
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/ban-types
> = FormInstance<Schema> | (any[] & {})
/**
* Props for the Field component.
*/
@ -148,3 +213,36 @@ export interface FieldProps {
// eslint-disable-next-line @typescript-eslint/naming-convention
'aria-details'?: string | undefined
}
/**
* Base Props for a Form Field.
* @private
*/
export interface FormFieldProps<
BaseValueType,
Schema extends TSchema,
TFieldName extends FieldPath<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 formContext from './FormProvider'
import type * as types from './types'
import * as formContext from './useFormContext'
/**
* Options for {@link useField} hook.
@ -29,24 +29,16 @@ export function useField<
Schema extends types.TSchema,
TFieldName extends types.FieldPath<Schema>,
>(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.
// 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 formInstance = formContext.useFormContext(options.form)
const { field, fieldState, formState } = reactHookForm.useController({
name,
disabled: isDisabled,
control: formInstance.control,
...(defaultValue != null ? { defaultValue } : {}),
})
return {
field,
fieldState,
formState,
formInstance,
} as const
return { field, fieldState, formState, formInstance } as const
}

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.
*/
import * as sentry from '@sentry/react'
import * as React from 'react'
import * as zodResolver from '@hookform/resolvers/zod'
import * as reactHookForm from 'react-hook-form'
import invariant from 'tiny-invariant'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useOffline, useOfflineChange } from '#/hooks/offlineHooks'
import { useText } from '#/providers/TextProvider'
import * as errorUtils from '#/utilities/error'
import { useMutation } from '@tanstack/react-query'
import * as schemaModule from './schema'
import type * as types from './types'
@ -39,36 +44,43 @@ function mapValueOnEvent(value: unknown) {
* But be careful, You should not switch between the two types of arguments.
* Otherwise you'll be fired
*/
export function useForm<Schema extends types.TSchema>(
optionsOrFormInstance: types.UseFormProps<Schema> | types.UseFormReturn<Schema>,
export function useForm<Schema extends types.TSchema, SubmitResult = void>(
optionsOrFormInstance: types.UseFormProps<Schema, SubmitResult> | types.UseFormReturn<Schema>,
): types.UseFormReturn<Schema> {
const { getText } = useText()
const initialTypePassed = React.useRef(getArgsType(optionsOrFormInstance))
const [initialTypePassed] = React.useState(() => getArgsType(optionsOrFormInstance))
const argsType = getArgsType(optionsOrFormInstance)
invariant(
initialTypePassed.current === argsType,
initialTypePassed === argsType,
`
Found a switch between form options and form instance. This is not allowed. Please use either form options or form instance and stick to it.\n\n
Initially passed: ${initialTypePassed.current}, Currently passed: ${argsType}.
Initially passed: ${initialTypePassed}, Currently passed: ${argsType}.
`,
)
if ('formState' in optionsOrFormInstance) {
return optionsOrFormInstance
} else {
const { schema, ...options } = optionsOrFormInstance
const {
schema,
onSubmit,
canSubmitOffline = false,
onSubmitFailed,
onSubmitted,
onSubmitSuccess,
debugName,
...options
} = optionsOrFormInstance
const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema
const formInstance = reactHookForm.useForm<
types.FieldValues<Schema>,
unknown,
types.TransformedValues<Schema>
>({
const formInstance = reactHookForm.useForm({
...options,
resolver: zodResolver.zodResolver(computedSchema, {
resolver: zodResolver.zodResolver(
computedSchema,
{
async: true,
errorMap: (issue) => {
switch (issue.code) {
@ -96,7 +108,9 @@ export function useForm<Schema extends types.TSchema>(
}
}
},
}),
},
{ mode: 'async' },
),
})
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> = {
...registered,
...(registered.disabled != null ? { isDisabled: registered.disabled } : {}),
...(registered.required != null ? { isRequired: registered.required } : {}),
disabled: registered.disabled ?? false,
isDisabled: registered.disabled ?? false,
invalid: !!formInstance.formState.errors[name],
isInvalid: !!formInstance.formState.errors[name],
required: registered.required ?? false,
isRequired: registered.required ?? false,
onChange,
onBlur,
}
@ -120,19 +137,106 @@ export function useForm<Schema extends types.TSchema>(
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,
submit,
control: { ...formInstance.control, register },
register,
} satisfies types.UseFormReturn<Schema>
schema: computedSchema,
setFormError,
handleSubmit: formInstance.handleSubmit,
}
return form
}
}
/**
* Get the type of arguments passed to the useForm hook
*/
function getArgsType<Schema extends types.TSchema>(
args: types.UseFormProps<Schema> | types.UseFormReturn<Schema>,
function getArgsType<Schema extends types.TSchema, SubmitResult = void>(
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 schemaComponent from '#/components/AriaComponents/Form/components/schema'
import type * as types from '#/components/AriaComponents/Form/components/types'
import * as schemaComponent from './schema'
import type * as types from './types'
// =====================
// === useFormSchema ===

View File

@ -7,6 +7,8 @@ import type * as React from 'react'
import type * as reactHookForm from 'react-hook-form'
import type { DeepPartialSkipArrayKey } from 'react-hook-form'
import type { TestIdProps } from '../types'
import type * as components from './components'
import type * as styles from './styles'
@ -15,8 +17,11 @@ export type * from './components'
/**
* Props for the Form component
*/
export type FormProps<Schema extends components.TSchema> = BaseFormProps<Schema> &
(FormPropsWithOptions<Schema> | FormPropsWithParentForm<Schema>)
export type FormProps<
Schema extends components.TSchema,
SubmitResult = void,
> = BaseFormProps<Schema> &
(FormPropsWithOptions<Schema, SubmitResult> | FormPropsWithParentForm<Schema>)
/**
* Base props for the Form component.
@ -26,20 +31,8 @@ interface BaseFormProps<Schema extends components.TSchema>
React.HTMLProps<HTMLFormElement>,
'children' | 'className' | 'form' | 'onSubmit' | 'onSubmitCapture' | 'style'
>,
styles.FormStyleProps {
/**
* The default values for the form fields
*
* __Note:__ Even though this is optional,
* it is recommended to provide default values and specify all fields defined in the schema.
* Otherwise Typescript fails to infer the correct type for the form values.
* This is a known limitation and we are working on a solution.
*/
readonly defaultValues?: components.UseFormProps<Schema>['defaultValues']
readonly onSubmit?: (
values: components.TransformedValues<Schema>,
form: components.UseFormReturn<Schema>,
) => unknown
Omit<styles.FormStyleProps, 'class' | 'className'>,
TestIdProps {
readonly style?:
| React.CSSProperties
| ((props: components.UseFormReturn<Schema>) => React.CSSProperties)
@ -48,17 +41,13 @@ interface BaseFormProps<Schema extends components.TSchema>
| ((
props: components.UseFormReturn<Schema> & {
readonly form: components.UseFormReturn<Schema>
readonly values: DeepPartialSkipArrayKey<components.FieldValues<Schema>>
},
) => React.ReactNode)
readonly formRef?: React.MutableRefObject<components.UseFormReturn<Schema>>
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.
*/
@ -76,16 +65,33 @@ interface FormPropsWithParentForm<Schema extends components.TSchema> {
readonly form: components.UseFormReturn<Schema>
readonly schema?: never
readonly formOptions?: never
readonly defaultValues?: never
readonly onSubmit?: never
readonly onSubmitSuccess?: never
readonly onSubmitFailed?: never
readonly onSubmitted?: never
}
/**
* Props for the Form component with schema and form options.
* Creates a new form instance. This is the default way to use the form.
*/
interface FormPropsWithOptions<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 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 formOptions?: Omit<components.UseFormProps<Schema>, 'resolver' | 'schema'>
}
/**
@ -134,38 +140,3 @@ export type FormStateRenderProps<Schema extends components.TSchema> = Pick<
/** The form instance. */
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'
import { useText } from '#/providers/TextProvider'
import { forwardRef } from '#/utilities/react'
import { Controller } from 'react-hook-form'
import { tv, type VariantProps } from 'tailwind-variants'
import type { VariantProps } from '#/utilities/tailwindVariants'
import { tv } from '#/utilities/tailwindVariants'
const DATE_PICKER_STYLES = tv({
base: '',
@ -135,7 +135,7 @@ export const DatePicker = forwardRef(function DatePicker<
ref={ref}
style={props.style}
>
<Controller
<Form.Controller
control={formInstance.control}
name={name}
render={(renderProps) => {

View File

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

View File

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

View File

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

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 {
Button,
Input,
type FieldPath,
type FieldValues,
type InputProps,
type TSchema,
@ -17,7 +18,7 @@ import {
// ================
/** 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'> {}
/** 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>,
'disabled' | 'invalid' | 'rounded' | 'size' | 'variant'
>,
FieldVariantProps,
Omit<FieldProps, 'variant'>,
FieldVariantProps,
Pick<VariantProps<typeof INPUT_STYLES>, 'rounded' | 'size' | 'variant'>,
Omit<
VariantProps<typeof CONTENT_EDITABLE_STYLES>,
'disabled' | 'invalid' | 'rounded' | 'size' | 'variant'

View File

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

View File

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

View File

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

View File

@ -39,7 +39,7 @@ export const INPUT_STYLES = tv({
variant: {
custom: {},
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',
},
},

View File

@ -127,6 +127,8 @@ export const Switch = forwardRef(function Switch<
{...mergeProps<AriaSwitchProps>()(ariaSwitchProps, fieldProps, {
defaultSelected: field.value,
className: switchStyles(),
onChange: field.onChange,
onBlur: field.onBlur,
})}
>
<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> & {
// eslint-disable-next-line @typescript-eslint/naming-convention
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} />
})
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
gap="small"
formOptions={{ mode: 'onChange' }}
schema={FEATURE_FLAGS_SCHEMA}
formOptions={{ mode: 'onChange' }}
defaultValues={{
enableMultitabs: featureFlags.enableMultitabs,
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 * as tvw from 'tailwind-variants'
import DoneIcon from '#/assets/check_mark.svg'
import * as eventCallback from '#/hooks/eventCallbackHooks'
import * as ariaComponents from '#/components/AriaComponents'
import { ErrorBoundary } from '#/components/ErrorBoundary'
import { Suspense } from '#/components/Suspense'
import SvgMask from '#/components/SvgMask'
import { Step } from './Step'
import { StepContent } from './StepContent'
import * as stepperProvider from './StepperProvider'
import type { BaseRenderProps, RenderChildrenProps, RenderStepProps } from './types'
import * as stepperState from './useStepperState'
/**
* Render props for the stepper component.
*/
export interface BaseRenderProps {
readonly goToStep: (step: number) => void
readonly nextStep: () => void
readonly previousStep: () => void
readonly currentStep: number
readonly totalSteps: number
}
/**
* Render props for rendering children of the stepper component.
*/
export interface RenderChildrenProps extends BaseRenderProps {
readonly isFirst: boolean
readonly isLast: boolean
}
/**
* Render props for lazy rendering of steps.
*/
export interface RenderStepProps extends BaseRenderProps {
/**
* The index of the step, starting from 0.
*/
readonly index: number
readonly isCurrent: boolean
readonly isCompleted: boolean
readonly isFirst: boolean
readonly isLast: boolean
readonly isDisabled: boolean
}
/**
* Render props for styling the stepper component.
*/
export interface RenderStepperProps {
readonly currentStep: number
readonly totalSteps: number
readonly isFirst: boolean
readonly isLast: boolean
}
/**
* Props for {@link Stepper} component.
*/
@ -228,187 +183,6 @@ export function Stepper(props: StepperProps) {
)
}
/** A prop with the given type, or a function to produce a value of the given type. */
type StepProp<T> = 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.StepContent = StepContent
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,20 +81,29 @@ export function useStepperState(props: StepperStateProps): UseStepperStateResult
const setCurrentStep = eventCallbackHooks.useEventCallback(
(step: number | ((current: number) => number)) => {
React.startTransition(() => {
privateSetCurrentStep((current) => {
const nextStep = typeof step === 'function' ? step(current.current) : step
const direction = nextStep > current.current ? 'forward' : 'back'
if (nextStep < 0) {
return { current: 0, direction: 'back-none' }
return {
current: 0,
direction: 'back-none',
}
} else if (nextStep > steps - 1) {
onCompletedStableCallback()
return { current: steps - 1, direction: 'forward-none' }
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
}
default: {
return
break
}
}
} else {
@ -549,18 +549,18 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
}
case AssetEventType.deleteLabel: {
setAsset((oldAsset) => {
// The IIFE is required to prevent TypeScript from narrowing this value.
let found = (() => false)()
const labels =
oldAsset.labels?.filter((label) => {
if (label === event.labelName) {
found = true
return false
} else {
return true
const oldLabels = oldAsset.labels ?? []
const labels: backendModule.LabelName[] = []
for (const label of oldLabels) {
if (label !== event.labelName) {
labels.push(label)
}
}) ?? null
return found ? object.merge(oldAsset, { labels }) : oldAsset
}
return oldLabels.length !== labels.length ?
object.merge(oldAsset, { labels })
: oldAsset
})
break
}

View File

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

View File

@ -203,13 +203,13 @@ declare global {
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_CLOUD_STRIPE_KEY?: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_CLOUD_COGNITO_USER_POOL_ID?: string
readonly ENSO_CLOUD_COGNITO_USER_POOL_ID: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID?: string
readonly ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_CLOUD_COGNITO_DOMAIN?: string
readonly ENSO_CLOUD_COGNITO_DOMAIN: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_CLOUD_COGNITO_REGION?: string
readonly ENSO_CLOUD_COGNITO_REGION: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_CLOUD_GOOGLE_ANALYTICS_TAG?: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars.

View File

@ -183,7 +183,7 @@ export default function Settings() {
}, [isQueryBlank, doesEntryMatchQuery, getText, isMatch, effectiveTab])
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.MenuTrigger isOpen={isSidebarPopoverOpen} onOpenChange={setIsSidebarPopoverOpen}>
<Button image={BurgerMenuIcon} buttonClassName="mr-3 sm:hidden" onPress={() => {}} />
@ -208,13 +208,12 @@ export default function Settings() {
<ariaComponents.Text
variant="h1"
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
>
{data.organizationOnly === true ? organization?.name ?? 'your organization' : user.name}
</ariaComponents.Text>
</aria.Heading>
<div className="flex sm:ml-[222px]">
<SearchBar
data-testid="settings-search-bar"
query={query}
@ -222,8 +221,9 @@ export default function Settings() {
label={getText('settingsSearchBarLabel')}
placeholder={getText('settingsSearchBarPlaceholder')}
/>
</div>
<div className="flex flex-1 gap-6 overflow-hidden pr-0.5">
</aria.Heading>
<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">
<SettingsSidebar
context={context}
@ -232,6 +232,7 @@ export default function Settings() {
setTab={setTab}
/>
</aside>
<main className="flex flex-1 flex-col overflow-y-auto pb-12 pl-1 scrollbar-gutter-stable">
<SettingsTab
context={context}
data={data}
@ -241,6 +242,7 @@ export default function Settings() {
}
}}
/>
</main>
</div>
</div>
)

View File

@ -67,20 +67,20 @@ export default function SettingsTab(props: SettingsTabProps) {
} else {
const content =
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) => (
<SettingsSection key={section.nameId} context={context} data={section} />
))}
</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}
>
{columns.map((sectionsInColumn, i) => (
<div
key={i}
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],
)}
>

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 * as object from '#/utilities/object'
import { SetupTwoFaForm } from './SetupTwoFaForm'
// =========================
// === 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',
heading: false,

View File

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

View File

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

View File

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

View File

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

View File

@ -3,15 +3,17 @@ import * as router from 'react-router-dom'
import { CLOUD_DASHBOARD_DOMAIN } from 'enso-common'
import { FORGOT_PASSWORD_PATH, REGISTRATION_PATH } from '#/appUtils'
import { DASHBOARD_PATH, FORGOT_PASSWORD_PATH, REGISTRATION_PATH } from '#/appUtils'
import ArrowRightIcon from '#/assets/arrow_right.svg'
import AtIcon from '#/assets/at.svg'
import CreateAccountIcon from '#/assets/create_account.svg'
import GithubIcon from '#/assets/github_color.svg'
import GoogleIcon from '#/assets/google_color.svg'
import LockIcon from '#/assets/lock.svg'
import { Button, Form, Input, Password } from '#/components/AriaComponents'
import type { CognitoUser } from '#/authentication/cognito'
import { Button, Form, Input, OTPInput, Password, Text } from '#/components/AriaComponents'
import Link from '#/components/Link'
import { Stepper } from '#/components/Stepper'
import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
import { passwordSchema } from '#/pages/authentication/schemas'
import { useAuth } from '#/providers/AuthProvider'
@ -26,14 +28,52 @@ import { useState } from 'react'
/** A form for users to log in. */
export default function Login() {
const location = router.useLocation()
const { signInWithGoogle, signInWithGitHub, signInWithPassword } = useAuth()
const navigate = router.useNavigate()
const { signInWithGoogle, signInWithGitHub, signInWithPassword, cognito } = useAuth()
const { getText } = useText()
const query = new URLSearchParams(location.search)
const initialEmail = query.get('email') ?? ''
const form = Form.useForm({
schema: (z) =>
z.object({
email: z
.string()
.min(1, getText('arbitraryFieldRequired'))
.email(getText('invalidEmailValidationError')),
password: passwordSchema(getText),
}),
defaultValues: { email: initialEmail },
onSubmit: async ({ email, password }) => {
const res = await signInWithPassword(email, password)
switch (res.challenge) {
case 'NO_CHALLENGE':
navigate(DASHBOARD_PATH)
break
case 'SMS_MFA':
case 'SOFTWARE_TOKEN_MFA':
setUser(res.user)
nextStep()
break
default:
throw new Error('Unsupported challenge')
}
},
})
const [emailInput, setEmailInput] = useState(initialEmail)
const [user, setUser] = useState<CognitoUser | null>(null)
const localBackend = useLocalBackend()
const supportsOffline = localBackend != null
const { nextStep, stepperState, previousStep } = Stepper.useStepperState({
steps: 2,
defaultStep: 0,
})
return (
<AuthenticationPage
title={getText('loginToYourAccount')}
@ -52,11 +92,14 @@ export default function Login() {
/>
}
>
<Stepper state={stepperState} renderStep={() => null}>
<Stepper.StepContent index={0}>
{() => (
<div className="flex flex-col gap-auth">
<Button
size="large"
variant="outline"
icon={<img src={GoogleIcon} />}
icon={GoogleIcon}
onPress={async () => {
await signInWithGoogle()
}}
@ -66,29 +109,15 @@ export default function Login() {
<Button
size="large"
variant="outline"
icon={<img src={GithubIcon} />}
icon={GithubIcon}
onPress={async () => {
await signInWithGitHub()
}}
>
{getText('signUpOrLoginWithGitHub')}
</Button>
</div>
<Form
schema={(z) =>
z.object({
email: z
.string()
.min(1, getText('arbitraryFieldRequired'))
.email(getText('invalidEmailValidationError')),
password: passwordSchema(getText),
})
}
gap="medium"
defaultValues={{ email: initialEmail }}
onSubmit={({ email, password }) => signInWithPassword(email, password)}
>
<Form form={form} gap="medium">
<Input
autoFocus
required
@ -131,6 +160,58 @@ export default function Login() {
<Form.FormError />
</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>
)
}

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 })
@ -161,24 +169,14 @@ export default function Registration() {
{getText('createANewAccount')}
</Text.Heading>
<Form
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={signupForm}>
{({ form }) => (
<>
<Input
form={form}
autoFocus
required
data-testid="email-input"
testId="email-input"
name="email"
label={getText('emailLabel')}
type="email"
@ -193,7 +191,7 @@ export default function Registration() {
<Password
form={form}
required
data-testid="password-input"
testId="password-input"
name="password"
label={getText('passwordLabel')}
autoComplete="new-password"
@ -205,7 +203,7 @@ export default function Registration() {
<Password
form={form}
required
data-testid="confirm-password-input"
testId="confirm-password-input"
name="confirmPassword"
label={getText('confirmPasswordLabel')}
autoComplete="new-password"

View File

@ -92,7 +92,13 @@ interface AuthContextType {
readonly setUsername: (username: string) => Promise<boolean>
readonly signInWithGoogle: () => 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 changePassword: (oldPassword: string, newPassword: string) => Promise<boolean>
readonly resetPassword: (email: string, code: string, password: string) => Promise<void>
@ -116,6 +122,7 @@ interface AuthContextType {
readonly isUserDeleted: () => boolean
/** Return `true` if the user is soft deleted. */
readonly isUserSoftDeleted: () => boolean
readonly cognito: cognitoModule.Cognito
}
const AuthContext = React.createContext<AuthContextType | null>(null)
@ -163,7 +170,7 @@ function createUsersMeQuery(
/** Props for an {@link AuthProvider}. */
export interface AuthProviderProps {
readonly shouldStartInOfflineMode: boolean
readonly authService: authServiceModule.AuthService | null
readonly authService: authServiceModule.AuthService
/** Callback to execute once the user has authenticated successfully. */
readonly onAuthenticated: (accessToken: string | null) => void
readonly children: React.ReactNode
@ -174,7 +181,7 @@ export default function AuthProvider(props: AuthProviderProps) {
const { authService, onAuthenticated } = props
const { children } = props
const remoteBackend = backendProvider.useRemoteBackendStrict()
const { cognito } = authService ?? {}
const { cognito } = authService
const { session, sessionQueryKey } = sessionProvider.useSession()
const { localStorage } = localStorageProvider.useLocalStorage()
const { getText } = textProvider.useText()
@ -194,7 +201,6 @@ export default function AuthProvider(props: AuthProviderProps) {
}, [])
const performLogout = async () => {
if (cognito != null) {
await cognito.signOut()
const parentDomain = location.hostname.replace(/^[^.]*\./, '')
@ -208,9 +214,6 @@ export default function AuthProvider(props: AuthProviderProps) {
await queryClient.clearWithPersister()
return Promise.resolve()
} else {
return Promise.reject()
}
}
const logoutMutation = reactQuery.useMutation({
@ -289,7 +292,6 @@ export default function AuthProvider(props: AuthProviderProps) {
const signUp = useEventCallback(
async (username: string, password: string, organizationId: string | null) => {
if (cognito != null) {
gtagEvent('cloud_sign_up')
const result = await cognito.signUp(username, password, organizationId)
@ -298,14 +300,10 @@ export default function AuthProvider(props: AuthProviderProps) {
} else {
return
}
}
},
)
const confirmSignUp = useEventCallback(async (email: string, code: string) => {
if (cognito == null) {
throw new Error(getText('confirmSignUpError'))
} else {
gtagEvent('cloud_confirm_sign_up')
const result = await cognito.confirmSignUp(email, code)
@ -320,27 +318,32 @@ export default function AuthProvider(props: AuthProviderProps) {
}
}
}
}
})
const signInWithPassword = useEventCallback(async (email: string, password: string) => {
if (cognito != null) {
gtagEvent('cloud_sign_in', { provider: 'Email' })
const result = await cognito.signInWithPassword(email, password)
if (result.ok) {
void queryClient.invalidateQueries({ queryKey: sessionQueryKey })
navigate(appUtils.DASHBOARD_PATH)
return
const user = result.unwrap()
const challenge = user.challengeName ?? 'NO_CHALLENGE'
if (['SMS_MFA', 'SOFTWARE_TOKEN_MFA'].includes(challenge)) {
// eslint-disable-next-line no-restricted-syntax
return { challenge, user } as const
}
return queryClient
.invalidateQueries({ queryKey: sessionQueryKey })
.then(() => ({ challenge, user }) as const)
} else {
throw new Error(result.val.message)
}
}
})
const setUsername = useEventCallback(async (username: string) => {
if (cognito == null) {
return false
} else {
gtagEvent('cloud_user_created')
if (userData?.type === UserSessionType.full) {
@ -358,31 +361,22 @@ export default function AuthProvider(props: AuthProviderProps) {
}
return true
}
})
const deleteUser = useEventCallback(async () => {
if (cognito == null) {
return false
} else {
await deleteUserMutation.mutateAsync()
toastSuccess(getText('deleteUserSuccess'))
return true
}
})
const restoreUser = useEventCallback(async () => {
if (cognito == null) {
return false
} else {
await restoreUserMutation.mutateAsync()
toastSuccess(getText('restoreUserSuccess'))
return true
}
})
/**
@ -402,7 +396,6 @@ export default function AuthProvider(props: AuthProviderProps) {
})
const forgotPassword = useEventCallback(async (email: string) => {
if (cognito != null) {
const result = await cognito.forgotPassword(email)
if (result.ok) {
navigate(appUtils.LOGIN_PATH)
@ -410,33 +403,29 @@ export default function AuthProvider(props: AuthProviderProps) {
} else {
throw new Error(result.val.message)
}
}
})
const resetPassword = useEventCallback(async (email: string, code: string, password: string) => {
if (cognito != null) {
const result = await cognito.forgotPasswordSubmit(email, code, password)
if (result.ok) {
navigate(appUtils.LOGIN_PATH)
return
} else {
throw new Error(result.val.message)
}
}
})
const changePassword = useEventCallback(async (oldPassword: string, newPassword: string) => {
if (cognito == null) {
return false
} else {
const result = await cognito.changePassword(oldPassword, newPassword)
if (result.ok) {
toastSuccess(getText('changePasswordSuccess'))
} else {
toastError(result.val.message)
}
return result.ok
}
})
const isUserMarkedForDeletion = useEventCallback(
@ -503,11 +492,10 @@ export default function AuthProvider(props: AuthProviderProps) {
isUserSoftDeleted,
restoreUser,
deleteUser,
cognito,
signInWithGoogle: useEventCallback(() => {
if (cognito == null) {
return Promise.resolve(false)
} else {
gtagEvent('cloud_sign_in', { provider: 'Google' })
return cognito
.signInWithGoogle()
.then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey }))
@ -515,13 +503,10 @@ export default function AuthProvider(props: AuthProviderProps) {
() => true,
() => false,
)
}
}),
signInWithGitHub: useEventCallback(() => {
if (cognito == null) {
return Promise.resolve(false)
} else {
gtagEvent('cloud_sign_in', { provider: 'GitHub' })
return cognito
.signInWithGitHub()
.then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey }))
@ -529,7 +514,6 @@ export default function AuthProvider(props: AuthProviderProps) {
() => true,
() => false,
)
}
}),
signInWithPassword,
forgotPassword,

View File

@ -25,6 +25,9 @@ export type TVWithoutExtends<T> = ExtractFunction<T> & Omit<T, 'extend'>
* Props for a component that uses `tailwind-variants`.
*/
// 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
}

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

View File

@ -216,12 +216,21 @@
"likes": "Likes",
"shortcuts": "Shortcuts",
"download": "Download",
"disable": "Disable",
"enable": "Enable",
"email": "Email",
"intro": "Intro",
"emailIsRequired": "Email is required",
"emailIsInvalid": "Email is invalid",
"emailAlreadyExists": "Email already exists",
"emailAlreadyAdded": "Email already added",
"otp": "OTP",
"totp": "TOTP",
"display": "Display",
"invalidOtp": "Invalid OTP",
"invalidTotp": "Invalid TOTP",
"enterOtp": "To continue, please enter the 6-digit verification code generated by your authenticator app.",
"enterTotp": "To continue, please enter the 6-digit verification code generated by your authenticator app.",
"password": "Password",
"reset": "Reset",
"members": "Members",
@ -473,6 +482,14 @@
"enableAssetsTableBackgroundRefreshInterval": "Refresh interval",
"enableAssetsTableBackgroundRefreshIntervalDescription": "Set the interval in ms for the assets table to refresh in the background.",
"enableMultitabs": "Enable Multi-Tabs",
"enableMultitabsDescription": "Open multiple projects at the same time.",
"enableAssetsTableBackgroundRefresh": "Enable Assets Table Background Refresh",
"enableAssetsTableBackgroundRefreshDescription": "Automatically refresh the assets table in the background.",
"enableAssetsTableBackgroundRefreshInterval": "Refresh interval",
"enableAssetsTableBackgroundRefreshIntervalDescription": "Set the interval in ms for the assets table to refresh in the background.",
"deleteLabelActionText": "delete the label '$0'",
"deleteSelectedAssetActionText": "delete '$0'",
"deleteSelectedAssetsActionText": "delete $0 selected items",
@ -814,6 +831,24 @@
"userNameSettingsInput": "Name",
"userEmailSettingsInput": "Email",
"changePasswordSettingsSection": "Change Password",
"setup2FASettingsSection": "Two-Factor Authentication (2FA)",
"setup2FASettingsCustomEntryAliases": "two-factor authentication\n2fa",
"setup2FASettingsDescription": "Add an extra layer of security to your account by enabling two-factor authentication.",
"enable2FA": "Enable 2FA",
"enable2FADescription": "Require a code from your authenticator app to log in.",
"scanQR": "Scan this QR code",
"scanQRDescription": "Use an authenticator app like Google Authenticator or Authy to scan this QR code and set up 2FA.",
"copyLink": "Copy this link",
"copyLinkDescription": "If you can't scan the QR code, you can copy this link and paste it into your authenticator app.",
"enterCode": "Enter the code",
"enterCodeDescription": "Enter the code from your authenticator app to verify 2FA.",
"verificationCode": "Verification code",
"verificationCodePlaceholder": "Enter the verification code",
"2FAEnabled": "2FA is enabled",
"2FAEnabledDescription": "Your account is currently protected with two-factor authentication.",
"disable2FA": "Disable 2FA",
"disable2FADescription": "Turning off two-factor authentication will make your account less secure.",
"disable2FAWarning": "Are you sure you want to disable two-factor authentication? Turning off two-factor authentication will make your account less secure. You will still need to enter a verification code to disable 2FA.",
"changePasswordSettingsCustomEntryAliases": "current password\nnew password\nconfirm new password",
"deleteUserAccountSettingsSection": "Delete User Account",
"deleteUserAccountSettingsCustomEntryAliases": "danger zone\ndelete this user account",

View File

@ -79,6 +79,9 @@ importers:
ajv:
specifier: ^8.12.0
version: 8.16.0
amazon-cognito-identity-js:
specifier: 6.3.6
version: 6.3.6
clsx:
specifier: ^2.1.1
version: 2.1.1
@ -88,12 +91,18 @@ importers:
framer-motion:
specifier: 11.3.0
version: 11.3.0(@emotion/is-prop-valid@1.3.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
input-otp:
specifier: 1.2.4
version: 1.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
is-network-error:
specifier: ^1.0.1
version: 1.1.0
monaco-editor:
specifier: 0.48.0
version: 0.48.0
qrcode.react:
specifier: 3.1.0
version: 3.1.0(react@18.3.1)
react:
specifier: ^18.3.1
version: 18.3.1
@ -5155,6 +5164,12 @@ packages:
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
input-otp@1.2.4:
resolution: {integrity: sha512-md6rhmD+zmMnUh5crQNSQxq3keBRYvE3odbr4Qb9g2NWzQv9azi+t1a3X4TBTbh98fsGHgEEJlzbe1q860uGCA==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
install@0.13.0:
resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==}
engines: {node: '>= 0.10'}
@ -6495,6 +6510,11 @@ packages:
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
qrcode.react@3.1.0:
resolution: {integrity: sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
qs@6.11.0:
resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
engines: {node: '>=0.6'}
@ -13351,6 +13371,11 @@ snapshots:
ini@1.3.8: {}
input-otp@1.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
install@0.13.0: {}
internal-slot@1.0.7:
@ -14601,6 +14626,10 @@ snapshots:
pure-rand@6.1.0: {}
qrcode.react@3.1.0(react@18.3.1):
dependencies:
react: 18.3.1
qs@6.11.0:
dependencies:
side-channel: 1.0.6