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": {
|
"dependencies": {
|
||||||
"@aws-amplify/auth": "5.6.5",
|
"@aws-amplify/auth": "5.6.5",
|
||||||
|
"amazon-cognito-identity-js": "6.3.6",
|
||||||
"@aws-amplify/core": "5.8.5",
|
"@aws-amplify/core": "5.8.5",
|
||||||
"@hookform/resolvers": "^3.4.0",
|
"@hookform/resolvers": "^3.4.0",
|
||||||
"@internationalized/date": "^3.5.5",
|
"@internationalized/date": "^3.5.5",
|
||||||
@ -60,7 +61,9 @@
|
|||||||
"ts-results": "^3.3.0",
|
"ts-results": "^3.3.0",
|
||||||
"validator": "^13.12.0",
|
"validator": "^13.12.0",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zustand": "^4.5.4"
|
"zustand": "^4.5.4",
|
||||||
|
"input-otp": "1.2.4",
|
||||||
|
"qrcode.react": "3.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fast-check/vitest": "^0.0.8",
|
"@fast-check/vitest": "^0.0.8",
|
||||||
|
@ -346,6 +346,7 @@ function AppRouter(props: AppRouterProps) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}, [localStorage, inputBindingsRaw])
|
}, [localStorage, inputBindingsRaw])
|
||||||
|
|
||||||
const mainPageUrl = getMainPageUrl()
|
const mainPageUrl = getMainPageUrl()
|
||||||
|
|
||||||
// Subscribe to `localStorage` updates to trigger a rerender when the terms of service
|
// Subscribe to `localStorage` updates to trigger a rerender when the terms of service
|
||||||
@ -354,10 +355,10 @@ function AppRouter(props: AppRouterProps) {
|
|||||||
localStorageProvider.useLocalStorageState('privacyPolicy')
|
localStorageProvider.useLocalStorageState('privacyPolicy')
|
||||||
|
|
||||||
const authService = useInitAuthService(props)
|
const authService = useInitAuthService(props)
|
||||||
const userSession = authService?.cognito.userSession.bind(authService.cognito) ?? null
|
|
||||||
const refreshUserSession =
|
const userSession = authService.cognito.userSession.bind(authService.cognito)
|
||||||
authService?.cognito.refreshUserSession.bind(authService.cognito) ?? null
|
const refreshUserSession = authService.cognito.refreshUserSession.bind(authService.cognito)
|
||||||
const registerAuthEventListener = authService?.registerAuthEventListener ?? null
|
const registerAuthEventListener = authService.registerAuthEventListener
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if ('menuApi' in window) {
|
if ('menuApi' in window) {
|
||||||
@ -490,7 +491,7 @@ function AppRouter(props: AppRouterProps) {
|
|||||||
<FeatureFlagsProvider>
|
<FeatureFlagsProvider>
|
||||||
<RouterProvider navigate={navigate}>
|
<RouterProvider navigate={navigate}>
|
||||||
<SessionProvider
|
<SessionProvider
|
||||||
saveAccessToken={authService?.cognito.saveAccessToken.bind(authService.cognito) ?? null}
|
saveAccessToken={authService.cognito.saveAccessToken.bind(authService.cognito)}
|
||||||
mainPageUrl={mainPageUrl}
|
mainPageUrl={mainPageUrl}
|
||||||
userSession={userSession}
|
userSession={userSession}
|
||||||
registerAuthEventListener={registerAuthEventListener}
|
registerAuthEventListener={registerAuthEventListener}
|
||||||
|
@ -14,6 +14,7 @@ export const LOGIN_PATH = '/login'
|
|||||||
export const REGISTRATION_PATH = '/registration'
|
export const REGISTRATION_PATH = '/registration'
|
||||||
/** Path to the confirm registration page. */
|
/** Path to the confirm registration page. */
|
||||||
export const CONFIRM_REGISTRATION_PATH = '/confirmation'
|
export const CONFIRM_REGISTRATION_PATH = '/confirmation'
|
||||||
|
|
||||||
export const SETUP_PATH = '/setup'
|
export const SETUP_PATH = '/setup'
|
||||||
/** Path to the page in which a user can restore their account after it has been
|
/** Path to the page in which a user can restore their account after it has been
|
||||||
* marked for deletion. */
|
* marked for deletion. */
|
||||||
|
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() {
|
async refreshUserSession() {
|
||||||
return Promise.resolve(results.Ok(null))
|
return Promise.resolve(results.Ok(null))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns MFA preference for the current user.
|
||||||
|
*/
|
||||||
|
async getMFAPreference() {
|
||||||
|
return Promise.resolve(results.Ok('NOMFA'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================
|
// ===================
|
||||||
|
@ -30,7 +30,7 @@
|
|||||||
* `kind` field provides a unique string that can be used to brand the error in place of the
|
* `kind` field provides a unique string that can be used to brand the error in place of the
|
||||||
* `internalCode`, when rethrowing the error. */
|
* `internalCode`, when rethrowing the error. */
|
||||||
import * as amplify from '@aws-amplify/auth'
|
import * as amplify from '@aws-amplify/auth'
|
||||||
import type * as cognito from 'amazon-cognito-identity-js'
|
import * as cognito from 'amazon-cognito-identity-js'
|
||||||
import * as results from 'ts-results'
|
import * as results from 'ts-results'
|
||||||
|
|
||||||
import * as detect from 'enso-common/src/detect'
|
import * as detect from 'enso-common/src/detect'
|
||||||
@ -70,6 +70,18 @@ interface UserAttributes {
|
|||||||
}
|
}
|
||||||
/* eslint-enable @typescript-eslint/naming-convention */
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of multi-factor authentication (MFA) that the user has set up.
|
||||||
|
*/
|
||||||
|
export type MfaType = 'NOMFA' | 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA' | 'TOTP'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of challenge that the user is currently facing after signing in.
|
||||||
|
*
|
||||||
|
* The `NO_CHALLENGE` value is used when the user is not currently facing any challenge.
|
||||||
|
*/
|
||||||
|
export type UserSessionChallenge = cognito.ChallengeName | 'NO_CHALLENGE'
|
||||||
|
|
||||||
/** User information returned from {@link amplify.Auth.currentUserInfo}. */
|
/** User information returned from {@link amplify.Auth.currentUserInfo}. */
|
||||||
interface UserInfo {
|
interface UserInfo {
|
||||||
readonly username: string
|
readonly username: string
|
||||||
@ -214,6 +226,16 @@ export class Cognito {
|
|||||||
return userInfo.attributes['custom:organizationId'] ?? null
|
return userInfo.attributes['custom:organizationId'] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets user email from cognito
|
||||||
|
*/
|
||||||
|
async email() {
|
||||||
|
// This `any` comes from a third-party API and cannot be avoided.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
const userInfo: UserInfo = await amplify.Auth.currentUserInfo()
|
||||||
|
return userInfo.attributes.email
|
||||||
|
}
|
||||||
|
|
||||||
/** Sign up with username and password.
|
/** Sign up with username and password.
|
||||||
*
|
*
|
||||||
* Does not rely on federated identity providers (e.g., Google or GitHub). */
|
* Does not rely on federated identity providers (e.g., Google or GitHub). */
|
||||||
@ -268,7 +290,20 @@ export class Cognito {
|
|||||||
* Does not rely on external identity providers (e.g., Google or GitHub). */
|
* Does not rely on external identity providers (e.g., Google or GitHub). */
|
||||||
async signInWithPassword(username: string, password: string) {
|
async signInWithPassword(username: string, password: string) {
|
||||||
const result = await results.Result.wrapAsync(async () => {
|
const result = await results.Result.wrapAsync(async () => {
|
||||||
await amplify.Auth.signIn(username, password)
|
// This `any` comes from a third-party API and cannot be avoided.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
const maybeUser = await amplify.Auth.signIn(username, password)
|
||||||
|
|
||||||
|
if (maybeUser instanceof cognito.CognitoUser) {
|
||||||
|
return maybeUser
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line no-restricted-properties
|
||||||
|
console.error(
|
||||||
|
'Unknown result from signIn, expected CognitoUser, got ' + typeof maybeUser,
|
||||||
|
JSON.stringify(maybeUser),
|
||||||
|
)
|
||||||
|
throw new Error('Unknown response from the server, please try again later ')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignInWithPasswordErrorOrThrow)
|
return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignInWithPasswordErrorOrThrow)
|
||||||
@ -363,6 +398,112 @@ export class Cognito {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the TOTP setup process. Returns the secret and the URL to scan the QR code.
|
||||||
|
*/
|
||||||
|
async setupTOTP() {
|
||||||
|
const email = await this.email()
|
||||||
|
const cognitoUserResult = await currentAuthenticatedUser()
|
||||||
|
if (cognitoUserResult.ok) {
|
||||||
|
const cognitoUser = cognitoUserResult.unwrap()
|
||||||
|
|
||||||
|
const result = (
|
||||||
|
await results.Result.wrapAsync(() => amplify.Auth.setupTOTP(cognitoUser))
|
||||||
|
).map((data) => {
|
||||||
|
const str = 'otpauth://totp/AWSCognito:' + email + '?secret=' + data + '&issuer=' + 'Enso'
|
||||||
|
|
||||||
|
return { secret: data, url: str } as const
|
||||||
|
})
|
||||||
|
|
||||||
|
return result.mapErr(intoAmplifyErrorOrThrow)
|
||||||
|
} else {
|
||||||
|
return results.Err(cognitoUserResult.val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the TOTP token during the setup process.
|
||||||
|
* Use it *only* during the setup process.
|
||||||
|
*/
|
||||||
|
async verifyTotpSetup(totpToken: string) {
|
||||||
|
const cognitoUserResult = await currentAuthenticatedUser()
|
||||||
|
if (cognitoUserResult.ok) {
|
||||||
|
const cognitoUser = cognitoUserResult.unwrap()
|
||||||
|
const result = await results.Result.wrapAsync(async () => {
|
||||||
|
await amplify.Auth.verifyTotpToken(cognitoUser, totpToken)
|
||||||
|
})
|
||||||
|
return result.mapErr(intoAmplifyErrorOrThrow)
|
||||||
|
} else {
|
||||||
|
return results.Err(cognitoUserResult.val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the user's preferred MFA method.
|
||||||
|
*/
|
||||||
|
async updateMFAPreference(mfaMethod: MfaType) {
|
||||||
|
const cognitoUserResult = await currentAuthenticatedUser()
|
||||||
|
if (cognitoUserResult.ok) {
|
||||||
|
const cognitoUser = cognitoUserResult.unwrap()
|
||||||
|
const result = await results.Result.wrapAsync(
|
||||||
|
async () => await amplify.Auth.setPreferredMFA(cognitoUser, mfaMethod),
|
||||||
|
)
|
||||||
|
return result.mapErr(intoAmplifyErrorOrThrow)
|
||||||
|
} else {
|
||||||
|
return results.Err(cognitoUserResult.val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's preferred MFA method.
|
||||||
|
*/
|
||||||
|
async getMFAPreference() {
|
||||||
|
const cognitoUserResult = await currentAuthenticatedUser()
|
||||||
|
if (cognitoUserResult.ok) {
|
||||||
|
const cognitoUser = cognitoUserResult.unwrap()
|
||||||
|
const result = await results.Result.wrapAsync(async () => {
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
return (await amplify.Auth.getPreferredMFA(cognitoUser)) as MfaType
|
||||||
|
})
|
||||||
|
return result.mapErr(intoAmplifyErrorOrThrow)
|
||||||
|
} else {
|
||||||
|
return results.Err(cognitoUserResult.val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the TOTP token.
|
||||||
|
* Returns the user session if the token is valid.
|
||||||
|
*/
|
||||||
|
async verifyTotpToken(totpToken: string) {
|
||||||
|
const cognitoUserResult = await currentAuthenticatedUser()
|
||||||
|
|
||||||
|
if (cognitoUserResult.ok) {
|
||||||
|
const cognitoUser = cognitoUserResult.unwrap()
|
||||||
|
|
||||||
|
return (
|
||||||
|
await results.Result.wrapAsync(() => amplify.Auth.verifyTotpToken(cognitoUser, totpToken))
|
||||||
|
).mapErr(intoAmplifyErrorOrThrow)
|
||||||
|
} else {
|
||||||
|
return results.Err(cognitoUserResult.val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm the sign in with the MFA token.
|
||||||
|
*/
|
||||||
|
async confirmSignIn(
|
||||||
|
user: amplify.CognitoUser,
|
||||||
|
confirmationCode: string,
|
||||||
|
mfaType: 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA',
|
||||||
|
) {
|
||||||
|
const result = await results.Result.wrapAsync(() =>
|
||||||
|
amplify.Auth.confirmSignIn(user, confirmationCode, mfaType),
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.mapErr(intoAmplifyErrorOrThrow)
|
||||||
|
}
|
||||||
|
|
||||||
/** We want to signal to Amplify to fire a "custom state change" event when the user is
|
/** We want to signal to Amplify to fire a "custom state change" event when the user is
|
||||||
* redirected back to the application after signing in via an external identity provider. This
|
* redirected back to the application after signing in via an external identity provider. This
|
||||||
* is done so we get a chance to fix the location history. The location history is the history
|
* is done so we get a chance to fix the location history. The location history is the history
|
||||||
@ -707,3 +848,4 @@ async function currentAuthenticatedUser() {
|
|||||||
)
|
)
|
||||||
return result.mapErr(intoAmplifyErrorOrThrow)
|
return result.mapErr(intoAmplifyErrorOrThrow)
|
||||||
}
|
}
|
||||||
|
export { CognitoUser } from '@aws-amplify/auth'
|
||||||
|
@ -116,20 +116,17 @@ export interface AuthService {
|
|||||||
*
|
*
|
||||||
* This hook should only be called in a single place, as it performs global configuration of the
|
* This hook should only be called in a single place, as it performs global configuration of the
|
||||||
* Amplify library. */
|
* Amplify library. */
|
||||||
export function useInitAuthService(authConfig: AuthConfig): AuthService | null {
|
export function useInitAuthService(authConfig: AuthConfig): AuthService {
|
||||||
const { supportsDeepLinks } = authConfig
|
const { supportsDeepLinks } = authConfig
|
||||||
|
|
||||||
const logger = useLogger()
|
const logger = useLogger()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return React.useMemo(() => {
|
return React.useMemo(() => {
|
||||||
const amplifyConfig = loadAmplifyConfig(logger, supportsDeepLinks, navigate)
|
const amplifyConfig = loadAmplifyConfig(logger, supportsDeepLinks, navigate)
|
||||||
const cognito =
|
const cognito = new cognitoModule.Cognito(logger, supportsDeepLinks, amplifyConfig)
|
||||||
amplifyConfig == null ? null : (
|
|
||||||
new cognitoModule.Cognito(logger, supportsDeepLinks, amplifyConfig)
|
|
||||||
)
|
|
||||||
|
|
||||||
return cognito == null ? null : (
|
return { cognito, registerAuthEventListener: listen.registerAuthEventListener }
|
||||||
{ cognito, registerAuthEventListener: listen.registerAuthEventListener }
|
|
||||||
)
|
|
||||||
}, [logger, navigate, supportsDeepLinks])
|
}, [logger, navigate, supportsDeepLinks])
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,7 +135,7 @@ function loadAmplifyConfig(
|
|||||||
logger: Logger,
|
logger: Logger,
|
||||||
supportsDeepLinks: boolean,
|
supportsDeepLinks: boolean,
|
||||||
navigate: (url: string) => void,
|
navigate: (url: string) => void,
|
||||||
): AmplifyConfig | null {
|
): AmplifyConfig {
|
||||||
let urlOpener: ((url: string) => void) | null = null
|
let urlOpener: ((url: string) => void) | null = null
|
||||||
let saveAccessToken: ((accessToken: saveAccessTokenModule.AccessToken | null) => void) | null =
|
let saveAccessToken: ((accessToken: saveAccessTokenModule.AccessToken | null) => void) | null =
|
||||||
null
|
null
|
||||||
@ -175,25 +172,18 @@ function loadAmplifyConfig(
|
|||||||
|
|
||||||
/** Load the platform-specific Amplify configuration. */
|
/** Load the platform-specific Amplify configuration. */
|
||||||
const signInOutRedirect = supportsDeepLinks ? `${common.DEEP_LINK_SCHEME}://auth` : redirectUrl
|
const signInOutRedirect = supportsDeepLinks ? `${common.DEEP_LINK_SCHEME}://auth` : redirectUrl
|
||||||
return (
|
return {
|
||||||
process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID == null ||
|
userPoolId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID,
|
||||||
process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID == null ||
|
userPoolWebClientId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID,
|
||||||
process.env.ENSO_CLOUD_COGNITO_DOMAIN == null ||
|
domain: process.env.ENSO_CLOUD_COGNITO_DOMAIN,
|
||||||
process.env.ENSO_CLOUD_COGNITO_REGION == null
|
region: process.env.ENSO_CLOUD_COGNITO_REGION,
|
||||||
) ?
|
redirectSignIn: signInOutRedirect,
|
||||||
null
|
redirectSignOut: signInOutRedirect,
|
||||||
: {
|
scope: ['email', 'openid', 'aws.cognito.signin.user.admin'],
|
||||||
userPoolId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID,
|
responseType: 'code',
|
||||||
userPoolWebClientId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID,
|
urlOpener,
|
||||||
domain: process.env.ENSO_CLOUD_COGNITO_DOMAIN,
|
saveAccessToken,
|
||||||
region: process.env.ENSO_CLOUD_COGNITO_REGION,
|
}
|
||||||
redirectSignIn: signInOutRedirect,
|
|
||||||
redirectSignOut: signInOutRedirect,
|
|
||||||
scope: ['email', 'openid', 'aws.cognito.signin.user.admin'],
|
|
||||||
responseType: 'code',
|
|
||||||
urlOpener,
|
|
||||||
saveAccessToken,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set the callback that will be invoked when a deep link to the application is opened.
|
/** Set the callback that will be invoked when a deep link to the application is opened.
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
/** @file Alert component. */
|
/** @file Alert component. */
|
||||||
import { type ForwardedRef, type HTMLAttributes, type PropsWithChildren } from 'react'
|
import { type ForwardedRef, type HTMLAttributes, type PropsWithChildren } from 'react'
|
||||||
|
|
||||||
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
|
||||||
import { forwardRef } from '#/utilities/react'
|
import { forwardRef } from '#/utilities/react'
|
||||||
import { tv, type VariantProps } from '#/utilities/tailwindVariants'
|
import { tv, type VariantProps } from '#/utilities/tailwindVariants'
|
||||||
|
|
||||||
@ -9,7 +11,7 @@ import { tv, type VariantProps } from '#/utilities/tailwindVariants'
|
|||||||
// =================
|
// =================
|
||||||
|
|
||||||
export const ALERT_STYLES = tv({
|
export const ALERT_STYLES = tv({
|
||||||
base: 'flex flex-col items-stretch',
|
base: 'flex items-stretch gap-2',
|
||||||
variants: {
|
variants: {
|
||||||
fullWidth: { true: 'w-full' },
|
fullWidth: { true: 'w-full' },
|
||||||
variant: {
|
variant: {
|
||||||
@ -37,6 +39,11 @@ export const ALERT_STYLES = tv({
|
|||||||
large: 'px-4 pt-2 pb-2',
|
large: 'px-4 pt-2 pb-2',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
slots: {
|
||||||
|
iconContainer: 'flex items-center justify-center w-6 h-6',
|
||||||
|
children: 'flex flex-col items-stretch',
|
||||||
|
icon: 'flex items-center justify-center w-6 h-6 mr-2',
|
||||||
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
@ -53,7 +60,12 @@ export const ALERT_STYLES = tv({
|
|||||||
export interface AlertProps
|
export interface AlertProps
|
||||||
extends PropsWithChildren,
|
extends PropsWithChildren,
|
||||||
VariantProps<typeof ALERT_STYLES>,
|
VariantProps<typeof ALERT_STYLES>,
|
||||||
HTMLAttributes<HTMLDivElement> {}
|
HTMLAttributes<HTMLDivElement> {
|
||||||
|
/**
|
||||||
|
* The icon to display in the Alert
|
||||||
|
*/
|
||||||
|
readonly icon?: React.ReactElement | string | null | undefined
|
||||||
|
}
|
||||||
|
|
||||||
/** Alert component. */
|
/** Alert component. */
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
@ -61,20 +73,45 @@ export const Alert = forwardRef(function Alert(
|
|||||||
props: AlertProps,
|
props: AlertProps,
|
||||||
ref: ForwardedRef<HTMLDivElement>,
|
ref: ForwardedRef<HTMLDivElement>,
|
||||||
) {
|
) {
|
||||||
const { children, className, variant, size, rounded, fullWidth, ...containerProps } = props
|
const {
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
rounded,
|
||||||
|
fullWidth,
|
||||||
|
icon,
|
||||||
|
variants = ALERT_STYLES,
|
||||||
|
...containerProps
|
||||||
|
} = props
|
||||||
|
|
||||||
if (variant === 'error') {
|
if (variant === 'error') {
|
||||||
containerProps.tabIndex = -1
|
containerProps.tabIndex = -1
|
||||||
containerProps.role = 'alert'
|
containerProps.role = 'alert'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const classes = variants({
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
rounded,
|
||||||
|
fullWidth,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={classes.base({ className })} ref={ref} {...containerProps}>
|
||||||
className={ALERT_STYLES({ variant, size, className, rounded, fullWidth })}
|
{icon != null &&
|
||||||
ref={ref}
|
(() => {
|
||||||
{...containerProps}
|
if (typeof icon === 'string') {
|
||||||
>
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
{children}
|
return (
|
||||||
|
<div className={classes.iconContainer()}>
|
||||||
|
<SvgMask src={icon} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <div className={classes.iconContainer()}>{icon}</div>
|
||||||
|
})()}
|
||||||
|
<div className={classes.children()}>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@ -72,6 +72,7 @@ export const CheckboxGroup = forwardRef(
|
|||||||
return (
|
return (
|
||||||
<Form.Controller
|
<Form.Controller
|
||||||
name={name}
|
name={name}
|
||||||
|
control={formInstance.control}
|
||||||
{...(defaultValueOverride != null && { defaultValue: defaultValueOverride })}
|
{...(defaultValueOverride != null && { defaultValue: defaultValueOverride })}
|
||||||
render={({ field, fieldState }) => {
|
render={({ field, fieldState }) => {
|
||||||
const defaultValue = defaultValueOverride ?? formInstance.control._defaultValues[name]
|
const defaultValue = defaultValueOverride ?? formInstance.control._defaultValues[name]
|
||||||
|
@ -53,7 +53,9 @@ const MODAL_STYLES = tv({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const DIALOG_STYLES = tv({
|
const DIALOG_STYLES = tv({
|
||||||
base: DIALOG_BACKGROUND({ className: 'w-full flex flex-col text-left align-middle shadow-xl' }),
|
base: DIALOG_BACKGROUND({
|
||||||
|
className: 'w-full max-w-full flex flex-col text-left align-middle shadow-xl',
|
||||||
|
}),
|
||||||
variants: {
|
variants: {
|
||||||
type: {
|
type: {
|
||||||
modal: {
|
modal: {
|
||||||
@ -149,7 +151,7 @@ const DIALOG_STYLES = tv({
|
|||||||
* Can be used to display alerts, confirmations, or other content. */
|
* Can be used to display alerts, confirmations, or other content. */
|
||||||
export function Dialog(props: DialogProps) {
|
export function Dialog(props: DialogProps) {
|
||||||
const {
|
const {
|
||||||
children,
|
children: Children,
|
||||||
title,
|
title,
|
||||||
type = 'modal',
|
type = 'modal',
|
||||||
closeButton = 'normal',
|
closeButton = 'normal',
|
||||||
@ -302,7 +304,9 @@ export function Dialog(props: DialogProps) {
|
|||||||
<suspense.Suspense
|
<suspense.Suspense
|
||||||
loaderProps={{ minHeight: type === 'fullscreen' ? 'full' : 'h32' }}
|
loaderProps={{ minHeight: type === 'fullscreen' ? 'full' : 'h32' }}
|
||||||
>
|
>
|
||||||
{typeof children === 'function' ? children(opts) : children}
|
{typeof Children === 'function' ?
|
||||||
|
<Children {...opts} />
|
||||||
|
: Children}
|
||||||
</suspense.Suspense>
|
</suspense.Suspense>
|
||||||
</errorBoundary.ErrorBoundary>
|
</errorBoundary.ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,18 +1,11 @@
|
|||||||
/** @file Form component. */
|
/** @file Form component. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import * as sentry from '@sentry/react'
|
|
||||||
import * as reactQuery from '@tanstack/react-query'
|
|
||||||
import * as reactHookForm from 'react-hook-form'
|
|
||||||
|
|
||||||
import * as offlineHooks from '#/hooks/offlineHooks'
|
|
||||||
|
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
import * as aria from '#/components/aria'
|
import * as aria from '#/components/aria'
|
||||||
|
|
||||||
import * as errorUtils from '#/utilities/error'
|
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||||
|
|
||||||
import { forwardRef } from '#/utilities/react'
|
import { forwardRef } from '#/utilities/react'
|
||||||
import * as dialog from '../Dialog'
|
import * as dialog from '../Dialog'
|
||||||
import * as components from './components'
|
import * as components from './components'
|
||||||
@ -24,27 +17,25 @@ import type * as types from './types'
|
|||||||
* Provides better error handling and form state management and better UX out of the box. */
|
* Provides better error handling and form state management and better UX out of the box. */
|
||||||
// There is no way to avoid type casting here
|
// There is no way to avoid type casting here
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
export const Form = forwardRef(function Form<Schema extends components.TSchema>(
|
export const Form = forwardRef(function Form<
|
||||||
props: types.FormProps<Schema>,
|
Schema extends components.TSchema,
|
||||||
ref: React.Ref<HTMLFormElement>,
|
SubmitResult = void,
|
||||||
) {
|
>(props: types.FormProps<Schema, SubmitResult>, ref: React.Ref<HTMLFormElement>) {
|
||||||
/** Input values for this form. */
|
/** Input values for this form. */
|
||||||
type FieldValues = components.FieldValues<Schema>
|
type FieldValues = components.FieldValues<Schema>
|
||||||
const formId = React.useId()
|
const formId = React.useId()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
children,
|
children,
|
||||||
onSubmit,
|
|
||||||
formRef,
|
formRef,
|
||||||
form,
|
form,
|
||||||
formOptions = {},
|
formOptions,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
onSubmitted = () => {},
|
onSubmitted = () => {},
|
||||||
onSubmitSuccess = () => {},
|
onSubmitSuccess = () => {},
|
||||||
onSubmitFailed = () => {},
|
onSubmitFailed = () => {},
|
||||||
id = formId,
|
id = formId,
|
||||||
testId,
|
|
||||||
schema,
|
schema,
|
||||||
defaultValues,
|
defaultValues,
|
||||||
gap,
|
gap,
|
||||||
@ -55,78 +46,47 @@ export const Form = forwardRef(function Form<Schema extends components.TSchema>(
|
|||||||
|
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
|
|
||||||
if (defaultValues) {
|
|
||||||
formOptions.defaultValues = defaultValues
|
|
||||||
}
|
|
||||||
|
|
||||||
const innerForm = components.useForm(form ?? { shouldFocusError: true, schema, ...formOptions })
|
|
||||||
|
|
||||||
React.useImperativeHandle(formRef, () => innerForm, [innerForm])
|
|
||||||
|
|
||||||
const dialogContext = dialog.useDialogContext()
|
const dialogContext = dialog.useDialogContext()
|
||||||
|
|
||||||
const formMutation = reactQuery.useMutation({
|
const onSubmit = useEventCallback(
|
||||||
// We use template literals to make the mutation key more readable in the devtools
|
async (fieldValues: types.FieldValues<Schema>, formInstance: types.UseFormReturn<Schema>) => {
|
||||||
// This mutation exists only for debug purposes - React Query dev tools record the mutation,
|
// This is SAFE because we're passing the result transparently, and it's typed outside
|
||||||
// the result, and the variables(form fields).
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
// In general, prefer using object literals for the mutation key.
|
const result = (await props.onSubmit?.(fieldValues, formInstance)) as SubmitResult
|
||||||
mutationKey: ['Form submission', `testId: ${testId}`, `id: ${id}`],
|
|
||||||
mutationFn: async (fieldValues: FieldValues) => {
|
|
||||||
try {
|
|
||||||
await onSubmit?.(fieldValues, innerForm)
|
|
||||||
|
|
||||||
if (method === 'dialog') {
|
if (method === 'dialog') {
|
||||||
dialogContext?.close()
|
dialogContext?.close()
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const isJSError = errorUtils.isJSError(error)
|
|
||||||
|
|
||||||
if (isJSError) {
|
|
||||||
sentry.captureException(error, {
|
|
||||||
contexts: { form: { values: fieldValues } },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const message =
|
|
||||||
isJSError ?
|
|
||||||
getText('arbitraryFormErrorMessage')
|
|
||||||
: errorUtils.tryGetMessage(error, getText('arbitraryFormErrorMessage'))
|
|
||||||
|
|
||||||
innerForm.setError('root.submit', { message })
|
|
||||||
|
|
||||||
// We need to throw the error to make the mutation fail
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
},
|
},
|
||||||
onError: onSubmitFailed,
|
|
||||||
onSuccess: onSubmitSuccess,
|
|
||||||
onSettled: onSubmitted,
|
|
||||||
})
|
|
||||||
|
|
||||||
// There is no way to avoid type casting here
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax,@typescript-eslint/no-unsafe-argument
|
|
||||||
const formOnSubmit = innerForm.handleSubmit(formMutation.mutateAsync as any)
|
|
||||||
|
|
||||||
const { isOffline } = offlineHooks.useOffline()
|
|
||||||
|
|
||||||
offlineHooks.useOfflineChange(
|
|
||||||
(offline) => {
|
|
||||||
if (offline) {
|
|
||||||
innerForm.setError('root.offline', { message: getText('unavailableOffline') })
|
|
||||||
} else {
|
|
||||||
innerForm.clearErrors('root.offline')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ isDisabled: canSubmitOffline },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const testId = props['testId'] ?? props['data-testid'] ?? 'form'
|
||||||
|
|
||||||
|
const innerForm = components.useForm<Schema, SubmitResult>(
|
||||||
|
form ?? {
|
||||||
|
...formOptions,
|
||||||
|
...(defaultValues ? { defaultValues } : {}),
|
||||||
|
schema,
|
||||||
|
canSubmitOffline,
|
||||||
|
onSubmit,
|
||||||
|
onSubmitFailed,
|
||||||
|
onSubmitSuccess,
|
||||||
|
onSubmitted,
|
||||||
|
shouldFocusError: true,
|
||||||
|
debugName: `Form ${testId} id: ${id}`,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useImperativeHandle(formRef, () => innerForm, [innerForm])
|
||||||
|
|
||||||
const base = styles.FORM_STYLES({
|
const base = styles.FORM_STYLES({
|
||||||
className: typeof className === 'function' ? className(innerForm) : className,
|
className: typeof className === 'function' ? className(innerForm) : className,
|
||||||
gap,
|
gap,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { formState, setError } = innerForm
|
const { formState } = innerForm
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
const errors = Object.fromEntries(
|
const errors = Object.fromEntries(
|
||||||
@ -136,39 +96,30 @@ export const Form = forwardRef(function Form<Schema extends components.TSchema>(
|
|||||||
}),
|
}),
|
||||||
) as Record<keyof FieldValues, string>
|
) as Record<keyof FieldValues, string>
|
||||||
|
|
||||||
return (
|
const values = components.useWatch({ control: innerForm.control })
|
||||||
<>
|
|
||||||
<form
|
|
||||||
id={id}
|
|
||||||
ref={ref}
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
|
|
||||||
if (isOffline && !canSubmitOffline) {
|
return (
|
||||||
setError('root.offline', { message: getText('unavailableOffline') })
|
<form
|
||||||
} else {
|
{...formProps}
|
||||||
void formOnSubmit(event)
|
id={id}
|
||||||
}
|
ref={ref}
|
||||||
}}
|
className={base}
|
||||||
className={base}
|
style={typeof style === 'function' ? style(innerForm) : style}
|
||||||
style={typeof style === 'function' ? style(innerForm) : style}
|
noValidate
|
||||||
noValidate
|
data-testid={testId}
|
||||||
data-testid={testId}
|
onSubmit={innerForm.submit}
|
||||||
{...formProps}
|
>
|
||||||
>
|
<aria.FormValidationContext.Provider value={errors}>
|
||||||
<aria.FormValidationContext.Provider value={errors}>
|
<components.FormProvider form={innerForm}>
|
||||||
<reactHookForm.FormProvider {...innerForm}>
|
{typeof children === 'function' ?
|
||||||
{typeof children === 'function' ?
|
children({ ...innerForm, form: innerForm, values })
|
||||||
children({ ...innerForm, form: innerForm })
|
: children}
|
||||||
: children}
|
</components.FormProvider>
|
||||||
</reactHookForm.FormProvider>
|
</aria.FormValidationContext.Provider>
|
||||||
</aria.FormValidationContext.Provider>
|
</form>
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}) as unknown as (<Schema extends components.TSchema>(
|
}) as unknown as (<Schema extends components.TSchema, SubmitResult = void>(
|
||||||
props: React.RefAttributes<HTMLFormElement> & types.FormProps<Schema>,
|
props: React.RefAttributes<HTMLFormElement> & types.FormProps<Schema, SubmitResult>,
|
||||||
) => React.JSX.Element) & {
|
) => React.JSX.Element) & {
|
||||||
/* eslint-disable @typescript-eslint/naming-convention */
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
schema: typeof components.schema
|
schema: typeof components.schema
|
||||||
@ -183,7 +134,9 @@ export const Form = forwardRef(function Form<Schema extends components.TSchema>(
|
|||||||
FIELD_STYLES: typeof components.FIELD_STYLES
|
FIELD_STYLES: typeof components.FIELD_STYLES
|
||||||
useFormContext: typeof components.useFormContext
|
useFormContext: typeof components.useFormContext
|
||||||
useOptionalFormContext: typeof components.useOptionalFormContext
|
useOptionalFormContext: typeof components.useOptionalFormContext
|
||||||
useWatch: typeof reactHookForm.useWatch
|
useWatch: typeof components.useWatch
|
||||||
|
useFieldRegister: typeof components.useFieldRegister
|
||||||
|
useFieldState: typeof components.useFieldState
|
||||||
/* eslint-enable @typescript-eslint/naming-convention */
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,5 +151,7 @@ Form.useFormContext = components.useFormContext
|
|||||||
Form.useOptionalFormContext = components.useOptionalFormContext
|
Form.useOptionalFormContext = components.useOptionalFormContext
|
||||||
Form.Field = components.Field
|
Form.Field = components.Field
|
||||||
Form.Controller = components.Controller
|
Form.Controller = components.Controller
|
||||||
Form.useWatch = reactHookForm.useWatch
|
Form.useWatch = components.useWatch
|
||||||
Form.FIELD_STYLES = components.FIELD_STYLES
|
Form.FIELD_STYLES = components.FIELD_STYLES
|
||||||
|
Form.useFieldRegister = components.useFieldRegister
|
||||||
|
Form.useFieldState = components.useFieldState
|
||||||
|
@ -11,8 +11,8 @@ import { forwardRef } from '#/utilities/react'
|
|||||||
import { tv, type VariantProps } from '#/utilities/tailwindVariants'
|
import { tv, type VariantProps } from '#/utilities/tailwindVariants'
|
||||||
import type { Path } from 'react-hook-form'
|
import type { Path } from 'react-hook-form'
|
||||||
import * as text from '../../Text'
|
import * as text from '../../Text'
|
||||||
|
import { Form } from '../Form'
|
||||||
import type * as types from './types'
|
import type * as types from './types'
|
||||||
import * as formContext from './useFormContext'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for Field component
|
* Props for Field component
|
||||||
@ -44,6 +44,7 @@ export interface FieldChildrenRenderProps {
|
|||||||
readonly isDirty: boolean
|
readonly isDirty: boolean
|
||||||
readonly isTouched: boolean
|
readonly isTouched: boolean
|
||||||
readonly isValidating: boolean
|
readonly isValidating: boolean
|
||||||
|
readonly hasError: boolean
|
||||||
readonly error?: string | undefined
|
readonly error?: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,36 +74,29 @@ export const Field = forwardRef(function Field<Schema extends types.TSchema>(
|
|||||||
ref: React.ForwardedRef<HTMLFieldSetElement>,
|
ref: React.ForwardedRef<HTMLFieldSetElement>,
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
form = formContext.useFormContext() as unknown as types.FormInstance<Schema>,
|
|
||||||
isInvalid,
|
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
fullWidth,
|
fullWidth,
|
||||||
error,
|
error,
|
||||||
name,
|
|
||||||
isHidden,
|
isHidden,
|
||||||
|
isInvalid = false,
|
||||||
isRequired = false,
|
isRequired = false,
|
||||||
variants = FIELD_STYLES,
|
variants = FIELD_STYLES,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const fieldState = form.getFieldState(name)
|
|
||||||
|
|
||||||
const labelId = React.useId()
|
const labelId = React.useId()
|
||||||
const descriptionId = React.useId()
|
const descriptionId = React.useId()
|
||||||
const errorId = React.useId()
|
const errorId = React.useId()
|
||||||
|
|
||||||
const invalid = isInvalid === true || fieldState.invalid
|
const fieldState = Form.useFieldState(props)
|
||||||
|
|
||||||
const classes = variants({
|
const invalid = isInvalid || fieldState.hasError
|
||||||
fullWidth,
|
|
||||||
isInvalid: invalid,
|
|
||||||
isHidden,
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasError = (error ?? fieldState.error?.message) != null
|
const classes = variants({ fullWidth, isInvalid: invalid, isHidden })
|
||||||
|
|
||||||
|
const hasError = (error ?? fieldState.error) != null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset
|
<fieldset
|
||||||
@ -138,7 +132,8 @@ export const Field = forwardRef(function Field<Schema extends types.TSchema>(
|
|||||||
isDirty: fieldState.isDirty,
|
isDirty: fieldState.isDirty,
|
||||||
isTouched: fieldState.isTouched,
|
isTouched: fieldState.isTouched,
|
||||||
isValidating: fieldState.isValidating,
|
isValidating: fieldState.isValidating,
|
||||||
error: fieldState.error?.message,
|
hasError: fieldState.hasError,
|
||||||
|
error: fieldState.error,
|
||||||
})
|
})
|
||||||
: children}
|
: children}
|
||||||
</div>
|
</div>
|
||||||
@ -152,7 +147,7 @@ export const Field = forwardRef(function Field<Schema extends types.TSchema>(
|
|||||||
|
|
||||||
{hasError && (
|
{hasError && (
|
||||||
<span data-testid="error" id={errorId} className={classes.error()}>
|
<span data-testid="error" id={errorId} className={classes.error()}>
|
||||||
{error ?? fieldState.error?.message}
|
{error ?? fieldState.error}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
@ -10,8 +10,8 @@ import * as textProvider from '#/providers/TextProvider'
|
|||||||
|
|
||||||
import * as reactAriaComponents from '#/components/AriaComponents'
|
import * as reactAriaComponents from '#/components/AriaComponents'
|
||||||
|
|
||||||
|
import * as formContext from './FormProvider'
|
||||||
import type * as types from './types'
|
import type * as types from './types'
|
||||||
import * as formContext from './useFormContext'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the FormError component.
|
* Props for the FormError component.
|
||||||
@ -26,14 +26,9 @@ export interface FormErrorProps extends Omit<reactAriaComponents.AlertProps, 'ch
|
|||||||
* Form error component.
|
* Form error component.
|
||||||
*/
|
*/
|
||||||
export function FormError(props: FormErrorProps) {
|
export function FormError(props: FormErrorProps) {
|
||||||
const {
|
const { size = 'large', variant = 'error', rounded = 'large', ...alertProps } = props
|
||||||
form = formContext.useFormContext(),
|
|
||||||
size = 'large',
|
|
||||||
variant = 'error',
|
|
||||||
rounded = 'large',
|
|
||||||
...alertProps
|
|
||||||
} = props
|
|
||||||
|
|
||||||
|
const form = formContext.useFormContext(props.form)
|
||||||
const { formState } = form
|
const { formState } = form
|
||||||
const { errors } = formState
|
const { errors } = formState
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
|
@ -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 * as ariaComponents from '#/components/AriaComponents'
|
||||||
|
|
||||||
|
import { useText } from '#/providers/TextProvider'
|
||||||
|
import * as formContext from './FormProvider'
|
||||||
import type * as types from './types'
|
import type * as types from './types'
|
||||||
import * as formContext from './useFormContext'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the Reset component.
|
* Props for the Reset component.
|
||||||
@ -29,14 +30,16 @@ export interface ResetProps extends Omit<ariaComponents.ButtonProps, 'loading'>
|
|||||||
* Reset button for forms.
|
* Reset button for forms.
|
||||||
*/
|
*/
|
||||||
export function Reset(props: ResetProps): React.JSX.Element {
|
export function Reset(props: ResetProps): React.JSX.Element {
|
||||||
|
const { getText } = useText()
|
||||||
const {
|
const {
|
||||||
form = formContext.useFormContext(),
|
variant = 'ghost-fading',
|
||||||
variant = 'cancel',
|
|
||||||
size = 'medium',
|
size = 'medium',
|
||||||
testId = 'form-reset-button',
|
testId = 'form-reset-button',
|
||||||
|
children = getText('reset'),
|
||||||
...buttonProps
|
...buttonProps
|
||||||
} = props
|
} = props
|
||||||
const { formState } = form
|
|
||||||
|
const { formState } = formContext.useFormContext(props.form)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ariaComponents.Button
|
<ariaComponents.Button
|
||||||
@ -48,6 +51,7 @@ export function Reset(props: ResetProps): React.JSX.Element {
|
|||||||
size={size}
|
size={size}
|
||||||
isDisabled={formState.isSubmitting || !formState.isDirty}
|
isDisabled={formState.isSubmitting || !formState.isDirty}
|
||||||
testId={testId}
|
testId={testId}
|
||||||
|
children={children}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -10,8 +10,8 @@ import * as textProvider from '#/providers/TextProvider'
|
|||||||
|
|
||||||
import * as ariaComponents from '#/components/AriaComponents'
|
import * as ariaComponents from '#/components/AriaComponents'
|
||||||
|
|
||||||
|
import * as formContext from './FormProvider'
|
||||||
import type * as types from './types'
|
import type * as types from './types'
|
||||||
import * as formContext from './useFormContext'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Additional props for the Submit component.
|
* Additional props for the Submit component.
|
||||||
@ -48,22 +48,23 @@ export type SubmitProps = Omit<ariaComponents.ButtonProps, 'href' | 'variant'> &
|
|||||||
* Manages the form state and displays a loading spinner when the form is submitting.
|
* Manages the form state and displays a loading spinner when the form is submitting.
|
||||||
*/
|
*/
|
||||||
export function Submit(props: SubmitProps): React.JSX.Element {
|
export function Submit(props: SubmitProps): React.JSX.Element {
|
||||||
|
const { getText } = textProvider.useText()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
form = formContext.useFormContext(),
|
|
||||||
variant = 'submit',
|
|
||||||
size = 'medium',
|
size = 'medium',
|
||||||
testId = 'form-submit-button',
|
|
||||||
formnovalidate = false,
|
formnovalidate = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
children,
|
children = formnovalidate ? getText('cancel') : getText('submit'),
|
||||||
|
variant = formnovalidate ? 'ghost-fading' : 'submit',
|
||||||
|
testId = formnovalidate ? 'form-cancel-button' : 'form-submit-button',
|
||||||
...buttonProps
|
...buttonProps
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const { getText } = textProvider.useText()
|
|
||||||
const dialogContext = ariaComponents.useDialogContext()
|
const dialogContext = ariaComponents.useDialogContext()
|
||||||
|
const form = formContext.useFormContext(props.form)
|
||||||
const { formState } = form
|
const { formState } = form
|
||||||
|
|
||||||
const isLoading = loading || formState.isSubmitting
|
const isLoading = formnovalidate ? false : loading || formState.isSubmitting
|
||||||
const type = formnovalidate || isLoading ? 'button' : 'submit'
|
const type = formnovalidate || isLoading ? 'button' : 'submit'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -82,7 +83,7 @@ export function Submit(props: SubmitProps): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children ?? getText('submit')}
|
{children}
|
||||||
</ariaComponents.Button>
|
</ariaComponents.Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -3,14 +3,16 @@
|
|||||||
*
|
*
|
||||||
* Barrel file for form components.
|
* Barrel file for form components.
|
||||||
*/
|
*/
|
||||||
export { Controller } from 'react-hook-form'
|
export { Controller, useWatch } from 'react-hook-form'
|
||||||
export * from './Field'
|
export * from './Field'
|
||||||
export * from './FormError'
|
export * from './FormError'
|
||||||
|
export * from './FormProvider'
|
||||||
export * from './Reset'
|
export * from './Reset'
|
||||||
export * from './schema'
|
export * from './schema'
|
||||||
export * from './Submit'
|
export * from './Submit'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
export * from './useField'
|
export * from './useField'
|
||||||
|
export * from './useFieldRegister'
|
||||||
|
export * from './useFieldState'
|
||||||
export * from './useForm'
|
export * from './useForm'
|
||||||
export * from './useFormContext'
|
|
||||||
export * from './useFormSchema'
|
export * from './useFormSchema'
|
||||||
|
@ -7,6 +7,7 @@ import type * as React from 'react'
|
|||||||
import type * as reactHookForm from 'react-hook-form'
|
import type * as reactHookForm from 'react-hook-form'
|
||||||
import type * as z from 'zod'
|
import type * as z from 'zod'
|
||||||
|
|
||||||
|
import type { FormEvent } from 'react'
|
||||||
import type * as schemaModule from './schema'
|
import type * as schemaModule from './schema'
|
||||||
|
|
||||||
/** The type of the inputs to the form, used for UI inputs. */
|
/** The type of the inputs to the form, used for UI inputs. */
|
||||||
@ -31,15 +32,61 @@ export type TSchema =
|
|||||||
| z.ZodEffects<z.AnyZodObject>
|
| z.ZodEffects<z.AnyZodObject>
|
||||||
| z.ZodEffects<z.ZodEffects<z.AnyZodObject>>
|
| z.ZodEffects<z.ZodEffects<z.AnyZodObject>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OnSubmitCallbacks type.
|
||||||
|
*/
|
||||||
|
export interface OnSubmitCallbacks<Schema extends TSchema, SubmitResult = void> {
|
||||||
|
readonly onSubmit?:
|
||||||
|
| ((
|
||||||
|
values: FieldValues<Schema>,
|
||||||
|
form: UseFormReturn<Schema>,
|
||||||
|
) => Promise<SubmitResult> | SubmitResult)
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
readonly onSubmitFailed?:
|
||||||
|
| ((
|
||||||
|
error: unknown,
|
||||||
|
values: FieldValues<Schema>,
|
||||||
|
form: UseFormReturn<Schema>,
|
||||||
|
) => Promise<void> | void)
|
||||||
|
| undefined
|
||||||
|
readonly onSubmitSuccess?:
|
||||||
|
| ((
|
||||||
|
data: SubmitResult,
|
||||||
|
values: FieldValues<Schema>,
|
||||||
|
form: UseFormReturn<Schema>,
|
||||||
|
) => Promise<void> | void)
|
||||||
|
| undefined
|
||||||
|
readonly onSubmitted?:
|
||||||
|
| ((
|
||||||
|
data: SubmitResult | undefined,
|
||||||
|
error: unknown,
|
||||||
|
values: FieldValues<Schema>,
|
||||||
|
form: UseFormReturn<Schema>,
|
||||||
|
) => Promise<void> | void)
|
||||||
|
| undefined
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the useForm hook.
|
* Props for the useForm hook.
|
||||||
*/
|
*/
|
||||||
export interface UseFormProps<Schema extends TSchema>
|
export interface UseFormProps<Schema extends TSchema, SubmitResult = void>
|
||||||
extends Omit<
|
extends Omit<
|
||||||
reactHookForm.UseFormProps<FieldValues<Schema>>,
|
reactHookForm.UseFormProps<FieldValues<Schema>>,
|
||||||
'handleSubmit' | 'resetOptions' | 'resolver'
|
'handleSubmit' | 'resetOptions' | 'resolver'
|
||||||
> {
|
>,
|
||||||
|
OnSubmitCallbacks<Schema, SubmitResult> {
|
||||||
readonly schema: Schema | ((schema: typeof schemaModule.schema) => Schema)
|
readonly schema: Schema | ((schema: typeof schemaModule.schema) => Schema)
|
||||||
|
/**
|
||||||
|
* Whether the form can submit offline.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
readonly canSubmitOffline?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug name for the form. Use it to identify the form in the tanstack query devtools.
|
||||||
|
*/
|
||||||
|
readonly debugName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -50,7 +97,6 @@ export type UseFormRegister<Schema extends TSchema> = <
|
|||||||
>(
|
>(
|
||||||
name: TFieldName,
|
name: TFieldName,
|
||||||
options?: reactHookForm.RegisterOptions<FieldValues<Schema>, TFieldName>,
|
options?: reactHookForm.RegisterOptions<FieldValues<Schema>, TFieldName>,
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
) => UseFormRegisterReturn<Schema, TFieldName>
|
) => UseFormRegisterReturn<Schema, TFieldName>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -64,9 +110,12 @@ export interface UseFormRegisterReturn<
|
|||||||
readonly onChange: <Value>(value: Value) => Promise<boolean | void>
|
readonly onChange: <Value>(value: Value) => Promise<boolean | void>
|
||||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||||
readonly onBlur: <Value>(value: Value) => Promise<boolean | void>
|
readonly onBlur: <Value>(value: Value) => Promise<boolean | void>
|
||||||
readonly isDisabled?: boolean
|
readonly isDisabled: boolean
|
||||||
readonly isRequired?: boolean
|
readonly isRequired: boolean
|
||||||
readonly isInvalid?: boolean
|
readonly isInvalid: boolean
|
||||||
|
readonly disabled: boolean
|
||||||
|
readonly required: boolean
|
||||||
|
readonly invalid: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -74,8 +123,14 @@ export interface UseFormRegisterReturn<
|
|||||||
* @alias reactHookForm.UseFormReturn
|
* @alias reactHookForm.UseFormReturn
|
||||||
*/
|
*/
|
||||||
export interface UseFormReturn<Schema extends TSchema>
|
export interface UseFormReturn<Schema extends TSchema>
|
||||||
extends reactHookForm.UseFormReturn<FieldValues<Schema>, unknown, TransformedValues<Schema>> {
|
extends Omit<
|
||||||
|
reactHookForm.UseFormReturn<FieldValues<Schema>, unknown, TransformedValues<Schema>>,
|
||||||
|
'onSubmit' | 'resetOptions' | 'resolver'
|
||||||
|
> {
|
||||||
readonly register: UseFormRegister<Schema>
|
readonly register: UseFormRegister<Schema>
|
||||||
|
readonly submit: (event?: FormEvent<HTMLFormElement> | null | undefined) => Promise<void>
|
||||||
|
readonly schema: Schema
|
||||||
|
readonly setFormError: (error: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -113,6 +168,16 @@ export interface FormWithValueValidation<
|
|||||||
| undefined
|
| undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form instance type that has been validated.
|
||||||
|
* Cast validatable form type to FormInstance
|
||||||
|
*/
|
||||||
|
export type FormInstanceValidated<
|
||||||
|
Schema extends TSchema,
|
||||||
|
// We use any here because we want to bypass the type check for Error type as it won't be a case here
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/ban-types
|
||||||
|
> = FormInstance<Schema> | (any[] & {})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the Field component.
|
* Props for the Field component.
|
||||||
*/
|
*/
|
||||||
@ -148,3 +213,36 @@ export interface FieldProps {
|
|||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
'aria-details'?: string | undefined
|
'aria-details'?: string | undefined
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Base Props for a Form Field.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
export interface FormFieldProps<
|
||||||
|
BaseValueType,
|
||||||
|
Schema extends TSchema,
|
||||||
|
TFieldName extends FieldPath<Schema>,
|
||||||
|
> extends FormWithValueValidation<BaseValueType, Schema, TFieldName> {
|
||||||
|
readonly name: TFieldName
|
||||||
|
readonly value?: BaseValueType extends FieldValues<Schema> ? FieldValues<Schema>[TFieldName]
|
||||||
|
: never
|
||||||
|
readonly defaultValue?: FieldValues<Schema>[TFieldName] | undefined
|
||||||
|
readonly isDisabled?: boolean | undefined
|
||||||
|
readonly isRequired?: boolean | undefined
|
||||||
|
readonly isInvalid?: boolean | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field State Props
|
||||||
|
*/
|
||||||
|
export type FieldStateProps<
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
BaseProps extends { value?: unknown },
|
||||||
|
Schema extends TSchema,
|
||||||
|
TFieldName extends FieldPath<Schema>,
|
||||||
|
> = FormFieldProps<BaseProps['value'], Schema, TFieldName> & {
|
||||||
|
// to avoid conflicts with the FormFieldProps we need to omit the FormFieldProps from the BaseProps
|
||||||
|
[K in keyof Omit<
|
||||||
|
BaseProps,
|
||||||
|
keyof FormFieldProps<BaseProps['value'], Schema, TFieldName>
|
||||||
|
>]: BaseProps[K]
|
||||||
|
}
|
||||||
|
@ -5,8 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
import * as reactHookForm from 'react-hook-form'
|
import * as reactHookForm from 'react-hook-form'
|
||||||
|
|
||||||
|
import * as formContext from './FormProvider'
|
||||||
import type * as types from './types'
|
import type * as types from './types'
|
||||||
import * as formContext from './useFormContext'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for {@link useField} hook.
|
* Options for {@link useField} hook.
|
||||||
@ -29,24 +29,16 @@ export function useField<
|
|||||||
Schema extends types.TSchema,
|
Schema extends types.TSchema,
|
||||||
TFieldName extends types.FieldPath<Schema>,
|
TFieldName extends types.FieldPath<Schema>,
|
||||||
>(options: UseFieldOptions<BaseValueType, Schema, TFieldName>) {
|
>(options: UseFieldOptions<BaseValueType, Schema, TFieldName>) {
|
||||||
const { form = formContext.useFormContext(), name, defaultValue, isDisabled = false } = options
|
const { name, defaultValue, isDisabled = false } = options
|
||||||
|
|
||||||
// This is safe, because the form is always passed either via the options or via the context.
|
const formInstance = formContext.useFormContext(options.form)
|
||||||
// The assertion is needed because we use additional type validation for form instance and throw
|
|
||||||
// ts error if form does not pass the validation.
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
const formInstance = form as types.FormInstance<Schema>
|
|
||||||
|
|
||||||
const { field, fieldState, formState } = reactHookForm.useController({
|
const { field, fieldState, formState } = reactHookForm.useController({
|
||||||
name,
|
name,
|
||||||
disabled: isDisabled,
|
disabled: isDisabled,
|
||||||
|
control: formInstance.control,
|
||||||
...(defaultValue != null ? { defaultValue } : {}),
|
...(defaultValue != null ? { defaultValue } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return { field, fieldState, formState, formInstance } as const
|
||||||
field,
|
|
||||||
fieldState,
|
|
||||||
formState,
|
|
||||||
formInstance,
|
|
||||||
} as const
|
|
||||||
}
|
}
|
||||||
|
@ -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.
|
* A hook that returns a form instance.
|
||||||
*/
|
*/
|
||||||
|
import * as sentry from '@sentry/react'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import * as zodResolver from '@hookform/resolvers/zod'
|
import * as zodResolver from '@hookform/resolvers/zod'
|
||||||
import * as reactHookForm from 'react-hook-form'
|
import * as reactHookForm from 'react-hook-form'
|
||||||
import invariant from 'tiny-invariant'
|
import invariant from 'tiny-invariant'
|
||||||
|
|
||||||
|
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||||
|
import { useOffline, useOfflineChange } from '#/hooks/offlineHooks'
|
||||||
import { useText } from '#/providers/TextProvider'
|
import { useText } from '#/providers/TextProvider'
|
||||||
|
import * as errorUtils from '#/utilities/error'
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import * as schemaModule from './schema'
|
import * as schemaModule from './schema'
|
||||||
import type * as types from './types'
|
import type * as types from './types'
|
||||||
|
|
||||||
@ -39,64 +44,73 @@ function mapValueOnEvent(value: unknown) {
|
|||||||
* But be careful, You should not switch between the two types of arguments.
|
* But be careful, You should not switch between the two types of arguments.
|
||||||
* Otherwise you'll be fired
|
* Otherwise you'll be fired
|
||||||
*/
|
*/
|
||||||
export function useForm<Schema extends types.TSchema>(
|
export function useForm<Schema extends types.TSchema, SubmitResult = void>(
|
||||||
optionsOrFormInstance: types.UseFormProps<Schema> | types.UseFormReturn<Schema>,
|
optionsOrFormInstance: types.UseFormProps<Schema, SubmitResult> | types.UseFormReturn<Schema>,
|
||||||
): types.UseFormReturn<Schema> {
|
): types.UseFormReturn<Schema> {
|
||||||
const { getText } = useText()
|
const { getText } = useText()
|
||||||
const initialTypePassed = React.useRef(getArgsType(optionsOrFormInstance))
|
const [initialTypePassed] = React.useState(() => getArgsType(optionsOrFormInstance))
|
||||||
|
|
||||||
const argsType = getArgsType(optionsOrFormInstance)
|
const argsType = getArgsType(optionsOrFormInstance)
|
||||||
|
|
||||||
invariant(
|
invariant(
|
||||||
initialTypePassed.current === argsType,
|
initialTypePassed === argsType,
|
||||||
`
|
`
|
||||||
Found a switch between form options and form instance. This is not allowed. Please use either form options or form instance and stick to it.\n\n
|
Found a switch between form options and form instance. This is not allowed. Please use either form options or form instance and stick to it.\n\n
|
||||||
Initially passed: ${initialTypePassed.current}, Currently passed: ${argsType}.
|
Initially passed: ${initialTypePassed}, Currently passed: ${argsType}.
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
|
||||||
if ('formState' in optionsOrFormInstance) {
|
if ('formState' in optionsOrFormInstance) {
|
||||||
return optionsOrFormInstance
|
return optionsOrFormInstance
|
||||||
} else {
|
} else {
|
||||||
const { schema, ...options } = optionsOrFormInstance
|
const {
|
||||||
|
schema,
|
||||||
|
onSubmit,
|
||||||
|
canSubmitOffline = false,
|
||||||
|
onSubmitFailed,
|
||||||
|
onSubmitted,
|
||||||
|
onSubmitSuccess,
|
||||||
|
debugName,
|
||||||
|
...options
|
||||||
|
} = optionsOrFormInstance
|
||||||
|
|
||||||
const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema
|
const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema
|
||||||
|
|
||||||
const formInstance = reactHookForm.useForm<
|
const formInstance = reactHookForm.useForm({
|
||||||
types.FieldValues<Schema>,
|
|
||||||
unknown,
|
|
||||||
types.TransformedValues<Schema>
|
|
||||||
>({
|
|
||||||
...options,
|
...options,
|
||||||
resolver: zodResolver.zodResolver(computedSchema, {
|
resolver: zodResolver.zodResolver(
|
||||||
async: true,
|
computedSchema,
|
||||||
errorMap: (issue) => {
|
{
|
||||||
switch (issue.code) {
|
async: true,
|
||||||
case 'too_small':
|
errorMap: (issue) => {
|
||||||
if (issue.minimum === 0) {
|
switch (issue.code) {
|
||||||
return {
|
case 'too_small':
|
||||||
message: getText('arbitraryFieldRequired'),
|
if (issue.minimum === 0) {
|
||||||
|
return {
|
||||||
|
message: getText('arbitraryFieldRequired'),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
message: getText('arbitraryFieldTooSmall', issue.minimum.toString()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
case 'too_big':
|
||||||
return {
|
return {
|
||||||
message: getText('arbitraryFieldTooSmall', issue.minimum.toString()),
|
message: getText('arbitraryFieldTooLarge', issue.maximum.toString()),
|
||||||
}
|
}
|
||||||
}
|
case 'invalid_type':
|
||||||
case 'too_big':
|
return {
|
||||||
return {
|
message: getText('arbitraryFieldInvalid'),
|
||||||
message: getText('arbitraryFieldTooLarge', issue.maximum.toString()),
|
}
|
||||||
}
|
default:
|
||||||
case 'invalid_type':
|
return {
|
||||||
return {
|
message: getText('arbitraryFieldInvalid'),
|
||||||
message: getText('arbitraryFieldInvalid'),
|
}
|
||||||
}
|
}
|
||||||
default:
|
},
|
||||||
return {
|
|
||||||
message: getText('arbitraryFieldInvalid'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}),
|
{ mode: 'async' },
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
const register: types.UseFormRegister<Schema> = (name, opts) => {
|
const register: types.UseFormRegister<Schema> = (name, opts) => {
|
||||||
@ -110,9 +124,12 @@ export function useForm<Schema extends types.TSchema>(
|
|||||||
|
|
||||||
const result: types.UseFormRegisterReturn<Schema, typeof name> = {
|
const result: types.UseFormRegisterReturn<Schema, typeof name> = {
|
||||||
...registered,
|
...registered,
|
||||||
...(registered.disabled != null ? { isDisabled: registered.disabled } : {}),
|
disabled: registered.disabled ?? false,
|
||||||
...(registered.required != null ? { isRequired: registered.required } : {}),
|
isDisabled: registered.disabled ?? false,
|
||||||
|
invalid: !!formInstance.formState.errors[name],
|
||||||
isInvalid: !!formInstance.formState.errors[name],
|
isInvalid: !!formInstance.formState.errors[name],
|
||||||
|
required: registered.required ?? false,
|
||||||
|
isRequired: registered.required ?? false,
|
||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
}
|
}
|
||||||
@ -120,19 +137,106 @@ export function useForm<Schema extends types.TSchema>(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
const formMutation = useMutation({
|
||||||
|
// We use template literals to make the mutation key more readable in the devtools
|
||||||
|
// This mutation exists only for debug purposes - React Query dev tools record the mutation,
|
||||||
|
// the result, and the variables(form fields).
|
||||||
|
// In general, prefer using object literals for the mutation key.
|
||||||
|
mutationKey: ['Form submission', `debugName: ${debugName}`],
|
||||||
|
mutationFn: async (fieldValues: types.FieldValues<Schema>) => {
|
||||||
|
try {
|
||||||
|
// This is safe, because we transparently passing the result of the onSubmit function,
|
||||||
|
// and the type of the result is the same as the type of the SubmitResult.
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
return (await onSubmit?.(fieldValues, form)) as SubmitResult
|
||||||
|
} catch (error) {
|
||||||
|
const isJSError = errorUtils.isJSError(error)
|
||||||
|
|
||||||
|
if (isJSError) {
|
||||||
|
sentry.captureException(error, {
|
||||||
|
contexts: { form: { values: fieldValues } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
isJSError ?
|
||||||
|
getText('arbitraryFormErrorMessage')
|
||||||
|
: errorUtils.tryGetMessage(error, getText('arbitraryFormErrorMessage'))
|
||||||
|
|
||||||
|
setFormError(message)
|
||||||
|
// We need to throw the error to make the mutation fail
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error, values) => onSubmitFailed?.(error, values, form),
|
||||||
|
onSuccess: (data, values) => onSubmitSuccess?.(data, values, form),
|
||||||
|
onSettled: (data, error, values) => onSubmitted?.(data, error, values, form),
|
||||||
|
})
|
||||||
|
|
||||||
|
// There is no way to avoid type casting here
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax,@typescript-eslint/no-unsafe-argument
|
||||||
|
const formOnSubmit = formInstance.handleSubmit(formMutation.mutateAsync as any)
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
const { isOffline } = useOffline()
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
useOfflineChange(
|
||||||
|
(offline) => {
|
||||||
|
if (offline) {
|
||||||
|
formInstance.setError('root.offline', { message: getText('unavailableOffline') })
|
||||||
|
} else {
|
||||||
|
formInstance.clearErrors('root.offline')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isDisabled: canSubmitOffline },
|
||||||
|
)
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
const submit = useEventCallback(
|
||||||
|
(event: React.FormEvent<HTMLFormElement> | null | undefined) => {
|
||||||
|
event?.preventDefault()
|
||||||
|
event?.stopPropagation()
|
||||||
|
|
||||||
|
if (isOffline && !canSubmitOffline) {
|
||||||
|
formInstance.setError('root.offline', { message: getText('unavailableOffline') })
|
||||||
|
return Promise.resolve()
|
||||||
|
} else {
|
||||||
|
if (event) {
|
||||||
|
return formOnSubmit(event)
|
||||||
|
} else {
|
||||||
|
return formOnSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
const setFormError = useEventCallback((error: string) => {
|
||||||
|
formInstance.setError('root.submit', { message: error })
|
||||||
|
})
|
||||||
|
|
||||||
|
const form: types.UseFormReturn<Schema> = {
|
||||||
...formInstance,
|
...formInstance,
|
||||||
|
submit,
|
||||||
control: { ...formInstance.control, register },
|
control: { ...formInstance.control, register },
|
||||||
register,
|
register,
|
||||||
} satisfies types.UseFormReturn<Schema>
|
schema: computedSchema,
|
||||||
|
setFormError,
|
||||||
|
handleSubmit: formInstance.handleSubmit,
|
||||||
|
}
|
||||||
|
|
||||||
|
return form
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the type of arguments passed to the useForm hook
|
* Get the type of arguments passed to the useForm hook
|
||||||
*/
|
*/
|
||||||
function getArgsType<Schema extends types.TSchema>(
|
function getArgsType<Schema extends types.TSchema, SubmitResult = void>(
|
||||||
args: types.UseFormProps<Schema> | types.UseFormReturn<Schema>,
|
args: types.UseFormProps<Schema, SubmitResult>,
|
||||||
) {
|
) {
|
||||||
return 'formState' in args ? 'formInstance' : 'formOptions'
|
return 'formState' in args ? ('formInstance' as const) : ('formOptions' as const)
|
||||||
}
|
}
|
||||||
|
@ -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 callbackEventHooks from '#/hooks/eventCallbackHooks'
|
||||||
|
|
||||||
import * as schemaComponent from '#/components/AriaComponents/Form/components/schema'
|
import * as schemaComponent from './schema'
|
||||||
import type * as types from '#/components/AriaComponents/Form/components/types'
|
import type * as types from './types'
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
// === useFormSchema ===
|
// === useFormSchema ===
|
||||||
|
@ -7,6 +7,8 @@ import type * as React from 'react'
|
|||||||
|
|
||||||
import type * as reactHookForm from 'react-hook-form'
|
import type * as reactHookForm from 'react-hook-form'
|
||||||
|
|
||||||
|
import type { DeepPartialSkipArrayKey } from 'react-hook-form'
|
||||||
|
import type { TestIdProps } from '../types'
|
||||||
import type * as components from './components'
|
import type * as components from './components'
|
||||||
import type * as styles from './styles'
|
import type * as styles from './styles'
|
||||||
|
|
||||||
@ -15,8 +17,11 @@ export type * from './components'
|
|||||||
/**
|
/**
|
||||||
* Props for the Form component
|
* Props for the Form component
|
||||||
*/
|
*/
|
||||||
export type FormProps<Schema extends components.TSchema> = BaseFormProps<Schema> &
|
export type FormProps<
|
||||||
(FormPropsWithOptions<Schema> | FormPropsWithParentForm<Schema>)
|
Schema extends components.TSchema,
|
||||||
|
SubmitResult = void,
|
||||||
|
> = BaseFormProps<Schema> &
|
||||||
|
(FormPropsWithOptions<Schema, SubmitResult> | FormPropsWithParentForm<Schema>)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base props for the Form component.
|
* Base props for the Form component.
|
||||||
@ -26,20 +31,8 @@ interface BaseFormProps<Schema extends components.TSchema>
|
|||||||
React.HTMLProps<HTMLFormElement>,
|
React.HTMLProps<HTMLFormElement>,
|
||||||
'children' | 'className' | 'form' | 'onSubmit' | 'onSubmitCapture' | 'style'
|
'children' | 'className' | 'form' | 'onSubmit' | 'onSubmitCapture' | 'style'
|
||||||
>,
|
>,
|
||||||
styles.FormStyleProps {
|
Omit<styles.FormStyleProps, 'class' | 'className'>,
|
||||||
/**
|
TestIdProps {
|
||||||
* The default values for the form fields
|
|
||||||
*
|
|
||||||
* __Note:__ Even though this is optional,
|
|
||||||
* it is recommended to provide default values and specify all fields defined in the schema.
|
|
||||||
* Otherwise Typescript fails to infer the correct type for the form values.
|
|
||||||
* This is a known limitation and we are working on a solution.
|
|
||||||
*/
|
|
||||||
readonly defaultValues?: components.UseFormProps<Schema>['defaultValues']
|
|
||||||
readonly onSubmit?: (
|
|
||||||
values: components.TransformedValues<Schema>,
|
|
||||||
form: components.UseFormReturn<Schema>,
|
|
||||||
) => unknown
|
|
||||||
readonly style?:
|
readonly style?:
|
||||||
| React.CSSProperties
|
| React.CSSProperties
|
||||||
| ((props: components.UseFormReturn<Schema>) => React.CSSProperties)
|
| ((props: components.UseFormReturn<Schema>) => React.CSSProperties)
|
||||||
@ -48,17 +41,13 @@ interface BaseFormProps<Schema extends components.TSchema>
|
|||||||
| ((
|
| ((
|
||||||
props: components.UseFormReturn<Schema> & {
|
props: components.UseFormReturn<Schema> & {
|
||||||
readonly form: components.UseFormReturn<Schema>
|
readonly form: components.UseFormReturn<Schema>
|
||||||
|
readonly values: DeepPartialSkipArrayKey<components.FieldValues<Schema>>
|
||||||
},
|
},
|
||||||
) => React.ReactNode)
|
) => React.ReactNode)
|
||||||
readonly formRef?: React.MutableRefObject<components.UseFormReturn<Schema>>
|
readonly formRef?: React.MutableRefObject<components.UseFormReturn<Schema>>
|
||||||
|
|
||||||
readonly className?: string | ((props: components.UseFormReturn<Schema>) => string)
|
readonly className?: string | ((props: components.UseFormReturn<Schema>) => string)
|
||||||
|
|
||||||
readonly onSubmitFailed?: (error: unknown) => Promise<void> | void
|
|
||||||
readonly onSubmitSuccess?: () => Promise<void> | void
|
|
||||||
readonly onSubmitted?: () => Promise<void> | void
|
|
||||||
|
|
||||||
readonly testId?: string
|
|
||||||
/**
|
/**
|
||||||
* When set to `dialog`, form submission will close the parent dialog on successful submission.
|
* When set to `dialog`, form submission will close the parent dialog on successful submission.
|
||||||
*/
|
*/
|
||||||
@ -76,16 +65,33 @@ interface FormPropsWithParentForm<Schema extends components.TSchema> {
|
|||||||
readonly form: components.UseFormReturn<Schema>
|
readonly form: components.UseFormReturn<Schema>
|
||||||
readonly schema?: never
|
readonly schema?: never
|
||||||
readonly formOptions?: never
|
readonly formOptions?: never
|
||||||
|
readonly defaultValues?: never
|
||||||
|
readonly onSubmit?: never
|
||||||
|
readonly onSubmitSuccess?: never
|
||||||
|
readonly onSubmitFailed?: never
|
||||||
|
readonly onSubmitted?: never
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the Form component with schema and form options.
|
* Props for the Form component with schema and form options.
|
||||||
* Creates a new form instance. This is the default way to use the form.
|
* Creates a new form instance. This is the default way to use the form.
|
||||||
*/
|
*/
|
||||||
interface FormPropsWithOptions<Schema extends components.TSchema> {
|
interface FormPropsWithOptions<Schema extends components.TSchema, SubmitResult = void>
|
||||||
|
extends components.OnSubmitCallbacks<Schema, SubmitResult> {
|
||||||
readonly schema: Schema | ((schema: typeof components.schema) => Schema)
|
readonly schema: Schema | ((schema: typeof components.schema) => Schema)
|
||||||
|
readonly formOptions?: Omit<
|
||||||
|
components.UseFormProps<Schema, SubmitResult>,
|
||||||
|
'defaultValues' | 'onSubmit' | 'onSubmitFailed' | 'onSubmitSuccess' | 'onSubmitted' | 'schema'
|
||||||
|
>
|
||||||
|
/**
|
||||||
|
* The default values for the form fields
|
||||||
|
*
|
||||||
|
* __Note:__ Even though this is optional,
|
||||||
|
* it is recommended to provide default values and specify all fields defined in the schema.
|
||||||
|
* Otherwise Typescript fails to infer the correct type for the form values.
|
||||||
|
*/
|
||||||
|
readonly defaultValues?: components.UseFormProps<Schema>['defaultValues']
|
||||||
readonly form?: never
|
readonly form?: never
|
||||||
readonly formOptions?: Omit<components.UseFormProps<Schema>, 'resolver' | 'schema'>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -134,38 +140,3 @@ export type FormStateRenderProps<Schema extends components.TSchema> = Pick<
|
|||||||
/** The form instance. */
|
/** The form instance. */
|
||||||
readonly form: components.FormInstance<Schema>
|
readonly form: components.FormInstance<Schema>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Base Props for a Form Field.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
interface FormFieldProps<
|
|
||||||
BaseValueType,
|
|
||||||
Schema extends components.TSchema,
|
|
||||||
TFieldName extends components.FieldPath<Schema>,
|
|
||||||
> extends components.FormWithValueValidation<BaseValueType, Schema, TFieldName> {
|
|
||||||
readonly name: TFieldName
|
|
||||||
readonly value?: BaseValueType extends components.FieldValues<Schema>[TFieldName] ?
|
|
||||||
components.FieldValues<Schema>[TFieldName]
|
|
||||||
: never
|
|
||||||
readonly defaultValue?: components.FieldValues<Schema>[TFieldName] | undefined
|
|
||||||
readonly isDisabled?: boolean
|
|
||||||
readonly isRequired?: boolean
|
|
||||||
readonly isInvalid?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Field State Props
|
|
||||||
*/
|
|
||||||
export type FieldStateProps<
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
BaseProps extends { value?: unknown },
|
|
||||||
Schema extends components.TSchema,
|
|
||||||
TFieldName extends components.FieldPath<Schema>,
|
|
||||||
> = FormFieldProps<BaseProps['value'], Schema, TFieldName> & {
|
|
||||||
// to avoid conflicts with the FormFieldProps we need to omit the FormFieldProps from the BaseProps
|
|
||||||
[K in keyof Omit<
|
|
||||||
BaseProps,
|
|
||||||
keyof FormFieldProps<BaseProps['value'], Schema, TFieldName>
|
|
||||||
>]: BaseProps[K]
|
|
||||||
}
|
|
||||||
|
@ -37,8 +37,8 @@ import {
|
|||||||
} from '#/components/AriaComponents'
|
} from '#/components/AriaComponents'
|
||||||
import { useText } from '#/providers/TextProvider'
|
import { useText } from '#/providers/TextProvider'
|
||||||
import { forwardRef } from '#/utilities/react'
|
import { forwardRef } from '#/utilities/react'
|
||||||
import { Controller } from 'react-hook-form'
|
import type { VariantProps } from '#/utilities/tailwindVariants'
|
||||||
import { tv, type VariantProps } from 'tailwind-variants'
|
import { tv } from '#/utilities/tailwindVariants'
|
||||||
|
|
||||||
const DATE_PICKER_STYLES = tv({
|
const DATE_PICKER_STYLES = tv({
|
||||||
base: '',
|
base: '',
|
||||||
@ -135,7 +135,7 @@ export const DatePicker = forwardRef(function DatePicker<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
style={props.style}
|
style={props.style}
|
||||||
>
|
>
|
||||||
<Controller
|
<Form.Controller
|
||||||
control={formInstance.control}
|
control={formInstance.control}
|
||||||
name={name}
|
name={name}
|
||||||
render={(renderProps) => {
|
render={(renderProps) => {
|
||||||
|
@ -30,6 +30,7 @@ import SvgMask from '#/components/SvgMask'
|
|||||||
import { mergeRefs } from '#/utilities/mergeRefs'
|
import { mergeRefs } from '#/utilities/mergeRefs'
|
||||||
import { forwardRef } from '#/utilities/react'
|
import { forwardRef } from '#/utilities/react'
|
||||||
import type { ExtractFunction } from '#/utilities/tailwindVariants'
|
import type { ExtractFunction } from '#/utilities/tailwindVariants'
|
||||||
|
import { omit } from 'enso-common/src/utilities/data/object'
|
||||||
import { INPUT_STYLES } from '../variants'
|
import { INPUT_STYLES } from '../variants'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -62,24 +63,18 @@ export const Input = forwardRef(function Input<
|
|||||||
>(props: InputProps<Schema, TFieldName>, ref: ForwardedRef<HTMLFieldSetElement>) {
|
>(props: InputProps<Schema, TFieldName>, ref: ForwardedRef<HTMLFieldSetElement>) {
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
isDisabled = false,
|
|
||||||
form,
|
|
||||||
defaultValue,
|
|
||||||
description,
|
description,
|
||||||
inputRef,
|
inputRef,
|
||||||
addonStart,
|
addonStart,
|
||||||
addonEnd,
|
addonEnd,
|
||||||
label,
|
|
||||||
size,
|
size,
|
||||||
rounded,
|
rounded,
|
||||||
isRequired = false,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
icon,
|
icon,
|
||||||
type = 'text',
|
type = 'text',
|
||||||
variant,
|
variant,
|
||||||
variants = INPUT_STYLES,
|
variants = INPUT_STYLES,
|
||||||
fieldVariants,
|
fieldVariants,
|
||||||
|
form,
|
||||||
...inputProps
|
...inputProps
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
@ -87,32 +82,14 @@ export const Input = forwardRef(function Input<
|
|||||||
|
|
||||||
const privateInputRef = useRef<HTMLInputElement>(null)
|
const privateInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const { fieldState, formInstance } = Form.useField({
|
const { fieldProps, formInstance } = Form.useFieldRegister<
|
||||||
name,
|
Omit<aria.InputProps, 'children' | 'size'>,
|
||||||
isDisabled,
|
Schema,
|
||||||
|
TFieldName
|
||||||
|
>({
|
||||||
|
...props,
|
||||||
form,
|
form,
|
||||||
defaultValue,
|
setValueAs: (value: unknown) => {
|
||||||
})
|
|
||||||
|
|
||||||
const classes = variants({
|
|
||||||
variant,
|
|
||||||
size,
|
|
||||||
rounded,
|
|
||||||
invalid: fieldState.invalid,
|
|
||||||
readOnly: inputProps.readOnly,
|
|
||||||
disabled: isDisabled || formInstance.formState.isSubmitting,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { ref: fieldRef, ...field } = formInstance.register(name, {
|
|
||||||
disabled: isDisabled,
|
|
||||||
required: isRequired,
|
|
||||||
...(inputProps.onBlur && { onBlur: inputProps.onBlur }),
|
|
||||||
...(inputProps.onChange && { onChange: inputProps.onChange }),
|
|
||||||
...(inputProps.minLength != null ? { minLength: inputProps.minLength } : {}),
|
|
||||||
...(inputProps.maxLength != null ? { maxLength: inputProps.maxLength } : {}),
|
|
||||||
...(min != null ? { min } : {}),
|
|
||||||
...(max != null ? { max } : {}),
|
|
||||||
setValueAs: (value) => {
|
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
if (type === 'number') {
|
if (type === 'number') {
|
||||||
return Number(value)
|
return Number(value)
|
||||||
@ -128,24 +105,26 @@ export const Input = forwardRef(function Input<
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const classes = variants({
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
rounded,
|
||||||
|
invalid: fieldProps.isInvalid,
|
||||||
|
readOnly: inputProps.readOnly,
|
||||||
|
disabled: fieldProps.disabled || formInstance.formState.isSubmitting,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Field
|
<Form.Field
|
||||||
data-testid={testId}
|
{...aria.mergeProps<FieldComponentProps<Schema>>()(inputProps, omit(fieldProps), {
|
||||||
form={formInstance}
|
isHidden: props.hidden,
|
||||||
name={name}
|
fullWidth: true,
|
||||||
fullWidth
|
variants: fieldVariants,
|
||||||
isHidden={inputProps.hidden}
|
form: formInstance,
|
||||||
label={label}
|
})}
|
||||||
aria-label={props['aria-label']}
|
|
||||||
aria-labelledby={props['aria-labelledby']}
|
|
||||||
aria-describedby={props['aria-describedby']}
|
|
||||||
isRequired={field.required}
|
|
||||||
isInvalid={fieldState.invalid}
|
|
||||||
aria-details={props['aria-details']}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={props.style}
|
name={props.name}
|
||||||
className={props.className}
|
data-testid={testId}
|
||||||
variants={fieldVariants}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={classes.base()}
|
className={classes.base()}
|
||||||
@ -158,12 +137,12 @@ export const Input = forwardRef(function Input<
|
|||||||
|
|
||||||
<div className={classes.inputContainer()}>
|
<div className={classes.inputContainer()}>
|
||||||
<aria.Input
|
<aria.Input
|
||||||
ref={mergeRefs(inputRef, privateInputRef, fieldRef)}
|
|
||||||
{...aria.mergeProps<aria.InputProps>()(
|
{...aria.mergeProps<aria.InputProps>()(
|
||||||
{ className: classes.textArea(), type, name, min, max },
|
|
||||||
inputProps,
|
inputProps,
|
||||||
field,
|
{ className: classes.textArea(), type, name },
|
||||||
|
omit(fieldProps, 'isInvalid', 'isRequired', 'isDisabled', 'invalid'),
|
||||||
)}
|
)}
|
||||||
|
ref={mergeRefs(inputRef, privateInputRef, fieldProps.ref)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -22,7 +22,6 @@ import { mergeRefs } from '#/utilities/mergeRefs'
|
|||||||
import { forwardRef } from '#/utilities/react'
|
import { forwardRef } from '#/utilities/react'
|
||||||
import { tv } from '#/utilities/tailwindVariants'
|
import { tv } from '#/utilities/tailwindVariants'
|
||||||
import { omit, unsafeRemoveUndefined } from 'enso-common/src/utilities/data/object'
|
import { omit, unsafeRemoveUndefined } from 'enso-common/src/utilities/data/object'
|
||||||
import { Controller } from 'react-hook-form'
|
|
||||||
import { MultiSelectorOption } from './MultiSelectorOption'
|
import { MultiSelectorOption } from './MultiSelectorOption'
|
||||||
|
|
||||||
/** * Props for the MultiSelector component. */
|
/** * Props for the MultiSelector component. */
|
||||||
@ -141,7 +140,7 @@ export const MultiSelector = forwardRef(function MultiSelector<
|
|||||||
className={classes.base()}
|
className={classes.base()}
|
||||||
onClick={() => privateInputRef.current?.focus({ preventScroll: true })}
|
onClick={() => privateInputRef.current?.focus({ preventScroll: true })}
|
||||||
>
|
>
|
||||||
<Controller
|
<Form.Controller
|
||||||
control={formInstance.control}
|
control={formInstance.control}
|
||||||
name={name}
|
name={name}
|
||||||
render={(renderProps) => {
|
render={(renderProps) => {
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
/** @file An option in a selector. */
|
/** @file An option in a selector. */
|
||||||
import { ListBoxItem, type ListBoxItemProps } from '#/components/aria'
|
import { ListBoxItem, type ListBoxItemProps } from '#/components/aria'
|
||||||
import { forwardRef } from '#/utilities/react'
|
import { forwardRef } from '#/utilities/react'
|
||||||
|
import type { VariantProps } from '#/utilities/tailwindVariants'
|
||||||
import { tv } from '#/utilities/tailwindVariants'
|
import { tv } from '#/utilities/tailwindVariants'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import type { VariantProps } from 'tailwind-variants'
|
|
||||||
import { TEXT_STYLE } from '../../Text'
|
import { TEXT_STYLE } from '../../Text'
|
||||||
|
|
||||||
/** Props for a {@link MultiSelectorOption}. */
|
/** Props for a {@link MultiSelectorOption}. */
|
||||||
|
@ -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 {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
|
type FieldPath,
|
||||||
type FieldValues,
|
type FieldValues,
|
||||||
type InputProps,
|
type InputProps,
|
||||||
type TSchema,
|
type TSchema,
|
||||||
@ -17,7 +18,7 @@ import {
|
|||||||
// ================
|
// ================
|
||||||
|
|
||||||
/** Props for a {@link Password}. */
|
/** Props for a {@link Password}. */
|
||||||
export interface PasswordProps<Schema extends TSchema, TFieldName extends Path<FieldValues<Schema>>>
|
export interface PasswordProps<Schema extends TSchema, TFieldName extends FieldPath<Schema>>
|
||||||
extends Omit<InputProps<Schema, TFieldName>, 'type'> {}
|
extends Omit<InputProps<Schema, TFieldName>, 'type'> {}
|
||||||
|
|
||||||
/** A component wrapping {@link Input} with the ability to show and hide password. */
|
/** A component wrapping {@link Input} with the ability to show and hide password. */
|
||||||
|
@ -35,8 +35,10 @@ export interface ResizableContentEditableInputProps<
|
|||||||
VariantProps<typeof INPUT_STYLES>,
|
VariantProps<typeof INPUT_STYLES>,
|
||||||
'disabled' | 'invalid' | 'rounded' | 'size' | 'variant'
|
'disabled' | 'invalid' | 'rounded' | 'size' | 'variant'
|
||||||
>,
|
>,
|
||||||
|
FieldVariantProps,
|
||||||
Omit<FieldProps, 'variant'>,
|
Omit<FieldProps, 'variant'>,
|
||||||
FieldVariantProps,
|
FieldVariantProps,
|
||||||
|
Pick<VariantProps<typeof INPUT_STYLES>, 'rounded' | 'size' | 'variant'>,
|
||||||
Omit<
|
Omit<
|
||||||
VariantProps<typeof CONTENT_EDITABLE_STYLES>,
|
VariantProps<typeof CONTENT_EDITABLE_STYLES>,
|
||||||
'disabled' | 'invalid' | 'rounded' | 'size' | 'variant'
|
'disabled' | 'invalid' | 'rounded' | 'size' | 'variant'
|
||||||
|
@ -4,12 +4,14 @@ import * as React from 'react'
|
|||||||
import type * as twv from 'tailwind-variants'
|
import type * as twv from 'tailwind-variants'
|
||||||
|
|
||||||
import { mergeProps, type RadioGroupProps } from '#/components/aria'
|
import { mergeProps, type RadioGroupProps } from '#/components/aria'
|
||||||
|
import type { FieldComponentProps } from '#/components/AriaComponents'
|
||||||
import {
|
import {
|
||||||
|
Form,
|
||||||
type FieldPath,
|
type FieldPath,
|
||||||
type FieldProps,
|
type FieldProps,
|
||||||
type FieldStateProps,
|
type FieldStateProps,
|
||||||
type FieldValues,
|
type FieldValues,
|
||||||
Form,
|
type FieldVariantProps,
|
||||||
type TSchema,
|
type TSchema,
|
||||||
} from '#/components/AriaComponents'
|
} from '#/components/AriaComponents'
|
||||||
|
|
||||||
@ -18,7 +20,6 @@ import RadioGroup from '#/components/styled/RadioGroup'
|
|||||||
import { mergeRefs } from '#/utilities/mergeRefs'
|
import { mergeRefs } from '#/utilities/mergeRefs'
|
||||||
import { forwardRef } from '#/utilities/react'
|
import { forwardRef } from '#/utilities/react'
|
||||||
import { tv } from '#/utilities/tailwindVariants'
|
import { tv } from '#/utilities/tailwindVariants'
|
||||||
import { Controller } from 'react-hook-form'
|
|
||||||
import { SelectorOption } from './SelectorOption'
|
import { SelectorOption } from './SelectorOption'
|
||||||
|
|
||||||
/** * Props for the Selector component. */
|
/** * Props for the Selector component. */
|
||||||
@ -29,7 +30,8 @@ export interface SelectorProps<Schema extends TSchema, TFieldName extends FieldP
|
|||||||
TFieldName
|
TFieldName
|
||||||
>,
|
>,
|
||||||
FieldProps,
|
FieldProps,
|
||||||
Omit<twv.VariantProps<typeof SELECTOR_STYLES>, 'disabled' | 'invalid'> {
|
Omit<twv.VariantProps<typeof SELECTOR_STYLES>, 'disabled' | 'invalid'>,
|
||||||
|
FieldVariantProps {
|
||||||
readonly items: readonly FieldValues<Schema>[TFieldName][]
|
readonly items: readonly FieldValues<Schema>[TFieldName][]
|
||||||
readonly children?: (item: FieldValues<Schema>[TFieldName]) => string
|
readonly children?: (item: FieldValues<Schema>[TFieldName]) => string
|
||||||
readonly columns?: number
|
readonly columns?: number
|
||||||
@ -90,23 +92,20 @@ export const Selector = forwardRef(function Selector<
|
|||||||
isDisabled = false,
|
isDisabled = false,
|
||||||
columns,
|
columns,
|
||||||
form,
|
form,
|
||||||
defaultValue,
|
|
||||||
inputRef,
|
inputRef,
|
||||||
label,
|
label,
|
||||||
size,
|
size,
|
||||||
rounded,
|
rounded,
|
||||||
isRequired = false,
|
isRequired = false,
|
||||||
|
isInvalid = false,
|
||||||
|
fieldVariants,
|
||||||
|
defaultValue,
|
||||||
...inputProps
|
...inputProps
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const privateInputRef = React.useRef<HTMLDivElement>(null)
|
const privateInputRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const { fieldState, formInstance } = Form.useField({
|
const formInstance = Form.useFormContext(form)
|
||||||
name,
|
|
||||||
isDisabled,
|
|
||||||
form,
|
|
||||||
...(defaultValue != null ? { defaultValue } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const classes = SELECTOR_STYLES({
|
const classes = SELECTOR_STYLES({
|
||||||
size,
|
size,
|
||||||
@ -116,51 +115,49 @@ export const Selector = forwardRef(function Selector<
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Field
|
<Form.Controller
|
||||||
form={formInstance}
|
control={formInstance.control}
|
||||||
name={name}
|
name={name}
|
||||||
fullWidth
|
render={(renderProps) => {
|
||||||
label={label}
|
const { value } = renderProps.field
|
||||||
aria-label={props['aria-label']}
|
return (
|
||||||
aria-labelledby={props['aria-labelledby']}
|
<Form.Field
|
||||||
aria-describedby={props['aria-describedby']}
|
{...mergeProps<FieldComponentProps<Schema>>()(inputProps, renderProps.field, {
|
||||||
isRequired={isRequired}
|
fullWidth: true,
|
||||||
isInvalid={fieldState.invalid}
|
variants: fieldVariants,
|
||||||
aria-details={props['aria-details']}
|
form: formInstance,
|
||||||
ref={ref}
|
label,
|
||||||
style={props.style}
|
isRequired,
|
||||||
className={props.className}
|
})}
|
||||||
>
|
name={props.name}
|
||||||
<div
|
ref={ref}
|
||||||
className={classes.base()}
|
>
|
||||||
onClick={() => privateInputRef.current?.focus({ preventScroll: true })}
|
<div
|
||||||
>
|
className={classes.base()}
|
||||||
<Controller
|
onClick={() => privateInputRef.current?.focus({ preventScroll: true })}
|
||||||
control={formInstance.control}
|
>
|
||||||
name={name}
|
|
||||||
render={(renderProps) => {
|
|
||||||
const { ref: fieldRef, value, onChange, ...field } = renderProps.field
|
|
||||||
return (
|
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
ref={mergeRefs(inputRef, privateInputRef, fieldRef)}
|
|
||||||
{...mergeProps<RadioGroupProps>()(
|
{...mergeProps<RadioGroupProps>()(
|
||||||
{
|
{
|
||||||
className: classes.radioGroup(),
|
className: classes.radioGroup(),
|
||||||
name,
|
name,
|
||||||
isRequired,
|
isRequired,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
|
isInvalid,
|
||||||
style:
|
style:
|
||||||
columns != null ? { gridTemplateColumns: `repeat(${columns}, 1fr)` } : {},
|
columns != null ? { gridTemplateColumns: `repeat(${columns}, 1fr)` } : {},
|
||||||
|
...(defaultValue != null ? { defaultValue } : {}),
|
||||||
},
|
},
|
||||||
inputProps,
|
inputProps,
|
||||||
field,
|
renderProps.field,
|
||||||
)}
|
)}
|
||||||
|
ref={mergeRefs(inputRef, privateInputRef, renderProps.field.ref)}
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
aria-label={props['aria-label'] ?? (typeof label === 'string' ? label : '')}
|
aria-label={props['aria-label'] ?? (typeof label === 'string' ? label : '')}
|
||||||
value={String(items.indexOf(value))}
|
value={String(items.indexOf(value))}
|
||||||
onChange={(newValue) => {
|
onChange={(newValue) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
onChange(items[Number(newValue)])
|
renderProps.field.onChange(items[Number(newValue)])
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AnimatedBackground value={String(items.indexOf(value))}>
|
<AnimatedBackground value={String(items.indexOf(value))}>
|
||||||
@ -169,10 +166,10 @@ export const Selector = forwardRef(function Selector<
|
|||||||
))}
|
))}
|
||||||
</AnimatedBackground>
|
</AnimatedBackground>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
)
|
</div>
|
||||||
}}
|
</Form.Field>
|
||||||
/>
|
)
|
||||||
</div>
|
}}
|
||||||
</Form.Field>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@ -2,9 +2,9 @@
|
|||||||
import { AnimatedBackground } from '#/components/AnimatedBackground'
|
import { AnimatedBackground } from '#/components/AnimatedBackground'
|
||||||
import { Radio, type RadioProps } from '#/components/aria'
|
import { Radio, type RadioProps } from '#/components/aria'
|
||||||
import { forwardRef } from '#/utilities/react'
|
import { forwardRef } from '#/utilities/react'
|
||||||
|
import type { VariantProps } from '#/utilities/tailwindVariants'
|
||||||
import { tv } from '#/utilities/tailwindVariants'
|
import { tv } from '#/utilities/tailwindVariants'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import type { VariantProps } from 'tailwind-variants'
|
|
||||||
import { TEXT_STYLE } from '../../Text'
|
import { TEXT_STYLE } from '../../Text'
|
||||||
|
|
||||||
/** Props for a {@link SelectorOption}. */
|
/** Props for a {@link SelectorOption}. */
|
||||||
@ -99,9 +99,18 @@ export const SelectorOption = forwardRef(function SelectorOption(
|
|||||||
props: SelectorOptionProps,
|
props: SelectorOptionProps,
|
||||||
ref: React.ForwardedRef<HTMLLabelElement>,
|
ref: React.ForwardedRef<HTMLLabelElement>,
|
||||||
) {
|
) {
|
||||||
const { label, value, size, rounded, variant, className, ...radioProps } = props
|
const {
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
size,
|
||||||
|
rounded,
|
||||||
|
variant,
|
||||||
|
className,
|
||||||
|
variants = SELECTOR_OPTION_STYLES,
|
||||||
|
...radioProps
|
||||||
|
} = props
|
||||||
|
|
||||||
const styles = SELECTOR_OPTION_STYLES({ size, rounded, variant })
|
const styles = variants({ size, rounded, variant })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedBackground.Item
|
<AnimatedBackground.Item
|
||||||
|
@ -8,6 +8,7 @@ export * from './DatePicker'
|
|||||||
export * from './Dropdown'
|
export * from './Dropdown'
|
||||||
export * from './Input'
|
export * from './Input'
|
||||||
export * from './MultiSelector'
|
export * from './MultiSelector'
|
||||||
|
export * from './OTPInput'
|
||||||
export * from './Password'
|
export * from './Password'
|
||||||
export * from './ResizableInput'
|
export * from './ResizableInput'
|
||||||
export * from './Selector'
|
export * from './Selector'
|
||||||
|
@ -39,7 +39,7 @@ export const INPUT_STYLES = tv({
|
|||||||
variant: {
|
variant: {
|
||||||
custom: {},
|
custom: {},
|
||||||
outline: {
|
outline: {
|
||||||
base: 'border-[0.5px] border-primary/20 outline-offset-2 focus-within:border-primary/50 focus-within:outline focus-within:outline-2 focus-within:outline-offset-[0.5px] focus-within:outline-primary',
|
base: 'border-[0.5px] border-primary/20 outline-offset-2 focus-within:border-primary/50 focus-within:outline focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-primary',
|
||||||
textArea: 'border-transparent focus-within:border-transparent',
|
textArea: 'border-transparent focus-within:border-transparent',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -127,6 +127,8 @@ export const Switch = forwardRef(function Switch<
|
|||||||
{...mergeProps<AriaSwitchProps>()(ariaSwitchProps, fieldProps, {
|
{...mergeProps<AriaSwitchProps>()(ariaSwitchProps, fieldProps, {
|
||||||
defaultSelected: field.value,
|
defaultSelected: field.value,
|
||||||
className: switchStyles(),
|
className: switchStyles(),
|
||||||
|
onChange: field.onChange,
|
||||||
|
onBlur: field.onBlur,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className={background()} role="presentation">
|
<div className={background()} role="presentation">
|
||||||
|
@ -212,6 +212,8 @@ export const Text = forwardRef(function Text(props: TextProps, ref: React.Ref<HT
|
|||||||
}) as unknown as React.FC<React.RefAttributes<HTMLSpanElement> & TextProps> & {
|
}) as unknown as React.FC<React.RefAttributes<HTMLSpanElement> & TextProps> & {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
Heading: typeof Heading
|
Heading: typeof Heading
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
Group: React.FC<React.PropsWithChildren>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -234,3 +236,14 @@ const Heading = forwardRef(function Heading(
|
|||||||
return <Text ref={ref} elementType={`h${level}`} variant="h1" balance {...textProps} />
|
return <Text ref={ref} elementType={`h${level}`} variant="h1" balance {...textProps} />
|
||||||
})
|
})
|
||||||
Text.Heading = Heading
|
Text.Heading = Heading
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text group component. It's used to visually group text elements together
|
||||||
|
*/
|
||||||
|
Text.Group = function TextGroup(props: React.PropsWithChildren) {
|
||||||
|
return (
|
||||||
|
<textProvider.TextProvider value={{ isInsideTextComponent: true }}>
|
||||||
|
{props.children}
|
||||||
|
</textProvider.TextProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -168,8 +168,8 @@ export function EnsoDevtools() {
|
|||||||
|
|
||||||
<ariaComponents.Form
|
<ariaComponents.Form
|
||||||
gap="small"
|
gap="small"
|
||||||
formOptions={{ mode: 'onChange' }}
|
|
||||||
schema={FEATURE_FLAGS_SCHEMA}
|
schema={FEATURE_FLAGS_SCHEMA}
|
||||||
|
formOptions={{ mode: 'onChange' }}
|
||||||
defaultValues={{
|
defaultValues={{
|
||||||
enableMultitabs: featureFlags.enableMultitabs,
|
enableMultitabs: featureFlags.enableMultitabs,
|
||||||
enableAssetsTableBackgroundRefresh: featureFlags.enableAssetsTableBackgroundRefresh,
|
enableAssetsTableBackgroundRefresh: featureFlags.enableAssetsTableBackgroundRefresh,
|
||||||
|
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 { AnimatePresence, motion } from 'framer-motion'
|
||||||
import * as tvw from 'tailwind-variants'
|
import * as tvw from 'tailwind-variants'
|
||||||
|
|
||||||
import DoneIcon from '#/assets/check_mark.svg'
|
|
||||||
|
|
||||||
import * as eventCallback from '#/hooks/eventCallbackHooks'
|
import * as eventCallback from '#/hooks/eventCallbackHooks'
|
||||||
|
|
||||||
import * as ariaComponents from '#/components/AriaComponents'
|
|
||||||
import { ErrorBoundary } from '#/components/ErrorBoundary'
|
import { ErrorBoundary } from '#/components/ErrorBoundary'
|
||||||
import { Suspense } from '#/components/Suspense'
|
import { Suspense } from '#/components/Suspense'
|
||||||
import SvgMask from '#/components/SvgMask'
|
|
||||||
|
|
||||||
|
import { Step } from './Step'
|
||||||
|
import { StepContent } from './StepContent'
|
||||||
import * as stepperProvider from './StepperProvider'
|
import * as stepperProvider from './StepperProvider'
|
||||||
|
import type { BaseRenderProps, RenderChildrenProps, RenderStepProps } from './types'
|
||||||
import * as stepperState from './useStepperState'
|
import * as stepperState from './useStepperState'
|
||||||
|
|
||||||
/**
|
|
||||||
* Render props for the stepper component.
|
|
||||||
*/
|
|
||||||
export interface BaseRenderProps {
|
|
||||||
readonly goToStep: (step: number) => void
|
|
||||||
readonly nextStep: () => void
|
|
||||||
readonly previousStep: () => void
|
|
||||||
readonly currentStep: number
|
|
||||||
readonly totalSteps: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render props for rendering children of the stepper component.
|
|
||||||
*/
|
|
||||||
export interface RenderChildrenProps extends BaseRenderProps {
|
|
||||||
readonly isFirst: boolean
|
|
||||||
readonly isLast: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render props for lazy rendering of steps.
|
|
||||||
*/
|
|
||||||
export interface RenderStepProps extends BaseRenderProps {
|
|
||||||
/**
|
|
||||||
* The index of the step, starting from 0.
|
|
||||||
*/
|
|
||||||
readonly index: number
|
|
||||||
readonly isCurrent: boolean
|
|
||||||
readonly isCompleted: boolean
|
|
||||||
readonly isFirst: boolean
|
|
||||||
readonly isLast: boolean
|
|
||||||
readonly isDisabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render props for styling the stepper component.
|
|
||||||
*/
|
|
||||||
export interface RenderStepperProps {
|
|
||||||
readonly currentStep: number
|
|
||||||
readonly totalSteps: number
|
|
||||||
readonly isFirst: boolean
|
|
||||||
readonly isLast: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for {@link Stepper} component.
|
* Props for {@link Stepper} component.
|
||||||
*/
|
*/
|
||||||
@ -228,187 +183,6 @@ export function Stepper(props: StepperProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A prop with the given type, or a function to produce a value of the given type. */
|
|
||||||
type StepProp<T> = T | ((props: RenderStepProps) => T)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for {@link Step} component.
|
|
||||||
*/
|
|
||||||
export interface StepProps extends RenderStepProps {
|
|
||||||
readonly className?: StepProp<string | null | undefined>
|
|
||||||
readonly icon?: StepProp<React.ReactElement | string | null | undefined>
|
|
||||||
readonly completeIcon?: StepProp<React.ReactElement | string | null | undefined>
|
|
||||||
readonly title?: StepProp<React.ReactElement | string | null | undefined>
|
|
||||||
readonly description?: StepProp<React.ReactElement | string | null | undefined>
|
|
||||||
readonly children?: StepProp<React.ReactNode>
|
|
||||||
}
|
|
||||||
|
|
||||||
const STEP_STYLES = tvw.tv({
|
|
||||||
base: 'relative flex items-center gap-2 select-none',
|
|
||||||
slots: {
|
|
||||||
icon: 'w-6 h-6 border-0.5 flex-none border-current rounded-full flex items-center justify-center transition-colors duration-200',
|
|
||||||
titleContainer: '-mt-1 flex flex-col items-start justify-start transition-colors duration-200',
|
|
||||||
content: 'flex-1',
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
position: { first: 'rounded-l-full', last: 'rounded-r-full' },
|
|
||||||
status: {
|
|
||||||
completed: {
|
|
||||||
base: 'text-primary',
|
|
||||||
icon: 'bg-primary border-transparent text-invert',
|
|
||||||
content: 'text-primary',
|
|
||||||
},
|
|
||||||
current: { base: 'text-primary', content: 'text-primary/30' },
|
|
||||||
next: { base: 'text-primary/30', content: 'text-primary/30' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A step component is used to represent a single step in a stepper component.
|
|
||||||
*/
|
|
||||||
function Step(props: StepProps) {
|
|
||||||
const {
|
|
||||||
index,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
isCompleted,
|
|
||||||
goToStep,
|
|
||||||
nextStep,
|
|
||||||
previousStep,
|
|
||||||
totalSteps,
|
|
||||||
currentStep,
|
|
||||||
isCurrent,
|
|
||||||
isLast,
|
|
||||||
isFirst,
|
|
||||||
isDisabled,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
icon = (
|
|
||||||
<ariaComponents.Text variant="subtitle" color="current" aria-hidden>
|
|
||||||
{index + 1}
|
|
||||||
</ariaComponents.Text>
|
|
||||||
),
|
|
||||||
completeIcon = DoneIcon,
|
|
||||||
} = props
|
|
||||||
|
|
||||||
const { state } = stepperProvider.useStepperContext()
|
|
||||||
|
|
||||||
const renderStepProps = {
|
|
||||||
isCompleted,
|
|
||||||
goToStep,
|
|
||||||
nextStep,
|
|
||||||
previousStep,
|
|
||||||
totalSteps,
|
|
||||||
currentStep,
|
|
||||||
isCurrent,
|
|
||||||
isLast,
|
|
||||||
isFirst,
|
|
||||||
isDisabled,
|
|
||||||
index,
|
|
||||||
} satisfies RenderStepProps
|
|
||||||
|
|
||||||
const classes = typeof className === 'function' ? className(renderStepProps) : className
|
|
||||||
const descriptionElement =
|
|
||||||
typeof description === 'function' ? description(renderStepProps) : description
|
|
||||||
const titleElement = typeof title === 'function' ? title(renderStepProps) : title
|
|
||||||
const iconElement = typeof icon === 'function' ? icon(renderStepProps) : icon
|
|
||||||
const doneIconElement =
|
|
||||||
typeof completeIcon === 'function' ? completeIcon(renderStepProps) : completeIcon
|
|
||||||
|
|
||||||
const styles = STEP_STYLES({
|
|
||||||
className: classes,
|
|
||||||
position:
|
|
||||||
isFirst ? 'first'
|
|
||||||
: isLast ? 'last'
|
|
||||||
: undefined,
|
|
||||||
status:
|
|
||||||
isCompleted ? 'completed'
|
|
||||||
: isCurrent ? 'current'
|
|
||||||
: 'next',
|
|
||||||
})
|
|
||||||
|
|
||||||
const stepAnimationRotation = 30
|
|
||||||
const stepAnimationScale = 0.5
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.base()}>
|
|
||||||
<AnimatePresence initial={false} mode="sync" custom={state.direction}>
|
|
||||||
<motion.div
|
|
||||||
key={isCompleted ? 'done' : 'icon'}
|
|
||||||
className={styles.icon()}
|
|
||||||
initial="enter"
|
|
||||||
animate="center"
|
|
||||||
exit="exit"
|
|
||||||
variants={{
|
|
||||||
enter: {
|
|
||||||
rotate:
|
|
||||||
state.direction === 'forward' ? -stepAnimationRotation : stepAnimationRotation,
|
|
||||||
scale: stepAnimationScale,
|
|
||||||
opacity: 0,
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
},
|
|
||||||
center: {
|
|
||||||
rotate: 0,
|
|
||||||
scale: 1,
|
|
||||||
opacity: 1,
|
|
||||||
position: 'static',
|
|
||||||
},
|
|
||||||
exit: (direction: stepperState.StepperState['direction']) => ({
|
|
||||||
rotate: direction === 'back' ? -stepAnimationRotation : stepAnimationRotation,
|
|
||||||
scale: stepAnimationScale,
|
|
||||||
opacity: 0,
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
|
||||||
rotate: { type: 'spring', stiffness: 500, damping: 100, bounce: 0, duration: 0.2 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
const renderIconElement = isCompleted ? doneIconElement : iconElement
|
|
||||||
|
|
||||||
if (renderIconElement == null) {
|
|
||||||
return null
|
|
||||||
} else if (typeof renderIconElement === 'string') {
|
|
||||||
return <SvgMask src={renderIconElement} />
|
|
||||||
} else {
|
|
||||||
return renderIconElement
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<div className={styles.titleContainer()}>
|
|
||||||
{titleElement != null && (
|
|
||||||
<div>
|
|
||||||
{typeof titleElement === 'string' ?
|
|
||||||
<ariaComponents.Text nowrap color="current">
|
|
||||||
{titleElement}
|
|
||||||
</ariaComponents.Text>
|
|
||||||
: titleElement}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{descriptionElement != null && (
|
|
||||||
<div>
|
|
||||||
{typeof descriptionElement === 'string' ?
|
|
||||||
<ariaComponents.Text variant="body" color="current" truncate="2">
|
|
||||||
{descriptionElement}
|
|
||||||
</ariaComponents.Text>
|
|
||||||
: descriptionElement}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.content()}>
|
|
||||||
{typeof children === 'function' ? children(renderStepProps) : children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Stepper.Step = Step
|
Stepper.Step = Step
|
||||||
|
Stepper.StepContent = StepContent
|
||||||
Stepper.useStepperState = stepperState.useStepperState
|
Stepper.useStepperState = stepperState.useStepperState
|
||||||
|
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(
|
const setCurrentStep = eventCallbackHooks.useEventCallback(
|
||||||
(step: number | ((current: number) => number)) => {
|
(step: number | ((current: number) => number)) => {
|
||||||
privateSetCurrentStep((current) => {
|
React.startTransition(() => {
|
||||||
const nextStep = typeof step === 'function' ? step(current.current) : step
|
privateSetCurrentStep((current) => {
|
||||||
const direction = nextStep > current.current ? 'forward' : 'back'
|
const nextStep = typeof step === 'function' ? step(current.current) : step
|
||||||
|
const direction = nextStep > current.current ? 'forward' : 'back'
|
||||||
|
|
||||||
if (nextStep < 0) {
|
if (nextStep < 0) {
|
||||||
return { current: 0, direction: 'back-none' }
|
return {
|
||||||
} else if (nextStep > steps - 1) {
|
current: 0,
|
||||||
onCompletedStableCallback()
|
direction: 'back-none',
|
||||||
return { current: steps - 1, direction: 'forward-none' }
|
}
|
||||||
} else {
|
} else if (nextStep > steps - 1) {
|
||||||
onStepChangeStableCallback(nextStep, direction)
|
onCompletedStableCallback()
|
||||||
return { current: nextStep, direction }
|
return {
|
||||||
}
|
current: steps - 1,
|
||||||
|
direction: 'forward-none',
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onStepChangeStableCallback(nextStep, direction)
|
||||||
|
|
||||||
|
return { current: nextStep, direction }
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -331,7 +331,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
return
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -549,18 +549,18 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
|
|||||||
}
|
}
|
||||||
case AssetEventType.deleteLabel: {
|
case AssetEventType.deleteLabel: {
|
||||||
setAsset((oldAsset) => {
|
setAsset((oldAsset) => {
|
||||||
// The IIFE is required to prevent TypeScript from narrowing this value.
|
const oldLabels = oldAsset.labels ?? []
|
||||||
let found = (() => false)()
|
const labels: backendModule.LabelName[] = []
|
||||||
const labels =
|
|
||||||
oldAsset.labels?.filter((label) => {
|
for (const label of oldLabels) {
|
||||||
if (label === event.labelName) {
|
if (label !== event.labelName) {
|
||||||
found = true
|
labels.push(label)
|
||||||
return false
|
}
|
||||||
} else {
|
}
|
||||||
return true
|
|
||||||
}
|
return oldLabels.length !== labels.length ?
|
||||||
}) ?? null
|
object.merge(oldAsset, { labels })
|
||||||
return found ? object.merge(oldAsset, { labels }) : oldAsset
|
: oldAsset
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -6,10 +6,9 @@ import type * as jsonSchemaInput from '#/components/JSONSchemaInput'
|
|||||||
import JSONSchemaInput from '#/components/JSONSchemaInput'
|
import JSONSchemaInput from '#/components/JSONSchemaInput'
|
||||||
|
|
||||||
import { FieldError } from '#/components/aria'
|
import { FieldError } from '#/components/aria'
|
||||||
import type { FieldValues, FormInstance, TSchema } from '#/components/AriaComponents'
|
import type { FieldPath, FormInstance, TSchema } from '#/components/AriaComponents'
|
||||||
import { useFormContext } from '#/components/AriaComponents/Form/components/useFormContext'
|
import { Form } from '#/components/AriaComponents'
|
||||||
import * as error from '#/utilities/error'
|
import * as error from '#/utilities/error'
|
||||||
import { Controller, type FieldPath } from 'react-hook-form'
|
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// === Constants ===
|
// === Constants ===
|
||||||
@ -52,17 +51,17 @@ export default function DatalinkInput(props: DatalinkInputProps) {
|
|||||||
export interface DatalinkFormInputProps<Schema extends TSchema>
|
export interface DatalinkFormInputProps<Schema extends TSchema>
|
||||||
extends Omit<DatalinkInputProps, 'onChange' | 'value'> {
|
extends Omit<DatalinkInputProps, 'onChange' | 'value'> {
|
||||||
readonly form?: FormInstance<Schema>
|
readonly form?: FormInstance<Schema>
|
||||||
readonly name: FieldPath<FieldValues<Schema>>
|
readonly name: FieldPath<Schema>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A dynamic wizard for creating an arbitrary type of Datalink. */
|
/** A dynamic wizard for creating an arbitrary type of Datalink. */
|
||||||
export function DatalinkFormInput<Schema extends TSchema>(props: DatalinkFormInputProps<Schema>) {
|
export function DatalinkFormInput<Schema extends TSchema>(props: DatalinkFormInputProps<Schema>) {
|
||||||
const fallbackForm = useFormContext()
|
const { name, ...inputProps } = props
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
const { form = fallbackForm as unknown as FormInstance<Schema>, name, ...inputProps } = props
|
const form = Form.useFormContext(props.form)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Form.Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={name}
|
name={name}
|
||||||
render={({ field, fieldState }) => {
|
render={({ field, fieldState }) => {
|
||||||
|
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.
|
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
|
||||||
readonly ENSO_CLOUD_STRIPE_KEY?: string
|
readonly ENSO_CLOUD_STRIPE_KEY?: string
|
||||||
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
|
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
|
||||||
readonly ENSO_CLOUD_COGNITO_USER_POOL_ID?: string
|
readonly ENSO_CLOUD_COGNITO_USER_POOL_ID: string
|
||||||
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
|
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
|
||||||
readonly ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID?: string
|
readonly ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: string
|
||||||
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
|
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
|
||||||
readonly ENSO_CLOUD_COGNITO_DOMAIN?: string
|
readonly ENSO_CLOUD_COGNITO_DOMAIN: string
|
||||||
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
|
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
|
||||||
readonly ENSO_CLOUD_COGNITO_REGION?: string
|
readonly ENSO_CLOUD_COGNITO_REGION: string
|
||||||
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
|
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
|
||||||
readonly ENSO_CLOUD_GOOGLE_ANALYTICS_TAG?: string
|
readonly ENSO_CLOUD_GOOGLE_ANALYTICS_TAG?: string
|
||||||
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
|
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
|
||||||
|
@ -183,7 +183,7 @@ export default function Settings() {
|
|||||||
}, [isQueryBlank, doesEntryMatchQuery, getText, isMatch, effectiveTab])
|
}, [isQueryBlank, doesEntryMatchQuery, getText, isMatch, effectiveTab])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden px-page-x pt-4">
|
<div className="flex flex-1 flex-col gap-4 overflow-hidden pl-page-x pt-4">
|
||||||
<aria.Heading level={1} className="flex items-center px-heading-x">
|
<aria.Heading level={1} className="flex items-center px-heading-x">
|
||||||
<aria.MenuTrigger isOpen={isSidebarPopoverOpen} onOpenChange={setIsSidebarPopoverOpen}>
|
<aria.MenuTrigger isOpen={isSidebarPopoverOpen} onOpenChange={setIsSidebarPopoverOpen}>
|
||||||
<Button image={BurgerMenuIcon} buttonClassName="mr-3 sm:hidden" onPress={() => {}} />
|
<Button image={BurgerMenuIcon} buttonClassName="mr-3 sm:hidden" onPress={() => {}} />
|
||||||
@ -208,13 +208,12 @@ export default function Settings() {
|
|||||||
<ariaComponents.Text
|
<ariaComponents.Text
|
||||||
variant="h1"
|
variant="h1"
|
||||||
truncate="1"
|
truncate="1"
|
||||||
className="ml-2.5 max-w-lg rounded-full bg-white px-2.5 font-bold"
|
className="ml-2.5 mr-8 max-w-lg rounded-full bg-white px-2.5 font-bold"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
{data.organizationOnly === true ? organization?.name ?? 'your organization' : user.name}
|
{data.organizationOnly === true ? organization?.name ?? 'your organization' : user.name}
|
||||||
</ariaComponents.Text>
|
</ariaComponents.Text>
|
||||||
</aria.Heading>
|
|
||||||
<div className="flex sm:ml-[222px]">
|
|
||||||
<SearchBar
|
<SearchBar
|
||||||
data-testid="settings-search-bar"
|
data-testid="settings-search-bar"
|
||||||
query={query}
|
query={query}
|
||||||
@ -222,8 +221,9 @@ export default function Settings() {
|
|||||||
label={getText('settingsSearchBarLabel')}
|
label={getText('settingsSearchBarLabel')}
|
||||||
placeholder={getText('settingsSearchBarPlaceholder')}
|
placeholder={getText('settingsSearchBarPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</aria.Heading>
|
||||||
<div className="flex flex-1 gap-6 overflow-hidden pr-0.5">
|
<div className="flex sm:ml-[222px]" />
|
||||||
|
<div className="flex flex-1 gap-4 overflow-hidden">
|
||||||
<aside className="hidden h-full shrink-0 basis-[206px] flex-col overflow-y-auto overflow-x-hidden pb-12 sm:flex">
|
<aside className="hidden h-full shrink-0 basis-[206px] flex-col overflow-y-auto overflow-x-hidden pb-12 sm:flex">
|
||||||
<SettingsSidebar
|
<SettingsSidebar
|
||||||
context={context}
|
context={context}
|
||||||
@ -232,15 +232,17 @@ export default function Settings() {
|
|||||||
setTab={setTab}
|
setTab={setTab}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
<SettingsTab
|
<main className="flex flex-1 flex-col overflow-y-auto pb-12 pl-1 scrollbar-gutter-stable">
|
||||||
context={context}
|
<SettingsTab
|
||||||
data={data}
|
context={context}
|
||||||
onInteracted={() => {
|
data={data}
|
||||||
if (effectiveTab !== tab) {
|
onInteracted={() => {
|
||||||
setTab(effectiveTab)
|
if (effectiveTab !== tab) {
|
||||||
}
|
setTab(effectiveTab)
|
||||||
}}
|
}
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -67,20 +67,20 @@ export default function SettingsTab(props: SettingsTabProps) {
|
|||||||
} else {
|
} else {
|
||||||
const content =
|
const content =
|
||||||
columns.length === 1 ?
|
columns.length === 1 ?
|
||||||
<div className="flex grow flex-col gap-settings-subsection overflow-auto" {...contentProps}>
|
<div className="flex grow flex-col gap-settings-subsection" {...contentProps}>
|
||||||
{sections.map((section) => (
|
{sections.map((section) => (
|
||||||
<SettingsSection key={section.nameId} context={context} data={section} />
|
<SettingsSection key={section.nameId} context={context} data={section} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
: <div
|
: <div
|
||||||
className="flex min-h-full grow flex-col gap-settings-section overflow-auto lg:h-auto lg:flex-row"
|
className="flex min-h-full grow flex-col gap-settings-section lg:h-auto lg:flex-row"
|
||||||
{...contentProps}
|
{...contentProps}
|
||||||
>
|
>
|
||||||
{columns.map((sectionsInColumn, i) => (
|
{columns.map((sectionsInColumn, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={tailwindMerge.twMerge(
|
className={tailwindMerge.twMerge(
|
||||||
'flex flex-1 flex-col gap-settings-subsection',
|
'flex h-fit w-0 flex-1 flex-col gap-settings-subsection pb-12',
|
||||||
classes[i],
|
classes[i],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
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 { normalizePath } from '#/utilities/fileInfo'
|
||||||
import * as object from '#/utilities/object'
|
import * as object from '#/utilities/object'
|
||||||
|
import { SetupTwoFaForm } from './SetupTwoFaForm'
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// === SettingsEntryType ===
|
// === SettingsEntryType ===
|
||||||
@ -124,6 +125,23 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
nameId: 'setup2FASettingsSection',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
type: SettingsEntryType.custom,
|
||||||
|
render: SetupTwoFaForm,
|
||||||
|
getVisible: (context) => {
|
||||||
|
// The shape of the JWT payload is statically known.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
const username: string | null =
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-non-null-assertion
|
||||||
|
JSON.parse(atob(context.accessToken.split('.')[1]!)).username
|
||||||
|
return username != null ? !/^Github_|^Google_/.test(username) : false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
nameId: 'deleteUserAccountSettingsSection',
|
nameId: 'deleteUserAccountSettingsSection',
|
||||||
heading: false,
|
heading: false,
|
||||||
|
@ -1,18 +1,8 @@
|
|||||||
/** @file Modal for confirming delete of any type of asset. */
|
/** @file Modal for confirming delete of any type of asset. */
|
||||||
import * as z from 'zod'
|
import { ButtonGroup, Dialog, Form, Input, Password } from '#/components/AriaComponents'
|
||||||
|
|
||||||
import { Button, ButtonGroup, Dialog, Form, Input, Password } from '#/components/AriaComponents'
|
|
||||||
import { useText } from '#/providers/TextProvider'
|
import { useText } from '#/providers/TextProvider'
|
||||||
import type { SecretId } from '#/services/Backend'
|
import type { SecretId } from '#/services/Backend'
|
||||||
|
|
||||||
/** Create the schema for this form. */
|
|
||||||
function createUpsertSecretSchema() {
|
|
||||||
return z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
value: z.string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// === UpsertSecretModal ===
|
// === UpsertSecretModal ===
|
||||||
// =========================
|
// =========================
|
||||||
@ -34,49 +24,43 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={isCreatingSecret ? getText('newSecret') : getText('editSecret')}>
|
<Dialog title={isCreatingSecret ? getText('newSecret') : getText('editSecret')}>
|
||||||
{({ close }) => (
|
<Form
|
||||||
<Form
|
testId="upsert-secret-modal"
|
||||||
data-testid="upsert-secret-modal"
|
method="dialog"
|
||||||
method="dialog"
|
schema={(z) => z.object({ name: z.string().min(1), value: z.string() })}
|
||||||
schema={createUpsertSecretSchema()}
|
defaultValues={{ name: nameRaw ?? '', value: '' }}
|
||||||
onSubmit={async ({ name, value }) => {
|
onSubmit={async ({ name, value }) => {
|
||||||
await doCreate(name, value)
|
await doCreate(name, value)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ form }) => (
|
{({ form }) => (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
form={form}
|
form={form}
|
||||||
name="name"
|
name="name"
|
||||||
autoFocus
|
autoFocus
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
disabled={!isNameEditable}
|
disabled={!isNameEditable}
|
||||||
label={getText('name')}
|
label={getText('name')}
|
||||||
placeholder={getText('secretNamePlaceholder')}
|
placeholder={getText('secretNamePlaceholder')}
|
||||||
defaultValue={nameRaw ?? undefined}
|
/>
|
||||||
/>
|
<Password
|
||||||
<Password
|
form={form}
|
||||||
form={form}
|
name="value"
|
||||||
name="value"
|
autoFocus={!isNameEditable}
|
||||||
autoFocus={!isNameEditable}
|
autoComplete="off"
|
||||||
autoComplete="off"
|
label={getText('value')}
|
||||||
label={getText('value')}
|
placeholder={
|
||||||
placeholder={
|
isNameEditable ? getText('secretValuePlaceholder') : getText('secretValueHidden')
|
||||||
isNameEditable ? getText('secretValuePlaceholder') : getText('secretValueHidden')
|
}
|
||||||
}
|
/>
|
||||||
/>
|
<ButtonGroup>
|
||||||
<ButtonGroup className="relative">
|
<Form.Submit>{isCreatingSecret ? getText('create') : getText('update')}</Form.Submit>
|
||||||
<Form.Submit>
|
<Form.Submit formnovalidate />
|
||||||
{isCreatingSecret ? getText('create') : getText('update')}
|
</ButtonGroup>
|
||||||
</Form.Submit>
|
</>
|
||||||
<Button variant="outline" onPress={close}>
|
)}
|
||||||
{getText('cancel')}
|
</Form>
|
||||||
</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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
|
* Barrel file for payments api
|
||||||
*/
|
*/
|
||||||
|
export * from './createPaymentMethod'
|
||||||
export * from './useSubscriptionPrice'
|
export * from './useSubscriptionPrice'
|
||||||
|
@ -7,11 +7,11 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import * as stripeReact from '@stripe/react-stripe-js'
|
import * as stripeReact from '@stripe/react-stripe-js'
|
||||||
import type * as stripeJs from '@stripe/stripe-js'
|
import type * as stripeJs from '@stripe/stripe-js'
|
||||||
import * as reactQuery from '@tanstack/react-query'
|
|
||||||
|
|
||||||
import * as text from '#/providers/TextProvider'
|
import * as text from '#/providers/TextProvider'
|
||||||
|
|
||||||
import * as ariaComponents from '#/components/AriaComponents'
|
import * as ariaComponents from '#/components/AriaComponents'
|
||||||
|
import { useCreatePaymentMethodMutation } from '../api/createPaymentMethod'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for {@link AddPaymentMethodForm}.
|
* Props for {@link AddPaymentMethodForm}.
|
||||||
@ -44,6 +44,8 @@ export const ADD_PAYMENT_METHOD_FORM_SCHEMA = ariaComponents.Form.schema.object(
|
|||||||
(data) => data?.error == null,
|
(data) => data?.error == null,
|
||||||
(data) => ({ message: data?.error?.message ?? 'This field is required' }),
|
(data) => ({ message: data?.error?.message ?? 'This field is required' }),
|
||||||
),
|
),
|
||||||
|
cardElement: ariaComponents.Form.schema.custom<stripeJs.StripeCardElement | null>(),
|
||||||
|
stripeInstance: ariaComponents.Form.schema.custom<stripeJs.Stripe>(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -52,54 +54,33 @@ export const ADD_PAYMENT_METHOD_FORM_SCHEMA = ariaComponents.Form.schema.object(
|
|||||||
export function AddPaymentMethodForm<
|
export function AddPaymentMethodForm<
|
||||||
Schema extends typeof ADD_PAYMENT_METHOD_FORM_SCHEMA = typeof ADD_PAYMENT_METHOD_FORM_SCHEMA,
|
Schema extends typeof ADD_PAYMENT_METHOD_FORM_SCHEMA = typeof ADD_PAYMENT_METHOD_FORM_SCHEMA,
|
||||||
>(props: AddPaymentMethodFormProps<Schema>) {
|
>(props: AddPaymentMethodFormProps<Schema>) {
|
||||||
const { stripeInstance, elements, onSubmit, submitText, form } = props
|
const { stripeInstance, onSubmit, submitText, form } = props
|
||||||
|
|
||||||
const { getText } = text.useText()
|
const { getText } = text.useText()
|
||||||
|
|
||||||
const [cardElement, setCardElement] = React.useState<stripeJs.StripeCardElement | null>(() =>
|
|
||||||
elements.getElement(stripeReact.CardElement),
|
|
||||||
)
|
|
||||||
|
|
||||||
const dialogContext = ariaComponents.useDialogContext()
|
const dialogContext = ariaComponents.useDialogContext()
|
||||||
|
|
||||||
const createPaymentMethodMutation = reactQuery.useMutation({
|
const createPaymentMethodMutation = useCreatePaymentMethodMutation()
|
||||||
mutationFn: async () => {
|
|
||||||
if (!cardElement) {
|
|
||||||
throw new Error('Unexpected error')
|
|
||||||
} else {
|
|
||||||
return stripeInstance
|
|
||||||
.createPaymentMethod({ type: 'card', card: cardElement })
|
|
||||||
.then((result) => {
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error.message)
|
|
||||||
} else {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// No idea if it's safe or not, but outside of the function everything is fine
|
// No idea if it's safe or not, but outside of the function everything is fine
|
||||||
// but for some reason TypeScript fails to infer the `card` field from the schema (it should always be there)
|
// but for some reason TypeScript fails to infer the `card` field from the schema (it should always be there)
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
const formInstance = ariaComponents.Form.useForm(
|
const formInstance = ariaComponents.Form.useForm(
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
form ?? {
|
||||||
(form as ariaComponents.FormInstance<typeof ADD_PAYMENT_METHOD_FORM_SCHEMA> | undefined) ?? {
|
|
||||||
schema: ADD_PAYMENT_METHOD_FORM_SCHEMA,
|
schema: ADD_PAYMENT_METHOD_FORM_SCHEMA,
|
||||||
|
onSubmit: ({ cardElement }) =>
|
||||||
|
createPaymentMethodMutation.mutateAsync({ stripeInstance, cardElement }),
|
||||||
|
onSubmitSuccess: ({ paymentMethod }) => onSubmit?.(paymentMethod.id),
|
||||||
},
|
},
|
||||||
)
|
) as unknown as ariaComponents.FormInstance<typeof ADD_PAYMENT_METHOD_FORM_SCHEMA>
|
||||||
|
|
||||||
|
const cardElement = ariaComponents.Form.useWatch({
|
||||||
|
control: formInstance.control,
|
||||||
|
name: 'cardElement',
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ariaComponents.Form
|
<ariaComponents.Form method="dialog" form={formInstance}>
|
||||||
method="dialog"
|
|
||||||
form={formInstance}
|
|
||||||
onSubmit={() =>
|
|
||||||
createPaymentMethodMutation.mutateAsync().then(async ({ paymentMethod }) => {
|
|
||||||
cardElement?.clear()
|
|
||||||
await onSubmit?.(paymentMethod.id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ariaComponents.Form.Field name="card" fullWidth label={getText('bankCardLabel')}>
|
<ariaComponents.Form.Field name="card" fullWidth label={getText('bankCardLabel')}>
|
||||||
<stripeReact.CardElement
|
<stripeReact.CardElement
|
||||||
options={{
|
options={{
|
||||||
@ -112,7 +93,8 @@ export function AddPaymentMethodForm<
|
|||||||
}}
|
}}
|
||||||
onEscape={() => dialogContext?.close()}
|
onEscape={() => dialogContext?.close()}
|
||||||
onReady={(element) => {
|
onReady={(element) => {
|
||||||
setCardElement(element)
|
formInstance.setValue('cardElement', element)
|
||||||
|
formInstance.setValue('stripeInstance', stripeInstance)
|
||||||
}}
|
}}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
if (event.error?.message != null) {
|
if (event.error?.message != null) {
|
||||||
|
@ -27,7 +27,7 @@ import type { Plan } from '#/services/Backend'
|
|||||||
|
|
||||||
import { twMerge } from '#/utilities/tailwindMerge'
|
import { twMerge } from '#/utilities/tailwindMerge'
|
||||||
|
|
||||||
import { createSubscriptionPriceQuery } from '../../../api'
|
import { createSubscriptionPriceQuery, useCreatePaymentMethodMutation } from '../../../api'
|
||||||
import {
|
import {
|
||||||
MAX_SEATS_BY_PLAN,
|
MAX_SEATS_BY_PLAN,
|
||||||
PRICE_BY_PLAN,
|
PRICE_BY_PLAN,
|
||||||
@ -77,6 +77,8 @@ export function PlanSelectorDialog(props: PlanSelectorDialogProps) {
|
|||||||
const price = PRICE_BY_PLAN[plan]
|
const price = PRICE_BY_PLAN[plan]
|
||||||
const maxSeats = MAX_SEATS_BY_PLAN[plan]
|
const maxSeats = MAX_SEATS_BY_PLAN[plan]
|
||||||
|
|
||||||
|
const createPaymentMethodMutation = useCreatePaymentMethodMutation()
|
||||||
|
|
||||||
const form = Form.useForm({
|
const form = Form.useForm({
|
||||||
schema: (z) =>
|
schema: (z) =>
|
||||||
ADD_PAYMENT_METHOD_FORM_SCHEMA.extend({
|
ADD_PAYMENT_METHOD_FORM_SCHEMA.extend({
|
||||||
@ -95,10 +97,18 @@ export function PlanSelectorDialog(props: PlanSelectorDialogProps) {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||||
defaultValues: { seats: 1, period: 12, agree: [] },
|
defaultValues: { seats: 1, period: 12, agree: [] },
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
|
onSubmit: async ({ cardElement, stripeInstance, seats, period }) => {
|
||||||
|
const res = await createPaymentMethodMutation.mutateAsync({
|
||||||
|
cardElement,
|
||||||
|
stripeInstance,
|
||||||
|
})
|
||||||
|
|
||||||
|
return onSubmit?.(res.paymentMethod.id, seats, period)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const seats = form.watch('seats')
|
const seats = Form.useWatch({ name: 'seats', control: form.control })
|
||||||
const period = form.watch('period')
|
const period = Form.useWatch({ name: 'period', control: form.control })
|
||||||
|
|
||||||
const formatter = React.useMemo(
|
const formatter = React.useMemo(
|
||||||
() => new Intl.NumberFormat(locale, { style: 'currency', currency: PRICE_CURRENCY }),
|
() => new Intl.NumberFormat(locale, { style: 'currency', currency: PRICE_CURRENCY }),
|
||||||
|
@ -36,8 +36,8 @@ export default function AuthenticationPage<Schema extends TSchema>(
|
|||||||
props: AuthenticationPageProps<Schema>,
|
props: AuthenticationPageProps<Schema>,
|
||||||
) {
|
) {
|
||||||
const { title, children, footer, supportsOffline = false, ...formProps } = props
|
const { title, children, footer, supportsOffline = false, ...formProps } = props
|
||||||
const { form, schema, onSubmit } = formProps
|
const { form, schema } = formProps
|
||||||
const isForm = onSubmit != null && (form != null || schema != null)
|
const isForm = schema != null || form != null
|
||||||
|
|
||||||
const { getText } = useText()
|
const { getText } = useText()
|
||||||
const { isOffline } = useOffline()
|
const { isOffline } = useOffline()
|
||||||
@ -88,7 +88,7 @@ export default function AuthenticationPage<Schema extends TSchema>(
|
|||||||
: <Form
|
: <Form
|
||||||
// This is SAFE, as the props type of this type extends `FormProps`.
|
// This is SAFE, as the props type of this type extends `FormProps`.
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
{...(formProps as FormProps<Schema>)}
|
{...(form ? { form } : (formProps as FormProps<Schema>))}
|
||||||
className={containerClasses}
|
className={containerClasses}
|
||||||
>
|
>
|
||||||
{(innerProps) => (
|
{(innerProps) => (
|
||||||
|
@ -3,15 +3,17 @@ import * as router from 'react-router-dom'
|
|||||||
|
|
||||||
import { CLOUD_DASHBOARD_DOMAIN } from 'enso-common'
|
import { CLOUD_DASHBOARD_DOMAIN } from 'enso-common'
|
||||||
|
|
||||||
import { FORGOT_PASSWORD_PATH, REGISTRATION_PATH } from '#/appUtils'
|
import { DASHBOARD_PATH, FORGOT_PASSWORD_PATH, REGISTRATION_PATH } from '#/appUtils'
|
||||||
import ArrowRightIcon from '#/assets/arrow_right.svg'
|
import ArrowRightIcon from '#/assets/arrow_right.svg'
|
||||||
import AtIcon from '#/assets/at.svg'
|
import AtIcon from '#/assets/at.svg'
|
||||||
import CreateAccountIcon from '#/assets/create_account.svg'
|
import CreateAccountIcon from '#/assets/create_account.svg'
|
||||||
import GithubIcon from '#/assets/github_color.svg'
|
import GithubIcon from '#/assets/github_color.svg'
|
||||||
import GoogleIcon from '#/assets/google_color.svg'
|
import GoogleIcon from '#/assets/google_color.svg'
|
||||||
import LockIcon from '#/assets/lock.svg'
|
import LockIcon from '#/assets/lock.svg'
|
||||||
import { Button, Form, Input, Password } from '#/components/AriaComponents'
|
import type { CognitoUser } from '#/authentication/cognito'
|
||||||
|
import { Button, Form, Input, OTPInput, Password, Text } from '#/components/AriaComponents'
|
||||||
import Link from '#/components/Link'
|
import Link from '#/components/Link'
|
||||||
|
import { Stepper } from '#/components/Stepper'
|
||||||
import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
|
import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
|
||||||
import { passwordSchema } from '#/pages/authentication/schemas'
|
import { passwordSchema } from '#/pages/authentication/schemas'
|
||||||
import { useAuth } from '#/providers/AuthProvider'
|
import { useAuth } from '#/providers/AuthProvider'
|
||||||
@ -26,14 +28,52 @@ import { useState } from 'react'
|
|||||||
/** A form for users to log in. */
|
/** A form for users to log in. */
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const location = router.useLocation()
|
const location = router.useLocation()
|
||||||
const { signInWithGoogle, signInWithGitHub, signInWithPassword } = useAuth()
|
const navigate = router.useNavigate()
|
||||||
|
const { signInWithGoogle, signInWithGitHub, signInWithPassword, cognito } = useAuth()
|
||||||
const { getText } = useText()
|
const { getText } = useText()
|
||||||
|
|
||||||
const query = new URLSearchParams(location.search)
|
const query = new URLSearchParams(location.search)
|
||||||
const initialEmail = query.get('email') ?? ''
|
const initialEmail = query.get('email') ?? ''
|
||||||
|
|
||||||
|
const form = Form.useForm({
|
||||||
|
schema: (z) =>
|
||||||
|
z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, getText('arbitraryFieldRequired'))
|
||||||
|
.email(getText('invalidEmailValidationError')),
|
||||||
|
password: passwordSchema(getText),
|
||||||
|
}),
|
||||||
|
defaultValues: { email: initialEmail },
|
||||||
|
onSubmit: async ({ email, password }) => {
|
||||||
|
const res = await signInWithPassword(email, password)
|
||||||
|
|
||||||
|
switch (res.challenge) {
|
||||||
|
case 'NO_CHALLENGE':
|
||||||
|
navigate(DASHBOARD_PATH)
|
||||||
|
break
|
||||||
|
case 'SMS_MFA':
|
||||||
|
case 'SOFTWARE_TOKEN_MFA':
|
||||||
|
setUser(res.user)
|
||||||
|
nextStep()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error('Unsupported challenge')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const [emailInput, setEmailInput] = useState(initialEmail)
|
const [emailInput, setEmailInput] = useState(initialEmail)
|
||||||
|
|
||||||
|
const [user, setUser] = useState<CognitoUser | null>(null)
|
||||||
const localBackend = useLocalBackend()
|
const localBackend = useLocalBackend()
|
||||||
const supportsOffline = localBackend != null
|
const supportsOffline = localBackend != null
|
||||||
|
|
||||||
|
const { nextStep, stepperState, previousStep } = Stepper.useStepperState({
|
||||||
|
steps: 2,
|
||||||
|
defaultStep: 0,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthenticationPage
|
<AuthenticationPage
|
||||||
title={getText('loginToYourAccount')}
|
title={getText('loginToYourAccount')}
|
||||||
@ -52,85 +92,126 @@ export default function Login() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-auth">
|
<Stepper state={stepperState} renderStep={() => null}>
|
||||||
<Button
|
<Stepper.StepContent index={0}>
|
||||||
size="large"
|
{() => (
|
||||||
variant="outline"
|
<div className="flex flex-col gap-auth">
|
||||||
icon={<img src={GoogleIcon} />}
|
<Button
|
||||||
onPress={async () => {
|
size="large"
|
||||||
await signInWithGoogle()
|
variant="outline"
|
||||||
}}
|
icon={GoogleIcon}
|
||||||
>
|
onPress={async () => {
|
||||||
{getText('signUpOrLoginWithGoogle')}
|
await signInWithGoogle()
|
||||||
</Button>
|
}}
|
||||||
<Button
|
>
|
||||||
size="large"
|
{getText('signUpOrLoginWithGoogle')}
|
||||||
variant="outline"
|
</Button>
|
||||||
icon={<img src={GithubIcon} />}
|
<Button
|
||||||
onPress={async () => {
|
size="large"
|
||||||
await signInWithGitHub()
|
variant="outline"
|
||||||
}}
|
icon={GithubIcon}
|
||||||
>
|
onPress={async () => {
|
||||||
{getText('signUpOrLoginWithGitHub')}
|
await signInWithGitHub()
|
||||||
</Button>
|
}}
|
||||||
</div>
|
>
|
||||||
|
{getText('signUpOrLoginWithGitHub')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Form
|
<Form form={form} gap="medium">
|
||||||
schema={(z) =>
|
<Input
|
||||||
z.object({
|
autoFocus
|
||||||
email: z
|
required
|
||||||
.string()
|
data-testid="email-input"
|
||||||
.min(1, getText('arbitraryFieldRequired'))
|
name="email"
|
||||||
.email(getText('invalidEmailValidationError')),
|
label={getText('email')}
|
||||||
password: passwordSchema(getText),
|
type="email"
|
||||||
})
|
autoComplete="email"
|
||||||
}
|
icon={AtIcon}
|
||||||
gap="medium"
|
placeholder={getText('emailPlaceholder')}
|
||||||
defaultValues={{ email: initialEmail }}
|
onChange={(event) => {
|
||||||
onSubmit={({ email, password }) => signInWithPassword(email, password)}
|
setEmailInput(event.currentTarget.value)
|
||||||
>
|
}}
|
||||||
<Input
|
/>
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
data-testid="email-input"
|
|
||||||
name="email"
|
|
||||||
label={getText('email')}
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
icon={AtIcon}
|
|
||||||
placeholder={getText('emailPlaceholder')}
|
|
||||||
onChange={(event) => {
|
|
||||||
setEmailInput(event.currentTarget.value)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
<Password
|
<Password
|
||||||
required
|
required
|
||||||
data-testid="password-input"
|
data-testid="password-input"
|
||||||
name="password"
|
name="password"
|
||||||
label={getText('password')}
|
label={getText('password')}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
icon={LockIcon}
|
icon={LockIcon}
|
||||||
placeholder={getText('passwordPlaceholder')}
|
placeholder={getText('passwordPlaceholder')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
href={`${FORGOT_PASSWORD_PATH}?${new URLSearchParams({ email: emailInput }).toString()}`}
|
href={`${FORGOT_PASSWORD_PATH}?${new URLSearchParams({ email: emailInput }).toString()}`}
|
||||||
size="small"
|
size="small"
|
||||||
className="self-end"
|
className="self-end"
|
||||||
>
|
>
|
||||||
{getText('forgotYourPassword')}
|
{getText('forgotYourPassword')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Form.Submit size="large" icon={ArrowRightIcon} iconPosition="end" fullWidth>
|
<Form.Submit size="large" icon={ArrowRightIcon} iconPosition="end" fullWidth>
|
||||||
{getText('login')}
|
{getText('login')}
|
||||||
</Form.Submit>
|
</Form.Submit>
|
||||||
|
|
||||||
<Form.FormError />
|
<Form.FormError />
|
||||||
</Form>
|
</Form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Stepper.StepContent>
|
||||||
|
|
||||||
|
<Stepper.StepContent index={1}>
|
||||||
|
{() => (
|
||||||
|
<Form
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-magic-numbers */
|
||||||
|
schema={(z) => z.object({ otp: z.string().min(6).max(6) })}
|
||||||
|
onSubmit={async ({ otp }, formInstance) => {
|
||||||
|
if (user) {
|
||||||
|
const res = await cognito.confirmSignIn(user, otp, 'SOFTWARE_TOKEN_MFA')
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
navigate(DASHBOARD_PATH)
|
||||||
|
} else {
|
||||||
|
switch (res.val.code) {
|
||||||
|
case 'NotAuthorizedException':
|
||||||
|
previousStep()
|
||||||
|
form.setFormError(res.val.message)
|
||||||
|
setUser(null)
|
||||||
|
break
|
||||||
|
case 'CodeMismatchException':
|
||||||
|
formInstance.setError('otp', { message: res.val.message })
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw res.val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{getText('enterTotp')}</Text>
|
||||||
|
|
||||||
|
<OTPInput
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
testId="otp-input"
|
||||||
|
name="otp"
|
||||||
|
label={getText('totp')}
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form.Submit size="large" icon={ArrowRightIcon} iconPosition="end" fullWidth>
|
||||||
|
{getText('login')}
|
||||||
|
</Form.Submit>
|
||||||
|
|
||||||
|
<Form.FormError />
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Stepper.StepContent>
|
||||||
|
</Stepper>
|
||||||
</AuthenticationPage>
|
</AuthenticationPage>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -89,6 +89,14 @@ export default function Registration() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
onSubmit: async ({ email, password }) => {
|
||||||
|
localStorage.set('termsOfService', { versionHash: tosHash })
|
||||||
|
localStorage.set('privacyPolicy', { versionHash: privacyPolicyHash })
|
||||||
|
|
||||||
|
await signUp(email, password, organizationId)
|
||||||
|
|
||||||
|
stepperState.nextStep()
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const { stepperState } = useStepperState({ steps: 2, defaultStep: 0 })
|
const { stepperState } = useStepperState({ steps: 2, defaultStep: 0 })
|
||||||
@ -161,24 +169,14 @@ export default function Registration() {
|
|||||||
{getText('createANewAccount')}
|
{getText('createANewAccount')}
|
||||||
</Text.Heading>
|
</Text.Heading>
|
||||||
|
|
||||||
<Form
|
<Form form={signupForm}>
|
||||||
form={signupForm}
|
|
||||||
onSubmit={async ({ email, password }) => {
|
|
||||||
localStorage.set('termsOfService', { versionHash: tosHash })
|
|
||||||
localStorage.set('privacyPolicy', { versionHash: privacyPolicyHash })
|
|
||||||
|
|
||||||
await signUp(email, password, organizationId)
|
|
||||||
|
|
||||||
stepperState.nextStep()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{({ form }) => (
|
{({ form }) => (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
form={form}
|
form={form}
|
||||||
autoFocus
|
autoFocus
|
||||||
required
|
required
|
||||||
data-testid="email-input"
|
testId="email-input"
|
||||||
name="email"
|
name="email"
|
||||||
label={getText('emailLabel')}
|
label={getText('emailLabel')}
|
||||||
type="email"
|
type="email"
|
||||||
@ -193,7 +191,7 @@ export default function Registration() {
|
|||||||
<Password
|
<Password
|
||||||
form={form}
|
form={form}
|
||||||
required
|
required
|
||||||
data-testid="password-input"
|
testId="password-input"
|
||||||
name="password"
|
name="password"
|
||||||
label={getText('passwordLabel')}
|
label={getText('passwordLabel')}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
@ -205,7 +203,7 @@ export default function Registration() {
|
|||||||
<Password
|
<Password
|
||||||
form={form}
|
form={form}
|
||||||
required
|
required
|
||||||
data-testid="confirm-password-input"
|
testId="confirm-password-input"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
label={getText('confirmPasswordLabel')}
|
label={getText('confirmPasswordLabel')}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
@ -92,7 +92,13 @@ interface AuthContextType {
|
|||||||
readonly setUsername: (username: string) => Promise<boolean>
|
readonly setUsername: (username: string) => Promise<boolean>
|
||||||
readonly signInWithGoogle: () => Promise<boolean>
|
readonly signInWithGoogle: () => Promise<boolean>
|
||||||
readonly signInWithGitHub: () => Promise<boolean>
|
readonly signInWithGitHub: () => Promise<boolean>
|
||||||
readonly signInWithPassword: (email: string, password: string) => Promise<void>
|
readonly signInWithPassword: (
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
) => Promise<{
|
||||||
|
readonly challenge: cognitoModule.UserSessionChallenge
|
||||||
|
readonly user: cognitoModule.CognitoUser
|
||||||
|
}>
|
||||||
readonly forgotPassword: (email: string) => Promise<void>
|
readonly forgotPassword: (email: string) => Promise<void>
|
||||||
readonly changePassword: (oldPassword: string, newPassword: string) => Promise<boolean>
|
readonly changePassword: (oldPassword: string, newPassword: string) => Promise<boolean>
|
||||||
readonly resetPassword: (email: string, code: string, password: string) => Promise<void>
|
readonly resetPassword: (email: string, code: string, password: string) => Promise<void>
|
||||||
@ -116,6 +122,7 @@ interface AuthContextType {
|
|||||||
readonly isUserDeleted: () => boolean
|
readonly isUserDeleted: () => boolean
|
||||||
/** Return `true` if the user is soft deleted. */
|
/** Return `true` if the user is soft deleted. */
|
||||||
readonly isUserSoftDeleted: () => boolean
|
readonly isUserSoftDeleted: () => boolean
|
||||||
|
readonly cognito: cognitoModule.Cognito
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = React.createContext<AuthContextType | null>(null)
|
const AuthContext = React.createContext<AuthContextType | null>(null)
|
||||||
@ -163,7 +170,7 @@ function createUsersMeQuery(
|
|||||||
/** Props for an {@link AuthProvider}. */
|
/** Props for an {@link AuthProvider}. */
|
||||||
export interface AuthProviderProps {
|
export interface AuthProviderProps {
|
||||||
readonly shouldStartInOfflineMode: boolean
|
readonly shouldStartInOfflineMode: boolean
|
||||||
readonly authService: authServiceModule.AuthService | null
|
readonly authService: authServiceModule.AuthService
|
||||||
/** Callback to execute once the user has authenticated successfully. */
|
/** Callback to execute once the user has authenticated successfully. */
|
||||||
readonly onAuthenticated: (accessToken: string | null) => void
|
readonly onAuthenticated: (accessToken: string | null) => void
|
||||||
readonly children: React.ReactNode
|
readonly children: React.ReactNode
|
||||||
@ -174,7 +181,7 @@ export default function AuthProvider(props: AuthProviderProps) {
|
|||||||
const { authService, onAuthenticated } = props
|
const { authService, onAuthenticated } = props
|
||||||
const { children } = props
|
const { children } = props
|
||||||
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
||||||
const { cognito } = authService ?? {}
|
const { cognito } = authService
|
||||||
const { session, sessionQueryKey } = sessionProvider.useSession()
|
const { session, sessionQueryKey } = sessionProvider.useSession()
|
||||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
@ -194,23 +201,19 @@ export default function AuthProvider(props: AuthProviderProps) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const performLogout = async () => {
|
const performLogout = async () => {
|
||||||
if (cognito != null) {
|
await cognito.signOut()
|
||||||
await cognito.signOut()
|
|
||||||
|
|
||||||
const parentDomain = location.hostname.replace(/^[^.]*\./, '')
|
const parentDomain = location.hostname.replace(/^[^.]*\./, '')
|
||||||
document.cookie = `logged_in=no;max-age=0;domain=${parentDomain}`
|
document.cookie = `logged_in=no;max-age=0;domain=${parentDomain}`
|
||||||
gtagEvent('cloud_sign_out')
|
gtagEvent('cloud_sign_out')
|
||||||
cognito.saveAccessToken(null)
|
cognito.saveAccessToken(null)
|
||||||
localStorage.clearUserSpecificEntries()
|
localStorage.clearUserSpecificEntries()
|
||||||
sentry.setUser(null)
|
sentry.setUser(null)
|
||||||
|
|
||||||
await queryClient.invalidateQueries({ queryKey: sessionQueryKey })
|
await queryClient.invalidateQueries({ queryKey: sessionQueryKey })
|
||||||
await queryClient.clearWithPersister()
|
await queryClient.clearWithPersister()
|
||||||
|
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
} else {
|
|
||||||
return Promise.reject()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const logoutMutation = reactQuery.useMutation({
|
const logoutMutation = reactQuery.useMutation({
|
||||||
@ -289,100 +292,91 @@ export default function AuthProvider(props: AuthProviderProps) {
|
|||||||
|
|
||||||
const signUp = useEventCallback(
|
const signUp = useEventCallback(
|
||||||
async (username: string, password: string, organizationId: string | null) => {
|
async (username: string, password: string, organizationId: string | null) => {
|
||||||
if (cognito != null) {
|
gtagEvent('cloud_sign_up')
|
||||||
gtagEvent('cloud_sign_up')
|
const result = await cognito.signUp(username, password, organizationId)
|
||||||
const result = await cognito.signUp(username, password, organizationId)
|
|
||||||
|
|
||||||
if (result.err) {
|
if (result.err) {
|
||||||
throw new Error(result.val.message)
|
throw new Error(result.val.message)
|
||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const confirmSignUp = useEventCallback(async (email: string, code: string) => {
|
const confirmSignUp = useEventCallback(async (email: string, code: string) => {
|
||||||
if (cognito == null) {
|
gtagEvent('cloud_confirm_sign_up')
|
||||||
throw new Error(getText('confirmSignUpError'))
|
const result = await cognito.confirmSignUp(email, code)
|
||||||
} else {
|
|
||||||
gtagEvent('cloud_confirm_sign_up')
|
|
||||||
const result = await cognito.confirmSignUp(email, code)
|
|
||||||
|
|
||||||
if (result.err) {
|
if (result.err) {
|
||||||
switch (result.val.type) {
|
switch (result.val.type) {
|
||||||
case cognitoModule.CognitoErrorType.userAlreadyConfirmed:
|
case cognitoModule.CognitoErrorType.userAlreadyConfirmed:
|
||||||
case cognitoModule.CognitoErrorType.userNotFound: {
|
case cognitoModule.CognitoErrorType.userNotFound: {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new errorModule.UnreachableCaseError(result.val.type)
|
throw new errorModule.UnreachableCaseError(result.val.type)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const signInWithPassword = useEventCallback(async (email: string, password: string) => {
|
const signInWithPassword = useEventCallback(async (email: string, password: string) => {
|
||||||
if (cognito != null) {
|
gtagEvent('cloud_sign_in', { provider: 'Email' })
|
||||||
gtagEvent('cloud_sign_in', { provider: 'Email' })
|
|
||||||
const result = await cognito.signInWithPassword(email, password)
|
const result = await cognito.signInWithPassword(email, password)
|
||||||
if (result.ok) {
|
|
||||||
void queryClient.invalidateQueries({ queryKey: sessionQueryKey })
|
if (result.ok) {
|
||||||
navigate(appUtils.DASHBOARD_PATH)
|
const user = result.unwrap()
|
||||||
return
|
|
||||||
} else {
|
const challenge = user.challengeName ?? 'NO_CHALLENGE'
|
||||||
throw new Error(result.val.message)
|
|
||||||
|
if (['SMS_MFA', 'SOFTWARE_TOKEN_MFA'].includes(challenge)) {
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
return { challenge, user } as const
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return queryClient
|
||||||
|
.invalidateQueries({ queryKey: sessionQueryKey })
|
||||||
|
.then(() => ({ challenge, user }) as const)
|
||||||
|
} else {
|
||||||
|
throw new Error(result.val.message)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const setUsername = useEventCallback(async (username: string) => {
|
const setUsername = useEventCallback(async (username: string) => {
|
||||||
if (cognito == null) {
|
gtagEvent('cloud_user_created')
|
||||||
return false
|
|
||||||
|
if (userData?.type === UserSessionType.full) {
|
||||||
|
await updateUserMutation.mutateAsync({ username: username })
|
||||||
} else {
|
} else {
|
||||||
gtagEvent('cloud_user_created')
|
const organizationId = await cognito.organizationId()
|
||||||
|
const email = session?.email ?? ''
|
||||||
|
|
||||||
if (userData?.type === UserSessionType.full) {
|
await createUserMutation.mutateAsync({
|
||||||
await updateUserMutation.mutateAsync({ username: username })
|
userName: username,
|
||||||
} else {
|
userEmail: backendModule.EmailAddress(email),
|
||||||
const organizationId = await cognito.organizationId()
|
organizationId:
|
||||||
const email = session?.email ?? ''
|
organizationId != null ? backendModule.OrganizationId(organizationId) : null,
|
||||||
|
})
|
||||||
await createUserMutation.mutateAsync({
|
|
||||||
userName: username,
|
|
||||||
userEmail: backendModule.EmailAddress(email),
|
|
||||||
organizationId:
|
|
||||||
organizationId != null ? backendModule.OrganizationId(organizationId) : null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteUser = useEventCallback(async () => {
|
const deleteUser = useEventCallback(async () => {
|
||||||
if (cognito == null) {
|
await deleteUserMutation.mutateAsync()
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
await deleteUserMutation.mutateAsync()
|
|
||||||
|
|
||||||
toastSuccess(getText('deleteUserSuccess'))
|
toastSuccess(getText('deleteUserSuccess'))
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const restoreUser = useEventCallback(async () => {
|
const restoreUser = useEventCallback(async () => {
|
||||||
if (cognito == null) {
|
await restoreUserMutation.mutateAsync()
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
await restoreUserMutation.mutateAsync()
|
|
||||||
|
|
||||||
toastSuccess(getText('restoreUserSuccess'))
|
toastSuccess(getText('restoreUserSuccess'))
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -402,41 +396,36 @@ export default function AuthProvider(props: AuthProviderProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const forgotPassword = useEventCallback(async (email: string) => {
|
const forgotPassword = useEventCallback(async (email: string) => {
|
||||||
if (cognito != null) {
|
const result = await cognito.forgotPassword(email)
|
||||||
const result = await cognito.forgotPassword(email)
|
if (result.ok) {
|
||||||
if (result.ok) {
|
navigate(appUtils.LOGIN_PATH)
|
||||||
navigate(appUtils.LOGIN_PATH)
|
return
|
||||||
return
|
} else {
|
||||||
} else {
|
throw new Error(result.val.message)
|
||||||
throw new Error(result.val.message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const resetPassword = useEventCallback(async (email: string, code: string, password: string) => {
|
const resetPassword = useEventCallback(async (email: string, code: string, password: string) => {
|
||||||
if (cognito != null) {
|
const result = await cognito.forgotPasswordSubmit(email, code, password)
|
||||||
const result = await cognito.forgotPasswordSubmit(email, code, password)
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
navigate(appUtils.LOGIN_PATH)
|
navigate(appUtils.LOGIN_PATH)
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.val.message)
|
throw new Error(result.val.message)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const changePassword = useEventCallback(async (oldPassword: string, newPassword: string) => {
|
const changePassword = useEventCallback(async (oldPassword: string, newPassword: string) => {
|
||||||
if (cognito == null) {
|
const result = await cognito.changePassword(oldPassword, newPassword)
|
||||||
return false
|
|
||||||
|
if (result.ok) {
|
||||||
|
toastSuccess(getText('changePasswordSuccess'))
|
||||||
} else {
|
} else {
|
||||||
const result = await cognito.changePassword(oldPassword, newPassword)
|
toastError(result.val.message)
|
||||||
if (result.ok) {
|
|
||||||
toastSuccess(getText('changePasswordSuccess'))
|
|
||||||
} else {
|
|
||||||
toastError(result.val.message)
|
|
||||||
}
|
|
||||||
return result.ok
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result.ok
|
||||||
})
|
})
|
||||||
|
|
||||||
const isUserMarkedForDeletion = useEventCallback(
|
const isUserMarkedForDeletion = useEventCallback(
|
||||||
@ -503,33 +492,28 @@ export default function AuthProvider(props: AuthProviderProps) {
|
|||||||
isUserSoftDeleted,
|
isUserSoftDeleted,
|
||||||
restoreUser,
|
restoreUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
|
cognito,
|
||||||
signInWithGoogle: useEventCallback(() => {
|
signInWithGoogle: useEventCallback(() => {
|
||||||
if (cognito == null) {
|
gtagEvent('cloud_sign_in', { provider: 'Google' })
|
||||||
return Promise.resolve(false)
|
|
||||||
} else {
|
return cognito
|
||||||
gtagEvent('cloud_sign_in', { provider: 'Google' })
|
.signInWithGoogle()
|
||||||
return cognito
|
.then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey }))
|
||||||
.signInWithGoogle()
|
.then(
|
||||||
.then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey }))
|
() => true,
|
||||||
.then(
|
() => false,
|
||||||
() => true,
|
)
|
||||||
() => false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
signInWithGitHub: useEventCallback(() => {
|
signInWithGitHub: useEventCallback(() => {
|
||||||
if (cognito == null) {
|
gtagEvent('cloud_sign_in', { provider: 'GitHub' })
|
||||||
return Promise.resolve(false)
|
|
||||||
} else {
|
return cognito
|
||||||
gtagEvent('cloud_sign_in', { provider: 'GitHub' })
|
.signInWithGitHub()
|
||||||
return cognito
|
.then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey }))
|
||||||
.signInWithGitHub()
|
.then(
|
||||||
.then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey }))
|
() => true,
|
||||||
.then(
|
() => false,
|
||||||
() => true,
|
)
|
||||||
() => false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
signInWithPassword,
|
signInWithPassword,
|
||||||
forgotPassword,
|
forgotPassword,
|
||||||
|
@ -25,6 +25,9 @@ export type TVWithoutExtends<T> = ExtractFunction<T> & Omit<T, 'extend'>
|
|||||||
* Props for a component that uses `tailwind-variants`.
|
* Props for a component that uses `tailwind-variants`.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type VariantProps<T extends (...args: any) => any> = TvVariantProps<T> & {
|
export type VariantProps<T extends (...args: any) => any> = Omit<
|
||||||
|
TvVariantProps<T>,
|
||||||
|
'class' | 'className'
|
||||||
|
> & {
|
||||||
variants?: ExtractFunction<T> | undefined
|
variants?: ExtractFunction<T> | undefined
|
||||||
}
|
}
|
||||||
|
@ -379,6 +379,7 @@ inset 0 -8px 11.4px -11.4px #00000005, inset 0 -15px 21.3px -21.3px #00000006, \
|
|||||||
inset 0 -36px 51px -51px #00000014`,
|
inset 0 -36px 51px -51px #00000014`,
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
|
'caret-blink': 'caret-blink 1.5s ease-out infinite',
|
||||||
'spin-ease': 'spin cubic-bezier(0.67, 0.33, 0.33, 0.67) 1.5s infinite',
|
'spin-ease': 'spin cubic-bezier(0.67, 0.33, 0.33, 0.67) 1.5s infinite',
|
||||||
'appear-delayed': 'appear-delayed 0.5s ease-in-out',
|
'appear-delayed': 'appear-delayed 0.5s ease-in-out',
|
||||||
},
|
},
|
||||||
@ -420,6 +421,10 @@ inset 0 -36px 51px -51px #00000014`,
|
|||||||
'99%': { opacity: '0' },
|
'99%': { opacity: '0' },
|
||||||
'100%': { opacity: '1' },
|
'100%': { opacity: '1' },
|
||||||
},
|
},
|
||||||
|
'caret-blink': {
|
||||||
|
'0%,70%,100%': { opacity: '1' },
|
||||||
|
'20%,50%': { opacity: '0' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -429,6 +434,12 @@ inset 0 -36px 51px -51px #00000014`,
|
|||||||
plugin(({ addVariant, addUtilities, matchUtilities, addComponents, theme }) => {
|
plugin(({ addVariant, addUtilities, matchUtilities, addComponents, theme }) => {
|
||||||
addVariant('group-hover-2', ['.group:where([data-hovered]) &', '.group:where(:hover) &'])
|
addVariant('group-hover-2', ['.group:where([data-hovered]) &', '.group:where(:hover) &'])
|
||||||
|
|
||||||
|
addUtilities({
|
||||||
|
'.scrollbar-gutter-stable': {
|
||||||
|
scrollbarGutter: 'stable',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
addUtilities(
|
addUtilities(
|
||||||
{
|
{
|
||||||
'.container-size': {
|
'.container-size': {
|
||||||
|
@ -216,12 +216,21 @@
|
|||||||
"likes": "Likes",
|
"likes": "Likes",
|
||||||
"shortcuts": "Shortcuts",
|
"shortcuts": "Shortcuts",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
|
"disable": "Disable",
|
||||||
|
"enable": "Enable",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"intro": "Intro",
|
"intro": "Intro",
|
||||||
"emailIsRequired": "Email is required",
|
"emailIsRequired": "Email is required",
|
||||||
"emailIsInvalid": "Email is invalid",
|
"emailIsInvalid": "Email is invalid",
|
||||||
"emailAlreadyExists": "Email already exists",
|
"emailAlreadyExists": "Email already exists",
|
||||||
"emailAlreadyAdded": "Email already added",
|
"emailAlreadyAdded": "Email already added",
|
||||||
|
"otp": "OTP",
|
||||||
|
"totp": "TOTP",
|
||||||
|
"display": "Display",
|
||||||
|
"invalidOtp": "Invalid OTP",
|
||||||
|
"invalidTotp": "Invalid TOTP",
|
||||||
|
"enterOtp": "To continue, please enter the 6-digit verification code generated by your authenticator app.",
|
||||||
|
"enterTotp": "To continue, please enter the 6-digit verification code generated by your authenticator app.",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"members": "Members",
|
"members": "Members",
|
||||||
@ -473,6 +482,14 @@
|
|||||||
"enableAssetsTableBackgroundRefreshInterval": "Refresh interval",
|
"enableAssetsTableBackgroundRefreshInterval": "Refresh interval",
|
||||||
"enableAssetsTableBackgroundRefreshIntervalDescription": "Set the interval in ms for the assets table to refresh in the background.",
|
"enableAssetsTableBackgroundRefreshIntervalDescription": "Set the interval in ms for the assets table to refresh in the background.",
|
||||||
|
|
||||||
|
"enableMultitabs": "Enable Multi-Tabs",
|
||||||
|
"enableMultitabsDescription": "Open multiple projects at the same time.",
|
||||||
|
|
||||||
|
"enableAssetsTableBackgroundRefresh": "Enable Assets Table Background Refresh",
|
||||||
|
"enableAssetsTableBackgroundRefreshDescription": "Automatically refresh the assets table in the background.",
|
||||||
|
"enableAssetsTableBackgroundRefreshInterval": "Refresh interval",
|
||||||
|
"enableAssetsTableBackgroundRefreshIntervalDescription": "Set the interval in ms for the assets table to refresh in the background.",
|
||||||
|
|
||||||
"deleteLabelActionText": "delete the label '$0'",
|
"deleteLabelActionText": "delete the label '$0'",
|
||||||
"deleteSelectedAssetActionText": "delete '$0'",
|
"deleteSelectedAssetActionText": "delete '$0'",
|
||||||
"deleteSelectedAssetsActionText": "delete $0 selected items",
|
"deleteSelectedAssetsActionText": "delete $0 selected items",
|
||||||
@ -814,6 +831,24 @@
|
|||||||
"userNameSettingsInput": "Name",
|
"userNameSettingsInput": "Name",
|
||||||
"userEmailSettingsInput": "Email",
|
"userEmailSettingsInput": "Email",
|
||||||
"changePasswordSettingsSection": "Change Password",
|
"changePasswordSettingsSection": "Change Password",
|
||||||
|
"setup2FASettingsSection": "Two-Factor Authentication (2FA)",
|
||||||
|
"setup2FASettingsCustomEntryAliases": "two-factor authentication\n2fa",
|
||||||
|
"setup2FASettingsDescription": "Add an extra layer of security to your account by enabling two-factor authentication.",
|
||||||
|
"enable2FA": "Enable 2FA",
|
||||||
|
"enable2FADescription": "Require a code from your authenticator app to log in.",
|
||||||
|
"scanQR": "Scan this QR code",
|
||||||
|
"scanQRDescription": "Use an authenticator app like Google Authenticator or Authy to scan this QR code and set up 2FA.",
|
||||||
|
"copyLink": "Copy this link",
|
||||||
|
"copyLinkDescription": "If you can't scan the QR code, you can copy this link and paste it into your authenticator app.",
|
||||||
|
"enterCode": "Enter the code",
|
||||||
|
"enterCodeDescription": "Enter the code from your authenticator app to verify 2FA.",
|
||||||
|
"verificationCode": "Verification code",
|
||||||
|
"verificationCodePlaceholder": "Enter the verification code",
|
||||||
|
"2FAEnabled": "2FA is enabled",
|
||||||
|
"2FAEnabledDescription": "Your account is currently protected with two-factor authentication.",
|
||||||
|
"disable2FA": "Disable 2FA",
|
||||||
|
"disable2FADescription": "Turning off two-factor authentication will make your account less secure.",
|
||||||
|
"disable2FAWarning": "Are you sure you want to disable two-factor authentication? Turning off two-factor authentication will make your account less secure. You will still need to enter a verification code to disable 2FA.",
|
||||||
"changePasswordSettingsCustomEntryAliases": "current password\nnew password\nconfirm new password",
|
"changePasswordSettingsCustomEntryAliases": "current password\nnew password\nconfirm new password",
|
||||||
"deleteUserAccountSettingsSection": "Delete User Account",
|
"deleteUserAccountSettingsSection": "Delete User Account",
|
||||||
"deleteUserAccountSettingsCustomEntryAliases": "danger zone\ndelete this user account",
|
"deleteUserAccountSettingsCustomEntryAliases": "danger zone\ndelete this user account",
|
||||||
|
@ -79,6 +79,9 @@ importers:
|
|||||||
ajv:
|
ajv:
|
||||||
specifier: ^8.12.0
|
specifier: ^8.12.0
|
||||||
version: 8.16.0
|
version: 8.16.0
|
||||||
|
amazon-cognito-identity-js:
|
||||||
|
specifier: 6.3.6
|
||||||
|
version: 6.3.6
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
@ -88,12 +91,18 @@ importers:
|
|||||||
framer-motion:
|
framer-motion:
|
||||||
specifier: 11.3.0
|
specifier: 11.3.0
|
||||||
version: 11.3.0(@emotion/is-prop-valid@1.3.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 11.3.0(@emotion/is-prop-valid@1.3.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
input-otp:
|
||||||
|
specifier: 1.2.4
|
||||||
|
version: 1.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
is-network-error:
|
is-network-error:
|
||||||
specifier: ^1.0.1
|
specifier: ^1.0.1
|
||||||
version: 1.1.0
|
version: 1.1.0
|
||||||
monaco-editor:
|
monaco-editor:
|
||||||
specifier: 0.48.0
|
specifier: 0.48.0
|
||||||
version: 0.48.0
|
version: 0.48.0
|
||||||
|
qrcode.react:
|
||||||
|
specifier: 3.1.0
|
||||||
|
version: 3.1.0(react@18.3.1)
|
||||||
react:
|
react:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
@ -5155,6 +5164,12 @@ packages:
|
|||||||
ini@1.3.8:
|
ini@1.3.8:
|
||||||
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
||||||
|
|
||||||
|
input-otp@1.2.4:
|
||||||
|
resolution: {integrity: sha512-md6rhmD+zmMnUh5crQNSQxq3keBRYvE3odbr4Qb9g2NWzQv9azi+t1a3X4TBTbh98fsGHgEEJlzbe1q860uGCA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||||
|
|
||||||
install@0.13.0:
|
install@0.13.0:
|
||||||
resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==}
|
resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@ -6495,6 +6510,11 @@ packages:
|
|||||||
pure-rand@6.1.0:
|
pure-rand@6.1.0:
|
||||||
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
|
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
|
||||||
|
|
||||||
|
qrcode.react@3.1.0:
|
||||||
|
resolution: {integrity: sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||||
|
|
||||||
qs@6.11.0:
|
qs@6.11.0:
|
||||||
resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
|
resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
@ -13351,6 +13371,11 @@ snapshots:
|
|||||||
|
|
||||||
ini@1.3.8: {}
|
ini@1.3.8: {}
|
||||||
|
|
||||||
|
input-otp@1.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
install@0.13.0: {}
|
install@0.13.0: {}
|
||||||
|
|
||||||
internal-slot@1.0.7:
|
internal-slot@1.0.7:
|
||||||
@ -14601,6 +14626,10 @@ snapshots:
|
|||||||
|
|
||||||
pure-rand@6.1.0: {}
|
pure-rand@6.1.0: {}
|
||||||
|
|
||||||
|
qrcode.react@3.1.0(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
qs@6.11.0:
|
qs@6.11.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.0.6
|
side-channel: 1.0.6
|
||||||
|
Loading…
Reference in New Issue
Block a user