diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 000000000000..5de24b7a3903 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,57 @@ +name: Build and Push Docker Image + +on: + push: + branches: [ main ] + tags: [ 'v*' ] + pull_request: + branches: [ main ] + +env: + REGISTRY: docker.io + IMAGE_NAME: nodeloc/new-api + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/DOCKER_README.md b/DOCKER_README.md new file mode 100644 index 000000000000..203ff4ed7512 --- /dev/null +++ b/DOCKER_README.md @@ -0,0 +1,46 @@ +# NodeLoc New-API Docker部署 + +这是NodeLoc版本的New-API,支持NodeLoc OAuth2登录。 + +## 快速开始 + +1. 下载docker-compose.yml文件: +```bash +curl -O https://raw.githubusercontent.com/nodeloc/new-api/main/docker-compose.yml +``` + +2. 启动服务: +```bash +docker-compose up -d +``` + +3. 访问 http://localhost:3000 + + +### 其他配置 +```yaml +environment: + - SQL_DSN=root:123456@tcp(mysql:3306)/new-api + - REDIS_CONN_STRING=redis://redis + - TZ=Asia/Shanghai + - ERROR_LOG_ENABLED=true + - SESSION_SECRET=your_random_secret_key +``` + +## NodeLoc OAuth2设置 + +1. 访问 https://conn.nodeloc.cc/apps +2. 创建新应用 +3. 设置回调URL: `https://your-domain.com/api/oauth/nodeloc` +4. 获取Client ID和Client Secret +5. 在环境变量中配置 + +## 版本 + +- `latest`: 最新版本 +- `v1.x.x`: 具体版本号 + +## 支持 + +- 项目地址: https://github.com/nodeloc/new-api +- 问题反馈: https://github.com/nodeloc/new-api/issues diff --git a/common/constants.go b/common/constants.go index e6d59d101eed..1db84f9d60e1 100644 --- a/common/constants.go +++ b/common/constants.go @@ -44,6 +44,7 @@ var PasswordRegisterEnabled = true var EmailVerificationEnabled = false var GitHubOAuthEnabled = false var LinuxDOOAuthEnabled = false +var NodeLocOAuthEnabled = false var WeChatAuthEnabled = false var TelegramOAuthEnabled = false var TurnstileCheckEnabled = false @@ -85,6 +86,10 @@ var LinuxDOClientId = "" var LinuxDOClientSecret = "" var LinuxDOMinimumTrustLevel = 0 +var NodeLocClientId = "" +var NodeLocClientSecret = "" +var NodeLocMinimumTrustLevel = 0 + var WeChatServerAddress = "" var WeChatServerToken = "" var WeChatAccountQRCodeImageURL = "" diff --git a/controller/misc.go b/controller/misc.go index 897dad25487d..d107539ccbaa 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -51,6 +51,9 @@ func GetStatus(c *gin.Context) { "linuxdo_oauth": common.LinuxDOOAuthEnabled, "linuxdo_client_id": common.LinuxDOClientId, "linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel, + "nodeloc_oauth": common.NodeLocOAuthEnabled, + "nodeloc_client_id": common.NodeLocClientId, + "nodeloc_minimum_trust_level": common.NodeLocMinimumTrustLevel, "telegram_oauth": common.TelegramOAuthEnabled, "telegram_bot_name": common.TelegramBotName, "system_name": common.SystemName, diff --git a/controller/nodeloc.go b/controller/nodeloc.go new file mode 100644 index 000000000000..6f98e7fcd9a3 --- /dev/null +++ b/controller/nodeloc.go @@ -0,0 +1,283 @@ +package controller + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "one-api/common" + "one-api/model" + "strconv" + "strings" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +type NodeLocUser struct { + Sub string `json:"sub"` // 用户ID + Username string `json:"username"` // 用户名 + Email string `json:"email"` // 邮箱地址 + Groups []string `json:"groups"` // 用户组列表 +} + +func NodeLocBind(c *gin.Context) { + if !common.NodeLocOAuthEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未开启通过 NodeLoc 登录以及注册", + }) + return + } + + code := c.Query("code") + nodeLocUser, err := getNodeLocUserInfoByCode(code, c) + if err != nil { + common.ApiError(c, err) + return + } + + user := model.User{ + NodeLocId: nodeLocUser.Sub, + } + + if model.IsNodeLocIdAlreadyTaken(user.NodeLocId) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该 NodeLoc 账户已被绑定", + }) + return + } + + session := sessions.Default(c) + id := session.Get("id") + user.Id = id.(int) + + err = user.FillUserById() + if err != nil { + common.ApiError(c, err) + return + } + + user.NodeLocId = nodeLocUser.Sub + err = user.Update(false) + if err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "bind", + }) +} + +func getNodeLocUserInfoByCode(code string, c *gin.Context) (*NodeLocUser, error) { + if code == "" { + return nil, errors.New("invalid code") + } + + // Get access token using Basic Authentication as per NodeLoc documentation + tokenEndpoint := "https://conn.nodeloc.cc/oauth2/token" + + // Create Basic Auth header: Base64(client_id:client_secret) + credentials := common.NodeLocClientId + ":" + common.NodeLocClientSecret + basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials)) + + // Get redirect URI from request to match authorization request + scheme := "http" + if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" { + scheme = "https" + } + + host := c.Request.Host + if forwardedHost := c.GetHeader("X-Forwarded-Host"); forwardedHost != "" { + host = forwardedHost + } + + redirectURI := fmt.Sprintf("%s://%s/oauth/nodeloc", scheme, host) + + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("code", code) + data.Set("redirect_uri", redirectURI) + + req, err := http.NewRequest("POST", tokenEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", basicAuth) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + client := http.Client{Timeout: 5 * time.Second} + res, err := client.Do(req) + if err != nil { + return nil, errors.New("failed to connect to NodeLoc server") + } + defer res.Body.Close() + + // 读取响应体用于调试 + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + + // 检查HTTP状态码 + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token request failed with status %d: %s", res.StatusCode, string(body)) + } + + var tokenRes struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if err := json.Unmarshal(body, &tokenRes); err != nil { + return nil, fmt.Errorf("failed to parse token response: %v, body: %s", err, string(body)) + } + + if tokenRes.Error != "" { + return nil, fmt.Errorf("OAuth error: %s - %s", tokenRes.Error, tokenRes.ErrorDescription) + } + + if tokenRes.AccessToken == "" { + return nil, fmt.Errorf("no access token in response: %s", string(body)) + } + + // Get user info + userEndpoint := "https://conn.nodeloc.cc/oauth2/userinfo" + req, err = http.NewRequest("GET", userEndpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+tokenRes.AccessToken) + req.Header.Set("Accept", "application/json") + + res2, err := client.Do(req) + if err != nil { + return nil, errors.New("failed to get user info from NodeLoc") + } + defer res2.Body.Close() + + var nodeLocUser NodeLocUser + if err := json.NewDecoder(res2.Body).Decode(&nodeLocUser); err != nil { + return nil, err + } + + if nodeLocUser.Sub == "" { + return nil, errors.New("invalid user info returned") + } + + return &nodeLocUser, nil +} + +func NodeLocOAuth(c *gin.Context) { + session := sessions.Default(c) + + errorCode := c.Query("error") + if errorCode != "" { + errorDescription := c.Query("error_description") + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": errorDescription, + }) + return + } + + state := c.Query("state") + if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "state is empty or not same", + }) + return + } + + username := session.Get("username") + if username != nil { + NodeLocBind(c) + return + } + + if !common.NodeLocOAuthEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未开启通过 NodeLoc 登录以及注册", + }) + return + } + + code := c.Query("code") + nodeLocUser, err := getNodeLocUserInfoByCode(code, c) + if err != nil { + common.ApiError(c, err) + return + } + + user := model.User{ + NodeLocId: nodeLocUser.Sub, + } + + // Check if user exists + if model.IsNodeLocIdAlreadyTaken(user.NodeLocId) { + err := user.FillUserByNodeLocId() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + if user.Id == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户已注销", + }) + return + } + } else { + if common.RegisterEnabled { + user.Username = "NL_" + strconv.Itoa(model.GetMaxUserId()+1) + user.DisplayName = nodeLocUser.Username + user.Role = common.RoleCommonUser + user.Status = common.UserStatusEnabled + + affCode := session.Get("aff") + inviterId := 0 + if affCode != nil { + inviterId, _ = model.GetUserIdByAffCode(affCode.(string)) + } + + if err := user.Insert(inviterId); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } else { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员关闭了新用户注册", + }) + return + } + } + + if user.Status != common.UserStatusEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "用户已被封禁", + "success": false, + }) + return + } + + setupLogin(&user, c) +} diff --git a/controller/option.go b/controller/option.go index e5f2b75b0328..4471c0c692ca 100644 --- a/controller/option.go +++ b/controller/option.go @@ -86,6 +86,14 @@ func UpdateOption(c *gin.Context) { }) return } + case "NLAuthEnabled": + if option.Value == "true" && common.NodeLocClientId == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用 NodeLoc OAuth,请先填入 NodeLoc Client Id 以及 NodeLoc Client Secret!", + }) + return + } case "EmailDomainRestrictionEnabled": if option.Value == "true" && len(common.EmailDomainWhitelist) == 0 { c.JSON(http.StatusOK, gin.H{ diff --git a/controller/user.go b/controller/user.go index 982329cec0de..f364b87028ee 100644 --- a/controller/user.go +++ b/controller/user.go @@ -460,6 +460,7 @@ func GetSelf(c *gin.Context) { "aff_history_quota": user.AffHistoryQuota, "inviter_id": user.InviterId, "linux_do_id": user.LinuxDOId, + "nodeloc_id": user.NodeLocId, "setting": user.Setting, "stripe_customer": user.StripeCustomer, "sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段 diff --git a/docker-compose.yml b/docker-compose.yml index d98fd706e2e9..e57cf60158d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.4' services: new-api: - image: calciumion/new-api:latest + image: nodeloc/new-api:latest container_name: new-api restart: always command: --log-dir /app/logs diff --git a/docs/api/web_api.md b/docs/api/web_api.md index e64fd3594f64..0a00927fd29d 100644 --- a/docs/api/web_api.md +++ b/docs/api/web_api.md @@ -44,6 +44,7 @@ | GET | /api/oauth/github | 公开 | GitHub OAuth 跳转 | | GET | /api/oauth/oidc | 公开 | OIDC 通用 OAuth 跳转 | | GET | /api/oauth/linuxdo | 公开 | LinuxDo OAuth 跳转 | +| GET | /api/oauth/nodeloc | 公开 | NodeLoc OAuth 跳转 | | GET | /api/oauth/wechat | 公开 | 微信扫码登录跳转 | | GET | /api/oauth/wechat/bind | 公开 | 微信账户绑定 | | GET | /api/oauth/email/bind | 公开 | 邮箱绑定 | diff --git a/model/option.go b/model/option.go index 2121710cee59..25934f23df43 100644 --- a/model/option.go +++ b/model/option.go @@ -37,6 +37,7 @@ func InitOptionMap() { common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled) common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled) common.OptionMap["LinuxDOOAuthEnabled"] = strconv.FormatBool(common.LinuxDOOAuthEnabled) + common.OptionMap["NodeLocAuthEnabled"] = strconv.FormatBool(common.NodeLocOAuthEnabled) common.OptionMap["TelegramOAuthEnabled"] = strconv.FormatBool(common.TelegramOAuthEnabled) common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled) common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled) @@ -216,6 +217,8 @@ func updateOptionMap(key string, value string) (err error) { common.GitHubOAuthEnabled = boolValue case "LinuxDOOAuthEnabled": common.LinuxDOOAuthEnabled = boolValue + case "NodeLocOAuthEnabled": + common.NodeLocOAuthEnabled = boolValue case "WeChatAuthEnabled": common.WeChatAuthEnabled = boolValue case "TelegramOAuthEnabled": @@ -338,6 +341,10 @@ func updateOptionMap(key string, value string) (err error) { common.LinuxDOClientSecret = value case "LinuxDOMinimumTrustLevel": common.LinuxDOMinimumTrustLevel, _ = strconv.Atoi(value) + case "NodeLocClientId": + common.NodeLocClientId = value + case "NodeLocClientSecret": + common.NodeLocClientSecret = value case "Footer": common.Footer = value case "SystemName": diff --git a/model/user.go b/model/user.go index ea0584c5a05a..1ebbcaa6a032 100644 --- a/model/user.go +++ b/model/user.go @@ -42,6 +42,7 @@ type User struct { InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"` DeletedAt gorm.DeletedAt `gorm:"index"` LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"` + NodeLocId string `json:"nodeloc_id" gorm:"column:nodeloc_id;index"` Setting string `json:"setting" gorm:"type:text;column:setting"` Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"` StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"` @@ -907,6 +908,20 @@ func (user *User) FillUserByLinuxDOId() error { return err } +func IsNodeLocIdAlreadyTaken(nodeLocId string) bool { + var user User + err := DB.Unscoped().Where("nodeloc_id = ?", nodeLocId).First(&user).Error + return !errors.Is(err, gorm.ErrRecordNotFound) +} + +func (user *User) FillUserByNodeLocId() error { + if user.NodeLocId == "" { + return errors.New("NodeLoc id is empty") + } + err := DB.Where("nodeloc_id = ?", user.NodeLocId).First(user).Error + return err +} + func RootUserExists() bool { var user User err := DB.Where("role = ?", common.RoleRootUser).First(&user).Error diff --git a/router/api-router.go b/router/api-router.go index 77385738524b..70101287759a 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -30,6 +30,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/oauth/github", middleware.CriticalRateLimit(), controller.GitHubOAuth) apiRouter.GET("/oauth/oidc", middleware.CriticalRateLimit(), controller.OidcAuth) apiRouter.GET("/oauth/linuxdo", middleware.CriticalRateLimit(), controller.LinuxdoOAuth) + apiRouter.GET("/oauth/nodeloc", middleware.CriticalRateLimit(), controller.NodeLocOAuth) apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode) apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth) apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), controller.WeChatBind) diff --git a/web/src/App.jsx b/web/src/App.jsx index 635742f9161e..1c82dcda1eea 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -206,6 +206,14 @@ function App() { } /> + } key={location.pathname}> + + + } + /> { const [githubLoading, setGithubLoading] = useState(false); const [oidcLoading, setOidcLoading] = useState(false); const [linuxdoLoading, setLinuxdoLoading] = useState(false); + const [nodelocLoading, setNodelocLoading] = useState(false); const [emailLoginLoading, setEmailLoginLoading] = useState(false); const [loginLoading, setLoginLoading] = useState(false); const [resetPasswordLoading, setResetPasswordLoading] = useState(false); @@ -258,7 +261,16 @@ const LoginForm = () => { setTimeout(() => setLinuxdoLoading(false), 3000); } }; - + // 包装的NodeLoc登录点击处理 + const handleNodeLocAuthClick = () => { + setNodelocLoading(true); + try { + onNodeLocAuthClicked(status.nodeloc_client_id); + } finally { + // 由于重定向,这里不会执行到,但为了完整性添加 + setTimeout(() => setNodelocLoading(false), 3000); + } + }; // 包装的邮箱登录选项点击处理 const handleEmailLoginClick = () => { setEmailLoginLoading(true); @@ -375,14 +387,25 @@ const LoginForm = () => { {t('使用 LinuxDO 继续')} )} - - {status.telegram_oauth && ( -
- -
+ {status.nodeloc_oauth && ( + )} @@ -485,6 +508,7 @@ const LoginForm = () => { status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || + status.nodeloc_oauth || status.telegram_oauth) && ( <> @@ -620,6 +644,7 @@ const LoginForm = () => { status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || + status.nodeloc_oauth || status.telegram_oauth ) ? renderEmailLoginForm() diff --git a/web/src/components/auth/RegisterForm.jsx b/web/src/components/auth/RegisterForm.jsx index 9c98bdc3a0e9..5a4aa86fd1e1 100644 --- a/web/src/components/auth/RegisterForm.jsx +++ b/web/src/components/auth/RegisterForm.jsx @@ -43,10 +43,13 @@ import { import { onGitHubOAuthClicked, onLinuxDOOAuthClicked, + onNodeLocAuthClicked, onOIDCClicked, } from '../../helpers'; import OIDCIcon from '../common/logo/OIDCIcon'; import LinuxDoIcon from '../common/logo/LinuxDoIcon'; +import NodeLocIcon from '../common/logo/NodeLocIcon'; + import WeChatIcon from '../common/logo/WeChatIcon'; import TelegramLoginButton from 'react-telegram-login/src'; import { UserContext } from '../../context/User'; @@ -74,6 +77,7 @@ const RegisterForm = () => { const [githubLoading, setGithubLoading] = useState(false); const [oidcLoading, setOidcLoading] = useState(false); const [linuxdoLoading, setLinuxdoLoading] = useState(false); + const [nodelocLoading, setNodelocLoading] = useState(false); const [emailRegisterLoading, setEmailRegisterLoading] = useState(false); const [registerLoading, setRegisterLoading] = useState(false); const [verificationCodeLoading, setVerificationCodeLoading] = useState(false); @@ -250,7 +254,14 @@ const RegisterForm = () => { setTimeout(() => setLinuxdoLoading(false), 3000); } }; - + const handleNodeLocClick = () => { + setNodelocLoading(true); + try { + onNodeLocAuthClicked(status.nodeloc_client_id); + } finally { + setTimeout(() => setNodelocLoading(false), 3000); + } + }; const handleEmailRegisterClick = () => { setEmailRegisterLoading(true); setShowEmailRegister(true); @@ -379,6 +390,27 @@ const RegisterForm = () => { )} + {status.nodeloc_oauth && ( + + )} + {status.telegram_oauth && (
{ status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || + status.nodeloc_oauth || status.telegram_oauth) && ( <> @@ -618,6 +651,7 @@ const RegisterForm = () => { status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || + status.nodeloc_oauth || status.telegram_oauth ) ? renderEmailRegisterForm() diff --git a/web/src/components/common/logo/NodeLocIcon.jsx b/web/src/components/common/logo/NodeLocIcon.jsx new file mode 100644 index 000000000000..55515b94694a --- /dev/null +++ b/web/src/components/common/logo/NodeLocIcon.jsx @@ -0,0 +1,49 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Icon } from '@douyinfe/semi-ui'; + +const NodeLocIcon = (props) => { + function CustomIcon() { + return ( + + + + + + + + + + ); + } + + return } />; +}; + +export default NodeLocIcon; diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index 9c7eeaadce38..030be90c432b 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -86,6 +86,9 @@ const SystemSetting = () => { LinuxDOClientId: '', LinuxDOClientSecret: '', LinuxDOMinimumTrustLevel: '', + NodeLocOAuthEnabled: '', + NodeLocClientId: '', + NodeLocClientSecret: '', ServerAddress: '', }); @@ -97,6 +100,8 @@ const SystemSetting = () => { const [showPasswordLoginConfirmModal, setShowPasswordLoginConfirmModal] = useState(false); const [linuxDOOAuthEnabled, setLinuxDOOAuthEnabled] = useState(false); + const [nodeLocOAuthEnabled, setNodeLocOAuthEnabled] = useState(false); + const [emailToAdd, setEmailToAdd] = useState(''); const getOptions = async () => { @@ -125,6 +130,7 @@ const SystemSetting = () => { case 'EmailAliasRestrictionEnabled': case 'SMTPSSLEnabled': case 'LinuxDOOAuthEnabled': + case 'NodeLocOAuthEnabled': case 'oidc.enabled': case 'WorkerAllowHttpImageRequestEnabled': item.value = toBoolean(item.value); @@ -488,6 +494,27 @@ const SystemSetting = () => { } }; + const submitNodeLocOAuth = async () => { + const options = []; + + if (originInputs['NodeLocClientId'] !== inputs.NodeLocClientId) { + options.push({ key: 'NodeLocClientId', value: inputs.NodeLocClientId }); + } + if ( + originInputs['NodeLocClientSecret'] !== inputs.NodeLocClientSecret && + inputs.NodeLocClientSecret !== '' + ) { + options.push({ + key: 'NodeLocClientSecret', + value: inputs.NodeLocClientSecret, + }); + } + + if (options.length > 0) { + await updateOptions(options); + } + }; + const handleCheckboxChange = async (optionKey, event) => { const value = event.target.checked; @@ -499,6 +526,9 @@ const SystemSetting = () => { if (optionKey === 'LinuxDOOAuthEnabled') { setLinuxDOOAuthEnabled(value); } + if (optionKey === 'NodeLocOAuthEnabled') { + setNodeLocOAuthEnabled(value); + } }; const handlePasswordLoginConfirm = async () => { @@ -658,6 +688,15 @@ const SystemSetting = () => { > {t('允许通过 Linux DO 账户登录 & 注册')} + + handleCheckboxChange('NodeLocOAuthEnabled', e) + } + > + {t('允许通过 NodeLoc 账户登录 & 注册')} + { - + + + + {t('用以支持通过 NodeLoc 进行登录注册')} + + {t('点击此处')} + + {t('管理你的 NodeLoc OAuth App')} + + + + + + + + + + + + + {t('用以支持通过微信进行登录注册')} diff --git a/web/src/components/settings/personal/cards/AccountManagement.jsx b/web/src/components/settings/personal/cards/AccountManagement.jsx index 515a5c1918db..c055aadbeee6 100644 --- a/web/src/components/settings/personal/cards/AccountManagement.jsx +++ b/web/src/components/settings/personal/cards/AccountManagement.jsx @@ -44,8 +44,10 @@ import { onGitHubOAuthClicked, onOIDCClicked, onLinuxDOOAuthClicked, + onNodeLocAuthClicked, } from '../../../../helpers'; import TwoFASetting from '../components/TwoFASetting'; +import NodeLocIcon from '../../../common/logo/NodeLocIcon'; const AccountManagement = ({ t, @@ -359,6 +361,47 @@ const AccountManagement = ({
+ + {/* NodeLoc绑定 */} + +
+
+
+ +
+
+
+ {t('NodeLoc')} +
+
+ {renderAccountInfo( + userState.user?.nodeloc_id, + t('NodeLoc ID'), + )} +
+
+
+
+ +
+
+
diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index b7092fe775e2..7674bbb0e676 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -263,7 +263,19 @@ export async function onLinuxDOOAuthClicked(linuxdo_client_id) { `https://connect.linux.do/oauth2/authorize?response_type=code&client_id=${linuxdo_client_id}&state=${state}`, ); } - +export async function onNodeLocAuthClicked(nodeloc_client_id) { + const state = await getOAuthState(); + if (!state) return; + + // Get server address from status API to ensure redirect_uri matches backend + const statusRes = await API.get('/api/status'); + const serverAddress = statusRes.data?.server_address || `${window.location.protocol}//${window.location.host}`; + const redirectUri = `${serverAddress}/oauth/nodeloc`; + + window.location.href = + `https://conn.nodeloc.cc/oauth2/auth?response_type=code&client_id=${nodeloc_client_id}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=openid%20profile&state=${state}` + ; +} let channelModels = undefined; export async function loadChannelModels() { const res = await API.get('/api/models');