diff --git a/packages/ui/package.json b/packages/ui/package.json index 536dea50e..39336676e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -63,6 +63,7 @@ "react-string-replace": "^1.0.0", "react-timer-hook": "^3.0.5", "stylelint": "^14.8.2", + "superstruct": "^0.16.0", "typescript": "^4.6.2", "use-debounced-loader": "^0.1.1" }, @@ -72,8 +73,7 @@ "eslintConfig": { "extends": "@silverhand/react", "rules": { - "complexity": "off", - "no-restricted-syntax": "off" + "complexity": "off" } }, "stylelint": { diff --git a/packages/ui/src/containers/SocialSignIn/SocialSignInDropdown/index.tsx b/packages/ui/src/containers/SocialSignIn/SocialSignInDropdown/index.tsx index 19b94f404..1c2d3dc00 100644 --- a/packages/ui/src/containers/SocialSignIn/SocialSignInDropdown/index.tsx +++ b/packages/ui/src/containers/SocialSignIn/SocialSignInDropdown/index.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import Dropdown, { DropdownItem } from '@/components/Dropdown'; import useSocial from '@/hooks/use-social'; import { ConnectorData } from '@/types'; +import { isKeyOf } from '@/utils'; import * as styles from './index.module.scss'; @@ -47,8 +48,7 @@ const SocialSignInDropdown = ({ isOpen, onClose, connectors, anchorRef }: Props) > {connectors.map((connector) => { const { id, name, logo, logoDark } = connector; - const languageKey = Object.keys(name).find((key) => key === language) ?? 'en'; - const localName = name[languageKey as Language]; + const localName = isKeyOf(language, name) ? name[language] : name[Language.English]; return ( { - const state = useLocation().state as Optional; + const { state } = useLocation(); const { experienceSettings } = useContext(PageContext); const { result: registerResult, run: asyncRegisterWithSocial } = useApi(registerWithSocial); const { result: bindUserResult, run: asyncBindSocialRelatedUser } = useApi(bindSocialRelatedUser); @@ -54,7 +52,7 @@ const useBindSocial = () => { return { localSignInMethods, - relatedUser: state?.relatedUser, + relatedUser: conditional(is(state, bindSocialStateGuard) && state.relatedUser), registerWithSocial: createAccountHandler, bindSocialRelatedUser: bindRelatedUserHandler, }; diff --git a/packages/ui/src/hooks/use-form.ts b/packages/ui/src/hooks/use-form.ts index 941680588..3a0743ff1 100644 --- a/packages/ui/src/hooks/use-form.ts +++ b/packages/ui/src/hooks/use-form.ts @@ -1,9 +1,9 @@ import { useState, useCallback, useEffect, useRef, FormEvent } from 'react'; import { ErrorType } from '@/components/ErrorMessage'; -import { entries } from '@/utils'; +import { entries, fromEntries, Entries } from '@/utils'; -const useForm = (initialState: T) => { +const useForm = >(initialState: T) => { type ErrorState = { [key in keyof T]?: ErrorType; }; @@ -19,12 +19,12 @@ const useForm = (initialState: T) => { const fieldValidationsRef = useRef({}); const validateForm = useCallback(() => { - const errors = entries(fieldValue).map<[keyof T, ErrorType | undefined]>(([key, value]) => [ + const errors: Entries = entries(fieldValue).map(([key, value]) => [ key, fieldValidationsRef.current[key]?.(value), ]); - setFieldErrors(Object.fromEntries(errors) as ErrorState); + setFieldErrors(fromEntries(errors)); return errors.every(([, error]) => error === undefined); }, [fieldValidationsRef, fieldValue]); @@ -37,8 +37,7 @@ const useForm = (initialState: T) => { return { value: fieldValue[field], error: fieldErrors[field], - onChange: ({ target }: FormEvent) => { - const { value } = target as HTMLInputElement; + onChange: ({ currentTarget: { value } }: FormEvent) => { setFieldValue((previous) => ({ ...previous, [field]: value })); }, }; @@ -49,13 +48,13 @@ const useForm = (initialState: T) => { // Revalidate on Input change useEffect(() => { setFieldErrors((previous) => { - const errors = entries(fieldValue).map<[keyof T, ErrorType | undefined]>(([key, value]) => [ + const errors: Entries = entries(fieldValue).map(([key, value]) => [ key, // Only validate field with existing errors previous[key] && fieldValidationsRef.current[key]?.(value), ]); - return Object.fromEntries(errors) as ErrorState; + return fromEntries(errors); }); }, [fieldValue, fieldValidationsRef]); diff --git a/packages/ui/src/hooks/use-preview.ts b/packages/ui/src/hooks/use-preview.ts index 9bb4a386c..506138445 100644 --- a/packages/ui/src/hooks/use-preview.ts +++ b/packages/ui/src/hooks/use-preview.ts @@ -1,5 +1,4 @@ -import { Language } from '@logto/phrases-ui'; -import { AppearanceMode, ConnectorPlatform } from '@logto/schemas'; +import { ConnectorPlatform } from '@logto/schemas'; import { conditionalString } from '@silverhand/essentials'; import i18next from 'i18next'; import { useEffect, useState } from 'react'; @@ -7,19 +6,11 @@ import { useEffect, useState } from 'react'; import * as styles from '@/App.module.scss'; import { Context } from '@/hooks/use-page-context'; import initI18n from '@/i18n/init'; -import { SignInExperienceSettingsResponse, SignInExperienceSettings, Platform } from '@/types'; +import { SignInExperienceSettings, PreviewConfig } from '@/types'; import { parseQueryParameters } from '@/utils'; import { getPrimarySignInMethod, getSecondarySignInMethods } from '@/utils/sign-in-experience'; import { filterPreviewSocialConnectors } from '@/utils/social-connectors'; -type PreviewConfig = { - signInExperience: SignInExperienceSettingsResponse; - language: Language; - mode: AppearanceMode.LightMode | AppearanceMode.DarkMode; - platform: Platform; - isNative: boolean; -}; - const usePreview = (context: Context): [boolean, PreviewConfig?] => { const [previewConfig, setPreviewConfig] = useState(); const { setTheme, setExperienceSettings, setPlatform } = context; @@ -44,6 +35,8 @@ const usePreview = (context: Context): [boolean, PreviewConfig?] => { } if (event.data.sender === 'ac_preview') { + // #event.data should be guarded at the provider's side + // eslint-disable-next-line no-restricted-syntax setPreviewConfig(event.data.config as PreviewConfig); } }; diff --git a/packages/ui/src/hooks/use-social-sign-in-listener.ts b/packages/ui/src/hooks/use-social-sign-in-listener.ts index 78733a4ea..1336903e7 100644 --- a/packages/ui/src/hooks/use-social-sign-in-listener.ts +++ b/packages/ui/src/hooks/use-social-sign-in-listener.ts @@ -21,9 +21,7 @@ const useSocialSignInListener = () => { if (parameters.connector) { navigate(`/social/register/${parameters.connector}`, { replace: true, - state: { - ...(error.data as Record | undefined), - }, + state: error.data, }); } }, diff --git a/packages/ui/src/pages/Passcode/index.tsx b/packages/ui/src/pages/Passcode/index.tsx index 5a5de9f21..a72383e88 100644 --- a/packages/ui/src/pages/Passcode/index.tsx +++ b/packages/ui/src/pages/Passcode/index.tsx @@ -1,13 +1,12 @@ -import { Nullable } from '@silverhand/essentials'; -import { useContext, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams, useLocation } from 'react-router-dom'; +import { is } from 'superstruct'; import NavBar from '@/components/NavBar'; import PasscodeValidation from '@/containers/PasscodeValidation'; -import { PageContext } from '@/hooks/use-page-context'; import ErrorPage from '@/pages/ErrorPage'; import { UserFlow } from '@/types'; +import { passcodeStateGuard, passcodeMethodGuard } from '@/types/guard'; import * as styles from './index.module.scss'; @@ -16,30 +15,23 @@ type Parameters = { method: string; }; -type StateType = Nullable>; - const Passcode = () => { const { t } = useTranslation(); const { method, type } = useParams(); - const state = useLocation().state as StateType; + const { state } = useLocation(); const invalidType = type !== 'sign-in' && type !== 'register'; - const invalidMethod = method !== 'email' && method !== 'sms'; - const { setToast } = useContext(PageContext); - useEffect(() => { - if (method && !state?.[method]) { - setToast(t(method === 'email' ? 'error.invalid_email' : 'error.invalid_phone')); - } - }, [method, setToast, state, t]); + const invalidMethod = !is(method, passcodeMethodGuard); + const invalidState = !is(state, passcodeStateGuard); if (invalidType || invalidMethod) { return ; } - const target = state?.[method]; + const target = !invalidState && state[method]; if (!target) { - return ; + return ; } return ( diff --git a/packages/ui/src/types/guard.ts b/packages/ui/src/types/guard.ts new file mode 100644 index 000000000..b5135993c --- /dev/null +++ b/packages/ui/src/types/guard.ts @@ -0,0 +1,12 @@ +import * as s from 'superstruct'; + +export const bindSocialStateGuard = s.object({ + relatedUser: s.optional(s.string()), +}); + +export const passcodeStateGuard = s.object({ + email: s.optional(s.string()), + sms: s.optional(s.string()), +}); + +export const passcodeMethodGuard = s.union([s.literal('email'), s.literal('sms')]); diff --git a/packages/ui/src/types/index.ts b/packages/ui/src/types/index.ts index 3fded919a..3b48ff752 100644 --- a/packages/ui/src/types/index.ts +++ b/packages/ui/src/types/index.ts @@ -1,4 +1,5 @@ -import { SignInExperience, ConnectorMetadata } from '@logto/schemas'; +import { Language } from '@logto/phrases-ui'; +import { SignInExperience, ConnectorMetadata, AppearanceMode } from '@logto/schemas'; export type UserFlow = 'sign-in' | 'register'; export type SignInMethod = 'username' | 'email' | 'sms' | 'social'; @@ -34,3 +35,11 @@ export type SignInExperienceSettings = Omit< export enum TermsOfUseModalMessage { SHOW_DETAIL_MODAL = 'SHOW_DETAIL_MODAL', } + +export type PreviewConfig = { + signInExperience: SignInExperienceSettingsResponse; + language: Language; + mode: AppearanceMode.LightMode | AppearanceMode.DarkMode; + platform: Platform; + isNative: boolean; +}; diff --git a/packages/ui/src/utils/country-code.ts b/packages/ui/src/utils/country-code.ts index 12a048ecd..f0cf11cd6 100644 --- a/packages/ui/src/utils/country-code.ts +++ b/packages/ui/src/utils/country-code.ts @@ -18,6 +18,7 @@ export const countryCallingCodeMap: Record = { export const isValidCountryCode = (countryCode: string): countryCode is CountryCode => { try { // Use getCountryCallingCode method to guard the input's value is in CountryCode union type, if type not match exceptions are expected + // eslint-disable-next-line no-restricted-syntax getCountryCallingCode(countryCode as CountryCode); return true; diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index 007cce7ca..1be203b06 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -26,16 +26,21 @@ export const getSearchParameters = (parameters: string | URLSearchParams, key: s return searchParameters.get(key) ?? undefined; }; -type Entries = Array< +export type Entries = Array< { [K in keyof T]: [K, T[K]]; }[keyof T] >; -export const entries = (object: T): Entries => Object.entries(object) as Entries; +export const entries = >(object: T): Entries => + // eslint-disable-next-line no-restricted-syntax + Object.entries(object) as Entries; -// eslint-disable-next-line @typescript-eslint/ban-types -export const inOperator = ( - key: K, +export const fromEntries = >(entries: Entries) => + // eslint-disable-next-line no-restricted-syntax + Object.fromEntries(entries) as T; + +export const isKeyOf = >( + key: string | number | symbol, object: T -): object is T & Record => key in object; +): key is keyof T => key in object; diff --git a/packages/ui/src/utils/sign-in-experience.ts b/packages/ui/src/utils/sign-in-experience.ts index 6c828c7f9..4f0f4b8ec 100644 --- a/packages/ui/src/utils/sign-in-experience.ts +++ b/packages/ui/src/utils/sign-in-experience.ts @@ -9,10 +9,12 @@ import { getSignInExperience } from '@/apis/settings'; import { SignInMethod, SignInExperienceSettingsResponse, SignInExperienceSettings } from '@/types'; import { filterSocialConnectors } from '@/utils/social-connectors'; +import { entries } from '.'; + export const getPrimarySignInMethod = (signInMethods: SignInMethods) => { - for (const [key, value] of Object.entries(signInMethods)) { + for (const [key, value] of entries(signInMethods)) { if (value === 'primary') { - return key as keyof SignInMethods; + return key; } } @@ -20,9 +22,9 @@ export const getPrimarySignInMethod = (signInMethods: SignInMethods) => { }; export const getSecondarySignInMethods = (signInMethods: SignInMethods) => - Object.entries(signInMethods).reduce((methods, [key, value]) => { + entries(signInMethods).reduce((methods, [key, value]) => { if (value === 'secondary') { - return [...methods, key as SignInMethod]; + return [...methods, key]; } return methods; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f9255e24..5812b18dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1225,6 +1225,7 @@ importers: react-string-replace: ^1.0.0 react-timer-hook: ^3.0.5 stylelint: ^14.8.2 + superstruct: ^0.16.0 typescript: ^4.6.2 use-debounced-loader: ^0.1.1 dependencies: @@ -1277,6 +1278,7 @@ importers: react-string-replace: 1.0.0 react-timer-hook: 3.0.5_sfoxds7t5ydpegc3knd667wn6m stylelint: 14.8.2 + superstruct: 0.16.0 typescript: 4.6.2 use-debounced-loader: 0.1.1_react@17.0.2