mirror of
https://github.com/enso-org/enso.git
synced 2024-11-25 10:43:02 +03:00
MFA (#10875)
* Draft checkbox component
* Fixes in Setup Page
* Invite Users
* Add usergroup setup after subscription
* Fix comments
* Refetch Interval + Feature Toggles
* Fix lint
* Address issues
* Fix Dialog
* Assign users to the user group
* Use transitions to navigate between steps
* Small fixes
* Improve styling for scrollbars
* Fix typescript
* OTP input
* Fix setup logic
* Show Setup dialog only for admins
* Add otp
* OTP input
* 2FA settings section
* Small improvements
* Fixes
* Small fixes
* Remove w-full
* TOTP at login
* Fixes in 2FA
* Fixes in types
* Fix totp
* Merge fixes
* Merge fixes x2
* Merge fixes x2
* Fix types
* Fix types
* Fix cancel button
* Fix reset button
* Fix types
* Fix prettier
* Fix prettier
* Fix lint
* Fix lint
* Fix control
* Address prettier
* Fix MFA mock
* Fix sign in message
* Fix tests
* Address CR
* Fix types
(cherry picked from commit b5122348da
)
This commit is contained in:
parent
3e43d62eaa
commit
751551e18c
@ -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",
|
||||
|
@ -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}
|
||||
|
@ -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. */
|
||||
|
4
app/dashboard/src/assets/shield_break.svg
Normal file
4
app/dashboard/src/assets/shield_break.svg
Normal 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 |
3
app/dashboard/src/assets/shield_check.svg
Normal file
3
app/dashboard/src/assets/shield_check.svg
Normal 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 |
3
app/dashboard/src/assets/shield_crossed.svg
Normal file
3
app/dashboard/src/assets/shield_crossed.svg
Normal 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 |
3
app/dashboard/src/assets/un_fa.svg
Normal file
3
app/dashboard/src/assets/un_fa.svg
Normal 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 |
@ -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'))
|
||||
}
|
||||
}
|
||||
|
||||
// ===================
|
||||
|
@ -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'
|
||||
|
@ -116,20 +116,17 @@ export interface AuthService {
|
||||
*
|
||||
* This hook should only be called in a single place, as it performs global configuration of the
|
||||
* Amplify library. */
|
||||
export function useInitAuthService(authConfig: AuthConfig): AuthService | null {
|
||||
export function useInitAuthService(authConfig: AuthConfig): AuthService {
|
||||
const { supportsDeepLinks } = authConfig
|
||||
|
||||
const logger = useLogger()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return React.useMemo(() => {
|
||||
const amplifyConfig = loadAmplifyConfig(logger, supportsDeepLinks, navigate)
|
||||
const cognito =
|
||||
amplifyConfig == null ? null : (
|
||||
new cognitoModule.Cognito(logger, supportsDeepLinks, amplifyConfig)
|
||||
)
|
||||
const cognito = new cognitoModule.Cognito(logger, supportsDeepLinks, amplifyConfig)
|
||||
|
||||
return cognito == null ? null : (
|
||||
{ cognito, registerAuthEventListener: listen.registerAuthEventListener }
|
||||
)
|
||||
return { cognito, registerAuthEventListener: listen.registerAuthEventListener }
|
||||
}, [logger, navigate, supportsDeepLinks])
|
||||
}
|
||||
|
||||
@ -138,7 +135,7 @@ function loadAmplifyConfig(
|
||||
logger: Logger,
|
||||
supportsDeepLinks: boolean,
|
||||
navigate: (url: string) => void,
|
||||
): AmplifyConfig | null {
|
||||
): AmplifyConfig {
|
||||
let urlOpener: ((url: string) => void) | null = null
|
||||
let saveAccessToken: ((accessToken: saveAccessTokenModule.AccessToken | null) => void) | null =
|
||||
null
|
||||
@ -175,25 +172,18 @@ function loadAmplifyConfig(
|
||||
|
||||
/** Load the platform-specific Amplify configuration. */
|
||||
const signInOutRedirect = supportsDeepLinks ? `${common.DEEP_LINK_SCHEME}://auth` : redirectUrl
|
||||
return (
|
||||
process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID == null ||
|
||||
process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID == null ||
|
||||
process.env.ENSO_CLOUD_COGNITO_DOMAIN == null ||
|
||||
process.env.ENSO_CLOUD_COGNITO_REGION == null
|
||||
) ?
|
||||
null
|
||||
: {
|
||||
userPoolId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID,
|
||||
userPoolWebClientId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID,
|
||||
domain: process.env.ENSO_CLOUD_COGNITO_DOMAIN,
|
||||
region: process.env.ENSO_CLOUD_COGNITO_REGION,
|
||||
redirectSignIn: signInOutRedirect,
|
||||
redirectSignOut: signInOutRedirect,
|
||||
scope: ['email', 'openid', 'aws.cognito.signin.user.admin'],
|
||||
responseType: 'code',
|
||||
urlOpener,
|
||||
saveAccessToken,
|
||||
}
|
||||
return {
|
||||
userPoolId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID,
|
||||
userPoolWebClientId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID,
|
||||
domain: process.env.ENSO_CLOUD_COGNITO_DOMAIN,
|
||||
region: process.env.ENSO_CLOUD_COGNITO_REGION,
|
||||
redirectSignIn: signInOutRedirect,
|
||||
redirectSignOut: signInOutRedirect,
|
||||
scope: ['email', 'openid', 'aws.cognito.signin.user.admin'],
|
||||
responseType: 'code',
|
||||
urlOpener,
|
||||
saveAccessToken,
|
||||
}
|
||||
}
|
||||
|
||||
/** Set the callback that will be invoked when a deep link to the application is opened.
|
||||
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
@ -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]
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
if (method === 'dialog') {
|
||||
dialogContext?.close()
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
onError: onSubmitFailed,
|
||||
onSuccess: onSubmitSuccess,
|
||||
onSettled: onSubmitted,
|
||||
})
|
||||
|
||||
// There is no way to avoid type casting here
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax,@typescript-eslint/no-unsafe-argument
|
||||
const formOnSubmit = innerForm.handleSubmit(formMutation.mutateAsync as any)
|
||||
|
||||
const { isOffline } = offlineHooks.useOffline()
|
||||
|
||||
offlineHooks.useOfflineChange(
|
||||
(offline) => {
|
||||
if (offline) {
|
||||
innerForm.setError('root.offline', { message: getText('unavailableOffline') })
|
||||
} else {
|
||||
innerForm.clearErrors('root.offline')
|
||||
}
|
||||
},
|
||||
{ isDisabled: canSubmitOffline },
|
||||
)
|
||||
|
||||
const testId = props['testId'] ?? props['data-testid'] ?? 'form'
|
||||
|
||||
const innerForm = components.useForm<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>
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
id={id}
|
||||
ref={ref}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const values = components.useWatch({ control: innerForm.control })
|
||||
|
||||
if (isOffline && !canSubmitOffline) {
|
||||
setError('root.offline', { message: getText('unavailableOffline') })
|
||||
} else {
|
||||
void formOnSubmit(event)
|
||||
}
|
||||
}}
|
||||
className={base}
|
||||
style={typeof style === 'function' ? style(innerForm) : style}
|
||||
noValidate
|
||||
data-testid={testId}
|
||||
{...formProps}
|
||||
>
|
||||
<aria.FormValidationContext.Provider value={errors}>
|
||||
<reactHookForm.FormProvider {...innerForm}>
|
||||
{typeof children === 'function' ?
|
||||
children({ ...innerForm, form: innerForm })
|
||||
: children}
|
||||
</reactHookForm.FormProvider>
|
||||
</aria.FormValidationContext.Provider>
|
||||
</form>
|
||||
</>
|
||||
return (
|
||||
<form
|
||||
{...formProps}
|
||||
id={id}
|
||||
ref={ref}
|
||||
className={base}
|
||||
style={typeof style === 'function' ? style(innerForm) : style}
|
||||
noValidate
|
||||
data-testid={testId}
|
||||
onSubmit={innerForm.submit}
|
||||
>
|
||||
<aria.FormValidationContext.Provider value={errors}>
|
||||
<components.FormProvider form={innerForm}>
|
||||
{typeof children === 'function' ?
|
||||
children({ ...innerForm, form: innerForm, values })
|
||||
: children}
|
||||
</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
|
||||
|
@ -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>
|
||||
|
@ -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()
|
||||
|
@ -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!
|
||||
}
|
||||
}
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
> {
|
||||
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]
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -3,13 +3,18 @@
|
||||
*
|
||||
* A hook that returns a form instance.
|
||||
*/
|
||||
import * as sentry from '@sentry/react'
|
||||
import * as React from 'react'
|
||||
|
||||
import * as zodResolver from '@hookform/resolvers/zod'
|
||||
import * as reactHookForm from 'react-hook-form'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import { useOffline, useOfflineChange } from '#/hooks/offlineHooks'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import * as errorUtils from '#/utilities/error'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import * as schemaModule from './schema'
|
||||
import type * as types from './types'
|
||||
|
||||
@ -39,64 +44,73 @@ function mapValueOnEvent(value: unknown) {
|
||||
* But be careful, You should not switch between the two types of arguments.
|
||||
* Otherwise you'll be fired
|
||||
*/
|
||||
export function useForm<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, {
|
||||
async: true,
|
||||
errorMap: (issue) => {
|
||||
switch (issue.code) {
|
||||
case 'too_small':
|
||||
if (issue.minimum === 0) {
|
||||
return {
|
||||
message: getText('arbitraryFieldRequired'),
|
||||
resolver: zodResolver.zodResolver(
|
||||
computedSchema,
|
||||
{
|
||||
async: true,
|
||||
errorMap: (issue) => {
|
||||
switch (issue.code) {
|
||||
case 'too_small':
|
||||
if (issue.minimum === 0) {
|
||||
return {
|
||||
message: getText('arbitraryFieldRequired'),
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
message: getText('arbitraryFieldTooSmall', issue.minimum.toString()),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
case 'too_big':
|
||||
return {
|
||||
message: getText('arbitraryFieldTooSmall', issue.minimum.toString()),
|
||||
message: getText('arbitraryFieldTooLarge', issue.maximum.toString()),
|
||||
}
|
||||
}
|
||||
case 'too_big':
|
||||
return {
|
||||
message: getText('arbitraryFieldTooLarge', issue.maximum.toString()),
|
||||
}
|
||||
case 'invalid_type':
|
||||
return {
|
||||
message: getText('arbitraryFieldInvalid'),
|
||||
}
|
||||
default:
|
||||
return {
|
||||
message: getText('arbitraryFieldInvalid'),
|
||||
}
|
||||
}
|
||||
case 'invalid_type':
|
||||
return {
|
||||
message: getText('arbitraryFieldInvalid'),
|
||||
}
|
||||
default:
|
||||
return {
|
||||
message: getText('arbitraryFieldInvalid'),
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ mode: 'async' },
|
||||
),
|
||||
})
|
||||
|
||||
const register: types.UseFormRegister<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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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 ===
|
||||
|
@ -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]
|
||||
}
|
||||
|
@ -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) => {
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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) => {
|
||||
|
@ -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}. */
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Barrel export file for OTPInput
|
||||
*/
|
||||
export * from './OTPInput'
|
@ -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. */
|
||||
|
@ -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'
|
||||
|
@ -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']}
|
||||
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 (
|
||||
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}
|
||||
>
|
||||
<div
|
||||
className={classes.base()}
|
||||
onClick={() => privateInputRef.current?.focus({ preventScroll: true })}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</Form.Field>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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,
|
||||
|
199
app/dashboard/src/components/Stepper/Step.tsx
Normal file
199
app/dashboard/src/components/Stepper/Step.tsx
Normal 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>
|
||||
)
|
||||
}
|
42
app/dashboard/src/components/Stepper/StepContent.tsx
Normal file
42
app/dashboard/src/components/Stepper/StepContent.tsx
Normal 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
|
||||
}
|
||||
}
|
@ -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
|
||||
|
39
app/dashboard/src/components/Stepper/types.ts
Normal file
39
app/dashboard/src/components/Stepper/types.ts
Normal 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
|
||||
}
|
@ -81,19 +81,28 @@ export function useStepperState(props: StepperStateProps): UseStepperStateResult
|
||||
|
||||
const setCurrentStep = eventCallbackHooks.useEventCallback(
|
||||
(step: number | ((current: number) => number)) => {
|
||||
privateSetCurrentStep((current) => {
|
||||
const nextStep = typeof step === 'function' ? step(current.current) : step
|
||||
const direction = nextStep > current.current ? 'forward' : 'back'
|
||||
React.startTransition(() => {
|
||||
privateSetCurrentStep((current) => {
|
||||
const nextStep = typeof step === 'function' ? step(current.current) : step
|
||||
const direction = nextStep > current.current ? 'forward' : 'back'
|
||||
|
||||
if (nextStep < 0) {
|
||||
return { current: 0, direction: 'back-none' }
|
||||
} else if (nextStep > steps - 1) {
|
||||
onCompletedStableCallback()
|
||||
return { current: steps - 1, direction: 'forward-none' }
|
||||
} else {
|
||||
onStepChangeStableCallback(nextStep, direction)
|
||||
return { current: nextStep, direction }
|
||||
}
|
||||
if (nextStep < 0) {
|
||||
return {
|
||||
current: 0,
|
||||
direction: 'back-none',
|
||||
}
|
||||
} else if (nextStep > steps - 1) {
|
||||
onCompletedStableCallback()
|
||||
return {
|
||||
current: steps - 1,
|
||||
direction: 'forward-none',
|
||||
}
|
||||
} else {
|
||||
onStepChangeStableCallback(nextStep, direction)
|
||||
|
||||
return { current: nextStep, direction }
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
|
@ -331,7 +331,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
|
||||
break
|
||||
}
|
||||
default: {
|
||||
return
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -549,18 +549,18 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
|
||||
}
|
||||
case AssetEventType.deleteLabel: {
|
||||
setAsset((oldAsset) => {
|
||||
// The IIFE is required to prevent TypeScript from narrowing this value.
|
||||
let found = (() => false)()
|
||||
const labels =
|
||||
oldAsset.labels?.filter((label) => {
|
||||
if (label === event.labelName) {
|
||||
found = true
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}) ?? null
|
||||
return found ? object.merge(oldAsset, { labels }) : oldAsset
|
||||
const oldLabels = oldAsset.labels ?? []
|
||||
const labels: backendModule.LabelName[] = []
|
||||
|
||||
for (const label of oldLabels) {
|
||||
if (label !== event.labelName) {
|
||||
labels.push(label)
|
||||
}
|
||||
}
|
||||
|
||||
return oldLabels.length !== labels.length ?
|
||||
object.merge(oldAsset, { labels })
|
||||
: oldAsset
|
||||
})
|
||||
break
|
||||
}
|
||||
|
@ -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 }) => {
|
||||
|
8
app/dashboard/src/globals.d.ts
vendored
8
app/dashboard/src/globals.d.ts
vendored
@ -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.
|
||||
|
@ -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,15 +232,17 @@ export default function Settings() {
|
||||
setTab={setTab}
|
||||
/>
|
||||
</aside>
|
||||
<SettingsTab
|
||||
context={context}
|
||||
data={data}
|
||||
onInteracted={() => {
|
||||
if (effectiveTab !== tab) {
|
||||
setTab(effectiveTab)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<main className="flex flex-1 flex-col overflow-y-auto pb-12 pl-1 scrollbar-gutter-stable">
|
||||
<SettingsTab
|
||||
context={context}
|
||||
data={data}
|
||||
onInteracted={() => {
|
||||
if (effectiveTab !== tab) {
|
||||
setTab(effectiveTab)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -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],
|
||||
)}
|
||||
>
|
||||
|
252
app/dashboard/src/layouts/Settings/SetupTwoFaForm.tsx
Normal file
252
app/dashboard/src/layouts/Settings/SetupTwoFaForm.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
@ -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,
|
||||
|
@ -1,18 +1,8 @@
|
||||
/** @file Modal for confirming delete of any type of asset. */
|
||||
import * as z from 'zod'
|
||||
|
||||
import { Button, ButtonGroup, Dialog, Form, Input, Password } from '#/components/AriaComponents'
|
||||
import { ButtonGroup, Dialog, Form, Input, Password } from '#/components/AriaComponents'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import type { SecretId } from '#/services/Backend'
|
||||
|
||||
/** Create the schema for this form. */
|
||||
function createUpsertSecretSchema() {
|
||||
return z.object({
|
||||
name: z.string().min(1),
|
||||
value: z.string(),
|
||||
})
|
||||
}
|
||||
|
||||
// =========================
|
||||
// === UpsertSecretModal ===
|
||||
// =========================
|
||||
@ -34,49 +24,43 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) {
|
||||
|
||||
return (
|
||||
<Dialog title={isCreatingSecret ? getText('newSecret') : getText('editSecret')}>
|
||||
{({ close }) => (
|
||||
<Form
|
||||
data-testid="upsert-secret-modal"
|
||||
method="dialog"
|
||||
schema={createUpsertSecretSchema()}
|
||||
onSubmit={async ({ name, value }) => {
|
||||
await doCreate(name, value)
|
||||
}}
|
||||
>
|
||||
{({ form }) => (
|
||||
<>
|
||||
<Input
|
||||
form={form}
|
||||
name="name"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
disabled={!isNameEditable}
|
||||
label={getText('name')}
|
||||
placeholder={getText('secretNamePlaceholder')}
|
||||
defaultValue={nameRaw ?? undefined}
|
||||
/>
|
||||
<Password
|
||||
form={form}
|
||||
name="value"
|
||||
autoFocus={!isNameEditable}
|
||||
autoComplete="off"
|
||||
label={getText('value')}
|
||||
placeholder={
|
||||
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>
|
||||
)}
|
||||
<Form
|
||||
testId="upsert-secret-modal"
|
||||
method="dialog"
|
||||
schema={(z) => z.object({ name: z.string().min(1), value: z.string() })}
|
||||
defaultValues={{ name: nameRaw ?? '', value: '' }}
|
||||
onSubmit={async ({ name, value }) => {
|
||||
await doCreate(name, value)
|
||||
}}
|
||||
>
|
||||
{({ form }) => (
|
||||
<>
|
||||
<Input
|
||||
form={form}
|
||||
name="name"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
disabled={!isNameEditable}
|
||||
label={getText('name')}
|
||||
placeholder={getText('secretNamePlaceholder')}
|
||||
/>
|
||||
<Password
|
||||
form={form}
|
||||
name="value"
|
||||
autoFocus={!isNameEditable}
|
||||
autoComplete="off"
|
||||
label={getText('value')}
|
||||
placeholder={
|
||||
isNameEditable ? getText('secretValuePlaceholder') : getText('secretValueHidden')
|
||||
}
|
||||
/>
|
||||
<ButtonGroup>
|
||||
<Form.Submit>{isCreatingSecret ? getText('create') : getText('update')}</Form.Submit>
|
||||
<Form.Submit formnovalidate />
|
||||
</ButtonGroup>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
@ -3,4 +3,5 @@
|
||||
*
|
||||
* Barrel file for payments api
|
||||
*/
|
||||
export * from './createPaymentMethod'
|
||||
export * from './useSubscriptionPrice'
|
||||
|
@ -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)
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const formInstance = ariaComponents.Form.useForm(
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(form as ariaComponents.FormInstance<typeof ADD_PAYMENT_METHOD_FORM_SCHEMA> | undefined) ?? {
|
||||
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) {
|
||||
|
@ -27,7 +27,7 @@ import type { Plan } from '#/services/Backend'
|
||||
|
||||
import { twMerge } from '#/utilities/tailwindMerge'
|
||||
|
||||
import { createSubscriptionPriceQuery } from '../../../api'
|
||||
import { createSubscriptionPriceQuery, useCreatePaymentMethodMutation } from '../../../api'
|
||||
import {
|
||||
MAX_SEATS_BY_PLAN,
|
||||
PRICE_BY_PLAN,
|
||||
@ -77,6 +77,8 @@ export function PlanSelectorDialog(props: PlanSelectorDialogProps) {
|
||||
const price = PRICE_BY_PLAN[plan]
|
||||
const maxSeats = MAX_SEATS_BY_PLAN[plan]
|
||||
|
||||
const createPaymentMethodMutation = useCreatePaymentMethodMutation()
|
||||
|
||||
const form = Form.useForm({
|
||||
schema: (z) =>
|
||||
ADD_PAYMENT_METHOD_FORM_SCHEMA.extend({
|
||||
@ -95,10 +97,18 @@ export function PlanSelectorDialog(props: PlanSelectorDialogProps) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
defaultValues: { seats: 1, period: 12, agree: [] },
|
||||
mode: 'onChange',
|
||||
onSubmit: async ({ cardElement, stripeInstance, seats, period }) => {
|
||||
const res = await createPaymentMethodMutation.mutateAsync({
|
||||
cardElement,
|
||||
stripeInstance,
|
||||
})
|
||||
|
||||
return onSubmit?.(res.paymentMethod.id, seats, period)
|
||||
},
|
||||
})
|
||||
|
||||
const seats = form.watch('seats')
|
||||
const period = form.watch('period')
|
||||
const seats = Form.useWatch({ name: 'seats', control: form.control })
|
||||
const period = Form.useWatch({ name: 'period', control: form.control })
|
||||
|
||||
const formatter = React.useMemo(
|
||||
() => new Intl.NumberFormat(locale, { style: 'currency', currency: PRICE_CURRENCY }),
|
||||
|
@ -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) => (
|
||||
|
@ -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,85 +92,126 @@ export default function Login() {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-auth">
|
||||
<Button
|
||||
size="large"
|
||||
variant="outline"
|
||||
icon={<img src={GoogleIcon} />}
|
||||
onPress={async () => {
|
||||
await signInWithGoogle()
|
||||
}}
|
||||
>
|
||||
{getText('signUpOrLoginWithGoogle')}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="outline"
|
||||
icon={<img src={GithubIcon} />}
|
||||
onPress={async () => {
|
||||
await signInWithGitHub()
|
||||
}}
|
||||
>
|
||||
{getText('signUpOrLoginWithGitHub')}
|
||||
</Button>
|
||||
</div>
|
||||
<Stepper state={stepperState} renderStep={() => null}>
|
||||
<Stepper.StepContent index={0}>
|
||||
{() => (
|
||||
<div className="flex flex-col gap-auth">
|
||||
<Button
|
||||
size="large"
|
||||
variant="outline"
|
||||
icon={GoogleIcon}
|
||||
onPress={async () => {
|
||||
await signInWithGoogle()
|
||||
}}
|
||||
>
|
||||
{getText('signUpOrLoginWithGoogle')}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="outline"
|
||||
icon={GithubIcon}
|
||||
onPress={async () => {
|
||||
await signInWithGitHub()
|
||||
}}
|
||||
>
|
||||
{getText('signUpOrLoginWithGitHub')}
|
||||
</Button>
|
||||
|
||||
<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)}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
required
|
||||
data-testid="email-input"
|
||||
name="email"
|
||||
label={getText('email')}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
icon={AtIcon}
|
||||
placeholder={getText('emailPlaceholder')}
|
||||
onChange={(event) => {
|
||||
setEmailInput(event.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
<Form form={form} gap="medium">
|
||||
<Input
|
||||
autoFocus
|
||||
required
|
||||
data-testid="email-input"
|
||||
name="email"
|
||||
label={getText('email')}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
icon={AtIcon}
|
||||
placeholder={getText('emailPlaceholder')}
|
||||
onChange={(event) => {
|
||||
setEmailInput(event.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
<Password
|
||||
required
|
||||
data-testid="password-input"
|
||||
name="password"
|
||||
label={getText('password')}
|
||||
autoComplete="current-password"
|
||||
icon={LockIcon}
|
||||
placeholder={getText('passwordPlaceholder')}
|
||||
/>
|
||||
<div className="flex w-full flex-col">
|
||||
<Password
|
||||
required
|
||||
data-testid="password-input"
|
||||
name="password"
|
||||
label={getText('password')}
|
||||
autoComplete="current-password"
|
||||
icon={LockIcon}
|
||||
placeholder={getText('passwordPlaceholder')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
href={`${FORGOT_PASSWORD_PATH}?${new URLSearchParams({ email: emailInput }).toString()}`}
|
||||
size="small"
|
||||
className="self-end"
|
||||
>
|
||||
{getText('forgotYourPassword')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="link"
|
||||
href={`${FORGOT_PASSWORD_PATH}?${new URLSearchParams({ email: emailInput }).toString()}`}
|
||||
size="small"
|
||||
className="self-end"
|
||||
>
|
||||
{getText('forgotYourPassword')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form.Submit size="large" icon={ArrowRightIcon} iconPosition="end" fullWidth>
|
||||
{getText('login')}
|
||||
</Form.Submit>
|
||||
<Form.Submit size="large" icon={ArrowRightIcon} iconPosition="end" fullWidth>
|
||||
{getText('login')}
|
||||
</Form.Submit>
|
||||
|
||||
<Form.FormError />
|
||||
</Form>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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,23 +201,19 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
}, [])
|
||||
|
||||
const performLogout = async () => {
|
||||
if (cognito != null) {
|
||||
await cognito.signOut()
|
||||
await cognito.signOut()
|
||||
|
||||
const parentDomain = location.hostname.replace(/^[^.]*\./, '')
|
||||
document.cookie = `logged_in=no;max-age=0;domain=${parentDomain}`
|
||||
gtagEvent('cloud_sign_out')
|
||||
cognito.saveAccessToken(null)
|
||||
localStorage.clearUserSpecificEntries()
|
||||
sentry.setUser(null)
|
||||
const parentDomain = location.hostname.replace(/^[^.]*\./, '')
|
||||
document.cookie = `logged_in=no;max-age=0;domain=${parentDomain}`
|
||||
gtagEvent('cloud_sign_out')
|
||||
cognito.saveAccessToken(null)
|
||||
localStorage.clearUserSpecificEntries()
|
||||
sentry.setUser(null)
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: sessionQueryKey })
|
||||
await queryClient.clearWithPersister()
|
||||
await queryClient.invalidateQueries({ queryKey: sessionQueryKey })
|
||||
await queryClient.clearWithPersister()
|
||||
|
||||
return Promise.resolve()
|
||||
} else {
|
||||
return Promise.reject()
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const logoutMutation = reactQuery.useMutation({
|
||||
@ -289,100 +292,91 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
|
||||
const signUp = useEventCallback(
|
||||
async (username: string, password: string, organizationId: string | null) => {
|
||||
if (cognito != null) {
|
||||
gtagEvent('cloud_sign_up')
|
||||
const result = await cognito.signUp(username, password, organizationId)
|
||||
gtagEvent('cloud_sign_up')
|
||||
const result = await cognito.signUp(username, password, organizationId)
|
||||
|
||||
if (result.err) {
|
||||
throw new Error(result.val.message)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
if (result.err) {
|
||||
throw new Error(result.val.message)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const confirmSignUp = useEventCallback(async (email: string, code: string) => {
|
||||
if (cognito == null) {
|
||||
throw new Error(getText('confirmSignUpError'))
|
||||
} else {
|
||||
gtagEvent('cloud_confirm_sign_up')
|
||||
const result = await cognito.confirmSignUp(email, code)
|
||||
gtagEvent('cloud_confirm_sign_up')
|
||||
const result = await cognito.confirmSignUp(email, code)
|
||||
|
||||
if (result.err) {
|
||||
switch (result.val.type) {
|
||||
case cognitoModule.CognitoErrorType.userAlreadyConfirmed:
|
||||
case cognitoModule.CognitoErrorType.userNotFound: {
|
||||
return
|
||||
}
|
||||
default: {
|
||||
throw new errorModule.UnreachableCaseError(result.val.type)
|
||||
}
|
||||
if (result.err) {
|
||||
switch (result.val.type) {
|
||||
case cognitoModule.CognitoErrorType.userAlreadyConfirmed:
|
||||
case cognitoModule.CognitoErrorType.userNotFound: {
|
||||
return
|
||||
}
|
||||
default: {
|
||||
throw new errorModule.UnreachableCaseError(result.val.type)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const signInWithPassword = useEventCallback(async (email: string, password: string) => {
|
||||
if (cognito != null) {
|
||||
gtagEvent('cloud_sign_in', { provider: 'Email' })
|
||||
const result = await cognito.signInWithPassword(email, password)
|
||||
if (result.ok) {
|
||||
void queryClient.invalidateQueries({ queryKey: sessionQueryKey })
|
||||
navigate(appUtils.DASHBOARD_PATH)
|
||||
return
|
||||
} else {
|
||||
throw new Error(result.val.message)
|
||||
gtagEvent('cloud_sign_in', { provider: 'Email' })
|
||||
|
||||
const result = await cognito.signInWithPassword(email, password)
|
||||
|
||||
if (result.ok) {
|
||||
const user = result.unwrap()
|
||||
|
||||
const challenge = user.challengeName ?? 'NO_CHALLENGE'
|
||||
|
||||
if (['SMS_MFA', 'SOFTWARE_TOKEN_MFA'].includes(challenge)) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return { challenge, user } as const
|
||||
}
|
||||
|
||||
return queryClient
|
||||
.invalidateQueries({ queryKey: sessionQueryKey })
|
||||
.then(() => ({ challenge, user }) as const)
|
||||
} else {
|
||||
throw new Error(result.val.message)
|
||||
}
|
||||
})
|
||||
|
||||
const setUsername = useEventCallback(async (username: string) => {
|
||||
if (cognito == null) {
|
||||
return false
|
||||
gtagEvent('cloud_user_created')
|
||||
|
||||
if (userData?.type === UserSessionType.full) {
|
||||
await updateUserMutation.mutateAsync({ username: username })
|
||||
} else {
|
||||
gtagEvent('cloud_user_created')
|
||||
const organizationId = await cognito.organizationId()
|
||||
const email = session?.email ?? ''
|
||||
|
||||
if (userData?.type === UserSessionType.full) {
|
||||
await updateUserMutation.mutateAsync({ username: username })
|
||||
} else {
|
||||
const organizationId = await cognito.organizationId()
|
||||
const email = session?.email ?? ''
|
||||
|
||||
await createUserMutation.mutateAsync({
|
||||
userName: username,
|
||||
userEmail: backendModule.EmailAddress(email),
|
||||
organizationId:
|
||||
organizationId != null ? backendModule.OrganizationId(organizationId) : null,
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
await createUserMutation.mutateAsync({
|
||||
userName: username,
|
||||
userEmail: backendModule.EmailAddress(email),
|
||||
organizationId:
|
||||
organizationId != null ? backendModule.OrganizationId(organizationId) : null,
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
const deleteUser = useEventCallback(async () => {
|
||||
if (cognito == null) {
|
||||
return false
|
||||
} else {
|
||||
await deleteUserMutation.mutateAsync()
|
||||
await deleteUserMutation.mutateAsync()
|
||||
|
||||
toastSuccess(getText('deleteUserSuccess'))
|
||||
toastSuccess(getText('deleteUserSuccess'))
|
||||
|
||||
return true
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const restoreUser = useEventCallback(async () => {
|
||||
if (cognito == null) {
|
||||
return false
|
||||
} else {
|
||||
await restoreUserMutation.mutateAsync()
|
||||
await restoreUserMutation.mutateAsync()
|
||||
|
||||
toastSuccess(getText('restoreUserSuccess'))
|
||||
toastSuccess(getText('restoreUserSuccess'))
|
||||
|
||||
return true
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
/**
|
||||
@ -402,41 +396,36 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
})
|
||||
|
||||
const forgotPassword = useEventCallback(async (email: string) => {
|
||||
if (cognito != null) {
|
||||
const result = await cognito.forgotPassword(email)
|
||||
if (result.ok) {
|
||||
navigate(appUtils.LOGIN_PATH)
|
||||
return
|
||||
} else {
|
||||
throw new Error(result.val.message)
|
||||
}
|
||||
const result = await cognito.forgotPassword(email)
|
||||
if (result.ok) {
|
||||
navigate(appUtils.LOGIN_PATH)
|
||||
return
|
||||
} else {
|
||||
throw new Error(result.val.message)
|
||||
}
|
||||
})
|
||||
|
||||
const resetPassword = useEventCallback(async (email: string, code: string, password: string) => {
|
||||
if (cognito != null) {
|
||||
const result = await cognito.forgotPasswordSubmit(email, code, password)
|
||||
if (result.ok) {
|
||||
navigate(appUtils.LOGIN_PATH)
|
||||
return
|
||||
} else {
|
||||
throw new Error(result.val.message)
|
||||
}
|
||||
const result = await cognito.forgotPasswordSubmit(email, code, password)
|
||||
|
||||
if (result.ok) {
|
||||
navigate(appUtils.LOGIN_PATH)
|
||||
return
|
||||
} else {
|
||||
throw new Error(result.val.message)
|
||||
}
|
||||
})
|
||||
|
||||
const changePassword = useEventCallback(async (oldPassword: string, newPassword: string) => {
|
||||
if (cognito == null) {
|
||||
return false
|
||||
const result = await cognito.changePassword(oldPassword, newPassword)
|
||||
|
||||
if (result.ok) {
|
||||
toastSuccess(getText('changePasswordSuccess'))
|
||||
} else {
|
||||
const result = await cognito.changePassword(oldPassword, newPassword)
|
||||
if (result.ok) {
|
||||
toastSuccess(getText('changePasswordSuccess'))
|
||||
} else {
|
||||
toastError(result.val.message)
|
||||
}
|
||||
return result.ok
|
||||
toastError(result.val.message)
|
||||
}
|
||||
|
||||
return result.ok
|
||||
})
|
||||
|
||||
const isUserMarkedForDeletion = useEventCallback(
|
||||
@ -503,33 +492,28 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
isUserSoftDeleted,
|
||||
restoreUser,
|
||||
deleteUser,
|
||||
cognito,
|
||||
signInWithGoogle: useEventCallback(() => {
|
||||
if (cognito == null) {
|
||||
return Promise.resolve(false)
|
||||
} else {
|
||||
gtagEvent('cloud_sign_in', { provider: 'Google' })
|
||||
return cognito
|
||||
.signInWithGoogle()
|
||||
.then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey }))
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
}
|
||||
gtagEvent('cloud_sign_in', { provider: 'Google' })
|
||||
|
||||
return cognito
|
||||
.signInWithGoogle()
|
||||
.then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey }))
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
}),
|
||||
signInWithGitHub: useEventCallback(() => {
|
||||
if (cognito == null) {
|
||||
return Promise.resolve(false)
|
||||
} else {
|
||||
gtagEvent('cloud_sign_in', { provider: 'GitHub' })
|
||||
return cognito
|
||||
.signInWithGitHub()
|
||||
.then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey }))
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
}
|
||||
gtagEvent('cloud_sign_in', { provider: 'GitHub' })
|
||||
|
||||
return cognito
|
||||
.signInWithGitHub()
|
||||
.then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey }))
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
}),
|
||||
signInWithPassword,
|
||||
forgotPassword,
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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': {
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user