Просмотр исходного кода

Merge pull request #79 from Ehco1996/telegram-login

feat: support telegram login
Calcium-Ion 2 лет назад
Родитель
Сommit
a5cfeeaa63

+ 4 - 0
common/constants.go

@@ -51,6 +51,7 @@ var PasswordRegisterEnabled = true
 var EmailVerificationEnabled = false
 var GitHubOAuthEnabled = false
 var WeChatAuthEnabled = false
+var TelegramOAuthEnabled = false
 var TurnstileCheckEnabled = false
 var RegisterEnabled = true
 
@@ -88,6 +89,9 @@ var WeChatAccountQRCodeImageURL = ""
 var TurnstileSiteKey = ""
 var TurnstileSecretKey = ""
 
+var TelegramBotToken = ""
+var TelegramBotName = ""
+
 var QuotaForNewUser = 0
 var QuotaForInviter = 0
 var QuotaForInvitee = 0

+ 2 - 0
controller/misc.go

@@ -20,6 +20,8 @@ func GetStatus(c *gin.Context) {
 			"email_verification":       common.EmailVerificationEnabled,
 			"github_oauth":             common.GitHubOAuthEnabled,
 			"github_client_id":         common.GitHubClientId,
+			"telegram_oauth":           common.TelegramOAuthEnabled,
+			"telegram_bot_name":        common.TelegramBotName,
 			"system_name":              common.SystemName,
 			"logo":                     common.Logo,
 			"footer_html":              common.Footer,

+ 116 - 0
controller/telegram.go

@@ -0,0 +1,116 @@
+package controller
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"io"
+	"one-api/common"
+	"one-api/model"
+	"sort"
+
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-gonic/gin"
+)
+
+func TelegramBind(c *gin.Context) {
+	if !common.TelegramOAuthEnabled {
+		c.JSON(200, gin.H{
+			"message": "管理员未开启通过 Telegram 登录以及注册",
+			"success": false,
+		})
+		return
+	}
+	params := c.Request.URL.Query()
+	if !checkTelegramAuthorization(params, common.TelegramBotToken) {
+		c.JSON(200, gin.H{
+			"message": "无效的请求",
+			"success": false,
+		})
+		return
+	}
+	telegramId := params["id"][0]
+	if model.IsTelegramIdAlreadyTaken(telegramId) {
+		c.JSON(200, gin.H{
+			"message": "该 Telegram 账户已被绑定",
+			"success": false,
+		})
+		return
+	}
+
+	session := sessions.Default(c)
+	id := session.Get("id")
+	user := model.User{Id: id.(int)}
+	if err := user.FillUserById(); err != nil {
+		c.JSON(200, gin.H{
+			"message": err.Error(),
+			"success": false,
+		})
+		return
+	}
+	user.TelegramId = telegramId
+	if err := user.Update(false); err != nil {
+		c.JSON(200, gin.H{
+			"message": err.Error(),
+			"success": false,
+		})
+		return
+	}
+
+	c.Redirect(302, "/setting")
+}
+
+func TelegramLogin(c *gin.Context) {
+	if !common.TelegramOAuthEnabled {
+		c.JSON(200, gin.H{
+			"message": "管理员未开启通过 Telegram 登录以及注册",
+			"success": false,
+		})
+		return
+	}
+	params := c.Request.URL.Query()
+	if !checkTelegramAuthorization(params, common.TelegramBotToken) {
+		c.JSON(200, gin.H{
+			"message": "无效的请求",
+			"success": false,
+		})
+		return
+	}
+
+	telegramId := params["id"][0]
+	user := model.User{TelegramId: telegramId}
+	if err := user.FillUserByTelegramId(); err != nil {
+		c.JSON(200, gin.H{
+			"message": err.Error(),
+			"success": false,
+		})
+		return
+	}
+	setupLogin(&user, c)
+}
+
+func checkTelegramAuthorization(params map[string][]string, token string) bool {
+	strs := []string{}
+	var hash = ""
+	for k, v := range params {
+		if k == "hash" {
+			hash = v[0]
+			continue
+		}
+		strs = append(strs, k+"="+v[0])
+	}
+	sort.Strings(strs)
+	var imploded = ""
+	for _, s := range strs {
+		if imploded != "" {
+			imploded += "\n"
+		}
+		imploded += s
+	}
+	sha256hash := sha256.New()
+	io.WriteString(sha256hash, token)
+	hmachash := hmac.New(sha256.New, sha256hash.Sum(nil))
+	io.WriteString(hmachash, imploded)
+	ss := hex.EncodeToString(hmachash.Sum(nil))
+	return hash == ss
+}

+ 1 - 0
docker-compose.yml

@@ -3,6 +3,7 @@ version: '3.4'
 services:
   new-api:
     image: calciumion/new-api:latest
+    # build: .
     container_name: new-api
     restart: always
     command: --log-dir /app/logs

+ 14 - 0
makefile

@@ -0,0 +1,14 @@
+FRONTEND_DIR = ./web
+BACKEND_DIR = .
+
+.PHONY: all build-frontend start-backend
+
+all: build-frontend start-backend
+
+build-frontend:
+	@echo "Building frontend..."
+	@cd $(FRONTEND_DIR) && npm install && DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat VERSION) npm run build npm run build
+
+start-backend:
+	@echo "Starting backend dev server..."
+	@cd $(BACKEND_DIR) && go run main.go &

+ 9 - 0
model/option.go

@@ -30,6 +30,7 @@ func InitOptionMap() {
 	common.OptionMap["PasswordRegisterEnabled"] = strconv.FormatBool(common.PasswordRegisterEnabled)
 	common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled)
 	common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled)
+	common.OptionMap["TelegramOAuthEnabled"] = strconv.FormatBool(common.TelegramOAuthEnabled)
 	common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled)
 	common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled)
 	common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled)
@@ -64,6 +65,8 @@ func InitOptionMap() {
 	common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
 	common.OptionMap["GitHubClientId"] = ""
 	common.OptionMap["GitHubClientSecret"] = ""
+	common.OptionMap["TelegramBotToken"] = ""
+	common.OptionMap["TelegramBotName"] = ""
 	common.OptionMap["WeChatServerAddress"] = ""
 	common.OptionMap["WeChatServerToken"] = ""
 	common.OptionMap["WeChatAccountQRCodeImageURL"] = ""
@@ -154,6 +157,8 @@ func updateOptionMap(key string, value string) (err error) {
 			common.GitHubOAuthEnabled = boolValue
 		case "WeChatAuthEnabled":
 			common.WeChatAuthEnabled = boolValue
+		case "TelegramOAuthEnabled":
+			common.TelegramOAuthEnabled = boolValue
 		case "TurnstileCheckEnabled":
 			common.TurnstileCheckEnabled = boolValue
 		case "RegisterEnabled":
@@ -224,6 +229,10 @@ func updateOptionMap(key string, value string) (err error) {
 		common.WeChatServerToken = value
 	case "WeChatAccountQRCodeImageURL":
 		common.WeChatAccountQRCodeImageURL = value
+	case "TelegramBotToken":
+		common.TelegramBotToken = value
+	case "TelegramBotName":
+		common.TelegramBotName = value
 	case "TurnstileSiteKey":
 		common.TurnstileSiteKey = value
 	case "TurnstileSecretKey":

+ 18 - 1
model/user.go

@@ -3,10 +3,11 @@ package model
 import (
 	"errors"
 	"fmt"
-	"gorm.io/gorm"
 	"one-api/common"
 	"strings"
 	"time"
+
+	"gorm.io/gorm"
 )
 
 // User if you add sensitive fields, don't forget to clean them in setupLogin function.
@@ -21,6 +22,7 @@ type User struct {
 	Email            string         `json:"email" gorm:"index" validate:"max=50"`
 	GitHubId         string         `json:"github_id" gorm:"column:github_id;index"`
 	WeChatId         string         `json:"wechat_id" gorm:"column:wechat_id;index"`
+	TelegramId       string         `json:"telegram_id" gorm:"column:telegram_id;index"`
 	VerificationCode string         `json:"verification_code" gorm:"-:all"`                                    // this field is only for Email verification, don't save it to database!
 	AccessToken      string         `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
 	Quota            int            `json:"quota" gorm:"type:int;default:0"`
@@ -286,6 +288,17 @@ func (user *User) FillUserByUsername() error {
 	return nil
 }
 
+func (user *User) FillUserByTelegramId() error {
+	if user.TelegramId == "" {
+		return errors.New("Telegram id 为空!")
+	}
+	err := DB.Where(User{TelegramId: user.TelegramId}).First(user).Error
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		return errors.New("该 Telegram 账户未绑定")
+	}
+	return nil
+}
+
 func IsEmailAlreadyTaken(email string) bool {
 	return DB.Where("email = ?", email).Find(&User{}).RowsAffected == 1
 }
@@ -302,6 +315,10 @@ func IsUsernameAlreadyTaken(username string) bool {
 	return DB.Where("username = ?", username).Find(&User{}).RowsAffected == 1
 }
 
+func IsTelegramIdAlreadyTaken(telegramId string) bool {
+	return DB.Where("telegram_id = ?", telegramId).Find(&User{}).RowsAffected == 1
+}
+
 func ResetUserPasswordByEmail(email string, password string) error {
 	if email == "" || password == "" {
 		return errors.New("邮箱地址或密码为空!")

+ 2 - 0
router/api-router.go

@@ -26,6 +26,8 @@ func SetApiRouter(router *gin.Engine) {
 		apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)
 		apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.WeChatBind)
 		apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.EmailBind)
+		apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), controller.TelegramLogin)
+		apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.TelegramBind)
 
 		userRoute := apiRouter.Group("/user")
 		{

+ 3 - 2
web/package.json

@@ -3,10 +3,10 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
-    "@douyinfe/semi-ui": "^2.46.1",
     "@douyinfe/semi-icons": "^2.46.1",
-    "@visactor/vchart": "~1.8.8",
+    "@douyinfe/semi-ui": "^2.46.1",
     "@visactor/react-vchart": "~1.8.8",
+    "@visactor/vchart": "~1.8.8",
     "@visactor/vchart-semi-theme": "~1.8.8",
     "axios": "^0.27.2",
     "history": "^5.3.0",
@@ -17,6 +17,7 @@
     "react-fireworks": "^1.0.4",
     "react-router-dom": "^6.3.0",
     "react-scripts": "5.0.1",
+    "react-telegram-login": "^1.1.2",
     "react-toastify": "^9.0.8",
     "react-turnstile": "^1.0.5",
     "semantic-ui-css": "^2.5.0",

+ 52 - 24
web/src/components/LoginForm.js

@@ -1,14 +1,15 @@
-import React, {useContext, useEffect, useState} from 'react';
-import {Link, useNavigate, useSearchParams} from 'react-router-dom';
-import {UserContext} from '../context/User';
-import {API, getLogo, isMobile, showError, showInfo, showSuccess, showWarning} from '../helpers';
-import {onGitHubOAuthClicked} from './utils';
+import React, { useContext, useEffect, useState } from 'react';
+import { Link, useNavigate, useSearchParams } from 'react-router-dom';
+import { UserContext } from '../context/User';
+import { API, getLogo, isMobile, showError, showInfo, showSuccess, showWarning } from '../helpers';
+import { onGitHubOAuthClicked } from './utils';
 import Turnstile from "react-turnstile";
-import {Layout, Card, Image, Form, Button, Divider, Modal} from "@douyinfe/semi-ui";
+import { Layout, Card, Image, Form, Button, Divider, Modal } from "@douyinfe/semi-ui";
 import Title from "@douyinfe/semi-ui/lib/es/typography/title";
 import Text from "@douyinfe/semi-ui/lib/es/typography/text";
+import TelegramLoginButton from 'react-telegram-login';
 
-import {IconGithubLogo} from '@douyinfe/semi-icons';
+import { IconGithubLogo } from '@douyinfe/semi-icons';
 
 const LoginForm = () => {
     const [inputs, setInputs] = useState({
@@ -18,7 +19,7 @@ const LoginForm = () => {
     });
     const [searchParams, setSearchParams] = useSearchParams();
     const [submitted, setSubmitted] = useState(false);
-    const {username, password} = inputs;
+    const { username, password } = inputs;
     const [userState, userDispatch] = useContext(UserContext);
     const [turnstileEnabled, setTurnstileEnabled] = useState(false);
     const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
@@ -56,9 +57,9 @@ const LoginForm = () => {
         const res = await API.get(
             `/api/oauth/wechat?code=${inputs.wechat_verification_code}`
         );
-        const {success, message, data} = res.data;
+        const { success, message, data } = res.data;
         if (success) {
-            userDispatch({type: 'login', payload: data});
+            userDispatch({ type: 'login', payload: data });
             localStorage.setItem('user', JSON.stringify(data));
             navigate('/');
             showSuccess('登录成功!');
@@ -69,7 +70,7 @@ const LoginForm = () => {
     };
 
     function handleChange(name, value) {
-        setInputs((inputs) => ({...inputs, [name]: value}));
+        setInputs((inputs) => ({ ...inputs, [name]: value }));
     }
 
     async function handleSubmit(e) {
@@ -83,13 +84,13 @@ const LoginForm = () => {
                 username,
                 password
             });
-            const {success, message, data} = res.data;
+            const { success, message, data } = res.data;
             if (success) {
-                userDispatch({type: 'login', payload: data});
+                userDispatch({ type: 'login', payload: data });
                 localStorage.setItem('user', JSON.stringify(data));
                 showSuccess('登录成功!');
                 if (username === 'root' && password === '123456') {
-                    Modal.error({title: '您正在使用默认密码!', content: '请立刻修改默认密码!', centered: true});
+                    Modal.error({ title: '您正在使用默认密码!', content: '请立刻修改默认密码!', centered: true });
                 }
                 navigate('/token');
             } else {
@@ -100,16 +101,37 @@ const LoginForm = () => {
         }
     }
 
+    // 添加Telegram登录处理函数
+    const onTelegramLoginClicked = async (response) => {
+        const fields = ["id", "first_name", "last_name", "username", "photo_url", "auth_date", "hash", "lang"];
+        const params = {};
+        fields.forEach((field) => {
+            if (response[field]) {
+                params[field] = response[field];
+            }
+        });
+        const res = await API.get(`/api/oauth/telegram/login`, { params });
+        const { success, message, data } = res.data;
+        if (success) {
+            userDispatch({ type: 'login', payload: data });
+            localStorage.setItem('user', JSON.stringify(data));
+            showSuccess('登录成功!');
+            navigate('/');
+        } else {
+            showError(message);
+        }
+    };
+
     return (
         <div>
             <Layout>
                 <Layout.Header>
                 </Layout.Header>
                 <Layout.Content>
-                    <div style={{justifyContent: 'center', display: "flex", marginTop: 120}}>
-                        <div style={{width: 500}}>
+                    <div style={{ justifyContent: 'center', display: "flex", marginTop: 120 }}>
+                        <div style={{ width: 500 }}>
                             <Card>
-                                <Title heading={2} style={{textAlign: 'center'}}>
+                                <Title heading={2} style={{ textAlign: 'center' }}>
                                     用户登录
                                 </Title>
                                 <Form>
@@ -129,12 +151,12 @@ const LoginForm = () => {
                                         onChange={(value) => handleChange('password', value)}
                                     />
 
-                                    <Button theme='solid' style={{width: '100%'}} type={'primary'} size='large'
-                                            htmlType={'submit'} onClick={handleSubmit}>
+                                    <Button theme='solid' style={{ width: '100%' }} type={'primary'} size='large'
+                                        htmlType={'submit'} onClick={handleSubmit}>
                                         登录
                                     </Button>
                                 </Form>
-                                <div style={{display: 'flex', justifyContent: 'space-between', marginTop: 20}}>
+                                <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 20 }}>
                                     <Text>
                                         没有账号请先 <Link to='/register'>注册账号</Link>
                                     </Text>
@@ -142,16 +164,16 @@ const LoginForm = () => {
                                         忘记密码 <Link to='/reset'>点击重置</Link>
                                     </Text>
                                 </div>
-                                {status.github_oauth || status.wechat_login ? (
+                                {status.github_oauth || status.wechat_login || status.telegram_oauth ? (
                                     <>
                                         <Divider margin='12px' align='center'>
                                             第三方登录
                                         </Divider>
-                                        <div style={{display: 'flex', justifyContent: 'center', marginTop: 20}}>
+                                        <div style={{ display: 'flex', justifyContent: 'center', marginTop: 20 }}>
                                             {status.github_oauth ? (
                                                 <Button
                                                     type='primary'
-                                                    icon={<IconGithubLogo/>}
+                                                    icon={<IconGithubLogo />}
                                                     onClick={() => onGitHubOAuthClicked(status.github_client_id)}
                                                 />
                                             ) : (
@@ -167,6 +189,12 @@ const LoginForm = () => {
                                             {/*) : (*/}
                                             {/*    <></>*/}
                                             {/*)}*/}
+
+                                            {status.telegram_oauth ? (
+                                                <TelegramLoginButton dataOnauth={onTelegramLoginClicked} botName={status.telegram_bot_name} />
+                                            ) : (
+                                                <></>
+                                            )}
                                         </div>
                                     </>
                                 ) : (
@@ -208,7 +236,7 @@ const LoginForm = () => {
                                 {/*</Modal>*/}
                             </Card>
                             {turnstileEnabled ? (
-                                <div style={{display: 'flex', justifyContent: 'center', marginTop: 20}}>
+                                <div style={{ display: 'flex', justifyContent: 'center', marginTop: 20 }}>
                                     <Turnstile
                                         sitekey={turnstileSiteKey}
                                         onVerify={(token) => {

+ 20 - 0
web/src/components/PersonalSetting.js

@@ -21,6 +21,7 @@ import {getQuotaPerUnit, renderQuota, renderQuotaWithPrompt, stringToColor} from
 import EditToken from "../pages/Token/EditToken";
 import EditUser from "../pages/User/EditUser";
 import passwordResetConfirm from "./PasswordResetConfirm";
+import TelegramLoginButton from 'react-telegram-login';
 
 const PersonalSetting = () => {
     const [userState, userDispatch] = useContext(UserContext);
@@ -443,6 +444,25 @@ const PersonalSetting = () => {
                                 </div>
                             </div>
 
+                            <div style={{marginTop: 10}}>
+                                <Typography.Text strong>Telegram</Typography.Text>
+                                <div style={{display: 'flex', justifyContent: 'space-between'}}>
+                                    <div>
+                                        <Input
+                                            value={userState.user && userState.user.telegram_id !== ''?userState.user.telegram_id:'未绑定'}
+                                            readonly={true}
+                                        ></Input>
+                                    </div>
+                                    <div>
+                                        {status.telegram_oauth ?
+                                            userState.user.telegram_id !== '' ? <Button disabled={true}>已绑定</Button>
+                                            : <TelegramLoginButton dataAuthUrl="/api/oauth/telegram/bind" botName={status.telegram_bot_name} />
+                                        : <Button disabled={true}>未启用</Button>
+                                        }
+                                    </div>
+                                </div>
+                            </div>
+
                             <div style={{marginTop: 10}}>
                                 <Space>
                                     <Button onClick={generateAccessToken}>生成系统访问令牌</Button>

+ 59 - 19
web/src/components/SystemSetting.js

@@ -1,6 +1,6 @@
-import React, {useEffect, useState} from 'react';
-import {Button, Divider, Form, Grid, Header, Modal, Message} from 'semantic-ui-react';
-import {API, removeTrailingSlash, showError, verifyJSON} from '../helpers';
+import React, { useEffect, useState } from 'react';
+import { Button, Divider, Form, Grid, Header, Modal, Message } from 'semantic-ui-react';
+import { API, removeTrailingSlash, showError, verifyJSON } from '../helpers';
 
 const SystemSetting = () => {
     let [inputs, setInputs] = useState({
@@ -34,7 +34,11 @@ const SystemSetting = () => {
         TurnstileSecretKey: '',
         RegisterEnabled: '',
         EmailDomainRestrictionEnabled: '',
-        EmailDomainWhitelist: ''
+        EmailDomainWhitelist: '',
+        // telegram login
+        TelegramOAuthEnabled: '',
+        TelegramBotToken: '',
+        TelegramBotName: '',
     });
     const [originInputs, setOriginInputs] = useState({});
     let [loading, setLoading] = useState(false);
@@ -44,7 +48,7 @@ const SystemSetting = () => {
 
     const getOptions = async () => {
         const res = await API.get('/api/option/');
-        const {success, message, data} = res.data;
+        const { success, message, data } = res.data;
         if (success) {
             let newInputs = {};
             data.forEach((item) => {
@@ -60,7 +64,7 @@ const SystemSetting = () => {
             setOriginInputs(newInputs);
 
             setEmailDomainWhitelist(newInputs.EmailDomainWhitelist.split(',').map((item) => {
-                return {key: item, text: item, value: item};
+                return { key: item, text: item, value: item };
             }));
         } else {
             showError(message);
@@ -79,6 +83,7 @@ const SystemSetting = () => {
             case 'EmailVerificationEnabled':
             case 'GitHubOAuthEnabled':
             case 'WeChatAuthEnabled':
+            case 'TelegramOAuthEnabled':
             case 'TurnstileCheckEnabled':
             case 'EmailDomainRestrictionEnabled':
             case 'RegisterEnabled':
@@ -91,7 +96,7 @@ const SystemSetting = () => {
             key,
             value
         });
-        const {success, message} = res.data;
+        const { success, message } = res.data;
         if (success) {
             if (key === 'EmailDomainWhitelist') {
                 value = value.split(',');
@@ -108,7 +113,7 @@ const SystemSetting = () => {
         setLoading(false);
     };
 
-    const handleInputChange = async (e, {name, value}) => {
+    const handleInputChange = async (e, { name, value }) => {
         if (name === 'PasswordLoginEnabled' && inputs[name] === 'true') {
             // block disabling password login
             setShowPasswordWarningModal(true);
@@ -130,9 +135,11 @@ const SystemSetting = () => {
             name === 'TurnstileSiteKey' ||
             name === 'TurnstileSecretKey' ||
             name === 'EmailDomainWhitelist' ||
-            name === 'TopupGroupRatio'
+            name === 'TopupGroupRatio' ||
+            name === 'TelegramBotToken' ||
+            name === 'TelegramBotName'
         ) {
-            setInputs((inputs) => ({...inputs, [name]: value}));
+            setInputs((inputs) => ({ ...inputs, [name]: value }));
         } else {
             await updateOption(name, value);
         }
@@ -236,6 +243,12 @@ const SystemSetting = () => {
         }
     };
 
+    const submitTelegramSettings = async () => {
+        await updateOption('TelegramOAuthEnabled', inputs.TelegramOAuthEnabled);
+        await updateOption('TelegramBotToken', inputs.TelegramBotToken);
+        await updateOption('TelegramBotName', inputs.TelegramBotName);
+    };
+
     const submitTurnstile = async () => {
         if (originInputs['TurnstileSiteKey'] !== inputs.TurnstileSiteKey) {
             await updateOption('TurnstileSiteKey', inputs.TurnstileSiteKey);
@@ -337,7 +350,7 @@ const SystemSetting = () => {
                             label='充值分组倍率'
                             name='TopupGroupRatio'
                             onChange={handleInputChange}
-                            style={{minHeight: 250, fontFamily: 'JetBrains Mono, Consolas'}}
+                            style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
                             autoComplete='new-password'
                             value={inputs.TopupGroupRatio}
                             placeholder='为一个 JSON 文本,键为组名称,值为倍率'
@@ -346,7 +359,7 @@ const SystemSetting = () => {
                     <Form.Button onClick={submitPayAddress}>
                         更新支付设置
                     </Form.Button>
-                    <Divider/>
+                    <Divider />
                     <Header as='h3'>配置登录注册</Header>
                     <Form.Group inline>
                         <Form.Checkbox
@@ -361,7 +374,7 @@ const SystemSetting = () => {
                                 open={showPasswordWarningModal}
                                 onClose={() => setShowPasswordWarningModal(false)}
                                 size={'tiny'}
-                                style={{maxWidth: '450px'}}
+                                style={{ maxWidth: '450px' }}
                             >
                                 <Modal.Header>警告</Modal.Header>
                                 <Modal.Content>
@@ -405,6 +418,12 @@ const SystemSetting = () => {
                             name='WeChatAuthEnabled'
                             onChange={handleInputChange}
                         />
+                        <Form.Checkbox
+                            checked={inputs.TelegramOAuthEnabled === 'true'}
+                            label='允许通过 Telegram 进行登录'
+                            name='TelegramOAuthEnabled'
+                            onChange={handleInputChange}
+                        />
                     </Form.Group>
                     <Form.Group inline>
                         <Form.Checkbox
@@ -420,7 +439,7 @@ const SystemSetting = () => {
                             onChange={handleInputChange}
                         />
                     </Form.Group>
-                    <Divider/>
+                    <Divider />
                     <Header as='h3'>
                         配置邮箱域名白名单
                         <Header.Subheader>用以防止恶意用户利用临时邮箱批量注册</Header.Subheader>
@@ -462,13 +481,13 @@ const SystemSetting = () => {
                             autoComplete='new-password'
                             placeholder='输入新的允许的邮箱域名'
                             value={restrictedDomainInput}
-                            onChange={(e, {value}) => {
+                            onChange={(e, { value }) => {
                                 setRestrictedDomainInput(value);
                             }}
                         />
                     </Form.Group>
                     <Form.Button onClick={submitEmailDomainWhitelist}>保存邮箱域名白名单设置</Form.Button>
-                    <Divider/>
+                    <Divider />
                     <Header as='h3'>
                         配置 SMTP
                         <Header.Subheader>用以支持系统的邮件发送</Header.Subheader>
@@ -519,7 +538,7 @@ const SystemSetting = () => {
                         />
                     </Form.Group>
                     <Form.Button onClick={submitSMTP}>保存 SMTP 设置</Form.Button>
-                    <Divider/>
+                    <Divider />
                     <Header as='h3'>
                         配置 GitHub OAuth App
                         <Header.Subheader>
@@ -557,7 +576,7 @@ const SystemSetting = () => {
                     <Form.Button onClick={submitGitHubOAuth}>
                         保存 GitHub OAuth 设置
                     </Form.Button>
-                    <Divider/>
+                    <Divider />
                     <Header as='h3'>
                         配置 WeChat Server
                         <Header.Subheader>
@@ -601,7 +620,28 @@ const SystemSetting = () => {
                     <Form.Button onClick={submitWeChat}>
                         保存 WeChat Server 设置
                     </Form.Button>
-                    <Divider/>
+                    <Divider />
+                    <Header as='h3'>配置 Telegram 登录</Header>
+                    <Form.Group inline>
+                        <Form.Input
+                            label='Telegram Bot Token'
+                            name='TelegramBotToken'
+                            onChange={handleInputChange}
+                            value={inputs.TelegramBotToken}
+                            placeholder='输入你的 Telegram Bot Token'
+                        />
+                        <Form.Input
+                            label='Telegram Bot 名称'
+                            name='TelegramBotName'
+                            onChange={handleInputChange}
+                            value={inputs.TelegramBotName}
+                            placeholder='输入你的 Telegram Bot 名称'
+                        />
+                    </Form.Group>
+                    <Form.Button onClick={submitTelegramSettings}>
+                        保存 Telegram 登录设置
+                    </Form.Button>
+                    <Divider />
                     <Header as='h3'>
                         配置 Turnstile
                         <Header.Subheader>

+ 6 - 0
web/src/pages/Home/index.js

@@ -110,6 +110,12 @@ const Home = () => {
                                                     ? '已启用'
                                                     : '未启用'}
                                             </p>
+                                            <p>
+                                                Telegram 身份验证:
+                                                {statusState?.status?.telegram_oauth === true
+                                                    ? '已启用'
+                                                    : '未启用'}
+                                            </p>
                                         </Card.Description>
                                     </Card.Content>
                                 </Card>

+ 127 - 121
web/src/pages/User/EditUser.js

@@ -1,9 +1,9 @@
 import React, { useEffect, useState } from 'react';
 import { useParams, useNavigate } from 'react-router-dom';
-import {API, isMobile, showError, showSuccess} from '../../helpers';
+import { API, isMobile, showError, showSuccess } from '../../helpers';
 import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
 import Title from "@douyinfe/semi-ui/lib/es/typography/title";
-import {SideSheet, Space, Button, Spin, Input, Typography, Select, Divider} from "@douyinfe/semi-ui";
+import { SideSheet, Space, Button, Spin, Input, Typography, Select, Divider } from "@douyinfe/semi-ui";
 
 const EditUser = (props) => {
   const userId = props.editingUser.id;
@@ -19,8 +19,8 @@ const EditUser = (props) => {
     group: 'default'
   });
   const [groupOptions, setGroupOptions] = useState([]);
-  const { username, display_name, password, github_id, wechat_id, email, quota, group } =
-      inputs;
+  const { username, display_name, password, github_id, wechat_id, telegram_id, email, quota, group } =
+    inputs;
   const handleInputChange = (name, value) => {
     setInputs((inputs) => ({ ...inputs, [name]: value }));
   };
@@ -88,126 +88,132 @@ const EditUser = (props) => {
   };
 
   return (
-      <>
-        <SideSheet
-            placement={'right'}
-            title={<Title level={3}>{'编辑用户'}</Title>}
-            headerStyle={{borderBottom: '1px solid var(--semi-color-border)'}}
-            bodyStyle={{borderBottom: '1px solid var(--semi-color-border)'}}
-            visible={props.visible}
-            footer={
-              <div style={{display: 'flex', justifyContent: 'flex-end'}}>
-                <Space>
-                  <Button theme='solid' size={'large'} onClick={submit}>提交</Button>
-                  <Button theme='solid' size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>
-                </Space>
+    <>
+      <SideSheet
+        placement={'right'}
+        title={<Title level={3}>{'编辑用户'}</Title>}
+        headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
+        bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
+        visible={props.visible}
+        footer={
+          <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
+            <Space>
+              <Button theme='solid' size={'large'} onClick={submit}>提交</Button>
+              <Button theme='solid' size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>
+            </Space>
+          </div>
+        }
+        closeIcon={null}
+        onCancel={() => handleCancel()}
+        width={isMobile() ? '100%' : 600}
+      >
+        <Spin spinning={loading}>
+          <div style={{ marginTop: 20 }}>
+            <Typography.Text>用户名</Typography.Text>
+          </div>
+          <Input
+            label='用户名'
+            name='username'
+            placeholder={'请输入新的用户名'}
+            onChange={value => handleInputChange('username', value)}
+            value={username}
+            autoComplete='new-password'
+          />
+          <div style={{ marginTop: 20 }}>
+            <Typography.Text>密码</Typography.Text>
+          </div>
+          <Input
+            label='密码'
+            name='password'
+            type={'password'}
+            placeholder={'请输入新的密码,最短 8 位'}
+            onChange={value => handleInputChange('password', value)}
+            value={password}
+            autoComplete='new-password'
+          />
+          <div style={{ marginTop: 20 }}>
+            <Typography.Text>显示名称</Typography.Text>
+          </div>
+          <Input
+            label='显示名称'
+            name='display_name'
+            placeholder={'请输入新的显示名称'}
+            onChange={value => handleInputChange('display_name', value)}
+            value={display_name}
+            autoComplete='new-password'
+          />
+          {
+            userId && <>
+              <div style={{ marginTop: 20 }}>
+                <Typography.Text>分组</Typography.Text>
               </div>
-            }
-            closeIcon={null}
-            onCancel={() => handleCancel()}
-            width={isMobile() ? '100%' : 600}
-        >
-          <Spin spinning={loading}>
-            <div style={{marginTop: 20}}>
-              <Typography.Text>用户名</Typography.Text>
-            </div>
-            <Input
-                label='用户名'
-                name='username'
-                placeholder={'请输入新的用户名'}
-                onChange={value => handleInputChange('username', value)}
-                value={username}
+              <Select
+                placeholder={'请选择分组'}
+                name='group'
+                fluid
+                search
+                selection
+                allowAdditions
+                additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
+                onChange={value => handleInputChange('group', value)}
+                value={inputs.group}
                 autoComplete='new-password'
-            />
-            <div style={{marginTop: 20}}>
-              <Typography.Text>密码</Typography.Text>
-            </div>
-            <Input
-                label='密码'
-                name='password'
-                type={'password'}
-                placeholder={'请输入新的密码,最短 8 位'}
-                onChange={value => handleInputChange('password', value)}
-                value={password}
-                autoComplete='new-password'
-            />
-            <div style={{marginTop: 20}}>
-              <Typography.Text>显示名称</Typography.Text>
-            </div>
-            <Input
-                label='显示名称'
-                name='display_name'
-                placeholder={'请输入新的显示名称'}
-                onChange={value => handleInputChange('display_name', value)}
-                value={display_name}
-                autoComplete='new-password'
-            />
-            {
-                userId && <>
-                  <div style={{marginTop: 20}}>
-                    <Typography.Text>分组</Typography.Text>
-                  </div>
-                  <Select
-                      placeholder={'请选择分组'}
-                      name='group'
-                      fluid
-                      search
-                      selection
-                      allowAdditions
-                      additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
-                      onChange={value => handleInputChange('group', value)}
-                      value={inputs.group}
-                      autoComplete='new-password'
-                      optionList={groupOptions}
-                  />
-                  <div style={{marginTop: 20}}>
-                    <Typography.Text>{`剩余额度${renderQuotaWithPrompt(quota)}`}</Typography.Text>
-                  </div>
-                  <Input
-                      name='quota'
-                      placeholder={'请输入新的剩余额度'}
-                      onChange={value => handleInputChange('quota', value)}
-                      value={quota}
-                      type={'number'}
-                      autoComplete='new-password'
-                  />
-                </>
-            }
-            <Divider style={{marginTop: 20}}>以下信息不可修改</Divider>
-            <div style={{marginTop: 20}}>
-              <Typography.Text>已绑定的 GitHub 账户</Typography.Text>
-            </div>
-            <Input
-                name='github_id'
-                value={github_id}
-                autoComplete='new-password'
-                placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
-                readonly
-            />
-            <div style={{marginTop: 20}}>
-              <Typography.Text>已绑定的微信账户</Typography.Text>
-            </div>
-            <Input
-                name='wechat_id'
-                value={wechat_id}
-                autoComplete='new-password'
-                placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
-                readonly
-            />
-            <div style={{marginTop: 20}}>
-              <Typography.Text>已绑定的邮箱账户</Typography.Text>
-            </div>
-            <Input
-                name='email'
-                value={email}
+                optionList={groupOptions}
+              />
+              <div style={{ marginTop: 20 }}>
+                <Typography.Text>{`剩余额度${renderQuotaWithPrompt(quota)}`}</Typography.Text>
+              </div>
+              <Input
+                name='quota'
+                placeholder={'请输入新的剩余额度'}
+                onChange={value => handleInputChange('quota', value)}
+                value={quota}
+                type={'number'}
                 autoComplete='new-password'
-                placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
-                readonly
-            />
-          </Spin>
-
-        </SideSheet>
-      </>
+              />
+            </>
+          }
+          <Divider style={{ marginTop: 20 }}>以下信息不可修改</Divider>
+          <div style={{ marginTop: 20 }}>
+            <Typography.Text>已绑定的 GitHub 账户</Typography.Text>
+          </div>
+          <Input
+            name='github_id'
+            value={github_id}
+            autoComplete='new-password'
+            placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
+            readonly
+          />
+          <div style={{ marginTop: 20 }}>
+            <Typography.Text>已绑定的微信账户</Typography.Text>
+          </div>
+          <Input
+            name='wechat_id'
+            value={wechat_id}
+            autoComplete='new-password'
+            placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
+            readonly
+          />
+          <Input
+            name='telegram_id'
+            value={telegram_id}
+            autoComplete='new-password'
+            placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
+            readonly
+          />
+          <div style={{ marginTop: 20 }}>
+            <Typography.Text>已绑定的邮箱账户</Typography.Text>
+          </div>
+          <Input
+            name='email'
+            value={email}
+            autoComplete='new-password'
+            placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
+            readonly
+          />
+        </Spin>
+      </SideSheet>
+    </>
   );
 };