From ce729dff1fab81ee036f99f33de76daf856c07c7 Mon Sep 17 00:00:00 2001 From: archer <545436317@qq.com> Date: Mon, 7 Aug 2023 17:19:04 +0800 Subject: [PATCH] feat: git login --- client/data/config.json | 4 +- client/src/api/user.ts | 1 + client/src/components/Icon/icons/fill/git.svg | 1 + client/src/components/Icon/index.tsx | 1 + client/src/components/Layout/auth.tsx | 1 + client/src/components/Layout/index.tsx | 2 + client/src/pages/api/user/account/gitLogin.ts | 116 ++++++++++++++++++ .../src/pages/login/components/LoginForm.tsx | 30 +++++ client/src/pages/login/provider.tsx | 74 +++++++++++ client/src/store/global.ts | 80 +++++++----- client/src/types/index.d.ts | 2 + client/src/utils/tools.ts | 30 +++++ 12 files changed, 310 insertions(+), 32 deletions(-) create mode 100644 client/src/components/Icon/icons/fill/git.svg create mode 100644 client/src/pages/api/user/account/gitLogin.ts create mode 100644 client/src/pages/login/provider.tsx diff --git a/client/data/config.json b/client/data/config.json index 8bfc16784..7eaf85205 100644 --- a/client/data/config.json +++ b/client/data/config.json @@ -7,9 +7,11 @@ "show_git": true, "beianText": "", "systemTitle": "FastAI", - "authorText": "Made by FastAI Team." + "authorText": "Made by FastAI Team.", + "gitLoginKey": "" }, "SystemParams": { + "gitLoginSecret": "", "vectorMaxProcess": 15, "qaMaxProcess": 15, "pgIvfflatProbe": 20 diff --git a/client/src/api/user.ts b/client/src/api/user.ts index 79acbe5ef..bab130666 100644 --- a/client/src/api/user.ts +++ b/client/src/api/user.ts @@ -13,6 +13,7 @@ export const sendAuthCode = (data: { }) => POST('/user/sendAuthCode', data); export const getTokenLogin = () => GET('/user/account/tokenLogin'); +export const gitLogin = (code: string) => GET('/user/account/gitLogin', { code }); export const postRegister = ({ username, diff --git a/client/src/components/Icon/icons/fill/git.svg b/client/src/components/Icon/icons/fill/git.svg new file mode 100644 index 000000000..531df28c9 --- /dev/null +++ b/client/src/components/Icon/icons/fill/git.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/components/Icon/index.tsx b/client/src/components/Icon/index.tsx index 1c12299dd..200dd46a7 100644 --- a/client/src/components/Icon/index.tsx +++ b/client/src/components/Icon/index.tsx @@ -23,6 +23,7 @@ const map = { wx: require('./icons/wx.svg').default, out: require('./icons/out.svg').default, git: require('./icons/git.svg').default, + gitFill: require('./icons/fill/git.svg').default, menu: require('./icons/menu.svg').default, edit: require('./icons/edit.svg').default, inform: require('./icons/inform.svg').default, diff --git a/client/src/components/Layout/auth.tsx b/client/src/components/Layout/auth.tsx index 6a01b02b8..e5d291598 100644 --- a/client/src/components/Layout/auth.tsx +++ b/client/src/components/Layout/auth.tsx @@ -7,6 +7,7 @@ import { useQuery } from '@tanstack/react-query'; const unAuthPage: { [key: string]: boolean } = { '/': true, '/login': true, + '/login/provider': true, '/appStore': true, '/chat/share': true }; diff --git a/client/src/components/Layout/index.tsx b/client/src/components/Layout/index.tsx index b9c148b62..d10743d7c 100644 --- a/client/src/components/Layout/index.tsx +++ b/client/src/components/Layout/index.tsx @@ -14,6 +14,7 @@ import { getUnreadCount } from '@/api/user'; const pcUnShowLayoutRoute: Record = { '/': true, '/login': true, + '/login/provider': true, '/chat/share': true, '/app/edit': true, '/chat': true @@ -21,6 +22,7 @@ const pcUnShowLayoutRoute: Record = { const phoneUnShowLayoutRoute: Record = { '/': true, '/login': true, + '/login/provider': true, '/chat/share': true }; diff --git a/client/src/pages/api/user/account/gitLogin.ts b/client/src/pages/api/user/account/gitLogin.ts new file mode 100644 index 000000000..755e647d1 --- /dev/null +++ b/client/src/pages/api/user/account/gitLogin.ts @@ -0,0 +1,116 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type { NextApiRequest, NextApiResponse } from 'next'; +import { jsonRes } from '@/service/response'; +import { User } from '@/service/models/user'; +import { generateToken, setCookie } from '@/service/utils/tools'; +import axios from 'axios'; +import { parseQueryString } from '@/utils/tools'; +import { customAlphabet } from 'nanoid'; +const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 8); + +type GithubAccessTokenType = { + access_token: string; + expires_in: number; + refresh_token: string; + refresh_token_expires_in: number; + token_type: 'bearer'; + scope: string; +}; +type GithubUserType = { + email: string; + avatar_url: string; +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const { code } = req.query as { code: string }; + + const { data: gitAccessToken } = await axios.post( + `https://github.com/login/oauth/access_token?client_id=${global.feConfigs.gitLoginKey}&client_secret=${global.systemEnv.gitLoginSecret}&code=${code}` + ); + const jsonGitAccessToken = parseQueryString(gitAccessToken) as GithubAccessTokenType; + + const access_token = jsonGitAccessToken?.access_token; + if (!access_token) { + throw new Error('access_token is null'); + } + + const { + data: { email, avatar_url } + } = await axios.get('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${access_token}` + } + }); + + try { + jsonRes(res, { + data: await loginByUsername({ username: email, res }) + }); + } catch (err: any) { + if (err?.code === 500) { + jsonRes(res, { + data: await registerUser({ username: email, avatar: avatar_url, res }) + }); + } + throw new Error(err); + } + } catch (err) { + jsonRes(res, { + code: 500, + error: err + }); + } +} + +export async function loginByUsername({ + username, + res +}: { + username: string; + res: NextApiResponse; +}) { + const user = await User.findOne({ username }); + console.log(user, username); + + if (!user) { + return Promise.reject({ + code: 500 + }); + } + + const token = generateToken(user._id); + setCookie(res, token); + return { user, token }; +} + +export async function registerUser({ + username, + avatar, + res +}: { + username: string; + avatar?: string; + res: NextApiResponse; +}) { + const response = await User.create({ + username, + avatar, + password: nanoid() + }); + + // 根据 id 获取用户信息 + const user = await User.findById(response._id); + + if (!user) { + throw new Error('获取用户信息异常'); + } + + const token = generateToken(user._id); + setCookie(res, token); + + return { + user, + token + }; +} diff --git a/client/src/pages/login/components/LoginForm.tsx b/client/src/pages/login/components/LoginForm.tsx index 5f8ddbb40..5e2453945 100644 --- a/client/src/pages/login/components/LoginForm.tsx +++ b/client/src/pages/login/components/LoginForm.tsx @@ -1,11 +1,14 @@ import React, { useState, Dispatch, useCallback } from 'react'; import { FormControl, Flex, Input, Button, FormErrorMessage, Box } from '@chakra-ui/react'; import { useForm } from 'react-hook-form'; +import { useRouter } from 'next/router'; import { PageTypeEnum } from '@/constants/user'; import { postLogin } from '@/api/user'; import type { ResLogin } from '@/api/response/user'; import { useToast } from '@/hooks/useToast'; import { feConfigs } from '@/store/static'; +import { useGlobalStore } from '@/store/global'; +import MyIcon from '@/components/Icon'; interface Props { setPageType: Dispatch<`${PageTypeEnum}`>; @@ -18,7 +21,10 @@ interface LoginFormType { } const LoginForm = ({ setPageType, loginSuccess }: Props) => { + const router = useRouter(); + const { lastRoute = '/app/list' } = router.query as { lastRoute: string }; const { toast } = useToast(); + const { setLoginStore } = useGlobalStore(); const { register, handleSubmit, @@ -52,6 +58,19 @@ const LoginForm = ({ setPageType, loginSuccess }: Props) => { [loginSuccess, toast] ); + const onclickGit = useCallback(() => { + setLoginStore({ + provider: 'git', + lastRoute + }); + router.replace( + `https://github.com/login/oauth/authorize?client_id=${ + feConfigs?.gitLoginKey + }&redirect_uri=${`${location.origin}/login/provider`}&scope=user:email%20read:user`, + '_self' + ); + }, [lastRoute, setLoginStore]); + return ( <> @@ -117,6 +136,17 @@ const LoginForm = ({ setPageType, loginSuccess }: Props) => { > 登录 + {feConfigs?.show_register && ( + + + + )} ); diff --git a/client/src/pages/login/provider.tsx b/client/src/pages/login/provider.tsx new file mode 100644 index 000000000..4b613d18a --- /dev/null +++ b/client/src/pages/login/provider.tsx @@ -0,0 +1,74 @@ +import React, { useCallback, useEffect } from 'react'; +import { useRouter } from 'next/router'; +import { useGlobalStore } from '@/store/global'; +import { ResLogin } from '@/api/response/user'; +import { useChatStore } from '@/store/chat'; +import { useUserStore } from '@/store/user'; +import { setToken } from '@/utils/user'; +import { gitLogin } from '@/api/user'; +import { useToast } from '@/hooks/useToast'; +import Loading from '@/components/Loading'; + +const provider = () => { + const { loginStore } = useGlobalStore(); + const { setLastChatId, setLastChatAppId } = useChatStore(); + const { setUserInfo } = useUserStore(); + const router = useRouter(); + const { toast } = useToast(); + const { code } = router.query as { code?: string }; + + const loginSuccess = useCallback( + (res: ResLogin) => { + // init store + setLastChatId(''); + setLastChatAppId(''); + + setUserInfo(res.user); + setToken(res.token); + setTimeout(() => { + router.push( + loginStore?.lastRoute ? decodeURIComponent(loginStore?.lastRoute) : '/app/list' + ); + }, 100); + }, + [setLastChatId, setLastChatAppId, setUserInfo, router, loginStore?.lastRoute] + ); + + const authCode = useCallback(async () => { + if (!code) return; + if (!loginStore) { + router.replace('/login'); + return; + } + try { + const res = await (async () => { + if (loginStore.provider === 'git') { + return gitLogin(code); + } + return null; + })(); + if (!res) { + toast({ + status: 'warning', + title: '登录异常' + }); + return router.replace('/login'); + } + loginSuccess(res); + } catch (error) { + toast({ + status: 'warning', + title: '登录异常' + }); + router.replace('/login'); + } + }, [code, loginStore, loginSuccess]); + + useEffect(() => { + authCode(); + }, [authCode]); + + return ; +}; + +export default provider; diff --git a/client/src/store/global.ts b/client/src/store/global.ts index ac2f1d4fa..d4ba190f5 100644 --- a/client/src/store/global.ts +++ b/client/src/store/global.ts @@ -1,9 +1,13 @@ import { create } from 'zustand'; -import { devtools } from 'zustand/middleware'; +import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; import axios from 'axios'; +type LoginStoreType = { provider: 'git'; lastRoute: string }; + type State = { + loginStore?: LoginStoreType; + setLoginStore: (e: LoginStoreType) => void; loading: boolean; setLoading: (val: boolean) => null; screenWidth: number; @@ -16,39 +20,53 @@ type State = { export const useGlobalStore = create()( devtools( - immer((set, get) => ({ - loading: false, - setLoading: (val: boolean) => { - set((state) => { - state.loading = val; - }); - return null; - }, - screenWidth: 600, - setScreenWidth(val: number) { - set((state) => { - state.screenWidth = val; - state.isPc = val < 900 ? false : true; - }); - }, - isPc: undefined, - initIsPc(val: boolean) { - if (get().isPc !== undefined) return; - - set((state) => { - state.isPc = val; - }); - }, - gitStar: 2700, - async loadGitStar() { - try { - const { data: git } = await axios.get('https://api.github.com/repos/labring/FastGPT'); + persist( + immer((set, get) => ({ + loginStore: undefined, + setLoginStore(e) { + set((state) => { + state.loginStore = e; + }); + }, + loading: false, + setLoading: (val: boolean) => { + set((state) => { + state.loading = val; + }); + return null; + }, + screenWidth: 600, + setScreenWidth(val: number) { + set((state) => { + state.screenWidth = val; + state.isPc = val < 900 ? false : true; + }); + }, + isPc: undefined, + initIsPc(val: boolean) { + if (get().isPc !== undefined) return; set((state) => { - state.gitStar = git.stargazers_count; + state.isPc = val; }); - } catch (error) {} + }, + gitStar: 2700, + async loadGitStar() { + try { + const { data: git } = await axios.get('https://api.github.com/repos/labring/FastGPT'); + + set((state) => { + state.gitStar = git.stargazers_count; + }); + } catch (error) {} + } + })), + { + name: 'globalStore', + partialize: (state) => ({ + loginStore: state.loginStore + }) } - })) + ) ) ); diff --git a/client/src/types/index.d.ts b/client/src/types/index.d.ts index ac2473bbb..d040eadd3 100644 --- a/client/src/types/index.d.ts +++ b/client/src/types/index.d.ts @@ -24,9 +24,11 @@ export type FeConfigsType = { beianText?: string; googleClientVerKey?: string; baiduTongjiUrl?: string; + gitLoginKey?: string; }; export type SystemEnvType = { googleServiceVerKey?: string; + gitLoginSecret?: string; vectorMaxProcess: number; qaMaxProcess: number; pgIvfflatProbe: number; diff --git a/client/src/utils/tools.ts b/client/src/utils/tools.ts index 212e62837..92a88119c 100644 --- a/client/src/utils/tools.ts +++ b/client/src/utils/tools.ts @@ -53,6 +53,36 @@ export const Obj2Query = (obj: Record) => { return queryParams.toString(); }; +export const parseQueryString = (str: string) => { + const queryObject: Record = {}; + + const splitStr = str.split('?'); + + str = splitStr[1] || splitStr[0]; + + // 将字符串按照 '&' 分割成键值对数组 + const keyValuePairs = str.split('&'); + + // 遍历键值对数组,将每个键值对解析为对象的属性和值 + keyValuePairs.forEach(function (keyValuePair) { + const pair = keyValuePair.split('='); + const key = decodeURIComponent(pair[0]); + const value = decodeURIComponent(pair[1] || ''); + + // 如果对象中已经存在该属性,则将值转换为数组 + if (queryObject.hasOwnProperty(key)) { + if (!Array.isArray(queryObject[key])) { + queryObject[key] = [queryObject[key]]; + } + queryObject[key].push(value); + } else { + queryObject[key] = value; + } + }); + + return queryObject; +}; + /** * 格式化时间成聊天格式 */