diff --git a/shared/api/request.ts b/shared/api/request.ts new file mode 100644 index 00000000..a9bc6f50 --- /dev/null +++ b/shared/api/request.ts @@ -0,0 +1,62 @@ +import axios from 'axios'; +import _get from 'lodash/get'; +import _isString from 'lodash/isString'; +import _isNil from 'lodash/isNil'; +import _isFunction from 'lodash/isFunction'; +import { config } from '../config'; +import { getErrorHook, tokenGetter } from '../manager/request'; + +export type CommonRequestResult = + | ({ + result: false; + msg: string; + } & Partial) // 并上一个T是为了方便取值, 但需要判定 + | ({ + result: true; + } & T); + +class RequestError extends Error {} + +/** + * 创建请求实例 + */ +export function createRequest() { + const ins = axios.create({ + baseURL: config.serverUrl, + }); + + ins.interceptors.request.use(async (val) => { + if ( + ['post', 'get'].includes(String(val.method).toLowerCase()) && + !val.headers['X-Token'] + ) { + // 任何请求都尝试增加token + val.headers['X-Token'] = await tokenGetter(); + } + + return val; + }); + + ins.interceptors.response.use( + (val) => { + return val; + }, + (err) => { + // 尝试获取错误信息 + const errorMsg: string = _get(err, 'response.data.message'); + const code: number = _get(err, 'response.data.code'); + if (_isFunction(getErrorHook)) { + const isContinue = getErrorHook(err); + if (isContinue === false) { + return { data: { result: false, msg: errorMsg, code } }; + } + } + + throw new RequestError(errorMsg ?? err.message); + } + ); + + return ins; +} + +export const request = createRequest(); diff --git a/shared/api/socket.ts b/shared/api/socket.ts new file mode 100644 index 00000000..e69de29b diff --git a/shared/config.ts b/shared/config.ts new file mode 100644 index 00000000..fbbf9d2f --- /dev/null +++ b/shared/config.ts @@ -0,0 +1,7 @@ +/** + * Pawchat 共享配置 + */ + +export const config = { + serverUrl: 'http://127.0.0.1:11000', +}; diff --git a/shared/index.tsx b/shared/index.tsx index a603f9f9..b3959f1d 100644 --- a/shared/index.tsx +++ b/shared/index.tsx @@ -20,3 +20,7 @@ export { useMountedState } from './hooks/useMountedState'; // manager export { getStorage, setStorage, useStorage } from './manager/storage'; +export { setTokenGetter } from './manager/request'; + +// model +export { loginWithEmail } from './model/user'; diff --git a/shared/manager/request.ts b/shared/manager/request.ts new file mode 100644 index 00000000..c1d6f57a --- /dev/null +++ b/shared/manager/request.ts @@ -0,0 +1,9 @@ +import { buildRegFn, buildCachedRegFn } from './buildRegFn'; + +export const [getErrorHook, setErrorHook] = buildRegFn<(err: any) => boolean>( + 'requestErrorHook', + () => true +); + +export const [tokenGetter, setTokenGetter] = + buildCachedRegFn<() => Promise>('requestTokenGetter'); diff --git a/shared/model/user.ts b/shared/model/user.ts new file mode 100644 index 00000000..acb691c7 --- /dev/null +++ b/shared/model/user.ts @@ -0,0 +1,15 @@ +import { request } from '../api/request'; + +/** + * 邮箱登录 + * @param email 邮箱 + * @param password 密码 + */ +export async function loginWithEmail(email: string, password: string) { + const data = await request.post('/api/user/login', { + email, + password, + }); + + return data; +} diff --git a/shared/package.json b/shared/package.json index b7540592..abdf5238 100644 --- a/shared/package.json +++ b/shared/package.json @@ -7,6 +7,7 @@ "license": "GPLv3", "private": true, "dependencies": { + "axios": "^0.21.1", "formik": "^2.2.9", "lodash": "^4.17.21", "react-native-storage": "npm:@trpgengine/react-native-storage@^1.0.1", diff --git a/web/package.json b/web/package.json index 25436cf6..db99f540 100644 --- a/web/package.json +++ b/web/package.json @@ -16,6 +16,7 @@ "@loadable/component": "^5.15.0", "antd": "^4.16.6", "clsx": "^1.1.1", + "jwt-decode": "^3.1.2", "p-min-delay": "^4.0.0", "pawchat-shared": "*", "react": "^17.0.2", diff --git a/web/src/init.tsx b/web/src/init.tsx index a077f732..424cb644 100644 --- a/web/src/init.tsx +++ b/web/src/init.tsx @@ -1,4 +1,9 @@ -import { buildStorage, setStorage } from 'pawchat-shared'; +import { buildStorage, setStorage, setTokenGetter } from 'pawchat-shared'; +import { getUserJWT } from './utils/jwt-helper'; const webStorage = buildStorage(window.localStorage); setStorage(() => webStorage); + +setTokenGetter(async () => { + return await getUserJWT(); +}); diff --git a/web/src/routes/Entry/LoginView.tsx b/web/src/routes/Entry/LoginView.tsx index ffcae882..9043596c 100644 --- a/web/src/routes/Entry/LoginView.tsx +++ b/web/src/routes/Entry/LoginView.tsx @@ -1,15 +1,15 @@ import { Icon } from '@iconify/react'; import { Divider } from 'antd'; -import { useAsyncFn } from 'pawchat-shared'; +import { loginWithEmail, useAsyncFn } from 'pawchat-shared'; import React, { useState } from 'react'; import { Spinner } from '../../components/Spinner'; import { string } from 'yup'; /** + * TODO: * 第三方登录 */ const OAuthLoginView: React.FC = React.memo(() => { - // TODO return ( <> @@ -42,11 +42,7 @@ export const LoginView: React.FC = React.memo(() => { .required('密码不能为空') .validate(password); - await new Promise((resolve) => { - setTimeout(() => { - resolve(''); - }, 2000); - }); + await loginWithEmail(email, password); }, [email, password]); return ( diff --git a/web/src/utils/jwt-helper.ts b/web/src/utils/jwt-helper.ts new file mode 100644 index 00000000..145a8ba7 --- /dev/null +++ b/web/src/utils/jwt-helper.ts @@ -0,0 +1,69 @@ +import _isObject from 'lodash/isObject'; +import _get from 'lodash/get'; +import _isNull from 'lodash/isNull'; +import _isNil from 'lodash/isNil'; +import jwtDecode from 'jwt-decode'; +import { getStorage } from 'pawchat-shared'; + +/** + * 获取完整jwt字符串的载荷信息(尝试解析json) + * @param jwt 完整的jwt字符串 + */ +export function getJWTPayload(jwt: string): Partial { + try { + const decoded = jwtDecode(jwt); + return decoded; + } catch (e) { + console.error(`getJWTInfo Error: [jwt: ${jwt}]`, e); + } + + return {}; +} + +// JWT的内存缓存 +let _userJWT: string | null = null; + +/** + * 设置用户登录标识 + */ +export async function setUserJWT(jwt: string): Promise { + _userJWT = jwt; // 更新内存中的缓存 + + await getStorage().set('jsonwebtoken', jwt); +} + +/** + * 获取用户登录标识 + */ +export async function getUserJWT(): Promise { + if (_isNull(_userJWT)) { + const jwt = await getStorage().get('jsonwebtoken'); + _userJWT = jwt; // 将其缓存到内存中 + + return jwt; + } + return _userJWT; +} + +export interface JWTUserInfoData { + name?: string; + uuid?: string; + avatar?: string; +} +/** + * 获取token中的明文信息 + * 明确需要返回一个对象 + */ +export async function getJWTUserInfo(): Promise { + try { + const token = await getUserJWT(); + const info = getJWTPayload(token); + if (_isObject(info)) { + return info; + } + } catch (e) { + console.error('getJWTInfo Error:', e); + } + + return {}; +} diff --git a/yarn.lock b/yarn.lock index 59fef895..01868182 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1452,6 +1452,13 @@ autoprefixer@^10.2.6: normalize-range "^0.1.2" postcss-value-parser "^4.1.0" +axios@^0.21.1: + version "0.21.1" + resolved "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8" + integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA== + dependencies: + follow-redirects "^1.10.0" + babel-jest@^27.0.6: version "27.0.6" resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-27.0.6.tgz#e99c6e0577da2655118e3608b68761a5a69bd0d8" @@ -2947,7 +2954,7 @@ find-up@^4.0.0, find-up@^4.1.0: locate-path "^5.0.0" path-exists "^4.0.0" -follow-redirects@^1.0.0: +follow-redirects@^1.0.0, follow-redirects@^1.10.0: version "1.14.1" resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43" integrity sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg== @@ -4356,6 +4363,11 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jwt-decode@^3.1.2: + version "3.1.2" + resolved "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" + integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== + killable@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892"