diff --git a/packages/core/src/middleware/koa-error-handler.ts b/packages/core/src/middleware/koa-error-handler.ts index cd4c1cb9b..8e4a7318b 100644 --- a/packages/core/src/middleware/koa-error-handler.ts +++ b/packages/core/src/middleware/koa-error-handler.ts @@ -1,5 +1,6 @@ import RequestError from '@/errors/RequestError'; import { RequestErrorBody } from '@logto/schemas'; +import { LogtoErrorCode } from '@logto/phrases'; import decamelize from 'decamelize'; import { Middleware } from 'koa'; import { errors } from 'oidc-provider'; @@ -23,7 +24,8 @@ export default function koaErrorHandler(): Middleware< ctx.status = error.status; ctx.body = { message: error.error_description ?? error.message, - code: `oidc.${decamelize(error.name)}`, + // Assert error type of OIDCProviderError, code key should all covered in @logto/phrases + code: `oidc.${decamelize(error.name)}` as LogtoErrorCode, data: error.error_detail, }; return; diff --git a/packages/schemas/src/api/error.ts b/packages/schemas/src/api/error.ts index ab5cb9c5f..130083c2f 100644 --- a/packages/schemas/src/api/error.ts +++ b/packages/schemas/src/api/error.ts @@ -5,4 +5,4 @@ export type RequestErrorMetadata = Record & { status?: number; }; -export type RequestErrorBody = { message: string; data: unknown; code: string }; +export type RequestErrorBody = { message: string; data: unknown; code: LogtoErrorCode }; diff --git a/packages/ui/package.json b/packages/ui/package.json index c0f61aff6..2685fb99a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@logto/phrases": "^0.1.0", + "@logto/schemas": "^0.1.0", "classnames": "^2.3.1", "i18next": "^20.3.3", "i18next-browser-languagedetector": "^6.1.2", diff --git a/packages/ui/razzle.config.js b/packages/ui/razzle.config.js index dfb5be02b..223224f62 100644 --- a/packages/ui/razzle.config.js +++ b/packages/ui/razzle.config.js @@ -21,6 +21,11 @@ module.exports = { /** @type {import('@jest/types').Config.InitialOptions} **/ const config = { ...jestConfig }; + config.transformIgnorePatterns = [ + '^.+\\.module\\.(css|sass|scss)$', + '[/\\\\]node_modules[/\\\\]((?!ky[/\\\\]).)+\\.(js|jsx|mjs|cjs|ts|tsx)$', + ]; + config.moduleNameMapper = { ...config.moduleNameMapper, '^.+\\.(css|less|scss)$': 'babel-jest', diff --git a/packages/ui/src/hooks/use-api.ts b/packages/ui/src/hooks/use-api.ts new file mode 100644 index 000000000..a0431b902 --- /dev/null +++ b/packages/ui/src/hooks/use-api.ts @@ -0,0 +1,46 @@ +import { useState } from 'react'; +import { HTTPError } from 'ky'; +import { RequestErrorBody } from '@logto/schemas'; + +type UseApi = { + result?: U; + loading: boolean; + error: RequestErrorBody | null; + run: (...args: T) => Promise; +}; + +function useApi( + api: (...args: Args) => Promise +): UseApi { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(); + + const run = async (...args: Args) => { + setLoading(true); + setError(null); + + try { + const result = await api(...args); + setResult(result); + setLoading(false); + } catch (error: unknown) { + if (error instanceof HTTPError) { + const kyError = await error.response.json(); + setError(kyError); + } + + setLoading(false); + throw error; + } + }; + + return { + loading, + error, + result, + run, + }; +} + +export default useApi; diff --git a/packages/ui/src/include.d/dom.d.ts b/packages/ui/src/include.d/dom.d.ts index 9b1811626..5c68dd0b2 100644 --- a/packages/ui/src/include.d/dom.d.ts +++ b/packages/ui/src/include.d/dom.d.ts @@ -69,3 +69,7 @@ type AutoCompleteType = | 'sex' | 'url' | 'photo'; + +interface Body { + json(): Promise; +} diff --git a/packages/ui/src/pages/Register/index.tsx b/packages/ui/src/pages/Register/index.tsx index 0f9493d08..4c2fb2ff0 100644 --- a/packages/ui/src/pages/Register/index.tsx +++ b/packages/ui/src/pages/Register/index.tsx @@ -1,63 +1,65 @@ -import React, { FC, FormEventHandler, useState, useCallback } from 'react'; +import React, { FC, FormEventHandler, useState, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import classNames from 'classnames'; +import { LogtoErrorI18nKey } from '@logto/phrases'; import { register } from '@/apis/register'; import Button from '@/components/Button'; import Input from '@/components/Input'; import MessageBox from '@/components/MessageBox'; import TextLink from '@/components/TextLink'; +import useApi from '@/hooks/use-api'; import styles from './index.module.scss'; -export type PageState = 'idle' | 'loading' | 'error'; - -const App: FC = () => { - const { t } = useTranslation(); +const Register: FC = () => { + const { t, i18n } = useTranslation(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - const [pageState, setPageState] = useState('idle'); - const isLoading = pageState === 'loading'; + + const { loading, error, result, run: asyncRegister } = useApi(register); const signUp: FormEventHandler = useCallback( async (event) => { event.preventDefault(); - setPageState('loading'); - try { - window.location.href = (await register(username, password)).redirectTo; - } catch { - // TODO: Show specific error after merge into monorepo - setPageState('error'); - } + await asyncRegister(username, password); }, - [username, password] + [username, password, asyncRegister] ); + useEffect(() => { + if (result?.redirectTo) { + window.location.href = result.redirectTo; + } + }, [result]); + return (
{t('register.create_account')}
- {pageState === 'error' && ( - {t('sign_in.error')} + {error && ( + + {i18n.t(`errors:${error.code}`)} + )}