mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat: (ui) Read and show error from API response (#84)
Implemet useApi hooks to handle client side request read loading & error status from the useApi hook
This commit is contained in:
parent
f3bb467918
commit
b1bf5eccf0
9 changed files with 113 additions and 49 deletions
|
@ -1,5 +1,6 @@
|
||||||
import RequestError from '@/errors/RequestError';
|
import RequestError from '@/errors/RequestError';
|
||||||
import { RequestErrorBody } from '@logto/schemas';
|
import { RequestErrorBody } from '@logto/schemas';
|
||||||
|
import { LogtoErrorCode } from '@logto/phrases';
|
||||||
import decamelize from 'decamelize';
|
import decamelize from 'decamelize';
|
||||||
import { Middleware } from 'koa';
|
import { Middleware } from 'koa';
|
||||||
import { errors } from 'oidc-provider';
|
import { errors } from 'oidc-provider';
|
||||||
|
@ -23,7 +24,8 @@ export default function koaErrorHandler<StateT, ContextT>(): Middleware<
|
||||||
ctx.status = error.status;
|
ctx.status = error.status;
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
message: error.error_description ?? error.message,
|
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,
|
data: error.error_detail,
|
||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -5,4 +5,4 @@ export type RequestErrorMetadata = Record<string, unknown> & {
|
||||||
status?: number;
|
status?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RequestErrorBody = { message: string; data: unknown; code: string };
|
export type RequestErrorBody = { message: string; data: unknown; code: LogtoErrorCode };
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@logto/phrases": "^0.1.0",
|
"@logto/phrases": "^0.1.0",
|
||||||
|
"@logto/schemas": "^0.1.0",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"i18next": "^20.3.3",
|
"i18next": "^20.3.3",
|
||||||
"i18next-browser-languagedetector": "^6.1.2",
|
"i18next-browser-languagedetector": "^6.1.2",
|
||||||
|
|
|
@ -21,6 +21,11 @@ module.exports = {
|
||||||
/** @type {import('@jest/types').Config.InitialOptions} **/
|
/** @type {import('@jest/types').Config.InitialOptions} **/
|
||||||
const config = { ...jestConfig };
|
const config = { ...jestConfig };
|
||||||
|
|
||||||
|
config.transformIgnorePatterns = [
|
||||||
|
'^.+\\.module\\.(css|sass|scss)$',
|
||||||
|
'[/\\\\]node_modules[/\\\\]((?!ky[/\\\\]).)+\\.(js|jsx|mjs|cjs|ts|tsx)$',
|
||||||
|
];
|
||||||
|
|
||||||
config.moduleNameMapper = {
|
config.moduleNameMapper = {
|
||||||
...config.moduleNameMapper,
|
...config.moduleNameMapper,
|
||||||
'^.+\\.(css|less|scss)$': 'babel-jest',
|
'^.+\\.(css|less|scss)$': 'babel-jest',
|
||||||
|
|
46
packages/ui/src/hooks/use-api.ts
Normal file
46
packages/ui/src/hooks/use-api.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { HTTPError } from 'ky';
|
||||||
|
import { RequestErrorBody } from '@logto/schemas';
|
||||||
|
|
||||||
|
type UseApi<T extends any[], U> = {
|
||||||
|
result?: U;
|
||||||
|
loading: boolean;
|
||||||
|
error: RequestErrorBody | null;
|
||||||
|
run: (...args: T) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useApi<Args extends any[], Response>(
|
||||||
|
api: (...args: Args) => Promise<Response>
|
||||||
|
): UseApi<Args, Response> {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<RequestErrorBody | null>(null);
|
||||||
|
const [result, setResult] = useState<Response>();
|
||||||
|
|
||||||
|
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<RequestErrorBody>();
|
||||||
|
setError(kyError);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
result,
|
||||||
|
run,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useApi;
|
4
packages/ui/src/include.d/dom.d.ts
vendored
4
packages/ui/src/include.d/dom.d.ts
vendored
|
@ -69,3 +69,7 @@ type AutoCompleteType =
|
||||||
| 'sex'
|
| 'sex'
|
||||||
| 'url'
|
| 'url'
|
||||||
| 'photo';
|
| 'photo';
|
||||||
|
|
||||||
|
interface Body {
|
||||||
|
json<T>(): Promise<T>;
|
||||||
|
}
|
||||||
|
|
|
@ -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 { useTranslation } from 'react-i18next';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { LogtoErrorI18nKey } from '@logto/phrases';
|
||||||
|
|
||||||
import { register } from '@/apis/register';
|
import { register } from '@/apis/register';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import Input from '@/components/Input';
|
import Input from '@/components/Input';
|
||||||
import MessageBox from '@/components/MessageBox';
|
import MessageBox from '@/components/MessageBox';
|
||||||
import TextLink from '@/components/TextLink';
|
import TextLink from '@/components/TextLink';
|
||||||
|
import useApi from '@/hooks/use-api';
|
||||||
|
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
export type PageState = 'idle' | 'loading' | 'error';
|
const Register: FC = () => {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
const App: FC = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [pageState, setPageState] = useState<PageState>('idle');
|
|
||||||
const isLoading = pageState === 'loading';
|
const { loading, error, result, run: asyncRegister } = useApi(register);
|
||||||
|
|
||||||
const signUp: FormEventHandler = useCallback(
|
const signUp: FormEventHandler = useCallback(
|
||||||
async (event) => {
|
async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setPageState('loading');
|
await asyncRegister(username, password);
|
||||||
try {
|
|
||||||
window.location.href = (await register(username, password)).redirectTo;
|
|
||||||
} catch {
|
|
||||||
// TODO: Show specific error after merge into monorepo
|
|
||||||
setPageState('error');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[username, password]
|
[username, password, asyncRegister]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (result?.redirectTo) {
|
||||||
|
window.location.href = result.redirectTo;
|
||||||
|
}
|
||||||
|
}, [result]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.wrapper)}>
|
<div className={classNames(styles.wrapper)}>
|
||||||
<form className={classNames(styles.form)}>
|
<form className={classNames(styles.form)}>
|
||||||
<div className={styles.title}>{t('register.create_account')}</div>
|
<div className={styles.title}>{t('register.create_account')}</div>
|
||||||
<Input
|
<Input
|
||||||
name="username"
|
name="username"
|
||||||
isDisabled={isLoading}
|
isDisabled={loading}
|
||||||
placeholder={t('sign_in.username')}
|
placeholder={t('sign_in.username')}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={setUsername} // TODO: account validation
|
onChange={setUsername} // TODO: account validation
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
name="password"
|
name="password"
|
||||||
isDisabled={isLoading}
|
isDisabled={loading}
|
||||||
placeholder={t('sign_in.password')}
|
placeholder={t('sign_in.password')}
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={setPassword} // TODO: password validation
|
onChange={setPassword} // TODO: password validation
|
||||||
/>
|
/>
|
||||||
{pageState === 'error' && (
|
{error && (
|
||||||
<MessageBox className={styles.box}>{t('sign_in.error')}</MessageBox>
|
<MessageBox className={styles.box}>
|
||||||
|
{i18n.t<string, LogtoErrorI18nKey>(`errors:${error.code}`)}
|
||||||
|
</MessageBox>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
isDisabled={isLoading}
|
isDisabled={loading}
|
||||||
value={isLoading ? t('register.loading') : t('register.action')}
|
value={loading ? t('register.loading') : t('register.action')}
|
||||||
onClick={signUp}
|
onClick={signUp}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -70,4 +72,4 @@ const App: FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
export default Register;
|
||||||
|
|
|
@ -1,39 +1,39 @@
|
||||||
import React, { FC, FormEventHandler, useState, useCallback } from 'react';
|
import React, { FC, FormEventHandler, useState, useCallback, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { LogtoErrorI18nKey } from '@logto/phrases';
|
||||||
|
|
||||||
import { signInBasic } from '@/apis/sign-in';
|
import { signInBasic } from '@/apis/sign-in';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import Input from '@/components/Input';
|
import Input from '@/components/Input';
|
||||||
import MessageBox from '@/components/MessageBox';
|
import MessageBox from '@/components/MessageBox';
|
||||||
import TextLink from '@/components/TextLink';
|
import TextLink from '@/components/TextLink';
|
||||||
|
import useApi from '@/hooks/use-api';
|
||||||
|
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
|
|
||||||
export type PageState = 'idle' | 'loading' | 'error';
|
const SignIn: FC = () => {
|
||||||
|
// TODO: Consider creating cross page data modal
|
||||||
const Home: FC = () => {
|
const { t, i18n } = useTranslation();
|
||||||
// TODO: Consider creading cross page data modal
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [pageState, setPageState] = useState<PageState>('idle');
|
|
||||||
const isLoading = pageState === 'loading';
|
|
||||||
|
|
||||||
const signIn: FormEventHandler = useCallback(
|
const { loading, error, result, run: asyncSignInBasic } = useApi(signInBasic);
|
||||||
|
|
||||||
|
const signInHandler: FormEventHandler = useCallback(
|
||||||
async (event) => {
|
async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setPageState('loading');
|
await asyncSignInBasic(username, password);
|
||||||
try {
|
|
||||||
window.location.href = (await signInBasic(username, password)).redirectTo;
|
|
||||||
} catch {
|
|
||||||
// TODO: Show specific error after merge into monorepo
|
|
||||||
setPageState('error');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[username, password]
|
[username, password, asyncSignInBasic]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (result?.redirectTo) {
|
||||||
|
window.location.href = result.redirectTo;
|
||||||
|
}
|
||||||
|
}, [result]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.wrapper)}>
|
<div className={classNames(styles.wrapper)}>
|
||||||
<form className={classNames(styles.form)}>
|
<form className={classNames(styles.form)}>
|
||||||
|
@ -41,7 +41,7 @@ const Home: FC = () => {
|
||||||
<Input
|
<Input
|
||||||
name="username"
|
name="username"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
isDisabled={isLoading}
|
isDisabled={loading}
|
||||||
placeholder={t('sign_in.username')}
|
placeholder={t('sign_in.username')}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={setUsername}
|
onChange={setUsername}
|
||||||
|
@ -49,19 +49,21 @@ const Home: FC = () => {
|
||||||
<Input
|
<Input
|
||||||
name="password"
|
name="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
isDisabled={isLoading}
|
isDisabled={loading}
|
||||||
placeholder={t('sign_in.password')}
|
placeholder={t('sign_in.password')}
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={setPassword}
|
onChange={setPassword}
|
||||||
/>
|
/>
|
||||||
{pageState === 'error' && (
|
{error && (
|
||||||
<MessageBox className={styles.box}>{t('sign_in.error')}</MessageBox>
|
<MessageBox className={styles.box}>
|
||||||
|
{i18n.t<string, LogtoErrorI18nKey>(`errors:${error.code}`)}
|
||||||
|
</MessageBox>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
isDisabled={isLoading}
|
isDisabled={loading}
|
||||||
value={isLoading ? t('sign_in.loading') : t('sign_in.action')}
|
value={loading ? t('sign_in.loading') : t('sign_in.action')}
|
||||||
onClick={signIn}
|
onClick={signInHandler}
|
||||||
/>
|
/>
|
||||||
<TextLink className={styles.createAccount} href="/register">
|
<TextLink className={styles.createAccount} href="/register">
|
||||||
{t('register.create_account')}
|
{t('register.create_account')}
|
||||||
|
@ -71,4 +73,4 @@ const Home: FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Home;
|
export default SignIn;
|
||||||
|
|
|
@ -161,6 +161,7 @@ importers:
|
||||||
'@logto/eslint-config': ^0.1.0-rc.18
|
'@logto/eslint-config': ^0.1.0-rc.18
|
||||||
'@logto/eslint-config-react': ^0.1.0-rc.18
|
'@logto/eslint-config-react': ^0.1.0-rc.18
|
||||||
'@logto/phrases': ^0.1.0
|
'@logto/phrases': ^0.1.0
|
||||||
|
'@logto/schemas': ^0.1.0
|
||||||
'@logto/ts-config': ^0.1.0-rc.18
|
'@logto/ts-config': ^0.1.0-rc.18
|
||||||
'@logto/ts-config-react': ^0.1.0-rc.18
|
'@logto/ts-config-react': ^0.1.0-rc.18
|
||||||
'@testing-library/react': ^12.0.0
|
'@testing-library/react': ^12.0.0
|
||||||
|
@ -195,6 +196,7 @@ importers:
|
||||||
webpack-dev-server: ^3.11.2
|
webpack-dev-server: ^3.11.2
|
||||||
dependencies:
|
dependencies:
|
||||||
'@logto/phrases': link:../phrases
|
'@logto/phrases': link:../phrases
|
||||||
|
'@logto/schemas': link:../schemas
|
||||||
classnames: 2.3.1
|
classnames: 2.3.1
|
||||||
i18next: 20.3.5
|
i18next: 20.3.5
|
||||||
i18next-browser-languagedetector: 6.1.2
|
i18next-browser-languagedetector: 6.1.2
|
||||||
|
|
Loading…
Reference in a new issue