Compare commits

...

12 Commits

46 changed files with 2305 additions and 196 deletions

View File

@@ -58,7 +58,7 @@ ARG TARGETARCH
ENV BUILDX_ARCH="${TARGETOS:-linux}_${TARGETARCH:-amd64}"
RUN apt update
RUN apt install -y ca-certificates && update-ca-certificates
RUN apt install -y ca-certificates lsof && update-ca-certificates
WORKDIR /
COPY --from=BACK /go/src/casdoor/server_${BUILDX_ARCH} ./server

View File

@@ -312,6 +312,29 @@ func (c *ApiController) Signup() {
userId := user.GetId()
util.LogInfo(c.Ctx, "API: [%s] is signed up as new user", userId)
// Check if this is an OAuth flow and automatically generate code
clientId := c.Ctx.Input.Query("clientId")
responseType := c.Ctx.Input.Query("responseType")
redirectUri := c.Ctx.Input.Query("redirectUri")
scope := c.Ctx.Input.Query("scope")
state := c.Ctx.Input.Query("state")
nonce := c.Ctx.Input.Query("nonce")
codeChallenge := c.Ctx.Input.Query("code_challenge")
// If OAuth parameters are present, generate OAuth code and return it
if clientId != "" && responseType == ResponseTypeCode {
code, err := object.GetOAuthCode(userId, clientId, "", "password", responseType, redirectUri, scope, state, nonce, codeChallenge, c.Ctx.Request.Host, c.GetAcceptLanguage())
if err != nil {
c.ResponseError(err.Error(), nil)
return
}
resp := codeToResponse(code)
c.Data["json"] = resp
c.ServeJSON()
return
}
c.ResponseOk(userId)
}

View File

@@ -867,10 +867,16 @@ func (c *ApiController) Login() {
return
}
if application.IsSignupItemRequired("Invitation code") {
c.ResponseError(c.T("check:Invitation code cannot be blank"))
// Check and validate invitation code
invitation, msg := object.CheckInvitationCode(application, organization, &authForm, c.GetAcceptLanguage())
if msg != "" {
c.ResponseError(msg)
return
}
invitationName := ""
if invitation != nil {
invitationName = invitation.Name
}
// Handle UseEmailAsUsername for OAuth and Web3
if organization.UseEmailAsUsername && userInfo.Email != "" {
@@ -937,11 +943,16 @@ func (c *ApiController) Login() {
IsDeleted: false,
SignupApplication: application.Name,
Properties: properties,
Invitation: invitationName,
InvitationCode: authForm.InvitationCode,
RegisterType: "Application Signup",
RegisterSource: fmt.Sprintf("%s/%s", application.Organization, application.Name),
}
if providerItem.SignupGroup != "" {
// Set group from invitation code if available, otherwise use provider's signup group
if invitation != nil && invitation.SignupGroup != "" {
user.Groups = []string{invitation.SignupGroup}
} else if providerItem.SignupGroup != "" {
user.Groups = []string{providerItem.SignupGroup}
}
@@ -956,6 +967,16 @@ func (c *ApiController) Login() {
c.ResponseError(fmt.Sprintf(c.T("auth:Failed to create user, user information is invalid: %s"), util.StructToJson(user)))
return
}
// Increment invitation usage count
if invitation != nil {
invitation.UsedCount += 1
_, err = object.UpdateInvitation(invitation.GetId(), invitation, c.GetAcceptLanguage())
if err != nil {
c.ResponseError(err.Error())
return
}
}
}
// sync info from 3rd-party if possible

View File

@@ -303,6 +303,13 @@ func (c *ApiController) BatchEnforce() {
c.ResponseOk(res, keyRes)
}
// GetAllObjects
// @Title GetAllObjects
// @Tag Enforcer API
// @Description Get all objects for a user (Casbin API)
// @Param userId query string false "user id like built-in/admin"
// @Success 200 {object} controllers.Response The Response object
// @router /get-all-objects [get]
func (c *ApiController) GetAllObjects() {
userId := c.Ctx.Input.Query("userId")
if userId == "" {
@@ -322,6 +329,13 @@ func (c *ApiController) GetAllObjects() {
c.ResponseOk(objects)
}
// GetAllActions
// @Title GetAllActions
// @Tag Enforcer API
// @Description Get all actions for a user (Casbin API)
// @Param userId query string false "user id like built-in/admin"
// @Success 200 {object} controllers.Response The Response object
// @router /get-all-actions [get]
func (c *ApiController) GetAllActions() {
userId := c.Ctx.Input.Query("userId")
if userId == "" {
@@ -341,6 +355,13 @@ func (c *ApiController) GetAllActions() {
c.ResponseOk(actions)
}
// GetAllRoles
// @Title GetAllRoles
// @Tag Enforcer API
// @Description Get all roles for a user (Casbin API)
// @Param userId query string false "user id like built-in/admin"
// @Success 200 {object} controllers.Response The Response object
// @router /get-all-roles [get]
func (c *ApiController) GetAllRoles() {
userId := c.Ctx.Input.Query("userId")
if userId == "" {

View File

@@ -173,6 +173,9 @@ func (c *ApiController) GetOAuthToken() {
avatar := c.Ctx.Input.Query("avatar")
refreshToken := c.Ctx.Input.Query("refresh_token")
deviceCode := c.Ctx.Input.Query("device_code")
subjectToken := c.Ctx.Input.Query("subject_token")
subjectTokenType := c.Ctx.Input.Query("subject_token_type")
audience := c.Ctx.Input.Query("audience")
if clientId == "" && clientSecret == "" {
clientId, clientSecret, _ = c.Ctx.Request.BasicAuth()
@@ -219,6 +222,15 @@ func (c *ApiController) GetOAuthToken() {
if refreshToken == "" {
refreshToken = tokenRequest.RefreshToken
}
if subjectToken == "" {
subjectToken = tokenRequest.SubjectToken
}
if subjectTokenType == "" {
subjectTokenType = tokenRequest.SubjectTokenType
}
if audience == "" {
audience = tokenRequest.Audience
}
}
}
@@ -263,7 +275,7 @@ func (c *ApiController) GetOAuthToken() {
}
host := c.Ctx.Request.Host
token, err := object.GetOAuthToken(grantType, clientId, clientSecret, code, verifier, scope, nonce, username, password, host, refreshToken, tag, avatar, c.GetAcceptLanguage())
token, err := object.GetOAuthToken(grantType, clientId, clientSecret, code, verifier, scope, nonce, username, password, host, refreshToken, tag, avatar, c.GetAcceptLanguage(), subjectToken, subjectTokenType, audience)
if err != nil {
c.ResponseError(err.Error())
return

View File

@@ -15,16 +15,19 @@
package controllers
type TokenRequest struct {
ClientId string `json:"client_id"`
ClientSecret string `json:"client_secret"`
GrantType string `json:"grant_type"`
Code string `json:"code"`
Verifier string `json:"code_verifier"`
Scope string `json:"scope"`
Nonce string `json:"nonce"`
Username string `json:"username"`
Password string `json:"password"`
Tag string `json:"tag"`
Avatar string `json:"avatar"`
RefreshToken string `json:"refresh_token"`
ClientId string `json:"client_id"`
ClientSecret string `json:"client_secret"`
GrantType string `json:"grant_type"`
Code string `json:"code"`
Verifier string `json:"code_verifier"`
Scope string `json:"scope"`
Nonce string `json:"nonce"`
Username string `json:"username"`
Password string `json:"password"`
Tag string `json:"tag"`
Avatar string `json:"avatar"`
RefreshToken string `json:"refresh_token"`
SubjectToken string `json:"subject_token"`
SubjectTokenType string `json:"subject_token_type"`
Audience string `json:"audience"`
}

View File

@@ -187,6 +187,22 @@ func (c *ApiController) SendVerificationCode() {
return
}
// Check if "Forgot password?" signin item is visible when using forget verification
if vform.Method == ForgetVerification {
isForgotPasswordEnabled := false
for _, item := range application.SigninItems {
if item.Name == "Forgot password?" {
isForgotPasswordEnabled = item.Visible
break
}
}
// Block access if the signin item is not found or is explicitly hidden
if !isForgotPasswordEnabled {
c.ResponseError(c.T("verification:The forgot password feature is disabled"))
return
}
}
organization, err := object.GetOrganization(util.GetId(application.Owner, application.Organization))
if err != nil {
c.ResponseError(c.T(err.Error()))

2
go.mod
View File

@@ -14,6 +14,7 @@ require (
github.com/alibabacloud-go/openapi-util v0.1.0
github.com/alibabacloud-go/tea v1.3.2
github.com/alibabacloud-go/tea-utils/v2 v2.0.7
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107
github.com/aliyun/aliyun-oss-go-sdk v2.2.2+incompatible
github.com/aliyun/credentials-go v1.3.10
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0
@@ -111,7 +112,6 @@ require (
github.com/alibabacloud-go/tea-oss-utils v1.1.0 // indirect
github.com/alibabacloud-go/tea-utils v1.3.6 // indirect
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
github.com/aliyun/alibaba-cloud-sdk-go v1.62.545 // indirect
github.com/apistd/uni-go-sdk v0.0.2 // indirect
github.com/atc0005/go-teams-notify/v2 v2.13.0 // indirect
github.com/aws/aws-sdk-go v1.45.5 // indirect

8
go.sum
View File

@@ -766,8 +766,8 @@ github.com/alibabacloud-go/tea-xml v1.1.1/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCE
github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0=
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/aliyun/alibaba-cloud-sdk-go v1.62.545 h1:0LfzeUr4quwrrrTHn1kfLA0FBdsChCMs8eK2EzOwXVQ=
github.com/aliyun/alibaba-cloud-sdk-go v1.62.545/go.mod h1:Api2AkmMgGaSUAhmk76oaFObkoeCPc/bKAqcyplPODs=
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 h1:qagvUyrgOnBIlVRQWOyCZGVKUIYbMBdGdJ104vBpRFU=
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
github.com/aliyun/aliyun-oss-go-sdk v2.2.2+incompatible h1:9gWa46nstkJ9miBReJcN8Gq34cBFbzSpQZVVT9N09TM=
github.com/aliyun/aliyun-oss-go-sdk v2.2.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
@@ -1294,7 +1294,6 @@ github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aW
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
@@ -1308,7 +1307,6 @@ github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUB
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -2615,7 +2613,6 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.66.6/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
@@ -2637,6 +2634,7 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@@ -188,6 +188,7 @@
"verification": {
"Invalid captcha provider.": "Invalid captcha provider.",
"Phone number is invalid in your region %s": "Phone number is invalid in your region %s",
"The forgot password feature is disabled": "The forgot password feature is disabled",
"The verification code has already been used!": "The verification code has already been used!",
"The verification code has not been sent yet!": "The verification code has not been sent yet!",
"Turing test failed.": "Turing test failed.",

View File

@@ -188,6 +188,7 @@
"verification": {
"Invalid captcha provider.": "非法的验证码提供商",
"Phone number is invalid in your region %s": "您所在地区的电话号码无效 %s",
"The forgot password feature is disabled": "忘记密码功能已被禁用",
"The verification code has already been used!": "验证码已使用过!",
"The verification code has not been sent yet!": "验证码未发送!",
"Turing test failed.": "验证码还未发送",

View File

@@ -138,7 +138,7 @@ func GetOidcDiscovery(host string, applicationName string) OidcDiscovery {
IntrospectionEndpoint: fmt.Sprintf("%s/api/login/oauth/introspect", originBackend),
ResponseTypesSupported: []string{"code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token", "none"},
ResponseModesSupported: []string{"query", "fragment", "form_post"},
GrantTypesSupported: []string{"authorization_code", "implicit", "password", "client_credentials", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"},
GrantTypesSupported: []string{"authorization_code", "implicit", "password", "client_credentials", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code", "urn:ietf:params:oauth:grant-type:token-exchange"},
SubjectTypesSupported: []string{"public"},
IdTokenSigningAlgValuesSupported: []string{"RS256", "RS512", "ES256", "ES384", "ES512"},
ScopesSupported: []string{"openid", "email", "profile", "address", "phone", "offline_access"},

View File

@@ -26,6 +26,7 @@ type Order struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
UpdateTime string `xorm:"varchar(100)" json:"updateTime"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
// Product Info
@@ -43,10 +44,6 @@ type Order struct {
// Order State
State string `xorm:"varchar(100)" json:"state"`
Message string `xorm:"varchar(2000)" json:"message"`
// Order Duration
StartTime string `xorm:"varchar(100)" json:"startTime"`
EndTime string `xorm:"varchar(100)" json:"endTime"`
}
type ProductInfo struct {
@@ -138,6 +135,14 @@ func UpdateOrder(id string, order *Order) (bool, error) {
return false, nil
}
if o.State != order.State {
if order.State == "Created" {
order.UpdateTime = ""
} else {
order.UpdateTime = util.GetCurrentTime()
}
}
if !slices.Equal(o.Products, order.Products) {
existingInfos := make(map[string]ProductInfo, len(o.ProductInfos))
for _, info := range o.ProductInfos {

View File

@@ -99,8 +99,7 @@ func PlaceOrder(owner string, reqProductInfos []ProductInfo, user *User) (*Order
Currency: orderCurrency,
State: "Created",
Message: "",
StartTime: util.GetCurrentTime(),
EndTime: "",
UpdateTime: "",
}
affected, err := AddOrder(order)
@@ -344,7 +343,7 @@ func PayOrder(providerName, host, paymentEnv string, order *Order, lang string)
if provider.Type == "Dummy" || provider.Type == "Balance" {
order.State = "Paid"
order.Message = "Payment successful"
order.EndTime = util.GetCurrentTime()
order.UpdateTime = util.GetCurrentTime()
}
// Update order state first to avoid inconsistency
@@ -371,6 +370,6 @@ func CancelOrder(order *Order) (bool, error) {
order.State = "Canceled"
order.Message = "Canceled by user"
order.EndTime = util.GetCurrentTime()
order.UpdateTime = util.GetCurrentTime()
return UpdateOrder(order.GetId(), order)
}

View File

@@ -301,16 +301,19 @@ func NotifyPayment(body []byte, owner string, paymentName string, lang string) (
if payment.State == pp.PaymentStatePaid {
order.State = "Paid"
order.Message = "Payment successful"
order.EndTime = util.GetCurrentTime()
order.UpdateTime = util.GetCurrentTime()
} else if payment.State == pp.PaymentStateError {
order.State = "PaymentFailed"
order.Message = payment.Message
order.UpdateTime = util.GetCurrentTime()
} else if payment.State == pp.PaymentStateCanceled {
order.State = "Canceled"
order.Message = "Payment was cancelled"
order.UpdateTime = util.GetCurrentTime()
} else if payment.State == pp.PaymentStateTimeout {
order.State = "Timeout"
order.Message = "Payment timed out"
order.UpdateTime = util.GetCurrentTime()
}
_, err = UpdateOrder(order.GetId(), order)
if err != nil {

View File

@@ -175,8 +175,10 @@ func DeleteSession(id, curSessionId string) (bool, error) {
return false, err
}
// If session doesn't exist, return success with no rows affected
// This is a valid state (e.g., when a user has no active session)
if session == nil {
return false, fmt.Errorf("session is nil")
return false, nil
}
if slices.Contains(session.SessionId, curSessionId) {

View File

@@ -28,6 +28,8 @@ func getSmsClient(provider *Provider) (sender.SmsClient, error) {
client, err = sender.NewSmsClient(provider.Type, provider.ClientId, provider.ClientSecret, provider.SignName, provider.TemplateCode, provider.ProviderUrl, provider.AppId)
} else if provider.Type == "Custom HTTP SMS" {
client, err = newHttpSmsClient(provider.Endpoint, provider.Method, provider.Title, provider.TemplateCode, provider.HttpHeaders, provider.UserMapping, provider.IssuerUrl, provider.EnableProxy)
} else if provider.Type == "Alibaba Cloud PNVS SMS" {
client, err = newPnvsSmsClient(provider.ClientId, provider.ClientSecret, provider.SignName, provider.TemplateCode, provider.RegionId)
} else {
client, err = sender.NewSmsClient(provider.Type, provider.ClientId, provider.ClientSecret, provider.SignName, provider.TemplateCode, provider.AppId)
}
@@ -48,7 +50,7 @@ func SendSms(provider *Provider, content string, phoneNumbers ...string) error {
if provider.AppId != "" {
phoneNumbers = append([]string{provider.AppId}, phoneNumbers...)
}
} else if provider.Type == sender.Aliyun {
} else if provider.Type == sender.Aliyun || provider.Type == "Alibaba Cloud PNVS SMS" {
for i, number := range phoneNumbers {
phoneNumbers[i] = strings.TrimPrefix(number, "+86")
}

86
object/sms_pnvs.go Normal file
View File

@@ -0,0 +1,86 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"encoding/json"
"fmt"
"github.com/aliyun/alibaba-cloud-sdk-go/services/dypnsapi"
)
type PnvsSmsClient struct {
template string
sign string
core *dypnsapi.Client
}
func newPnvsSmsClient(accessId string, accessKey string, sign string, template string, regionId string) (*PnvsSmsClient, error) {
if regionId == "" {
regionId = "cn-hangzhou"
}
client, err := dypnsapi.NewClientWithAccessKey(regionId, accessId, accessKey)
if err != nil {
return nil, err
}
pnvsClient := &PnvsSmsClient{
template: template,
core: client,
sign: sign,
}
return pnvsClient, nil
}
func (c *PnvsSmsClient) SendMessage(param map[string]string, targetPhoneNumber ...string) error {
if len(targetPhoneNumber) == 0 {
return fmt.Errorf("missing parameter: targetPhoneNumber")
}
// PNVS sends to one phone number at a time
phoneNumber := targetPhoneNumber[0]
request := dypnsapi.CreateSendSmsVerifyCodeRequest()
request.Scheme = "https"
request.PhoneNumber = phoneNumber
request.TemplateCode = c.template
request.SignName = c.sign
// TemplateParam is optional for PNVS as it can auto-generate verification codes
// But if params are provided, we'll pass them
if len(param) > 0 {
templateParam, err := json.Marshal(param)
if err != nil {
return err
}
request.TemplateParam = string(templateParam)
}
response, err := c.core.SendSmsVerifyCode(request)
if err != nil {
return err
}
if response.Code != "OK" {
if response.Message != "" {
return fmt.Errorf(response.Message)
}
return fmt.Errorf("PNVS SMS send failed with code: %s", response.Code)
}
return nil
}

View File

@@ -370,3 +370,15 @@ func (p *ActiveDirectorySyncerProvider) adEntryToOriginalUser(entry *goldap.Entr
return user
}
// GetOriginalGroups retrieves all groups from Active Directory (not implemented yet)
func (p *ActiveDirectorySyncerProvider) GetOriginalGroups() ([]*OriginalGroup, error) {
// TODO: Implement Active Directory group sync
return []*OriginalGroup{}, nil
}
// GetOriginalUserGroups retrieves the group IDs that a user belongs to (not implemented yet)
func (p *ActiveDirectorySyncerProvider) GetOriginalUserGroups(userId string) ([]string, error) {
// TODO: Implement Active Directory user group membership sync
return []string{}, nil
}

View File

@@ -274,3 +274,15 @@ func (p *AzureAdSyncerProvider) getAzureAdOriginalUsers() ([]*OriginalUser, erro
return originalUsers, nil
}
// GetOriginalGroups retrieves all groups from Azure AD (not implemented yet)
func (p *AzureAdSyncerProvider) GetOriginalGroups() ([]*OriginalGroup, error) {
// TODO: Implement Azure AD group sync
return []*OriginalGroup{}, nil
}
// GetOriginalUserGroups retrieves the group IDs that a user belongs to (not implemented yet)
func (p *AzureAdSyncerProvider) GetOriginalUserGroups(userId string) ([]string, error) {
// TODO: Implement Azure AD user group membership sync
return []string{}, nil
}

View File

@@ -60,9 +60,19 @@ func addSyncerJob(syncer *Syncer) error {
return err
}
// Sync groups as well
err = syncer.syncGroups()
if err != nil {
// Log error but don't fail the entire sync
fmt.Printf("Warning: syncGroups() error: %s\n", err.Error())
}
schedule := fmt.Sprintf("@every %ds", syncer.SyncInterval)
cron := getCronMap(syncer.Name)
_, err = cron.AddFunc(schedule, syncer.syncUsersNoError)
_, err = cron.AddFunc(schedule, func() {
syncer.syncUsersNoError()
syncer.syncGroupsNoError()
})
if err != nil {
return err
}

View File

@@ -164,3 +164,15 @@ func (t dsnConnector) Connect(ctx context.Context) (driver.Conn, error) {
func (t dsnConnector) Driver() driver.Driver {
return t.driver
}
// GetOriginalGroups retrieves all groups from Database (not implemented yet)
func (p *DatabaseSyncerProvider) GetOriginalGroups() ([]*OriginalGroup, error) {
// TODO: Implement Database group sync
return []*OriginalGroup{}, nil
}
// GetOriginalUserGroups retrieves the group IDs that a user belongs to (not implemented yet)
func (p *DatabaseSyncerProvider) GetOriginalUserGroups(userId string) ([]string, error) {
// TODO: Implement Database user group membership sync
return []string{}, nil
}

View File

@@ -384,3 +384,15 @@ func (p *DingtalkSyncerProvider) dingtalkUserToOriginalUser(dingtalkUser *Dingta
return user
}
// GetOriginalGroups retrieves all groups from DingTalk (not implemented yet)
func (p *DingtalkSyncerProvider) GetOriginalGroups() ([]*OriginalGroup, error) {
// TODO: Implement DingTalk group sync
return []*OriginalGroup{}, nil
}
// GetOriginalUserGroups retrieves the group IDs that a user belongs to (not implemented yet)
func (p *DingtalkSyncerProvider) GetOriginalUserGroups(userId string) ([]string, error) {
// TODO: Implement DingTalk user group membership sync
return []string{}, nil
}

View File

@@ -101,6 +101,7 @@ func (p *GoogleWorkspaceSyncerProvider) getAdminService() (*admin.Service, error
PrivateKey: []byte(serviceAccount.PrivateKey),
Scopes: []string{
admin.AdminDirectoryUserReadonlyScope,
admin.AdminDirectoryGroupReadonlyScope,
},
TokenURL: google.JWTTokenURL,
Subject: adminEmail, // Impersonate the admin user
@@ -202,12 +203,189 @@ func (p *GoogleWorkspaceSyncerProvider) getGoogleWorkspaceOriginalUsers() ([]*Or
return nil, err
}
// Get all groups and their members to build a user-to-groups mapping
// This avoids N+1 queries by fetching group memberships upfront
userGroupsMap, err := p.buildUserGroupsMap(service)
if err != nil {
fmt.Printf("Warning: failed to fetch group memberships: %v. Users will have no groups assigned.\n", err)
userGroupsMap = make(map[string][]string)
}
// Convert Google Workspace users to Casdoor OriginalUser
originalUsers := []*OriginalUser{}
for _, gwUser := range gwUsers {
originalUser := p.googleWorkspaceUserToOriginalUser(gwUser)
// Assign groups from the pre-built map
if groups, exists := userGroupsMap[gwUser.PrimaryEmail]; exists {
originalUser.Groups = groups
} else {
originalUser.Groups = []string{}
}
originalUsers = append(originalUsers, originalUser)
}
return originalUsers, nil
}
// buildUserGroupsMap builds a map of user email to group emails by iterating through all groups
// and their members. This is more efficient than querying groups for each user individually.
func (p *GoogleWorkspaceSyncerProvider) buildUserGroupsMap(service *admin.Service) (map[string][]string, error) {
userGroupsMap := make(map[string][]string)
// Get all groups
groups, err := p.getGoogleWorkspaceGroups(service)
if err != nil {
return nil, fmt.Errorf("failed to fetch groups: %v", err)
}
// For each group, get its members and populate the user-to-groups map
for _, group := range groups {
members, err := p.getGroupMembers(service, group.Id)
if err != nil {
fmt.Printf("Warning: failed to get members for group %s: %v\n", group.Email, err)
continue
}
// Add this group to each member's group list
for _, member := range members {
userGroupsMap[member.Email] = append(userGroupsMap[member.Email], group.Email)
}
}
return userGroupsMap, nil
}
// getGroupMembers retrieves all members of a specific group
func (p *GoogleWorkspaceSyncerProvider) getGroupMembers(service *admin.Service, groupId string) ([]*admin.Member, error) {
allMembers := []*admin.Member{}
pageToken := ""
for {
call := service.Members.List(groupId).MaxResults(500)
if pageToken != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, fmt.Errorf("failed to list members: %v", err)
}
allMembers = append(allMembers, resp.Members...)
// Handle pagination
if resp.NextPageToken == "" {
break
}
pageToken = resp.NextPageToken
}
return allMembers, nil
}
// GetOriginalGroups retrieves all groups from Google Workspace
func (p *GoogleWorkspaceSyncerProvider) GetOriginalGroups() ([]*OriginalGroup, error) {
// Get Admin SDK service
service, err := p.getAdminService()
if err != nil {
return nil, err
}
// Get all groups from Google Workspace
gwGroups, err := p.getGoogleWorkspaceGroups(service)
if err != nil {
return nil, err
}
// Convert Google Workspace groups to Casdoor OriginalGroup
originalGroups := []*OriginalGroup{}
for _, gwGroup := range gwGroups {
originalGroup := p.googleWorkspaceGroupToOriginalGroup(gwGroup)
originalGroups = append(originalGroups, originalGroup)
}
return originalGroups, nil
}
// GetOriginalUserGroups retrieves the group IDs that a user belongs to
func (p *GoogleWorkspaceSyncerProvider) GetOriginalUserGroups(userId string) ([]string, error) {
// Get Admin SDK service
service, err := p.getAdminService()
if err != nil {
return nil, err
}
// Get groups for the user
groupIds := []string{}
pageToken := ""
for {
call := service.Groups.List().UserKey(userId)
if pageToken != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, fmt.Errorf("failed to list user groups: %v", err)
}
for _, group := range resp.Groups {
groupIds = append(groupIds, group.Email)
}
// Handle pagination
if resp.NextPageToken == "" {
break
}
pageToken = resp.NextPageToken
}
return groupIds, nil
}
// getGoogleWorkspaceGroups gets all groups from Google Workspace using Admin SDK API
func (p *GoogleWorkspaceSyncerProvider) getGoogleWorkspaceGroups(service *admin.Service) ([]*admin.Group, error) {
allGroups := []*admin.Group{}
pageToken := ""
// Get the customer ID (use "my_customer" for the domain)
customer := "my_customer"
for {
call := service.Groups.List().Customer(customer).MaxResults(500)
if pageToken != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, fmt.Errorf("failed to list groups: %v", err)
}
allGroups = append(allGroups, resp.Groups...)
// Handle pagination
if resp.NextPageToken == "" {
break
}
pageToken = resp.NextPageToken
}
return allGroups, nil
}
// googleWorkspaceGroupToOriginalGroup converts Google Workspace group to Casdoor OriginalGroup
func (p *GoogleWorkspaceSyncerProvider) googleWorkspaceGroupToOriginalGroup(gwGroup *admin.Group) *OriginalGroup {
group := &OriginalGroup{
Id: gwGroup.Id,
Name: gwGroup.Email,
DisplayName: gwGroup.Name,
Description: gwGroup.Description,
Email: gwGroup.Email,
}
return group
}

View File

@@ -0,0 +1,204 @@
// Copyright 2025 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"testing"
admin "google.golang.org/api/admin/directory/v1"
)
func TestGoogleWorkspaceUserToOriginalUser(t *testing.T) {
provider := &GoogleWorkspaceSyncerProvider{
Syncer: &Syncer{},
}
// Test case 1: Full Google Workspace user with all fields
gwUser := &admin.User{
Id: "user-123",
PrimaryEmail: "john.doe@example.com",
Name: &admin.UserName{
FullName: "John Doe",
GivenName: "John",
FamilyName: "Doe",
},
ThumbnailPhotoUrl: "https://example.com/avatar.jpg",
Suspended: false,
IsAdmin: true,
CreationTime: "2024-01-01T00:00:00Z",
}
originalUser := provider.googleWorkspaceUserToOriginalUser(gwUser)
// Verify basic fields
if originalUser.Id != "user-123" {
t.Errorf("Expected Id to be 'user-123', got '%s'", originalUser.Id)
}
if originalUser.Name != "john.doe@example.com" {
t.Errorf("Expected Name to be 'john.doe@example.com', got '%s'", originalUser.Name)
}
if originalUser.Email != "john.doe@example.com" {
t.Errorf("Expected Email to be 'john.doe@example.com', got '%s'", originalUser.Email)
}
if originalUser.DisplayName != "John Doe" {
t.Errorf("Expected DisplayName to be 'John Doe', got '%s'", originalUser.DisplayName)
}
if originalUser.FirstName != "John" {
t.Errorf("Expected FirstName to be 'John', got '%s'", originalUser.FirstName)
}
if originalUser.LastName != "Doe" {
t.Errorf("Expected LastName to be 'Doe', got '%s'", originalUser.LastName)
}
if originalUser.Avatar != "https://example.com/avatar.jpg" {
t.Errorf("Expected Avatar to be 'https://example.com/avatar.jpg', got '%s'", originalUser.Avatar)
}
if originalUser.IsForbidden != false {
t.Errorf("Expected IsForbidden to be false for non-suspended user, got %v", originalUser.IsForbidden)
}
if originalUser.IsAdmin != true {
t.Errorf("Expected IsAdmin to be true, got %v", originalUser.IsAdmin)
}
// Test case 2: Suspended Google Workspace user
suspendedUser := &admin.User{
Id: "user-456",
PrimaryEmail: "jane.doe@example.com",
Name: &admin.UserName{
FullName: "Jane Doe",
},
Suspended: true,
}
suspendedOriginalUser := provider.googleWorkspaceUserToOriginalUser(suspendedUser)
if suspendedOriginalUser.IsForbidden != true {
t.Errorf("Expected IsForbidden to be true for suspended user, got %v", suspendedOriginalUser.IsForbidden)
}
// Test case 3: User with no Name object (should not panic)
minimalUser := &admin.User{
Id: "user-789",
PrimaryEmail: "bob@example.com",
}
minimalOriginalUser := provider.googleWorkspaceUserToOriginalUser(minimalUser)
if minimalOriginalUser.DisplayName != "" {
t.Errorf("Expected DisplayName to be empty for minimal user, got '%s'", minimalOriginalUser.DisplayName)
}
// Test case 4: Display name construction from first/last name when FullName is empty
noFullNameUser := &admin.User{
Id: "user-101",
PrimaryEmail: "alice@example.com",
Name: &admin.UserName{
GivenName: "Alice",
FamilyName: "Jones",
},
}
noFullNameOriginalUser := provider.googleWorkspaceUserToOriginalUser(noFullNameUser)
if noFullNameOriginalUser.DisplayName != "Alice Jones" {
t.Errorf("Expected DisplayName to be constructed as 'Alice Jones', got '%s'", noFullNameOriginalUser.DisplayName)
}
}
func TestGoogleWorkspaceGroupToOriginalGroup(t *testing.T) {
provider := &GoogleWorkspaceSyncerProvider{
Syncer: &Syncer{},
}
// Test case 1: Full Google Workspace group with all fields
gwGroup := &admin.Group{
Id: "group-123",
Email: "team@example.com",
Name: "Engineering Team",
Description: "All engineering staff",
}
originalGroup := provider.googleWorkspaceGroupToOriginalGroup(gwGroup)
// Verify all fields
if originalGroup.Id != "group-123" {
t.Errorf("Expected Id to be 'group-123', got '%s'", originalGroup.Id)
}
if originalGroup.Name != "team@example.com" {
t.Errorf("Expected Name to be 'team@example.com', got '%s'", originalGroup.Name)
}
if originalGroup.DisplayName != "Engineering Team" {
t.Errorf("Expected DisplayName to be 'Engineering Team', got '%s'", originalGroup.DisplayName)
}
if originalGroup.Description != "All engineering staff" {
t.Errorf("Expected Description to be 'All engineering staff', got '%s'", originalGroup.Description)
}
if originalGroup.Email != "team@example.com" {
t.Errorf("Expected Email to be 'team@example.com', got '%s'", originalGroup.Email)
}
// Test case 2: Minimal group
minimalGroup := &admin.Group{
Id: "group-456",
Email: "minimal@example.com",
}
minimalOriginalGroup := provider.googleWorkspaceGroupToOriginalGroup(minimalGroup)
if minimalOriginalGroup.DisplayName != "" {
t.Errorf("Expected DisplayName to be empty for minimal group, got '%s'", minimalOriginalGroup.DisplayName)
}
if minimalOriginalGroup.Description != "" {
t.Errorf("Expected Description to be empty for minimal group, got '%s'", minimalOriginalGroup.Description)
}
}
func TestGetSyncerProviderGoogleWorkspace(t *testing.T) {
syncer := &Syncer{
Type: "Google Workspace",
Host: "admin@example.com",
}
provider := GetSyncerProvider(syncer)
if _, ok := provider.(*GoogleWorkspaceSyncerProvider); !ok {
t.Errorf("Expected GoogleWorkspaceSyncerProvider for type 'Google Workspace', got %T", provider)
}
}
func TestGoogleWorkspaceSyncerProviderEmptyMethods(t *testing.T) {
provider := &GoogleWorkspaceSyncerProvider{
Syncer: &Syncer{},
}
// Test AddUser returns error
_, err := provider.AddUser(&OriginalUser{})
if err == nil {
t.Error("Expected AddUser to return error for read-only syncer")
}
// Test UpdateUser returns error
_, err = provider.UpdateUser(&OriginalUser{})
if err == nil {
t.Error("Expected UpdateUser to return error for read-only syncer")
}
// Test Close returns no error
err = provider.Close()
if err != nil {
t.Errorf("Expected Close to return nil, got error: %v", err)
}
// Test InitAdapter returns no error
err = provider.InitAdapter()
if err != nil {
t.Errorf("Expected InitAdapter to return nil, got error: %v", err)
}
}

121
object/syncer_group.go Normal file
View File

@@ -0,0 +1,121 @@
// Copyright 2025 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"github.com/casdoor/casdoor/util"
)
func (syncer *Syncer) getOriginalGroups() ([]*OriginalGroup, error) {
provider := GetSyncerProvider(syncer)
return provider.GetOriginalGroups()
}
func (syncer *Syncer) createGroupFromOriginalGroup(originalGroup *OriginalGroup) *Group {
group := &Group{
Owner: syncer.Organization,
Name: originalGroup.Name,
CreatedTime: util.GetCurrentTime(),
UpdatedTime: util.GetCurrentTime(),
DisplayName: originalGroup.DisplayName,
Type: originalGroup.Type,
Manager: originalGroup.Manager,
IsEnabled: true,
IsTopGroup: true,
}
if originalGroup.Email != "" {
group.ContactEmail = originalGroup.Email
}
return group
}
func (syncer *Syncer) syncGroups() error {
fmt.Printf("Running syncGroups()..\n")
// Get existing groups from Casdoor
groups, err := GetGroups(syncer.Organization)
if err != nil {
line := fmt.Sprintf("[%s] %s\n", util.GetCurrentTime(), err.Error())
_, err2 := updateSyncerErrorText(syncer, line)
if err2 != nil {
panic(err2)
}
return err
}
// Get groups from the external system
oGroups, err := syncer.getOriginalGroups()
if err != nil {
line := fmt.Sprintf("[%s] %s\n", util.GetCurrentTime(), err.Error())
_, err2 := updateSyncerErrorText(syncer, line)
if err2 != nil {
panic(err2)
}
return err
}
fmt.Printf("Groups: %d, oGroups: %d\n", len(groups), len(oGroups))
// Create a map of existing groups by name
myGroups := map[string]*Group{}
for _, group := range groups {
myGroups[group.Name] = group
}
// Sync groups from external system to Casdoor
newGroups := []*Group{}
for _, oGroup := range oGroups {
if _, ok := myGroups[oGroup.Name]; !ok {
newGroup := syncer.createGroupFromOriginalGroup(oGroup)
fmt.Printf("New group: %v\n", newGroup)
newGroups = append(newGroups, newGroup)
} else {
// Group already exists, could update it here if needed
existingGroup := myGroups[oGroup.Name]
// Update group display name and other fields if they've changed
if existingGroup.DisplayName != oGroup.DisplayName {
existingGroup.DisplayName = oGroup.DisplayName
existingGroup.UpdatedTime = util.GetCurrentTime()
_, err = UpdateGroup(existingGroup.GetId(), existingGroup)
if err != nil {
fmt.Printf("Failed to update group %s: %v\n", existingGroup.Name, err)
} else {
fmt.Printf("Updated group: %s\n", existingGroup.Name)
}
}
}
}
if len(newGroups) != 0 {
_, err = AddGroupsInBatch(newGroups)
if err != nil {
return err
}
}
return nil
}
func (syncer *Syncer) syncGroupsNoError() {
err := syncer.syncGroups()
if err != nil {
fmt.Printf("syncGroupsNoError() error: %s\n", err.Error())
}
}

View File

@@ -14,6 +14,17 @@
package object
// OriginalGroup represents a group from an external system
type OriginalGroup struct {
Id string
Name string
DisplayName string
Description string
Type string
Manager string
Email string
}
// SyncerProvider defines the interface that all syncer implementations must satisfy.
// Different syncer types (Database, Keycloak, WeCom, Azure AD) implement this interface.
type SyncerProvider interface {
@@ -23,6 +34,12 @@ type SyncerProvider interface {
// GetOriginalUsers retrieves all users from the external system
GetOriginalUsers() ([]*OriginalUser, error)
// GetOriginalGroups retrieves all groups from the external system
GetOriginalGroups() ([]*OriginalGroup, error)
// GetOriginalUserGroups retrieves the group IDs that a user belongs to
GetOriginalUserGroups(userId string) ([]string, error)
// AddUser adds a new user to the external system
AddUser(user *OriginalUser) (bool, error)

View File

@@ -29,3 +29,15 @@ func (p *KeycloakSyncerProvider) GetOriginalUsers() ([]*OriginalUser, error) {
// Note: Keycloak-specific user mapping is handled in syncer_util.go
// via getOriginalUsersFromMap which checks syncer.Type == "Keycloak"
// GetOriginalGroups retrieves all groups from Keycloak (not implemented yet)
func (p *KeycloakSyncerProvider) GetOriginalGroups() ([]*OriginalGroup, error) {
// TODO: Implement Keycloak group sync
return []*OriginalGroup{}, nil
}
// GetOriginalUserGroups retrieves the group IDs that a user belongs to (not implemented yet)
func (p *KeycloakSyncerProvider) GetOriginalUserGroups(userId string) ([]string, error) {
// TODO: Implement Keycloak user group membership sync
return []string{}, nil
}

View File

@@ -414,3 +414,15 @@ func (p *LarkSyncerProvider) larkUserToOriginalUser(larkUser *LarkUser) *Origina
return user
}
// GetOriginalGroups retrieves all groups from Lark (not implemented yet)
func (p *LarkSyncerProvider) GetOriginalGroups() ([]*OriginalGroup, error) {
// TODO: Implement Lark group sync
return []*OriginalGroup{}, nil
}
// GetOriginalUserGroups retrieves the group IDs that a user belongs to (not implemented yet)
func (p *LarkSyncerProvider) GetOriginalUserGroups(userId string) ([]string, error) {
// TODO: Implement Lark user group membership sync
return []string{}, nil
}

View File

@@ -296,3 +296,15 @@ func (p *OktaSyncerProvider) getOktaOriginalUsers() ([]*OriginalUser, error) {
return originalUsers, nil
}
// GetOriginalGroups retrieves all groups from Okta (not implemented yet)
func (p *OktaSyncerProvider) GetOriginalGroups() ([]*OriginalGroup, error) {
// TODO: Implement Okta group sync
return []*OriginalGroup{}, nil
}
// GetOriginalUserGroups retrieves the group IDs that a user belongs to (not implemented yet)
func (p *OktaSyncerProvider) GetOriginalUserGroups(userId string) ([]string, error) {
// TODO: Implement Okta user group membership sync
return []string{}, nil
}

View File

@@ -335,3 +335,15 @@ func (p *SCIMSyncerProvider) scimUserToOriginalUser(scimUser *SCIMUser) *Origina
return user
}
// GetOriginalGroups retrieves all groups from SCIM (not implemented yet)
func (p *SCIMSyncerProvider) GetOriginalGroups() ([]*OriginalGroup, error) {
// TODO: Implement SCIM group sync
return []*OriginalGroup{}, nil
}
// GetOriginalUserGroups retrieves the group IDs that a user belongs to (not implemented yet)
func (p *SCIMSyncerProvider) GetOriginalUserGroups(userId string) ([]string, error) {
// TODO: Implement SCIM user group membership sync
return []string{}, nil
}

View File

@@ -303,3 +303,15 @@ func (p *WecomSyncerProvider) wecomUserToOriginalUser(wecomUser *WecomUser) *Ori
return user
}
// GetOriginalGroups retrieves all groups from WeCom (not implemented yet)
func (p *WecomSyncerProvider) GetOriginalGroups() ([]*OriginalGroup, error) {
// TODO: Implement WeCom group sync
return []*OriginalGroup{}, nil
}
// GetOriginalUserGroups retrieves the group IDs that a user belongs to (not implemented yet)
func (p *WecomSyncerProvider) GetOriginalUserGroups(userId string) ([]string, error) {
// TODO: Implement WeCom user group membership sync
return []string{}, nil
}

View File

@@ -18,6 +18,7 @@ import (
"crypto/sha256"
"encoding/base64"
"fmt"
"strings"
"sync"
"time"
@@ -209,7 +210,7 @@ func GetOAuthCode(userId string, clientId string, provider string, signinMethod
}, nil
}
func GetOAuthToken(grantType string, clientId string, clientSecret string, code string, verifier string, scope string, nonce string, username string, password string, host string, refreshToken string, tag string, avatar string, lang string) (interface{}, error) {
func GetOAuthToken(grantType string, clientId string, clientSecret string, code string, verifier string, scope string, nonce string, username string, password string, host string, refreshToken string, tag string, avatar string, lang string, subjectToken string, subjectTokenType string, audience string) (interface{}, error) {
application, err := GetApplicationByClientId(clientId)
if err != nil {
return nil, err
@@ -244,6 +245,8 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
token, tokenError, err = GetImplicitToken(application, username, scope, nonce, host)
case "urn:ietf:params:oauth:grant-type:device_code":
token, tokenError, err = GetImplicitToken(application, username, scope, nonce, host)
case "urn:ietf:params:oauth:grant-type:token-exchange": // Token Exchange Grant (RFC 8693)
token, tokenError, err = GetTokenExchangeToken(application, clientSecret, subjectToken, subjectTokenType, audience, scope, host)
case "refresh_token":
refreshToken2, err := RefreshToken(grantType, refreshToken, scope, clientId, clientSecret, host)
if err != nil {
@@ -963,6 +966,183 @@ func GetWechatMiniProgramToken(application *Application, code string, host strin
return token, nil, nil
}
// GetTokenExchangeToken
// Token Exchange Grant (RFC 8693)
// Exchanges a subject token for a new token with different audience or scope
func GetTokenExchangeToken(application *Application, clientSecret string, subjectToken string, subjectTokenType string, audience string, scope string, host string) (*Token, *TokenError, error) {
// Verify client secret
if application.ClientSecret != clientSecret {
return nil, &TokenError{
Error: InvalidClient,
ErrorDescription: "client_secret is invalid",
}, nil
}
// Validate subject_token parameter
if subjectToken == "" {
return nil, &TokenError{
Error: InvalidRequest,
ErrorDescription: "subject_token is required",
}, nil
}
// Validate subject_token_type parameter
// RFC 8693 defines standard token type identifiers
if subjectTokenType == "" {
subjectTokenType = "urn:ietf:params:oauth:token-type:access_token" // Default to access_token
}
// Support common token types
supportedTokenTypes := []string{
"urn:ietf:params:oauth:token-type:access_token",
"urn:ietf:params:oauth:token-type:jwt",
"urn:ietf:params:oauth:token-type:id_token",
}
isValidTokenType := false
for _, tokenType := range supportedTokenTypes {
if subjectTokenType == tokenType {
isValidTokenType = true
break
}
}
if !isValidTokenType {
return nil, &TokenError{
Error: InvalidRequest,
ErrorDescription: fmt.Sprintf("unsupported subject_token_type: %s", subjectTokenType),
}, nil
}
// Get certificate for token validation
cert, err := getCertByApplication(application)
if err != nil {
return nil, nil, err
}
if cert == nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("cert: %s cannot be found", application.Cert),
}, nil
}
// Parse and validate the subject token
var subjectOwner, subjectName, subjectScope string
if application.TokenFormat == "JWT-Standard" {
standardClaims, err := ParseStandardJwtToken(subjectToken, cert)
if err != nil {
return nil, &TokenError{
Error: InvalidGrant,
ErrorDescription: fmt.Sprintf("invalid subject_token: %s", err.Error()),
}, nil
}
subjectOwner = standardClaims.Owner
subjectName = standardClaims.Name
subjectScope = standardClaims.Scope
} else {
claims, err := ParseJwtToken(subjectToken, cert)
if err != nil {
return nil, &TokenError{
Error: InvalidGrant,
ErrorDescription: fmt.Sprintf("invalid subject_token: %s", err.Error()),
}, nil
}
subjectOwner = claims.Owner
subjectName = claims.Name
subjectScope = claims.Scope
}
// Get the user from the subject token
user, err := getUser(subjectOwner, subjectName)
if err != nil {
return nil, nil, err
}
if user == nil {
return nil, &TokenError{
Error: InvalidGrant,
ErrorDescription: fmt.Sprintf("user from subject_token does not exist: %s", util.GetId(subjectOwner, subjectName)),
}, nil
}
if user.IsForbidden {
return nil, &TokenError{
Error: InvalidGrant,
ErrorDescription: "the user is forbidden to sign in, please contact the administrator",
}, nil
}
// Handle scope parameter
// If scope is not provided, use the scope from the subject token
// If scope is provided, it should be a subset of the subject token's scope (downscoping)
if scope == "" {
scope = subjectScope
} else {
// Validate scope downscoping (basic implementation)
// In a production environment, you would implement more sophisticated scope validation
if subjectScope != "" {
subjectScopes := strings.Split(subjectScope, " ")
requestedScopes := strings.Split(scope, " ")
for _, requestedScope := range requestedScopes {
if requestedScope == "" {
continue // Skip empty strings
}
found := false
for _, existingScope := range subjectScopes {
if existingScope != "" && requestedScope == existingScope {
found = true
break
}
}
if !found {
return nil, &TokenError{
Error: InvalidScope,
ErrorDescription: fmt.Sprintf("requested scope '%s' is not in subject token's scope", requestedScope),
}, nil
}
}
}
}
// Extend user with roles and permissions
err = ExtendUserWithRolesAndPermissions(user)
if err != nil {
return nil, nil, err
}
// Generate new JWT token
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, "", "", "", scope, host)
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("generate jwt token error: %s", err.Error()),
}, nil
}
// Create token object
token := &Token{
Owner: application.Owner,
Name: tokenName,
CreatedTime: util.GetCurrentTime(),
Application: application.Name,
Organization: user.Owner,
User: user.Name,
Code: util.GenerateClientId(),
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int(application.ExpireInHours * float64(hourSeconds)),
Scope: scope,
TokenType: "Bearer",
CodeIsUsed: true,
}
_, err = AddToken(token)
if err != nil {
return nil, nil, err
}
return token, nil, nil
}
func GetAccessTokenByUser(user *User, host string) (string, error) {
application, err := GetApplicationByUser(user)
if err != nil {

View File

@@ -850,6 +850,69 @@
}
}
},
"/api/add-ticket": {
"post": {
"tags": [
"Ticket API"
],
"description": "add ticket",
"operationId": "ApiController.AddTicket",
"parameters": [
{
"in": "body",
"name": "body",
"description": "The details of the ticket",
"required": true,
"schema": {
"$ref": "#/definitions/object.Ticket"
}
}
],
"responses": {
"200": {
"description": "The Response object",
"schema": {
"$ref": "#/definitions/controllers.Response"
}
}
}
}
},
"/api/add-ticket-message": {
"post": {
"tags": [
"Ticket API"
],
"description": "add a message to a ticket",
"operationId": "ApiController.AddTicketMessage",
"parameters": [
{
"in": "query",
"name": "id",
"description": "The id ( owner/name ) of the ticket",
"required": true,
"type": "string"
},
{
"in": "body",
"name": "body",
"description": "The message to add",
"required": true,
"schema": {
"$ref": "#/definitions/object.TicketMessage"
}
}
],
"responses": {
"200": {
"description": "The Response object",
"schema": {
"$ref": "#/definitions/controllers.Response"
}
}
}
}
},
"/api/add-token": {
"post": {
"tags": [
@@ -1707,6 +1770,34 @@
}
}
},
"/api/delete-ticket": {
"post": {
"tags": [
"Ticket API"
],
"description": "delete ticket",
"operationId": "ApiController.DeleteTicket",
"parameters": [
{
"in": "body",
"name": "body",
"description": "The details of the ticket",
"required": true,
"schema": {
"$ref": "#/definitions/object.Ticket"
}
}
],
"responses": {
"200": {
"description": "The Response object",
"schema": {
"$ref": "#/definitions/controllers.Response"
}
}
}
}
},
"/api/delete-token": {
"post": {
"tags": [
@@ -1891,6 +1982,23 @@
}
}
},
"/api/exit-impersonation-user": {
"post": {
"tags": [
"User API"
],
"description": "clear impersonation info for current session",
"operationId": "ApiController.ExitImpersonateUser",
"responses": {
"200": {
"description": "The Response object",
"schema": {
"$ref": "#/definitions/controllers.Response"
}
}
}
}
},
"/api/faceid-signin-begin": {
"get": {
"tags": [
@@ -1996,6 +2104,81 @@
}
}
},
"/api/get-all-actions": {
"get": {
"tags": [
"Enforcer API"
],
"description": "Get all actions for a user (Casbin API)",
"operationId": "ApiController.GetAllActions",
"parameters": [
{
"in": "query",
"name": "userId",
"description": "user id like built-in/admin",
"type": "string"
}
],
"responses": {
"200": {
"description": "The Response object",
"schema": {
"$ref": "#/definitions/controllers.Response"
}
}
}
}
},
"/api/get-all-objects": {
"get": {
"tags": [
"Enforcer API"
],
"description": "Get all objects for a user (Casbin API)",
"operationId": "ApiController.GetAllObjects",
"parameters": [
{
"in": "query",
"name": "userId",
"description": "user id like built-in/admin",
"type": "string"
}
],
"responses": {
"200": {
"description": "The Response object",
"schema": {
"$ref": "#/definitions/controllers.Response"
}
}
}
}
},
"/api/get-all-roles": {
"get": {
"tags": [
"Enforcer API"
],
"description": "Get all roles for a user (Casbin API)",
"operationId": "ApiController.GetAllRoles",
"parameters": [
{
"in": "query",
"name": "userId",
"description": "user id like built-in/admin",
"type": "string"
}
],
"responses": {
"200": {
"description": "The Response object",
"schema": {
"$ref": "#/definitions/controllers.Response"
}
}
}
}
},
"/api/get-app-login": {
"get": {
"tags": [
@@ -3843,6 +4026,61 @@
}
}
},
"/api/get-ticket": {
"get": {
"tags": [
"Ticket API"
],
"description": "get ticket",
"operationId": "ApiController.GetTicket",
"parameters": [
{
"in": "query",
"name": "id",
"description": "The id ( owner/name ) of the ticket",
"required": true,
"type": "string"
}
],
"responses": {
"200": {
"description": "The Response object",
"schema": {
"$ref": "#/definitions/object.Ticket"
}
}
}
}
},
"/api/get-tickets": {
"get": {
"tags": [
"Ticket API"
],
"description": "get tickets",
"operationId": "ApiController.GetTickets",
"parameters": [
{
"in": "query",
"name": "owner",
"description": "The owner of tickets",
"required": true,
"type": "string"
}
],
"responses": {
"200": {
"description": "The Response object",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/object.Ticket"
}
}
}
}
}
},
"/api/get-token": {
"get": {
"tags": [
@@ -4299,6 +4537,32 @@
}
}
},
"/api/impersonation-user": {
"post": {
"tags": [
"User API"
],
"description": "set impersonation user for current admin session",
"operationId": "ApiController.ImpersonateUser",
"parameters": [
{
"in": "formData",
"name": "username",
"description": "The username to impersonate (owner/name)",
"required": true,
"type": "string"
}
],
"responses": {
"200": {
"description": "The Response object",
"schema": {
"$ref": "#/definitions/controllers.Response"
}
}
}
}
},
"/api/invoice-payment": {
"post": {
"tags": [
@@ -5219,8 +5483,16 @@
"tags": [
"Login API"
],
"description": "logout the current user from all applications",
"description": "logout the current user from all applications or current session only",
"operationId": "ApiController.SsoLogout",
"parameters": [
{
"in": "query",
"name": "logoutAll",
"description": "Whether to logout from all sessions. Accepted values: 'true', '1', or empty (default: true). Any other value means false.",
"type": "string"
}
],
"responses": {
"200": {
"description": "The Response object",
@@ -5234,8 +5506,16 @@
"tags": [
"Login API"
],
"description": "logout the current user from all applications",
"description": "logout the current user from all applications or current session only",
"operationId": "ApiController.SsoLogout",
"parameters": [
{
"in": "query",
"name": "logoutAll",
"description": "Whether to logout from all sessions. Accepted values: 'true', '1', or empty (default: true). Any other value means false.",
"type": "string"
}
],
"responses": {
"200": {
"description": "The Response object",
@@ -6082,6 +6362,41 @@
}
}
},
"/api/update-ticket": {
"post": {
"tags": [
"Ticket API"
],
"description": "update ticket",
"operationId": "ApiController.UpdateTicket",
"parameters": [
{
"in": "query",
"name": "id",
"description": "The id ( owner/name ) of the ticket",
"required": true,
"type": "string"
},
{
"in": "body",
"name": "body",
"description": "The details of the ticket",
"required": true,
"schema": {
"$ref": "#/definitions/object.Ticket"
}
}
],
"responses": {
"200": {
"description": "The Response object",
"schema": {
"$ref": "#/definitions/controllers.Response"
}
}
}
}
},
"/api/update-token": {
"post": {
"tags": [
@@ -6564,14 +6879,18 @@
}
},
"definitions": {
"217748.\u003cnil\u003e.string": {
"232967.\u003cnil\u003e.string": {
"title": "string",
"type": "object"
},
"217806.string.string": {
"233025.string.string": {
"title": "string",
"type": "object"
},
"McpResponse": {
"title": "McpResponse",
"type": "object"
},
"Response": {
"title": "Response",
"type": "object"
@@ -6763,6 +7082,9 @@
"regex": {
"type": "string"
},
"tab": {
"type": "string"
},
"viewRule": {
"type": "string"
},
@@ -6814,6 +7136,33 @@
}
}
},
"object.Address": {
"title": "Address",
"type": "object",
"properties": {
"city": {
"type": "string"
},
"line1": {
"type": "string"
},
"line2": {
"type": "string"
},
"region": {
"type": "string"
},
"state": {
"type": "string"
},
"tag": {
"type": "string"
},
"zipCode": {
"type": "string"
}
}
},
"object.Application": {
"title": "Application",
"type": "object",
@@ -6837,6 +7186,10 @@
"type": "integer",
"format": "int64"
},
"cookieExpireInHours": {
"type": "integer",
"format": "int64"
},
"createdTime": {
"type": "string"
},
@@ -6870,6 +7223,9 @@
"enablePassword": {
"type": "boolean"
},
"enableSamlAssertionSignature": {
"type": "boolean"
},
"enableSamlC14n10": {
"type": "boolean"
},
@@ -7822,9 +8178,6 @@
"displayName": {
"type": "string"
},
"endTime": {
"type": "string"
},
"message": {
"type": "string"
},
@@ -7837,18 +8190,15 @@
"payment": {
"type": "string"
},
"planName": {
"type": "string"
},
"price": {
"type": "number",
"format": "double"
},
"pricingName": {
"type": "string"
},
"productName": {
"type": "string"
"productInfos": {
"type": "array",
"items": {
"$ref": "#/definitions/object.ProductInfo"
}
},
"products": {
"type": "array",
@@ -7856,10 +8206,10 @@
"type": "string"
}
},
"startTime": {
"state": {
"type": "string"
},
"state": {
"updateTime": {
"type": "string"
},
"user": {
@@ -7877,6 +8227,9 @@
"$ref": "#/definitions/object.AccountItem"
}
},
"accountMenu": {
"type": "string"
},
"balanceCredit": {
"type": "number",
"format": "double"
@@ -8090,9 +8443,6 @@
"invoiceUrl": {
"type": "string"
},
"isRecharge": {
"type": "boolean"
},
"message": {
"type": "string"
},
@@ -8102,6 +8452,9 @@
"order": {
"type": "string"
},
"orderObj": {
"$ref": "#/definitions/object.Order"
},
"outOrderId": {
"type": "string"
},
@@ -8127,10 +8480,13 @@
"type": "number",
"format": "double"
},
"productDisplayName": {
"type": "string"
"products": {
"type": "array",
"items": {
"type": "string"
}
},
"productName": {
"productsDisplayName": {
"type": "string"
},
"provider": {
@@ -8142,9 +8498,6 @@
"successUrl": {
"type": "string"
},
"tag": {
"type": "string"
},
"type": {
"type": "string"
},
@@ -8402,6 +8755,47 @@
}
}
},
"object.ProductInfo": {
"title": "ProductInfo",
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"detail": {
"type": "string"
},
"displayName": {
"type": "string"
},
"image": {
"type": "string"
},
"isRecharge": {
"type": "boolean"
},
"name": {
"type": "string"
},
"owner": {
"type": "string"
},
"planName": {
"type": "string"
},
"price": {
"type": "number",
"format": "double"
},
"pricingName": {
"type": "string"
},
"quantity": {
"type": "integer",
"format": "int64"
}
}
},
"object.PrometheusInfo": {
"title": "PrometheusInfo",
"type": "object",
@@ -8482,6 +8876,9 @@
"emailRegex": {
"type": "string"
},
"enablePkce": {
"type": "boolean"
},
"enableProxy": {
"type": "boolean"
},
@@ -9024,6 +9421,63 @@
}
}
},
"object.Ticket": {
"title": "Ticket",
"type": "object",
"properties": {
"content": {
"type": "string"
},
"createdTime": {
"type": "string"
},
"displayName": {
"type": "string"
},
"messages": {
"type": "array",
"items": {
"$ref": "#/definitions/object.TicketMessage"
}
},
"name": {
"type": "string"
},
"owner": {
"type": "string"
},
"state": {
"type": "string"
},
"title": {
"type": "string"
},
"updatedTime": {
"type": "string"
},
"user": {
"type": "string"
}
}
},
"object.TicketMessage": {
"title": "TicketMessage",
"type": "object",
"properties": {
"author": {
"type": "string"
},
"isAdmin": {
"type": "boolean"
},
"text": {
"type": "string"
},
"timestamp": {
"type": "string"
}
}
},
"object.Token": {
"title": "Token",
"type": "object",
@@ -9132,7 +9586,7 @@
"type": "string"
},
"category": {
"type": "string"
"$ref": "#/definitions/object.TransactionCategory"
},
"createdTime": {
"type": "string"
@@ -9159,7 +9613,7 @@
"type": "string"
},
"state": {
"$ref": "#/definitions/pp.PaymentState"
"type": "string"
},
"subtype": {
"type": "string"
@@ -9175,6 +9629,15 @@
}
}
},
"object.TransactionCategory": {
"title": "TransactionCategory",
"type": "string",
"enum": [
"TransactionCategoryPurchase = \"Purchase\"",
"TransactionCategoryRecharge = \"Recharge\""
],
"example": "Purchase"
},
"object.User": {
"title": "User",
"type": "object",
@@ -9194,6 +9657,12 @@
"type": "string"
}
},
"addresses": {
"type": "array",
"items": {
"$ref": "#/definitions/object.Address"
}
},
"adfs": {
"type": "string"
},
@@ -9256,6 +9725,12 @@
"box": {
"type": "string"
},
"cart": {
"type": "array",
"items": {
"$ref": "#/definitions/object.ProductInfo"
}
},
"casdoor": {
"type": "string"
},
@@ -9569,10 +10044,10 @@
"onedrive": {
"type": "string"
},
"originalToken": {
"originalRefreshToken": {
"type": "string"
},
"originalRefreshToken": {
"originalToken": {
"type": "string"
},
"oura": {
@@ -9828,7 +10303,7 @@
"type": "object",
"properties": {
"aliases": {
"$ref": "#/definitions/217748.\u003cnil\u003e.string"
"$ref": "#/definitions/232967.\u003cnil\u003e.string"
},
"links": {
"type": "array",
@@ -9837,7 +10312,7 @@
}
},
"properties": {
"$ref": "#/definitions/217806.string.string"
"$ref": "#/definitions/233025.string.string"
},
"subject": {
"type": "string"

View File

@@ -548,6 +548,47 @@ paths:
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/add-ticket:
post:
tags:
- Ticket API
description: add ticket
operationId: ApiController.AddTicket
parameters:
- in: body
name: body
description: The details of the ticket
required: true
schema:
$ref: '#/definitions/object.Ticket'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/add-ticket-message:
post:
tags:
- Ticket API
description: add a message to a ticket
operationId: ApiController.AddTicketMessage
parameters:
- in: query
name: id
description: The id ( owner/name ) of the ticket
required: true
type: string
- in: body
name: body
description: The message to add
required: true
schema:
$ref: '#/definitions/object.TicketMessage'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/add-token:
post:
tags:
@@ -1099,6 +1140,24 @@ paths:
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/delete-ticket:
post:
tags:
- Ticket API
description: delete ticket
operationId: ApiController.DeleteTicket
parameters:
- in: body
name: body
description: The details of the ticket
required: true
schema:
$ref: '#/definitions/object.Ticket'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/delete-token:
post:
tags:
@@ -1218,6 +1277,17 @@ paths:
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/exit-impersonation-user:
post:
tags:
- User API
description: clear impersonation info for current session
operationId: ApiController.ExitImpersonateUser
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/faceid-signin-begin:
get:
tags:
@@ -1287,6 +1357,54 @@ paths:
type: array
items:
$ref: '#/definitions/object.Adapter'
/api/get-all-actions:
get:
tags:
- Enforcer API
description: Get all actions for a user (Casbin API)
operationId: ApiController.GetAllActions
parameters:
- in: query
name: userId
description: user id like built-in/admin
type: string
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/get-all-objects:
get:
tags:
- Enforcer API
description: Get all objects for a user (Casbin API)
operationId: ApiController.GetAllObjects
parameters:
- in: query
name: userId
description: user id like built-in/admin
type: string
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/get-all-roles:
get:
tags:
- Enforcer API
description: Get all roles for a user (Casbin API)
operationId: ApiController.GetAllRoles
parameters:
- in: query
name: userId
description: user id like built-in/admin
type: string
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/get-app-login:
get:
tags:
@@ -2498,6 +2616,42 @@ paths:
description: The Response object
schema:
$ref: '#/definitions/util.SystemInfo'
/api/get-ticket:
get:
tags:
- Ticket API
description: get ticket
operationId: ApiController.GetTicket
parameters:
- in: query
name: id
description: The id ( owner/name ) of the ticket
required: true
type: string
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/object.Ticket'
/api/get-tickets:
get:
tags:
- Ticket API
description: get tickets
operationId: ApiController.GetTickets
parameters:
- in: query
name: owner
description: The owner of tickets
required: true
type: string
responses:
"200":
description: The Response object
schema:
type: array
items:
$ref: '#/definitions/object.Ticket'
/api/get-token:
get:
tags:
@@ -2797,6 +2951,23 @@ paths:
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/impersonation-user:
post:
tags:
- User API
description: set impersonation user for current admin session
operationId: ApiController.ImpersonateUser
parameters:
- in: formData
name: username
description: The username to impersonate (owner/name)
required: true
type: string
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/invoice-payment:
post:
tags:
@@ -3406,8 +3577,13 @@ paths:
get:
tags:
- Login API
description: logout the current user from all applications
description: logout the current user from all applications or current session only
operationId: ApiController.SsoLogout
parameters:
- in: query
name: logoutAll
description: 'Whether to logout from all sessions. Accepted values: ''true'', ''1'', or empty (default: true). Any other value means false.'
type: string
responses:
"200":
description: The Response object
@@ -3416,8 +3592,13 @@ paths:
post:
tags:
- Login API
description: logout the current user from all applications
description: logout the current user from all applications or current session only
operationId: ApiController.SsoLogout
parameters:
- in: query
name: logoutAll
description: 'Whether to logout from all sessions. Accepted values: ''true'', ''1'', or empty (default: true). Any other value means false.'
type: string
responses:
"200":
description: The Response object
@@ -3971,6 +4152,29 @@ paths:
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/update-ticket:
post:
tags:
- Ticket API
description: update ticket
operationId: ApiController.UpdateTicket
parameters:
- in: query
name: id
description: The id ( owner/name ) of the ticket
required: true
type: string
- in: body
name: body
description: The details of the ticket
required: true
schema:
$ref: '#/definitions/object.Ticket'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/update-token:
post:
tags:
@@ -4286,12 +4490,15 @@ paths:
schema:
$ref: '#/definitions/controllers.Response'
definitions:
217748.<nil>.string:
232967.<nil>.string:
title: string
type: object
217806.string.string:
233025.string.string:
title: string
type: object
McpResponse:
title: McpResponse
type: object
Response:
title: Response
type: object
@@ -4423,6 +4630,8 @@ definitions:
type: string
regex:
type: string
tab:
type: string
viewRule:
type: string
visible:
@@ -4456,6 +4665,24 @@ definitions:
type: boolean
user:
type: string
object.Address:
title: Address
type: object
properties:
city:
type: string
line1:
type: string
line2:
type: string
region:
type: string
state:
type: string
tag:
type: string
zipCode:
type: string
object.Application:
title: Application
type: object
@@ -4473,6 +4700,9 @@ definitions:
codeResendTimeout:
type: integer
format: int64
cookieExpireInHours:
type: integer
format: int64
createdTime:
type: string
defaultGroup:
@@ -4495,6 +4725,8 @@ definitions:
type: boolean
enablePassword:
type: boolean
enableSamlAssertionSignature:
type: boolean
enableSamlC14n10:
type: boolean
enableSamlCompress:
@@ -5136,8 +5368,6 @@ definitions:
type: string
displayName:
type: string
endTime:
type: string
message:
type: string
name:
@@ -5146,23 +5376,21 @@ definitions:
type: string
payment:
type: string
planName:
type: string
price:
type: number
format: double
pricingName:
type: string
productName:
type: string
productInfos:
type: array
items:
$ref: '#/definitions/object.ProductInfo'
products:
type: array
items:
type: string
startTime:
type: string
state:
type: string
updateTime:
type: string
user:
type: string
object.Organization:
@@ -5173,6 +5401,8 @@ definitions:
type: array
items:
$ref: '#/definitions/object.AccountItem'
accountMenu:
type: string
balanceCredit:
type: number
format: double
@@ -5317,14 +5547,14 @@ definitions:
type: string
invoiceUrl:
type: string
isRecharge:
type: boolean
message:
type: string
name:
type: string
order:
type: string
orderObj:
$ref: '#/definitions/object.Order'
outOrderId:
type: string
owner:
@@ -5342,9 +5572,11 @@ definitions:
price:
type: number
format: double
productDisplayName:
type: string
productName:
products:
type: array
items:
type: string
productsDisplayName:
type: string
provider:
type: string
@@ -5352,8 +5584,6 @@ definitions:
$ref: '#/definitions/pp.PaymentState'
successUrl:
type: string
tag:
type: string
type:
type: string
user:
@@ -5526,6 +5756,34 @@ definitions:
type: string
tag:
type: string
object.ProductInfo:
title: ProductInfo
type: object
properties:
currency:
type: string
detail:
type: string
displayName:
type: string
image:
type: string
isRecharge:
type: boolean
name:
type: string
owner:
type: string
planName:
type: string
price:
type: number
format: double
pricingName:
type: string
quantity:
type: integer
format: int64
object.PrometheusInfo:
title: PrometheusInfo
type: object
@@ -5581,6 +5839,8 @@ definitions:
type: string
emailRegex:
type: string
enablePkce:
type: boolean
enableProxy:
type: boolean
enableSignAuthnRequest:
@@ -5945,6 +6205,44 @@ definitions:
type: boolean
themeType:
type: string
object.Ticket:
title: Ticket
type: object
properties:
content:
type: string
createdTime:
type: string
displayName:
type: string
messages:
type: array
items:
$ref: '#/definitions/object.TicketMessage'
name:
type: string
owner:
type: string
state:
type: string
title:
type: string
updatedTime:
type: string
user:
type: string
object.TicketMessage:
title: TicketMessage
type: object
properties:
author:
type: string
isAdmin:
type: boolean
text:
type: string
timestamp:
type: string
object.Token:
title: Token
type: object
@@ -6020,7 +6318,7 @@ definitions:
application:
type: string
category:
type: string
$ref: '#/definitions/object.TransactionCategory'
createdTime:
type: string
currency:
@@ -6038,7 +6336,7 @@ definitions:
provider:
type: string
state:
$ref: '#/definitions/pp.PaymentState'
type: string
subtype:
type: string
tag:
@@ -6047,6 +6345,13 @@ definitions:
type: string
user:
type: string
object.TransactionCategory:
title: TransactionCategory
type: string
enum:
- TransactionCategoryPurchase = "Purchase"
- TransactionCategoryRecharge = "Recharge"
example: Purchase
object.User:
title: User
type: object
@@ -6061,6 +6366,10 @@ definitions:
type: array
items:
type: string
addresses:
type: array
items:
$ref: '#/definitions/object.Address'
adfs:
type: string
affiliation:
@@ -6103,6 +6412,10 @@ definitions:
type: string
box:
type: string
cart:
type: array
items:
$ref: '#/definitions/object.ProductInfo'
casdoor:
type: string
cloudfoundry:
@@ -6312,10 +6625,10 @@ definitions:
type: string
onedrive:
type: string
originalToken:
type: string
originalRefreshToken:
type: string
originalToken:
type: string
oura:
type: string
owner:
@@ -6486,13 +6799,13 @@ definitions:
type: object
properties:
aliases:
$ref: '#/definitions/217748.<nil>.string'
$ref: '#/definitions/232967.<nil>.string'
links:
type: array
items:
$ref: '#/definitions/object.WebFingerLink'
properties:
$ref: '#/definitions/217806.string.string'
$ref: '#/definitions/233025.string.string'
subject:
type: string
object.WebFingerLink:

View File

@@ -22,6 +22,7 @@ import * as ProductBackend from "./backend/ProductBackend";
import i18next from "i18next";
import BaseListPage from "./BaseListPage";
import PopconfirmModal from "./common/modal/PopconfirmModal";
import {QuantityStepper} from "./common/product/CartControls";
class CartListPage extends BaseListPage {
constructor(props) {
@@ -30,6 +31,7 @@ class CartListPage extends BaseListPage {
...this.state,
data: [],
user: null,
updatingCartItems: {},
isPlacingOrder: false,
loading: false,
pagination: {
@@ -40,6 +42,8 @@ class CartListPage extends BaseListPage {
searchText: "",
searchedColumn: "",
};
this.updatingCartItemsRef = {};
}
clearCart() {
@@ -90,6 +94,9 @@ class CartListPage extends BaseListPage {
.then((res) => {
if (res.status === "ok") {
const order = res.data;
const user = Setting.deepCopy(this.state.user);
user.cart = [];
UserBackend.updateUser(user.owner, user.name, user);
Setting.showMessage("success", i18next.t("product:Order created successfully"));
Setting.goToLink(`/orders/${order.owner}/${order.name}/pay`);
} else {
@@ -132,6 +139,66 @@ class CartListPage extends BaseListPage {
});
}
updateCartItemQuantity(record, newQuantity) {
if (newQuantity < 1) {
return;
}
const itemKey = `${record.name}-${record.price}-${record.pricingName || ""}-${record.planName || ""}`;
if (this.updatingCartItemsRef?.[itemKey]) {
return;
}
this.updatingCartItemsRef[itemKey] = true;
const user = Setting.deepCopy(this.state.user);
const index = user.cart.findIndex(item => item.name === record.name && item.price === record.price && (item.pricingName || "") === (record.pricingName || "") && (item.planName || "") === (record.planName || ""));
if (index === -1) {
delete this.updatingCartItemsRef[itemKey];
return;
}
if (index !== -1) {
user.cart[index].quantity = newQuantity;
const newData = [...this.state.data];
const dataIndex = newData.findIndex(item => item.name === record.name && item.price === record.price && (item.pricingName || "") === (record.pricingName || "") && (item.planName || "") === (record.planName || ""));
if (dataIndex !== -1) {
newData[dataIndex].quantity = newQuantity;
this.setState({data: newData});
}
this.setState(prevState => ({
updatingCartItems: {
...(prevState.updatingCartItems || {}),
[itemKey]: true,
},
}));
UserBackend.updateUser(user.owner, user.name, user)
.then((res) => {
if (res.status === "ok") {
this.setState({user: user});
} else {
Setting.showMessage("error", res.msg);
this.fetch();
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
this.fetch();
})
.finally(() => {
delete this.updatingCartItemsRef[itemKey];
this.setState(prevState => {
const updatingCartItems = {...(prevState.updatingCartItems || {})};
delete updatingCartItems[itemKey];
return {updatingCartItems};
});
});
}
}
renderTable(carts) {
const isEmpty = carts === undefined || carts === null || carts.length === 0;
const owner = this.state.user?.owner || this.props.account.owner;
@@ -227,8 +294,22 @@ class CartListPage extends BaseListPage {
title: i18next.t("product:Quantity"),
dataIndex: "quantity",
key: "quantity",
width: "120px",
width: "100px",
sorter: true,
render: (text, record) => {
const itemKey = `${record.name}-${record.price}-${record.pricingName || ""}-${record.planName || ""}`;
const isUpdating = this.state.updatingCartItems?.[itemKey] === true;
return (
<QuantityStepper
value={text}
min={1}
onIncrease={() => this.updateCartItemQuantity(record, text + 1)}
onDecrease={() => this.updateCartItemQuantity(record, text - 1)}
onChange={null}
disabled={isUpdating}
/>
);
},
},
{
title: i18next.t("general:Action"),
@@ -338,13 +419,28 @@ class CartListPage extends BaseListPage {
const fullCartData = await Promise.all(productPromises);
const sortedData = [...fullCartData];
if (params.sortField && params.sortOrder) {
sortedData.sort((a, b) => {
const aValue = a[params.sortField];
const bValue = b[params.sortField];
if (aValue === bValue) {
return 0;
}
const comparison = aValue > bValue ? 1 : -1;
return params.sortOrder === "ascend" ? comparison : -comparison;
});
}
this.setState({
loading: false,
data: fullCartData,
data: sortedData,
user: res.data,
pagination: {
...params.pagination,
total: fullCartData.length,
total: sortedData.length,
},
searchText: params.searchText,
searchedColumn: params.searchedColumn,

View File

@@ -239,26 +239,6 @@ class OrderEditPage extends React.Component {
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("order:Start time")}:
</Col>
<Col span={22} >
<Input value={this.state.order.startTime} onChange={e => {
this.updateOrderField("startTime", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("order:End time")}:
</Col>
<Col span={22} >
<Input value={this.state.order.endTime} onChange={e => {
this.updateOrderField("endTime", e.target.value);
}} />
</Col>
</Row>
</Card>
);
}

View File

@@ -14,7 +14,7 @@
import React from "react";
import {Link} from "react-router-dom";
import {Button, List, Table, Tooltip} from "antd";
import {Button, Col, List, Row, Table, Tooltip} from "antd";
import moment from "moment";
import * as Setting from "./Setting";
import * as OrderBackend from "./backend/OrderBackend";
@@ -37,8 +37,6 @@ class OrderListPage extends BaseListPage {
payment: "",
state: "Created",
message: "",
startTime: moment().format(),
endTime: "",
};
}
@@ -159,21 +157,31 @@ class OrderListPage extends BaseListPage {
paddingBottom: 8,
}}
renderItem={(productInfo, i) => {
const price = productInfo.price * (productInfo.quantity || 1);
const price = productInfo.price || 0;
const number = productInfo.quantity || 1;
const currency = record.currency || "USD";
const productName = productInfo.displayName || productInfo.name;
return (
<List.Item>
<div style={{display: "inline"}}>
<Tooltip placement="topLeft" title={i18next.t("general:Edit")}>
<Button style={{marginRight: "5px"}} icon={<EditOutlined />} size="small" onClick={() => Setting.goToLinkSoft(this, `/products/${record.owner}/${productInfo.name}`)} />
</Tooltip>
<Link to={`/products/${record.owner}/${productInfo.name}`}>
{productInfo.displayName || productInfo.name}
</Link>
<span style={{marginLeft: "8px", color: "#666"}}>
{Setting.getPriceDisplay(price, currency)}
</span>
</div>
<Row style={{width: "100%"}} wrap={false} gutter={[12, 0]}>
<Col flex="auto" style={{minWidth: 0}}>
<div style={{display: "flex", alignItems: "center", minWidth: 0}}>
<Tooltip placement="topLeft" title={i18next.t("general:Edit")}>
<Button style={{marginRight: "5px"}} icon={<EditOutlined />} size="small" onClick={() => Setting.goToLinkSoft(this, `/products/${record.owner}/${productInfo.name}`)} />
</Tooltip>
<Tooltip placement="topLeft" title={productName}>
<Link to={`/products/${record.owner}/${productInfo.name}`} style={{display: "inline-block", maxWidth: "100%", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"}}>
{productName}
</Link>
</Tooltip>
</div>
</Col>
<Col flex="none" style={{whiteSpace: "nowrap"}}>
<span style={{color: "#666"}}>
{Setting.getCurrencySymbol(currency)}{price} ({Setting.getCurrencyText(currency)}) × {number}
</span>
</Col>
</Row>
</List.Item>
);
}}
@@ -229,29 +237,6 @@ class OrderListPage extends BaseListPage {
sorter: true,
...this.getColumnSearchProps("state"),
},
{
title: i18next.t("general:Start time"),
dataIndex: "startTime",
key: "startTime",
width: "160px",
sorter: true,
render: (text, record, index) => {
return Setting.getFormattedDate(text);
},
},
{
title: i18next.t("general:End time"),
dataIndex: "endTime",
key: "endTime",
width: "160px",
sorter: true,
render: (text, record, index) => {
if (text === "") {
return "(empty)";
}
return Setting.getFormattedDate(text);
},
},
{
title: i18next.t("general:Action"),
dataIndex: "",

View File

@@ -267,6 +267,17 @@ class OrderPayPage extends React.Component {
render() {
const {order, productInfos} = this.state;
const updateTime = order?.updateTime || "";
const state = order?.state || "";
const updateTimeMap = {
Paid: i18next.t("order:Payment time"),
Canceled: i18next.t("order:Cancel time"),
PaymentFailed: i18next.t("order:Payment failed time"),
Timeout: i18next.t("order:Timeout time"),
};
const updateTimeLabel = updateTimeMap[state] || i18next.t("general:Updated time");
const shouldShowUpdateTime = state !== "Created" && updateTime !== "";
if (!order || !productInfos) {
return null;
}
@@ -291,6 +302,13 @@ class OrderPayPage extends React.Component {
{Setting.getFormattedDate(order.createdTime)}
</span>
</Descriptions.Item>
{shouldShowUpdateTime && (
<Descriptions.Item label={updateTimeLabel}>
<span style={{fontSize: 16}}>
{Setting.getFormattedDate(updateTime)}
</span>
</Descriptions.Item>
)}
<Descriptions.Item label={i18next.t("general:User")}>
<span style={{fontSize: 16}}>
{order.user}

View File

@@ -21,6 +21,7 @@ import * as PricingBackend from "./backend/PricingBackend";
import * as OrderBackend from "./backend/OrderBackend";
import * as UserBackend from "./backend/UserBackend";
import * as Setting from "./Setting";
import {FloatingCartButton, QuantityStepper} from "./common/product/CartControls";
class ProductBuyPage extends React.Component {
constructor(props) {
@@ -40,6 +41,8 @@ class ProductBuyPage extends React.Component {
isPlacingOrder: false,
isAddingToCart: false,
customPrice: 100,
buyQuantity: params.get("quantity") ? parseInt(params.get("quantity"), 10) : 1,
cartItemCount: 0,
};
}
@@ -58,6 +61,22 @@ class ProductBuyPage extends React.Component {
UNSAFE_componentWillMount() {
this.getProduct();
this.getPaymentEnv();
this.getCartItemCount();
}
getCartItemCount() {
if (!this.props.account) {
return;
}
const userOwner = this.props.account.owner;
const userName = this.props.account.name;
UserBackend.getUser(userOwner, userName).then((res) => {
if (res.status === "ok" && res.data.cart) {
this.setState({
cartItemCount: res.data.cart.length,
});
}
});
}
setStateAsync(state) {
@@ -175,9 +194,10 @@ class ProductBuyPage extends React.Component {
}
const existingItemIndex = cart.findIndex(item => item.name === product.name && item.price === actualPrice && (item.pricingName || "") === pricingName && (item.planName || "") === planName);
const quantityToAdd = this.state.buyQuantity;
if (existingItemIndex !== -1) {
cart[existingItemIndex].quantity += 1;
cart[existingItemIndex].quantity = (cart[existingItemIndex].quantity ?? 1) + quantityToAdd;
} else {
const newProductInfo = {
name: product.name,
@@ -185,7 +205,7 @@ class ProductBuyPage extends React.Component {
currency: product.currency,
pricingName: pricingName,
planName: planName,
quantity: 1,
quantity: quantityToAdd,
};
cart.push(newProductInfo);
}
@@ -195,6 +215,9 @@ class ProductBuyPage extends React.Component {
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully added"));
this.setState({
cartItemCount: cart.length,
});
} else {
Setting.showMessage("error", res.msg);
}
@@ -230,7 +253,7 @@ class ProductBuyPage extends React.Component {
price: product.isRecharge ? customPrice : product.price,
pricingName: pricingName,
planName: planName,
quantity: 1,
quantity: this.state.buyQuantity,
}];
OrderBackend.placeOrder(product.owner, productInfos, this.state.userName ?? "")
@@ -322,23 +345,20 @@ class ProductBuyPage extends React.Component {
const isAmountZero = product.isRecharge && (this.state.customPrice === 0 || this.state.customPrice === null);
return (
<div style={{display: "flex", justifyContent: "center", alignItems: "center", gap: "20px"}}>
<Button
type="primary"
size="large"
<div style={{display: "flex", justifyContent: "center", alignItems: "center", gap: "25px"}}>
<QuantityStepper
value={this.state.buyQuantity}
min={1}
onIncrease={() => this.setState(prevState => ({buyQuantity: prevState.buyQuantity + 1}))}
onDecrease={() => this.setState(prevState => ({buyQuantity: Math.max(1, prevState.buyQuantity - 1)}))}
onChange={(val) => this.setState({buyQuantity: val || 1})}
disabled={isRechargeUnpurchasable || this.state.isAddingToCart || isAmountZero}
style={{
height: "50px",
fontSize: "18px",
borderRadius: "30px",
paddingLeft: "60px",
paddingRight: "60px",
width: "140px",
}}
onClick={() => this.placeOrder(product)}
disabled={this.state.isPlacingOrder || isRechargeUnpurchasable || isAmountZero}
loading={this.state.isPlacingOrder}
>
{i18next.t("order:Place Order")}
</Button>
/>
<Button
type="default"
size="large"
@@ -346,8 +366,8 @@ class ProductBuyPage extends React.Component {
height: "50px",
fontSize: "18px",
borderRadius: "30px",
paddingLeft: "30px",
paddingRight: "30px",
paddingLeft: "40px",
paddingRight: "40px",
}}
onClick={() => this.addToCart(product)}
disabled={isRechargeUnpurchasable || this.state.isAddingToCart || isAmountZero}
@@ -355,6 +375,22 @@ class ProductBuyPage extends React.Component {
>
{i18next.t("product:Add to cart")}
</Button>
<Button
type="primary"
size="large"
style={{
height: "50px",
fontSize: "18px",
borderRadius: "30px",
paddingLeft: "40px",
paddingRight: "40px",
}}
onClick={() => this.placeOrder(product)}
disabled={this.state.isPlacingOrder || isRechargeUnpurchasable || isAmountZero}
loading={this.state.isPlacingOrder}
>
{i18next.t("order:Place Order")}
</Button>
</div>
);
}
@@ -369,6 +405,10 @@ class ProductBuyPage extends React.Component {
return (
<div className="login-content">
<FloatingCartButton
itemCount={this.state.cartItemCount}
onClick={() => this.props.history.push("/cart")}
/>
<Spin spinning={this.state.isPlacingOrder} size="large" tip={i18next.t("product:Placing order...")} style={{paddingTop: "10%"}} >
<Descriptions title={<span style={Setting.isMobile() ? {fontSize: 20} : {fontSize: 28}}>{i18next.t("product:Buy Product")}</span>} bordered>
<Descriptions.Item label={i18next.t("general:Name")} span={3}>

View File

@@ -18,6 +18,7 @@ import * as Setting from "./Setting";
import * as ProductBackend from "./backend/ProductBackend";
import * as UserBackend from "./backend/UserBackend";
import i18next from "i18next";
import {FloatingCartButton, QuantityStepper} from "./common/product/CartControls";
const {Text, Title} = Typography;
@@ -30,14 +31,57 @@ class ProductStorePage extends React.Component {
products: [],
loading: true,
addingToCartProducts: [],
productQuantities: {},
cartItemCount: 0,
};
}
componentDidMount() {
if (!this.props.account) {
return;
}
this.getProducts();
this.getCartItemCount();
}
componentDidUpdate(prevProps) {
if (!prevProps.account && this.props.account) {
this.getProducts();
this.getCartItemCount();
}
}
getCartItemCount() {
if (!this.props.account) {
return;
}
const userOwner = this.props.account.owner;
const userName = this.props.account.name;
UserBackend.getUser(userOwner, userName).then((res) => {
if (res.status === "ok" && res.data.cart) {
this.setState({
cartItemCount: res.data.cart.length,
});
}
});
}
updateProductQuantity(productName, value) {
this.setState(prevState => ({
productQuantities: {
...prevState.productQuantities,
[productName]: value,
},
}));
}
getProducts() {
if (!this.props.account) {
return;
}
const pageSize = 100; // Max products to display in the store
const owner = Setting.isDefaultOrganizationSelected(this.props.account) ? "" : Setting.getRequestOrganization(this.props.account);
this.setState({loading: true});
@@ -85,9 +129,10 @@ class ProductStorePage extends React.Component {
}
const existingItemIndex = cart.findIndex(item => item.name === product.name && item.price === product.price);
const quantityToAdd = this.state.productQuantities[product.name] || 1;
if (existingItemIndex !== -1) {
cart[existingItemIndex].quantity += 1;
cart[existingItemIndex].quantity = (cart[existingItemIndex].quantity ?? 1) + quantityToAdd;
} else {
const newCartProductInfo = {
name: product.name,
@@ -95,7 +140,7 @@ class ProductStorePage extends React.Component {
currency: product.currency,
pricingName: "",
planName: "",
quantity: 1,
quantity: quantityToAdd,
};
cart.push(newCartProductInfo);
}
@@ -105,6 +150,9 @@ class ProductStorePage extends React.Component {
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully added"));
this.setState({
cartItemCount: cart.length,
});
} else {
Setting.showMessage("error", res.msg);
}
@@ -127,11 +175,14 @@ class ProductStorePage extends React.Component {
}
handleBuyProduct(product) {
this.props.history.push(`/products/${product.owner}/${product.name}/buy`);
const quantity = this.state.productQuantities[product.name] || 1;
this.props.history.push(`/products/${product.owner}/${product.name}/buy?quantity=${quantity}`);
}
renderProductCard(product) {
const isAdding = this.state.addingToCartProducts.includes(product.name);
const quantity = this.state.productQuantities[product.name] || 1;
return (
<Col xs={24} sm={12} md={8} lg={6} key={`${product.owner}/${product.name}`} style={{marginBottom: "20px"}}>
<Card
@@ -149,6 +200,40 @@ class ProductStorePage extends React.Component {
}
actions={[
<div key="actions" style={{display: "flex", justifyContent: "center", gap: "10px", width: "100%", padding: "0 10px"}} onClick={(e) => e.stopPropagation()}>
{!product.isRecharge && (
<>
<QuantityStepper
value={quantity}
min={1}
onIncrease={() => this.updateProductQuantity(product.name, quantity + 1)}
onDecrease={() => this.updateProductQuantity(product.name, Math.max(1, quantity - 1))}
onChange={(val) => this.updateProductQuantity(product.name, val || 1)}
disabled={isAdding}
style={{
height: "45px",
fontSize: "16px",
width: "120px",
}}
/>
<Button
key="add"
type="default"
onClick={(e) => {
e.stopPropagation();
this.addToCart(product);
}}
style={{
width: "150px",
height: "45px",
fontSize: "16px",
}}
disabled={isAdding}
loading={isAdding}
>
{i18next.t("product:Add to cart")}
</Button>
</>
)}
<Button
key="buy"
type="primary"
@@ -156,23 +241,14 @@ class ProductStorePage extends React.Component {
e.stopPropagation();
this.handleBuyProduct(product);
}}
style={{
width: "150px",
height: "45px",
fontSize: "16px",
}}
>
{i18next.t("product:Buy")}
</Button>
{!product.isRecharge && (
<Button
key="add"
type="default"
onClick={(e) => {
e.stopPropagation();
this.addToCart(product);
}}
disabled={isAdding}
loading={isAdding}
>
{i18next.t("product:Add to cart")}
</Button>
)}
</div>,
]}
bodyStyle={{flex: 1, display: "flex", flexDirection: "column"}}
@@ -253,6 +329,10 @@ class ProductStorePage extends React.Component {
render() {
return (
<div>
<FloatingCartButton
itemCount={this.state.cartItemCount}
onClick={() => this.props.history.push("/cart")}
/>
<Row gutter={[16, 16]}>
{this.state.loading ? (
<Col span={24}>

View File

@@ -1173,7 +1173,7 @@ class ProviderEditPage extends React.Component {
</Col>
</Row>
) : null}
{["AWS S3", "Tencent Cloud COS", "Qiniu Cloud Kodo", "Casdoor", "CUCloud OSS", "MinIO", "CUCloud"].includes(this.state.provider.type) ? (
{["AWS S3", "Tencent Cloud COS", "Qiniu Cloud Kodo", "Casdoor", "CUCloud OSS", "MinIO", "CUCloud", "Alibaba Cloud PNVS SMS"].includes(this.state.provider.type) ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{["Casdoor"].includes(this.state.provider.type) ?

View File

@@ -1293,6 +1293,7 @@ export function getProviderTypeOptions(category) {
return (
[
{id: "Aliyun SMS", name: "Alibaba Cloud SMS"},
{id: "Alibaba Cloud PNVS SMS", name: "Alibaba Cloud PNVS SMS"},
{id: "Amazon SNS", name: "Amazon SNS"},
{id: "Azure ACS", name: "Azure ACS"},
{id: "Custom HTTP SMS", name: "Custom HTTP SMS"},

View File

@@ -25,8 +25,8 @@ export function getAccount(query = "") {
}).then(res => res.json());
}
export function signup(values) {
return fetch(`${authConfig.serverUrl}/api/signup`, {
export function signup(values, oAuthParams) {
return fetch(`${authConfig.serverUrl}/api/signup${oAuthParamsToQuery(oAuthParams)}`, {
method: "POST",
credentials: "include",
body: JSON.stringify(values),

View File

@@ -276,9 +276,23 @@ class SignupPage extends React.Component {
const params = new URLSearchParams(window.location.search);
values.plan = params.get("plan");
values.pricing = params.get("pricing");
AuthBackend.signup(values)
// Get OAuth parameters if present
const oAuthParams = Util.getOAuthGetParameters();
AuthBackend.signup(values, oAuthParams)
.then((res) => {
if (res.status === "ok") {
// Check if this is OAuth flow with code response
// When OAuth parameters are present and code is returned, it won't contain '/'
if (oAuthParams && res.data && typeof res.data === "string" && !res.data.includes("/")) {
// OAuth code returned, redirect to redirect_uri with code
const code = res.data;
const redirectUrl = `${oAuthParams.redirectUri}${oAuthParams.redirectUri.includes("?") ? "&" : "?"}code=${code}&state=${oAuthParams.state}`;
Setting.goToLink(redirectUrl);
return;
}
// the user's id will be returned by `signup()`, if user signup by phone, the `username` in `values` is undefined.
values.username = res.data.split("/")[1];
if (Setting.hasPromptPage(application) && (!values.plan || !values.pricing)) {

View File

@@ -0,0 +1,96 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Badge, Button, InputNumber} from "antd";
import {MinusOutlined, PlusOutlined, ShoppingCartOutlined} from "@ant-design/icons";
export class QuantityStepper extends React.Component {
render() {
const {value, onIncrease, onDecrease, onChange, min = 1, max, disabled} = this.props;
const parsedValue = (value === null || value === undefined || value === "") ? NaN : Number(value);
const normalizedValue = Number.isFinite(parsedValue) ? parsedValue : min;
return (
<div style={{display: "inline-flex", alignItems: "center", border: "1px solid #d9d9d9", borderRadius: "6px", height: "36px", ...this.props.style}}>
<Button
type="text"
size="small"
icon={<MinusOutlined />}
disabled={disabled || normalizedValue <= min}
onClick={onDecrease}
style={{borderRadius: "6px 0 0 6px", height: "100%", width: "calc(100% / 3)"}}
/>
<InputNumber
min={min}
max={max}
value={normalizedValue}
onChange={onChange}
controls={false}
disabled={disabled}
style={{
width: "calc(100% / 3)",
height: "100%",
textAlign: "center",
border: "none",
boxShadow: "none",
pointerEvents: onChange ? "auto" : "none",
display: "flex",
alignItems: "center",
}}
/>
<Button
type="text"
size="small"
icon={<PlusOutlined />}
disabled={disabled || (max !== undefined && normalizedValue >= max)}
onClick={onIncrease}
style={{borderRadius: "0 6px 6px 0", height: "100%", width: "calc(100% / 3)"}}
/>
</div>
);
}
}
export class FloatingCartButton extends React.Component {
render() {
const {itemCount, onClick} = this.props;
return (
<div
style={{
position: "fixed",
bottom: "50px",
right: "50px",
zIndex: 1000,
cursor: "pointer",
}}
onClick={onClick}
>
<Badge count={itemCount} offset={[-5, 5]} size="default">
<Button
type="primary"
shape="circle"
icon={<ShoppingCartOutlined style={{fontSize: "24px"}} />}
size="large"
style={{width: "60px", height: "60px", boxShadow: "0 4px 8px rgba(0,0,0,0.15)"}}
/>
</Badge>
</div>
);
}
}