forked from casdoor/casdoor
Compare commits
12 Commits
copilot/fi
...
v2.290.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecf8039c5d | ||
|
|
0a6948034c | ||
|
|
442f8fb19e | ||
|
|
b771add9e3 | ||
|
|
df8e9fceea | ||
|
|
d674f0c33d | ||
|
|
1e1b5273d9 | ||
|
|
cf5e88915c | ||
|
|
c8973e6c9e | ||
|
|
87ea451561 | ||
|
|
8f32779b42 | ||
|
|
aba471b4e8 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
2
go.mod
@@ -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
8
go.sum
@@ -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=
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.": "验证码还未发送",
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
86
object/sms_pnvs.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
204
object/syncer_googleworkspace_test.go
Normal file
204
object/syncer_googleworkspace_test.go
Normal 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
121
object/syncer_group.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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) ?
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
96
web/src/common/product/CartControls.js
Normal file
96
web/src/common/product/CartControls.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user