Compare commits

...

75 Commits

Author SHA1 Message Date
Yang Luo
c4ce88198f feat: improve password popover positioning on signup page 2025-11-30 18:10:19 +08:00
Yang Luo
a11fa23add fix: fix i18n for "Please input your {field}!" validation message in signup page (#4610) 2025-11-30 17:47:25 +08:00
Yang Luo
add6ba32db fix: improve application edit page's Providers dropdown with search, icons, and display names (#4608) 2025-11-30 17:13:06 +08:00
Yang Luo
37379dee13 fix: fix get-groups API call in ApplicationEditPage to use correct owner parameter (#4606) 2025-11-30 16:23:28 +08:00
Yang Luo
2066670b76 feat: add Lemon Squeezy payment provider (#4604) 2025-11-30 13:40:48 +08:00
Yang Luo
e751148be2 feat: add FastSpring payment provider (#4601) 2025-11-30 12:02:18 +08:00
Yang Luo
c541d0bcdd feat: add Paddle payment provider (#4598) 2025-11-30 11:31:16 +08:00
Yang Luo
f0db95d006 feat: add Polar payment provider (#4595) 2025-11-30 10:45:11 +08:00
IsAurora6
e4db367eaa feat: Remove BuyProduct endpoint and legacy purchase logic. (#4591) 2025-11-28 23:51:22 +08:00
IsAurora6
9df81e3ffc feat: feat: add OrderPayPage.js, fix subscription redirect & refine list time format. (#4586) 2025-11-27 20:49:49 +08:00
IsAurora6
048d6acc83 feat: Implement the complete process of product purchase, order placement, and payment. (#4588) 2025-11-27 20:49:34 +08:00
Yang Luo
e440199977 feat: regenerate the Swagger docs 2025-11-25 22:24:32 +08:00
IsAurora6
cb4e559d51 feat: Added PlaceOrder, CancelOrder, and PayOrder methods, and added corresponding buttons to the frontend. (#4583) 2025-11-25 22:22:46 +08:00
zjumathcode
4d1d0b95d6 feat: drop legacy // +build comment (#4582) 2025-11-25 20:21:09 +08:00
Yang Luo
9cc1133a96 feat: upgrade gomail to v2.2.0 2025-11-25 01:03:45 +08:00
Yang Luo
897c28e8ad fix: fix SQL query in Keycloak syncer (#4578) 2025-11-24 23:40:30 +08:00
Yang Luo
9d37a7e38e fix: fix memory leaks in database syncer from unclosed connections (#4574) 2025-11-24 23:38:50 +08:00
Yang Luo
ea597296b4 fix: allow normal users to view their own transactions (#4572) 2025-11-24 01:47:10 +08:00
Yang Luo
427ddd215e feat: add Telegram OAuth provider (#4570) 2025-11-24 01:04:36 +08:00
Yang Luo
24de79b100 Improve getTransactionTableColumns UI 2025-11-23 22:07:33 +08:00
DacongDA
9ab9c7c8e0 fix: show error better for user upload (#4568) 2025-11-23 21:52:44 +08:00
Yang Luo
0728a9716b feat: deduplicate code between TransactionTable and TransactionListPage (#4567) 2025-11-23 21:47:58 +08:00
Yang Luo
471570f24a Improve AddTransaction API return value 2025-11-23 21:02:06 +08:00
Yang Luo
2fa520844b fix: fix product store page to pass owner parameter to API (#4565) 2025-11-23 20:48:15 +08:00
Yang Luo
2306acb416 fix: improve balanceCredit for org and user 2025-11-23 19:51:39 +08:00
Yang Luo
d3f3f76290 fix: add dry run mode to add-transaction API (#4563) 2025-11-23 17:36:51 +08:00
DacongDA
fe93128495 feat: improve user upload UX (#4542) 2025-11-23 16:05:46 +08:00
seth-shi
7fd890ff14 fix: ticket error handling in HandleOfficialAccountEvent() (#4557) 2025-11-23 14:58:23 +08:00
Yang Luo
83b56d7ceb feat: add product store page (#4544) 2025-11-23 14:54:35 +08:00
Yang Luo
503e5a75d2 feat: add User.OriginalToken field to expose OAuth provider access tokens (#4559) 2025-11-23 14:54:02 +08:00
seth-shi
5a607b4991 fix: close file handle in GetUploadXlsxPath to prevent resource leak (#4558) 2025-11-23 14:37:06 +08:00
Yang Luo
ca2dc2825d feat: add SSO logout notifications to user's signup application (#4547) 2025-11-23 00:47:29 +08:00
Yang Luo
446d0b9047 Improve TransactionTable UI 2025-11-23 00:45:47 +08:00
Yang Luo
ee708dbf48 feat: add Organization.OrgBalanceCredit and User.BalanceCredit fields for credit limit enforcement (#4552) 2025-11-23 00:37:44 +08:00
Yang Luo
221ca28488 fix: flatten top navbar to single level when ≤7 items (#4550) 2025-11-23 00:34:17 +08:00
Yang Luo
e93d3f6c13 Improve transaction list page UI 2025-11-22 23:35:04 +08:00
Yang Luo
e285396d4e fix: fix recharge transaction default values (#4546) 2025-11-22 23:27:29 +08:00
Yang Luo
10320bb49f Improve TransactionTable UI 2025-11-22 21:39:56 +08:00
seth-shi
4d27ebd82a feat: Use email as username when organization setting is enabled during login (#4539) 2025-11-22 20:58:27 +08:00
Yang Luo
6d5e6dab0a Fix account table missing item 2025-11-22 20:56:45 +08:00
Yang Luo
e600ea7efd feat: add i18n support for table column widgets (#4541) 2025-11-22 16:39:44 +08:00
Yang Luo
8002613398 feat: Add exchange rate conversion for balance calculations (#4534) 2025-11-21 22:13:26 +08:00
IsAurora6
a48b1d0c73 feat: Add recharge functionality with editable fields to transaction list page. (#4536) 2025-11-21 22:11:38 +08:00
Yang Luo
d8b5ecba36 feat: add transaction's subtype field and fix product recharge (#4531) 2025-11-21 19:27:07 +08:00
IsAurora6
e3a8a464d5 feat: Add balanceCurrency field to Organization and User models. (#4525) 2025-11-21 14:42:54 +08:00
IsAurora6
a575ba02d6 feat: Fixed a bug in addTransaction and optimized the transactionEdit page. (#4523) 2025-11-21 09:35:12 +08:00
IsAurora6
a9fcfceb8f feat: Add currency icons wherever currency appears, and optimize the display columns in the transaction table. (#4516) 2025-11-20 22:33:00 +08:00
ledigang
712482ffb9 refactor: omit unnecessary reassignment (#4509) 2025-11-20 18:47:03 +08:00
Yang Luo
84e2c760d9 feat: lazy-load Face ID models only when modal opens (#4508) 2025-11-20 18:46:31 +08:00
IsAurora6
4ab85d6781 feat: Distinguish and allow users to configure adminNavItems and userNavItems. (#4503) 2025-11-20 11:05:30 +08:00
Yang Luo
2ede56ac46 fix: refactor out Setting.CurrencyOptions (#4502) 2025-11-19 21:51:28 +08:00
Yang Luo
6a819a9a20 feat: persist hash column when updating users (#4500) 2025-11-19 21:50:32 +08:00
IsAurora6
ddaeac46e8 fix: optimize UpdateUserBalance and fix precision loss for orgBalance/userBalance. (#4499) 2025-11-19 21:13:32 +08:00
IsAurora6
f9d061d905 feat: return transaction IDs in API and disable links for anonymous user in transaction list (#4498) 2025-11-19 17:40:30 +08:00
Yang Luo
5e550e4364 feat: fix bug in createTable() 2025-11-19 17:33:51 +08:00
Yang Luo
146d54d6f6 feat: add Order pages (#4492) 2025-11-19 14:05:52 +08:00
IsAurora6
1df15a2706 fix: Transaction category & type links not navigating. (#4496) 2025-11-19 11:41:36 +08:00
Yang Luo
f7d73bbfdd Improve transaction fields 2025-11-19 09:14:49 +08:00
Yang Luo
a8b7217348 fix: add needSshfields() 2025-11-19 08:37:13 +08:00
Yang Luo
40a3b19cee feat: add Active Directory syncer support (#4495) 2025-11-19 08:30:01 +08:00
Yang Luo
98b45399a7 feat: add Google Workspace syncer (#4494) 2025-11-19 07:37:11 +08:00
Yang Luo
90edb7ab6b feat: refactor syncers into interface (#4490) 2025-11-19 01:28:37 +08:00
marun
e21b995eca feat: update payment providers when organization changes in PlanEditPage (#4462) 2025-11-19 00:14:01 +08:00
Yang Luo
81221f07f0 fix: improve isAllowedInDemoMode() for add-transaction API 2025-11-18 23:55:43 +08:00
Yang Luo
5fc2cdf637 feat: fix bug in GetEnforcer() API 2025-11-18 23:31:53 +08:00
Yang Luo
5e852e0121 feat: improve user edit page UI 2025-11-18 23:31:17 +08:00
Yang Luo
513ac6ffe9 fix: improve user edit page's transaction table UI 2025-11-18 23:31:16 +08:00
Yang Luo
821ba5673d Improve "Generate" button i18n 2025-11-18 23:31:16 +08:00
IsAurora6
d3ee73e48c feat: Add a URL field to the Transaction structure and optimize the display of the Transaction List. (#4487) 2025-11-18 21:45:57 +08:00
Yang Luo
1d719e3759 feat: fix OAuth-registered users to keep empty passwords unhashed (#4482) 2025-11-17 23:12:53 +08:00
Yang Luo
b3355a9fa6 fix: fix undefined owner in syncer edit page getCerts API call (#4471) 2025-11-17 22:51:12 +08:00
Yang Luo
ccc88cdafb feat: populate updated_time for all user creation paths (#4472) 2025-11-17 22:07:47 +08:00
Yang Luo
abf328bbe5 feat: allow setting email_verified in UpdateUser() API 2025-11-17 22:04:33 +08:00
DacongDA
5530253d38 feat: use correct org owner for UpdateOrganizationBalance (#4478) 2025-11-17 18:17:02 +08:00
Yang Luo
4cef6c5f3f feat: fix duplicate key error when re-importing users from different organization (#4473) 2025-11-17 02:13:35 +08:00
139 changed files with 8059 additions and 1683 deletions

View File

@@ -67,7 +67,6 @@ p, *, *, POST, /api/upload-users, *, *
p, *, *, GET, /api/get-resources, *, *
p, *, *, GET, /api/get-records, *, *
p, *, *, GET, /api/get-product, *, *
p, *, *, POST, /api/buy-product, *, *
p, *, *, GET, /api/get-payment, *, *
p, *, *, POST, /api/update-payment, *, *
p, *, *, POST, /api/invoice-payment, *, *
@@ -100,6 +99,8 @@ p, *, *, *, /api/metrics, *, *
p, *, *, GET, /api/get-pricing, *, *
p, *, *, GET, /api/get-plan, *, *
p, *, *, GET, /api/get-subscription, *, *
p, *, *, GET, /api/get-transactions, *, *
p, *, *, GET, /api/get-transaction, *, *
p, *, *, GET, /api/get-provider, *, *
p, *, *, GET, /api/get-organization-names, *, *
p, *, *, GET, /api/get-all-objects, *, *
@@ -183,7 +184,7 @@ func isAllowedInDemoMode(subOwner string, subName string, method string, urlPath
return true
}
return false
} else if urlPath == "/api/upload-resource" {
} else if urlPath == "/api/upload-resource" || urlPath == "/api/add-transaction" {
if subOwner == "app" && subName == "app-casibase" {
return true
}

View File

@@ -484,6 +484,21 @@ func (c *ApiController) SsoLogout() {
return
}
// Send SSO logout notifications to all notification providers in the user's signup application
userObj, err := object.GetUser(user)
if err != nil {
c.ResponseError(err.Error())
return
}
if userObj != nil {
err = object.SendSsoLogoutNotifications(userObj)
if err != nil {
c.ResponseError(err.Error())
return
}
}
util.LogInfo(c.Ctx, "API: [%s] logged out from all applications", user)
c.ResponseOk()

View File

@@ -724,6 +724,7 @@ func (c *ApiController) Login() {
return
}
userInfo := &idp.UserInfo{}
var token *oauth2.Token
if provider.Category == "SAML" {
// SAML
userInfo, err = object.ParseSamlResponse(authForm.SamlResponse, provider, c.Ctx.Request.Host)
@@ -754,7 +755,6 @@ func (c *ApiController) Login() {
}
// https://github.com/golang/oauth2/issues/123#issuecomment-103715338
var token *oauth2.Token
token, err = idProvider.GetToken(authForm.Code)
if err != nil {
c.ResponseError(err.Error())
@@ -804,7 +804,7 @@ func (c *ApiController) Login() {
if user != nil && !user.IsDeleted {
// Sign in via OAuth (want to sign up but already have account)
// sync info from 3rd-party if possible
_, err = object.SetUserOAuthProperties(organization, user, provider.Type, userInfo, provider.UserMapping)
_, err = object.SetUserOAuthProperties(organization, user, provider.Type, userInfo, token, provider.UserMapping)
if err != nil {
c.ResponseError(err.Error())
return
@@ -867,6 +867,11 @@ func (c *ApiController) Login() {
return
}
// Handle UseEmailAsUsername for OAuth and Web3
if organization.UseEmailAsUsername && userInfo.Email != "" {
userInfo.Username = userInfo.Email
}
// Handle username conflicts
var tmpUser *object.User
tmpUser, err = object.GetUser(util.GetId(application.Organization, userInfo.Username))
@@ -949,7 +954,7 @@ func (c *ApiController) Login() {
}
// sync info from 3rd-party if possible
_, err = object.SetUserOAuthProperties(organization, user, provider.Type, userInfo, provider.UserMapping)
_, err = object.SetUserOAuthProperties(organization, user, provider.Type, userInfo, token, provider.UserMapping)
if err != nil {
c.ResponseError(err.Error())
return
@@ -997,7 +1002,7 @@ func (c *ApiController) Login() {
}
// sync info from 3rd-party if possible
_, err = object.SetUserOAuthProperties(organization, user, provider.Type, userInfo, provider.UserMapping)
_, err = object.SetUserOAuthProperties(organization, user, provider.Type, userInfo, token, provider.UserMapping)
if err != nil {
c.ResponseError(err.Error())
return
@@ -1213,7 +1218,7 @@ func (c *ApiController) HandleOfficialAccountEvent() {
return
}
if data.Ticket == "" {
c.ResponseError(err.Error())
c.ResponseError("empty ticket")
return
}
@@ -1228,10 +1233,6 @@ func (c *ApiController) HandleOfficialAccountEvent() {
return
}
if data.Ticket == "" {
c.ResponseError("empty ticket")
return
}
if !idp.VerifyWechatSignature(provider.Content, nonce, timestamp, signature) {
c.ResponseError("invalid signature")
return

View File

@@ -83,15 +83,13 @@ func (c *ApiController) GetEnforcer() {
c.ResponseError(err.Error())
return
}
if enforcer == nil {
c.ResponseError(fmt.Sprintf(c.T("general:The enforcer: %s does not exist"), id))
return
}
if loadModelCfg == "true" && enforcer.Model != "" {
err := enforcer.LoadModelCfg()
if err != nil {
return
if enforcer != nil {
if loadModelCfg == "true" && enforcer.Model != "" {
err = enforcer.LoadModelCfg()
if err != nil {
return
}
}
}

166
controllers/order.go Normal file
View File

@@ -0,0 +1,166 @@
// 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 controllers
import (
"encoding/json"
"github.com/beego/beego/utils/pagination"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
// GetOrders
// @Title GetOrders
// @Tag Order API
// @Description get orders
// @Param owner query string true "The owner of orders"
// @Success 200 {array} object.Order The Response object
// @router /get-orders [get]
func (c *ApiController) GetOrders() {
owner := c.Input().Get("owner")
limit := c.Input().Get("pageSize")
page := c.Input().Get("p")
field := c.Input().Get("field")
value := c.Input().Get("value")
sortField := c.Input().Get("sortField")
sortOrder := c.Input().Get("sortOrder")
if limit == "" || page == "" {
orders, err := object.GetOrders(owner)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(orders)
} else {
limit := util.ParseInt(limit)
count, err := object.GetOrderCount(owner, field, value)
if err != nil {
c.ResponseError(err.Error())
return
}
paginator := pagination.SetPaginator(c.Ctx, limit, count)
orders, err := object.GetPaginationOrders(owner, paginator.Offset(), limit, field, value, sortField, sortOrder)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(orders, paginator.Nums())
}
}
// GetUserOrders
// @Title GetUserOrders
// @Tag Order API
// @Description get orders for a user
// @Param owner query string true "The owner of orders"
// @Param user query string true "The username of the user"
// @Success 200 {array} object.Order The Response object
// @router /get-user-orders [get]
func (c *ApiController) GetUserOrders() {
owner := c.Input().Get("owner")
user := c.Input().Get("user")
orders, err := object.GetUserOrders(owner, user)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(orders)
}
// GetOrder
// @Title GetOrder
// @Tag Order API
// @Description get order
// @Param id query string true "The id ( owner/name ) of the order"
// @Success 200 {object} object.Order The Response object
// @router /get-order [get]
func (c *ApiController) GetOrder() {
id := c.Input().Get("id")
order, err := object.GetOrder(id)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(order)
}
// UpdateOrder
// @Title UpdateOrder
// @Tag Order API
// @Description update order
// @Param id query string true "The id ( owner/name ) of the order"
// @Param body body object.Order true "The details of the order"
// @Success 200 {object} controllers.Response The Response object
// @router /update-order [post]
func (c *ApiController) UpdateOrder() {
id := c.Input().Get("id")
var order object.Order
err := json.Unmarshal(c.Ctx.Input.RequestBody, &order)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.UpdateOrder(id, &order))
c.ServeJSON()
}
// AddOrder
// @Title AddOrder
// @Tag Order API
// @Description add order
// @Param body body object.Order true "The details of the order"
// @Success 200 {object} controllers.Response The Response object
// @router /add-order [post]
func (c *ApiController) AddOrder() {
var order object.Order
err := json.Unmarshal(c.Ctx.Input.RequestBody, &order)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.AddOrder(&order))
c.ServeJSON()
}
// DeleteOrder
// @Title DeleteOrder
// @Tag Order API
// @Description delete order
// @Param body body object.Order true "The details of the order"
// @Success 200 {object} controllers.Response The Response object
// @router /delete-order [post]
func (c *ApiController) DeleteOrder() {
var order object.Order
err := json.Unmarshal(c.Ctx.Input.RequestBody, &order)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.DeleteOrder(&order))
c.ServeJSON()
}

169
controllers/order_pay.go Normal file
View File

@@ -0,0 +1,169 @@
// 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 controllers
import (
"fmt"
"strconv"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
// PlaceOrder
// @Title PlaceOrder
// @Tag Order API
// @Description place an order for a product
// @Param productId query string true "The id ( owner/name ) of the product"
// @Param pricingName query string false "The name of the pricing (for subscription)"
// @Param planName query string false "The name of the plan (for subscription)"
// @Param customPrice query number false "Custom price for recharge products"
// @Param userName query string false "The username to place order for (admin only)"
// @Success 200 {object} object.Order The Response object
// @router /place-order [post]
func (c *ApiController) PlaceOrder() {
productId := c.Input().Get("productId")
pricingName := c.Input().Get("pricingName")
planName := c.Input().Get("planName")
customPriceStr := c.Input().Get("customPrice")
paidUserName := c.Input().Get("userName")
if productId == "" {
c.ResponseError(c.T("general:ProductId is required"))
return
}
var customPrice float64
if customPriceStr != "" {
var err error
customPrice, err = strconv.ParseFloat(customPriceStr, 64)
if err != nil {
c.ResponseError(fmt.Sprintf(c.T("general:Invalid customPrice: %s"), customPriceStr))
return
}
}
owner, _, err := util.GetOwnerAndNameFromIdWithError(productId)
if err != nil {
c.ResponseError(err.Error())
return
}
var userId string
if paidUserName != "" {
userId = util.GetId(owner, paidUserName)
if userId != c.GetSessionUsername() && !c.IsAdmin() && userId != c.GetPaidUsername() {
c.ResponseError(c.T("general:Only admin user can specify user"))
return
}
c.SetSession("paidUsername", "")
} else {
userId = c.GetSessionUsername()
}
if userId == "" {
c.ResponseError(c.T("general:Please login first"))
return
}
user, err := object.GetUser(userId)
if err != nil {
c.ResponseError(err.Error())
return
}
if user == nil {
c.ResponseError(fmt.Sprintf(c.T("general:The user: %s doesn't exist"), userId))
return
}
order, err := object.PlaceOrder(productId, user, pricingName, planName, customPrice)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(order)
}
// PayOrder
// @Title PayOrder
// @Tag Order API
// @Description pay an existing order
// @Param id query string true "The id ( owner/name ) of the order"
// @Param providerName query string true "The name of the provider"
// @Success 200 {object} controllers.Response The Response object
// @router /pay-order [post]
func (c *ApiController) PayOrder() {
id := c.Input().Get("id")
host := c.Ctx.Request.Host
providerName := c.Input().Get("providerName")
paymentEnv := c.Input().Get("paymentEnv")
order, err := object.GetOrder(id)
if err != nil {
c.ResponseError(err.Error())
return
}
if order == nil {
c.ResponseError(fmt.Sprintf(c.T("auth:The order: %s does not exist"), id))
return
}
userId := c.GetSessionUsername()
orderUserId := util.GetId(order.Owner, order.User)
if userId != orderUserId && !c.IsAdmin() {
c.ResponseError(c.T("auth:Unauthorized operation"))
return
}
payment, attachInfo, err := object.PayOrder(providerName, host, paymentEnv, order)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(payment, attachInfo)
}
// CancelOrder
// @Title CancelOrder
// @Tag Order API
// @Description cancel an order
// @Param id query string true "The id ( owner/name ) of the order"
// @Success 200 {object} controllers.Response The Response object
// @router /cancel-order [post]
func (c *ApiController) CancelOrder() {
id := c.Input().Get("id")
order, err := object.GetOrder(id)
if err != nil {
c.ResponseError(err.Error())
return
}
if order == nil {
c.ResponseError(fmt.Sprintf(c.T("auth:The order: %s does not exist"), id))
return
}
userId := c.GetSessionUsername()
orderUserId := util.GetId(order.Owner, order.User)
if userId != orderUserId && !c.IsAdmin() {
c.ResponseError(c.T("auth:Unauthorized operation"))
return
}
c.Data["json"] = wrapActionResponse(object.CancelOrder(order))
c.ServeJSON()
}

View File

@@ -130,6 +130,10 @@ func (c *ApiController) UpdateOrganization() {
isGlobalAdmin, _ := c.isGlobalAdmin()
if organization.BalanceCurrency == "" {
organization.BalanceCurrency = "USD"
}
c.Data["json"] = wrapActionResponse(object.UpdateOrganization(id, &organization, isGlobalAdmin))
c.ServeJSON()
}
@@ -165,6 +169,10 @@ func (c *ApiController) AddOrganization() {
return
}
if organization.BalanceCurrency == "" {
organization.BalanceCurrency = "USD"
}
c.Data["json"] = wrapActionResponse(object.AddOrganization(&organization))
c.ServeJSON()
}

View File

@@ -16,8 +16,6 @@ package controllers
import (
"encoding/json"
"fmt"
"strconv"
"github.com/beego/beego/utils/pagination"
"github.com/casdoor/casdoor/object"
@@ -151,72 +149,3 @@ func (c *ApiController) DeleteProduct() {
c.Data["json"] = wrapActionResponse(object.DeleteProduct(&product))
c.ServeJSON()
}
// BuyProduct
// @Title BuyProduct
// @Tag Product API
// @Description buy product
// @Param id query string true "The id ( owner/name ) of the product"
// @Param providerName query string true "The name of the provider"
// @Success 200 {object} controllers.Response The Response object
// @router /buy-product [post]
func (c *ApiController) BuyProduct() {
id := c.Input().Get("id")
host := c.Ctx.Request.Host
providerName := c.Input().Get("providerName")
paymentEnv := c.Input().Get("paymentEnv")
customPriceStr := c.Input().Get("customPrice")
if customPriceStr == "" {
customPriceStr = "0"
}
customPrice, err := strconv.ParseFloat(customPriceStr, 64)
if err != nil {
c.ResponseError(err.Error())
return
}
// buy `pricingName/planName` for `paidUserName`
pricingName := c.Input().Get("pricingName")
planName := c.Input().Get("planName")
paidUserName := c.Input().Get("userName")
owner, _, err := util.GetOwnerAndNameFromIdWithError(id)
if err != nil {
c.ResponseError(err.Error())
return
}
var userId string
if paidUserName != "" {
userId = util.GetId(owner, paidUserName)
if userId != c.GetSessionUsername() && !c.IsAdmin() && userId != c.GetPaidUsername() {
c.ResponseError(c.T("general:Only admin user can specify user"))
return
}
c.SetSession("paidUsername", "")
} else {
userId = c.GetSessionUsername()
}
if userId == "" {
c.ResponseError(c.T("general:Please login first"))
return
}
user, err := object.GetUser(userId)
if err != nil {
c.ResponseError(err.Error())
return
}
if user == nil {
c.ResponseError(fmt.Sprintf(c.T("general:The user: %s doesn't exist"), userId))
return
}
payment, attachInfo, err := object.BuyProduct(id, user, providerName, pricingName, planName, host, paymentEnv, customPrice)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(payment, attachInfo)
}

View File

@@ -113,6 +113,27 @@ func (c *ApiController) GetTransaction() {
return
}
if transaction == nil {
c.ResponseOk(nil)
return
}
// Check if non-admin user is trying to access someone else's transaction
if !c.IsAdmin() {
user := c.GetSessionUsername()
_, userName, userErr := util.GetOwnerAndNameFromIdWithError(user)
if userErr != nil {
c.ResponseError(userErr.Error())
return
}
// Only allow users to view their own transactions
if transaction.User != userName {
c.ResponseError(c.T("auth:Unauthorized operation"))
return
}
}
c.ResponseOk(transaction)
}
@@ -143,6 +164,7 @@ func (c *ApiController) UpdateTransaction() {
// @Tag Transaction API
// @Description add transaction
// @Param body body object.Transaction true "The details of the transaction"
// @Param dryRun query string false "Dry run mode: set to 'true' or '1' to validate without committing"
// @Success 200 {object} controllers.Response The Response object
// @router /add-transaction [post]
func (c *ApiController) AddTransaction() {
@@ -153,8 +175,22 @@ func (c *ApiController) AddTransaction() {
return
}
c.Data["json"] = wrapActionResponse(object.AddTransaction(&transaction, c.GetAcceptLanguage()))
c.ServeJSON()
dryRunParam := c.Input().Get("dryRun")
dryRun := dryRunParam != ""
affected, transactionId, err := object.AddTransaction(&transaction, c.GetAcceptLanguage(), dryRun)
if err != nil {
c.ResponseError(err.Error())
return
}
if !affected {
c.Data["json"] = wrapActionResponse(false)
c.ServeJSON()
return
}
c.ResponseOk(transactionId)
}
// DeleteTransaction

View File

@@ -13,7 +13,6 @@
// limitations under the License.
//go:build !skipCi
// +build !skipCi
package deployment

15
go.mod
View File

@@ -4,6 +4,8 @@ go 1.23.0
require (
github.com/Masterminds/squirrel v1.5.3
github.com/NdoleStudio/lemonsqueezy-go v1.2.4
github.com/PaddleHQ/paddle-go-sdk v1.0.0
github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.4
github.com/alibabacloud-go/facebody-20191230/v5 v5.1.2
@@ -15,7 +17,7 @@ require (
github.com/beevik/etree v1.1.0
github.com/casbin/casbin/v2 v2.77.2
github.com/casdoor/go-sms-sender v0.25.0
github.com/casdoor/gomail/v2 v2.1.0
github.com/casdoor/gomail/v2 v2.2.0
github.com/casdoor/ldapserver v1.2.0
github.com/casdoor/notify v1.0.1
github.com/casdoor/oss v1.8.0
@@ -43,6 +45,7 @@ require (
github.com/markbates/goth v1.79.0
github.com/mitchellh/mapstructure v1.5.0
github.com/nyaruka/phonenumbers v1.2.2
github.com/polarsource/polar-go v0.12.0
github.com/pquerna/otp v1.4.0
github.com/prometheus/client_golang v1.11.1
github.com/prometheus/client_model v0.4.0
@@ -53,7 +56,7 @@ require (
github.com/sendgrid/sendgrid-go v3.14.0+incompatible
github.com/shirou/gopsutil v3.21.11+incompatible
github.com/siddontang/go-log v0.0.0-20190221022429-1e957dd83bed
github.com/stretchr/testify v1.10.0
github.com/stretchr/testify v1.11.1
github.com/stripe/stripe-go/v74 v74.29.0
github.com/tealeg/xlsx v1.0.5
github.com/thanhpk/randstr v1.0.4
@@ -125,6 +128,8 @@ require (
github.com/elazarl/go-bindata-assetfs v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
github.com/ggicci/httpin v0.19.0 // indirect
github.com/ggicci/owl v0.8.2 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.0 // indirect
github.com/go-lark/lark v1.9.0 // indirect
@@ -152,6 +157,7 @@ require (
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/gregdel/pushover v1.2.1 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
@@ -168,9 +174,9 @@ require (
github.com/line/line-bot-sdk-go v7.8.0+incompatible // indirect
github.com/markbates/going v1.0.0 // indirect
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-ieproxy v0.0.1 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mileusna/viber v1.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -197,6 +203,7 @@ require (
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/skeema/knownhosts v1.3.0 // indirect
github.com/slack-go/slack v0.12.3 // indirect
github.com/spyzhov/ajson v0.8.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/syndtr/goleveldb v1.0.0 // indirect
github.com/technoweenie/multipartstreamer v1.0.1 // indirect

36
go.sum
View File

@@ -78,7 +78,11 @@ github.com/Masterminds/squirrel v1.5.3/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA4
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/NdoleStudio/lemonsqueezy-go v1.2.4 h1:BhWlCUH+DIPfSn4g/V7f2nFkMCQuzno9DXKZ7YDrXXA=
github.com/NdoleStudio/lemonsqueezy-go v1.2.4/go.mod h1:2uZlWgn9sbNxOx3JQWLlPrDOC6NT/wmSTOgL3U/fMMw=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PaddleHQ/paddle-go-sdk v1.0.0 h1:+EXitsPFbRcc0CpQE/MIeudxiVOR8pFe/aOWTEUHDKU=
github.com/PaddleHQ/paddle-go-sdk v1.0.0/go.mod h1:kbBBzf0BHEj38QvhtoELqlGip3alKgA/I+vl7RQzB58=
github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk=
github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/RocketChat/Rocket.Chat.Go.SDK v0.0.0-20221121042443-a3fd332d56d9 h1:vuu1KBsr6l7XU3CHsWESP/4B1SNd+VZkrgeFZsUXrsY=
@@ -233,8 +237,8 @@ github.com/casdoor/go-reddit/v2 v2.1.0 h1:kIbfdJ7AA7H0uTQ8s0q4GGZqSS5V9wVE74RrXy
github.com/casdoor/go-reddit/v2 v2.1.0/go.mod h1:eagkvwlZ4Hcsuc/uQsLHYEulz5jN65SVSwV/AIE7zsc=
github.com/casdoor/go-sms-sender v0.25.0 h1:eF4cOCSbjVg7+0uLlJQnna/FQ0BWW+Fp/x4cXhzQu1Y=
github.com/casdoor/go-sms-sender v0.25.0/go.mod h1:bOm4H8/YfJmEHjBatEVQFOnAf0OOn1B0Wi5B7zDhws0=
github.com/casdoor/gomail/v2 v2.1.0 h1:ua97E3CARnF1Ik8ga/Drz9uGZfaElXJumFexiErWUxM=
github.com/casdoor/gomail/v2 v2.1.0/go.mod h1:GFzOD9RhY0nODiiPaQiOa6DfoKtmO9aTesu5qrp26OI=
github.com/casdoor/gomail/v2 v2.2.0 h1:gVMk43qvqq4XYkAJ+CDY5WWKF9yYRipuyXfp7P0HWIg=
github.com/casdoor/gomail/v2 v2.2.0/go.mod h1:GFzOD9RhY0nODiiPaQiOa6DfoKtmO9aTesu5qrp26OI=
github.com/casdoor/ldapserver v1.2.0 h1:HdSYe+ULU6z9K+2BqgTrJKQRR4//ERAXB64ttOun6Ow=
github.com/casdoor/ldapserver v1.2.0/go.mod h1:VwYU2vqQ2pA8sa00PRekH71R2XmgfzMKhmp1XrrDu2s=
github.com/casdoor/notify v1.0.1 h1:p0kzI7OBlvLbL7zWeKIu31LRcEAygNZGKr5gcFfSIoE=
@@ -350,6 +354,10 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/ggicci/httpin v0.19.0 h1:p0B3SWLVgg770VirYiHB14M5wdRx3zR8mCTzM/TkTQ8=
github.com/ggicci/httpin v0.19.0/go.mod h1:hzsQHcbqLabmGOycf7WNw6AAzcVbsMeoOp46bWAbIWc=
github.com/ggicci/owl v0.8.2 h1:og+lhqpzSMPDdEB+NJfzoAJARP7qCG3f8uUC3xvGukA=
github.com/ggicci/owl v0.8.2/go.mod h1:PHRD57u41vFN5UtFz2SF79yTVoM3HlWpjMiE+ZU2dj4=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/glendc/gopher-json v0.0.0-20170414221815-dc4743023d0c/go.mod h1:Gja1A+xZ9BoviGJNA2E9vFkPjjsl+CoJxSXiQM1UXtw=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
@@ -534,8 +542,9 @@ github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORR
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1 h1:LqbZZ9sNMWVjeXS4NN5oVvhMjDyLhmA1LG86oSo+IqY=
github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1/go.mod h1:YeAe0gNeiNT5hoiZRI4yiOky6jVdNvfO2N6Kav/HmxY=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
@@ -552,6 +561,7 @@ github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOj
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
github.com/hashicorp/go-hclog v0.16.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
@@ -597,8 +607,9 @@ github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHW
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/jmoiron/sqlx v1.3.3/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
@@ -617,6 +628,8 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
@@ -683,8 +696,9 @@ github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaO
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-ieproxy v0.0.1 h1:qiyop7gCflfhwCzGyeT0gro3sF9AIg9HU98JORTkqfI=
github.com/mattn/go-ieproxy v0.0.1/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
@@ -693,8 +707,9 @@ github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcME
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
@@ -788,6 +803,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/polarsource/polar-go v0.12.0 h1:um+6ftOPUMg2TQq9Kv/6fKGBOAl7dOc2YiDdx4Bb0y8=
github.com/polarsource/polar-go v0.12.0/go.mod h1:FB11Q4m2n3wIk6l/POOkz0MVOUx1o0Yt4Y97MnQfe0c=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
@@ -894,6 +911,8 @@ github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJ
github.com/sony/sonyflake v1.0.0 h1:MpU6Ro7tfXwgn2l5eluf9xQvQJDROTBImNCfRXn/YeM=
github.com/sony/sonyflake v1.0.0/go.mod h1:Jv3cfhf/UFtolOTTRd3q4Nl6ENqM+KfyZ5PseKfZGF4=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spyzhov/ajson v0.8.0 h1:sFXyMbi4Y/BKjrsfkUZHSjA2JM1184enheSjjoT/zCc=
github.com/spyzhov/ajson v0.8.0/go.mod h1:63V+CGM6f1Bu/p4nLIN8885ojBdt88TbLoSFzyqMuVA=
github.com/ssdb/gossdb v0.0.0-20180723034631-88f6b59b84ec/go.mod h1:QBvMkMya+gXctz3kmljlUCu/yB3GZ6oee+dUozsezQE=
github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
github.com/streadway/amqp v1.0.0/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
@@ -916,8 +935,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v74 v74.29.0 h1:ffJ+1Ta1Ccg7yDDz+SfjixX0KizEEJ/wNVRoFYkdwFY=
github.com/stripe/stripe-go/v74 v74.29.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
github.com/syndtr/goleveldb v0.0.0-20160425020131-cfa635847112/go.mod h1:Z4AUp2Km+PwemOoO/VB5AOx9XSsIItzFjoJlOSiYmn0=
@@ -1276,6 +1295,7 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -13,7 +13,6 @@
// limitations under the License.
//go:build !skipCi
// +build !skipCi
package i18n

View File

@@ -129,6 +129,8 @@ func GetIdProvider(idpInfo *ProviderInfo, redirectUrl string) (IdProvider, error
return NewWeb3OnboardIdProvider(), nil
case "Twitter":
return NewTwitterIdProvider(idpInfo.ClientId, idpInfo.ClientSecret, redirectUrl), nil
case "Telegram":
return NewTelegramIdProvider(idpInfo.ClientId, idpInfo.ClientSecret, redirectUrl), nil
default:
if isGothSupport(idpInfo.Type) {
return NewGothIdProvider(idpInfo.Type, idpInfo.ClientId, idpInfo.ClientSecret, idpInfo.ClientId2, idpInfo.ClientSecret2, redirectUrl, idpInfo.HostUrl)

169
idp/telegram.go Normal file
View File

@@ -0,0 +1,169 @@
// Copyright 2021 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 idp
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"golang.org/x/oauth2"
)
type TelegramIdProvider struct {
Client *http.Client
ClientId string
ClientSecret string
RedirectUrl string
}
func NewTelegramIdProvider(clientId string, clientSecret string, redirectUrl string) *TelegramIdProvider {
idp := &TelegramIdProvider{
ClientId: clientId,
ClientSecret: clientSecret,
RedirectUrl: redirectUrl,
}
return idp
}
func (idp *TelegramIdProvider) SetHttpClient(client *http.Client) {
idp.Client = client
}
// GetToken validates the Telegram auth data and returns a token
// Telegram uses a widget-based authentication, not standard OAuth2
// The "code" parameter contains the JSON-encoded auth data from Telegram
func (idp *TelegramIdProvider) GetToken(code string) (*oauth2.Token, error) {
// Decode the auth data from the code parameter
var authData map[string]interface{}
if err := json.Unmarshal([]byte(code), &authData); err != nil {
return nil, fmt.Errorf("failed to parse Telegram auth data: %v", err)
}
// Verify the data authenticity
if err := idp.verifyTelegramAuth(authData); err != nil {
return nil, fmt.Errorf("failed to verify Telegram auth data: %v", err)
}
// Create a token with the user ID as access token
userId, ok := authData["id"].(float64)
if !ok {
return nil, fmt.Errorf("invalid user id in auth data")
}
// Store the complete auth data in the token for later retrieval
authDataJson, err := json.Marshal(authData)
if err != nil {
return nil, fmt.Errorf("failed to marshal auth data: %v", err)
}
token := &oauth2.Token{
AccessToken: fmt.Sprintf("telegram_%d", int64(userId)),
TokenType: "Bearer",
}
// Store auth data in token extras to avoid additional API calls
token = token.WithExtra(map[string]interface{}{
"telegram_auth_data": string(authDataJson),
})
return token, nil
}
// verifyTelegramAuth verifies the authenticity of Telegram auth data
// According to Telegram docs: https://core.telegram.org/widgets/login#checking-authorization
func (idp *TelegramIdProvider) verifyTelegramAuth(authData map[string]interface{}) error {
// Extract hash from auth data
hash, ok := authData["hash"].(string)
if !ok {
return fmt.Errorf("hash not found in auth data")
}
// Prepare data check string
var dataCheckArr []string
for key, value := range authData {
if key == "hash" {
continue
}
dataCheckArr = append(dataCheckArr, fmt.Sprintf("%s=%v", key, value))
}
sort.Strings(dataCheckArr)
dataCheckString := strings.Join(dataCheckArr, "\n")
// Calculate secret key
secretKey := sha256.Sum256([]byte(idp.ClientSecret))
// Calculate hash
h := hmac.New(sha256.New, secretKey[:])
h.Write([]byte(dataCheckString))
calculatedHash := hex.EncodeToString(h.Sum(nil))
// Compare hashes
if calculatedHash != hash {
return fmt.Errorf("data verification failed")
}
return nil
}
func (idp *TelegramIdProvider) GetUserInfo(token *oauth2.Token) (*UserInfo, error) {
// Extract auth data from token
authDataStr, ok := token.Extra("telegram_auth_data").(string)
if !ok {
return nil, fmt.Errorf("telegram auth data not found in token")
}
// Parse the auth data
var authData map[string]interface{}
if err := json.Unmarshal([]byte(authDataStr), &authData); err != nil {
return nil, fmt.Errorf("failed to parse auth data: %v", err)
}
// Extract user information from auth data
userId, ok := authData["id"].(float64)
if !ok {
return nil, fmt.Errorf("invalid user id in auth data")
}
firstName, _ := authData["first_name"].(string)
lastName, _ := authData["last_name"].(string)
username, _ := authData["username"].(string)
photoUrl, _ := authData["photo_url"].(string)
// Build display name with fallback
displayName := strings.TrimSpace(firstName + " " + lastName)
if displayName == "" {
displayName = username
}
if displayName == "" {
displayName = strconv.FormatInt(int64(userId), 10)
}
userInfo := UserInfo{
Id: strconv.FormatInt(int64(userId), 10),
Username: username,
DisplayName: displayName,
AvatarUrl: photoUrl,
}
return &userInfo, nil
}

View File

@@ -872,7 +872,7 @@ func initDefinedTransaction(transaction *Transaction) {
}
}
transaction.CreatedTime = util.GetCurrentTime()
_, err = AddTransaction(transaction, "en")
_, _, err = AddTransaction(transaction, "en", false)
if err != nil {
panic(err)
}

View File

@@ -13,7 +13,6 @@
// limitations under the License.
//go:build !skipCi
// +build !skipCi
package object

View File

@@ -16,6 +16,8 @@ package object
import (
"context"
"encoding/json"
"fmt"
"github.com/casdoor/casdoor/notification"
"github.com/casdoor/notify"
@@ -40,3 +42,63 @@ func SendNotification(provider *Provider, content string) error {
err = client.Send(context.Background(), "", content)
return err
}
// SendSsoLogoutNotifications sends logout notifications to all notification providers
// configured in the user's signup application
func SendSsoLogoutNotifications(user *User) error {
if user == nil {
return nil
}
// If user's signup application is empty, don't send notifications
if user.SignupApplication == "" {
return nil
}
// Get the user's signup application
application, err := GetApplication(user.SignupApplication)
if err != nil {
return fmt.Errorf("failed to get signup application: %w", err)
}
if application == nil {
return fmt.Errorf("signup application not found: %s", user.SignupApplication)
}
// Prepare sanitized user data for notification
// Only include safe, non-sensitive fields
sanitizedData := map[string]interface{}{
"owner": user.Owner,
"name": user.Name,
"displayName": user.DisplayName,
"email": user.Email,
"phone": user.Phone,
"id": user.Id,
"event": "sso-logout",
}
userData, err := json.Marshal(sanitizedData)
if err != nil {
return fmt.Errorf("failed to marshal user data: %w", err)
}
content := string(userData)
// Send notifications to all notification providers in the signup application
for _, providerItem := range application.Providers {
if providerItem.Provider == nil {
continue
}
// Only send to notification providers
if providerItem.Provider.Category != "Notification" {
continue
}
// Send the notification using the provider from the providerItem
err = SendNotification(providerItem.Provider, content)
if err != nil {
return fmt.Errorf("failed to send SSO logout notification to provider %s/%s: %w", providerItem.Provider.Owner, providerItem.Provider.Name, err)
}
}
return nil
}

168
object/order.go Normal file
View File

@@ -0,0 +1,168 @@
// 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"
"github.com/xorm-io/core"
)
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"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
// Product Info
ProductName string `xorm:"varchar(100)" json:"productName"`
Products []string `xorm:"varchar(1000)" json:"products"` // Future support for multiple products per order. Using varchar(1000) for simple JSON array storage; can be refactored to separate table if needed
// Subscription Info (for subscription orders)
PricingName string `xorm:"varchar(100)" json:"pricingName"`
PlanName string `xorm:"varchar(100)" json:"planName"`
// User Info
User string `xorm:"varchar(100)" json:"user"`
// Payment Info
Payment string `xorm:"varchar(100)" json:"payment"`
Price float64 `json:"price"`
Currency string `xorm:"varchar(100)" json:"currency"`
// 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"`
}
func GetOrderCount(owner, field, value string) (int64, error) {
session := GetSession(owner, -1, -1, field, value, "", "")
return session.Count(&Order{Owner: owner})
}
func GetOrders(owner string) ([]*Order, error) {
orders := []*Order{}
err := ormer.Engine.Desc("created_time").Find(&orders, &Order{Owner: owner})
if err != nil {
return nil, err
}
return orders, nil
}
func GetUserOrders(owner, user string) ([]*Order, error) {
orders := []*Order{}
err := ormer.Engine.Desc("created_time").Find(&orders, &Order{Owner: owner, User: user})
if err != nil {
return nil, err
}
return orders, nil
}
func GetPaginationOrders(owner string, offset, limit int, field, value, sortField, sortOrder string) ([]*Order, error) {
orders := []*Order{}
session := GetSession(owner, offset, limit, field, value, sortField, sortOrder)
err := session.Find(&orders, &Order{Owner: owner})
if err != nil {
return nil, err
}
return orders, nil
}
func getOrder(owner string, name string) (*Order, error) {
if owner == "" || name == "" {
return nil, nil
}
order := Order{Owner: owner, Name: name}
existed, err := ormer.Engine.Get(&order)
if err != nil {
return nil, err
}
if existed {
return &order, nil
} else {
return nil, nil
}
}
func GetOrder(id string) (*Order, error) {
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
if err != nil {
return nil, err
}
return getOrder(owner, name)
}
func UpdateOrder(id string, order *Order) (bool, error) {
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
if err != nil {
return false, err
}
if o, err := getOrder(owner, name); err != nil {
return false, err
} else if o == nil {
return false, nil
}
affected, err := ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(order)
if err != nil {
return false, err
}
return affected != 0, nil
}
func AddOrder(order *Order) (bool, error) {
affected, err := ormer.Engine.Insert(order)
if err != nil {
return false, err
}
return affected != 0, nil
}
func DeleteOrder(order *Order) (bool, error) {
affected, err := ormer.Engine.ID(core.PK{order.Owner, order.Name}).Delete(&Order{})
if err != nil {
return false, err
}
return affected != 0, nil
}
func (order *Order) GetId() string {
return fmt.Sprintf("%s/%s", order.Owner, order.Name)
}
func GetOrderByPayment(owner string, paymentName string) (*Order, error) {
order := &Order{}
existed, err := ormer.Engine.Where("owner = ? AND payment = ?", owner, paymentName).Get(order)
if err != nil {
return nil, err
}
if existed {
return order, nil
}
return nil, nil
}

314
object/order_pay.go Normal file
View File

@@ -0,0 +1,314 @@
// 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/idp"
"github.com/casdoor/casdoor/pp"
"github.com/casdoor/casdoor/util"
)
func PlaceOrder(productId string, user *User, pricingName string, planName string, customPrice float64) (*Order, error) {
product, err := GetProduct(productId)
if err != nil {
return nil, err
}
if product == nil {
return nil, fmt.Errorf("the product: %s does not exist", productId)
}
userBalanceCurrency := user.BalanceCurrency
if userBalanceCurrency == "" {
org, err := getOrganization("admin", user.Owner)
if err == nil && org != nil && org.BalanceCurrency != "" {
userBalanceCurrency = org.BalanceCurrency
} else {
userBalanceCurrency = "USD"
}
}
productCurrency := product.Currency
if productCurrency == "" {
productCurrency = "USD"
}
var productPrice float64
if product.IsRecharge {
if customPrice <= 0 {
return nil, fmt.Errorf("the custom price should be greater than zero")
}
productPrice = customPrice
} else {
productPrice = product.Price
}
price := ConvertCurrency(productPrice, productCurrency, userBalanceCurrency)
orderName := fmt.Sprintf("order_%v", util.GenerateTimeId())
order := &Order{
Owner: product.Owner,
Name: orderName,
CreatedTime: util.GetCurrentTime(),
DisplayName: fmt.Sprintf("Order for %s", product.DisplayName),
ProductName: product.Name,
Products: []string{product.Name},
PricingName: pricingName,
PlanName: planName,
User: user.Name,
Payment: "", // Payment will be set when user pays
Price: price,
Currency: userBalanceCurrency,
State: "Created",
Message: "",
StartTime: util.GetCurrentTime(),
EndTime: "",
}
affected, err := AddOrder(order)
if err != nil {
return nil, err
}
if !affected {
return nil, fmt.Errorf("failed to add order: %s", util.StructToJson(order))
}
return order, nil
}
func PayOrder(providerName, host, paymentEnv string, order *Order) (payment *Payment, attachInfo map[string]interface{}, err error) {
if order.State != "Created" {
return nil, nil, fmt.Errorf("cannot pay for order: %s, current state is %s", order.GetId(), order.State)
}
productId := util.GetId(order.Owner, order.ProductName)
product, err := GetProduct(productId)
if err != nil {
return nil, nil, err
}
if product == nil {
return nil, nil, fmt.Errorf("the product: %s does not exist", productId)
}
user, err := GetUser(util.GetId(order.Owner, order.User))
if err != nil {
return nil, nil, err
}
if user == nil {
return nil, nil, fmt.Errorf("the user: %s does not exist", order.User)
}
provider, err := product.getProvider(providerName)
if err != nil {
return nil, nil, err
}
pProvider, err := GetPaymentProvider(provider)
if err != nil {
return nil, nil, err
}
owner := product.Owner
payerName := fmt.Sprintf("%s | %s", user.Name, user.DisplayName)
paymentName := fmt.Sprintf("payment_%v", util.GenerateTimeId())
originFrontend, originBackend := getOriginFromHost(host)
returnUrl := fmt.Sprintf("%s/payments/%s/%s/result", originFrontend, owner, paymentName)
notifyUrl := fmt.Sprintf("%s/api/notify-payment/%s/%s", originBackend, owner, paymentName)
// Create a subscription when pricing and plan are provided
// This allows both free users and paid users to subscribe to plans
if order.PricingName != "" && order.PlanName != "" {
plan, err := GetPlan(util.GetId(owner, order.PlanName))
if err != nil {
return nil, nil, err
}
if plan == nil {
return nil, nil, fmt.Errorf("the plan: %s does not exist", order.PlanName)
}
sub, err := NewSubscription(owner, user.Name, plan.Name, paymentName, plan.Period)
if err != nil {
return nil, nil, err
}
affected, err := AddSubscription(sub)
if err != nil {
return nil, nil, err
}
if !affected {
return nil, nil, fmt.Errorf("failed to add subscription: %s", sub.Name)
}
returnUrl = fmt.Sprintf("%s/buy-plan/%s/%s/result?subscription=%s", originFrontend, owner, order.PricingName, sub.Name)
}
if product.SuccessUrl != "" {
returnUrl = fmt.Sprintf("%s?transactionOwner=%s&transactionName=%s", product.SuccessUrl, owner, paymentName)
}
payReq := &pp.PayReq{
ProviderName: providerName,
ProductName: product.Name,
PayerName: payerName,
PayerId: user.Id,
PayerEmail: user.Email,
PaymentName: paymentName,
ProductDisplayName: product.DisplayName,
ProductDescription: product.Description,
ProductImage: product.Image,
Price: order.Price,
Currency: order.Currency,
ReturnUrl: returnUrl,
NotifyUrl: notifyUrl,
PaymentEnv: paymentEnv,
}
if provider.Type == "WeChat Pay" {
payReq.PayerId, err = getUserExtraProperty(user, "WeChat", idp.BuildWechatOpenIdKey(provider.ClientId2))
if err != nil {
return nil, nil, err
}
} else if provider.Type == "Balance" {
payReq.PayerId = user.GetId()
}
payResp, err := pProvider.Pay(payReq)
if err != nil {
return nil, nil, err
}
payment = &Payment{
Owner: product.Owner,
Name: paymentName,
CreatedTime: util.GetCurrentTime(),
DisplayName: paymentName,
Provider: provider.Name,
Type: provider.Type,
ProductName: product.Name,
ProductDisplayName: product.DisplayName,
Detail: product.Detail,
Tag: product.Tag,
Currency: order.Currency,
Price: order.Price,
ReturnUrl: product.ReturnUrl,
IsRecharge: product.IsRecharge,
User: user.Name,
PayUrl: payResp.PayUrl,
SuccessUrl: returnUrl,
State: pp.PaymentStateCreated,
OutOrderId: payResp.OrderId,
}
transaction := &Transaction{
Owner: payment.Owner,
Name: payment.Name,
CreatedTime: util.GetCurrentTime(),
DisplayName: payment.DisplayName,
Application: owner,
Domain: "",
Amount: payment.Price,
Currency: order.Currency,
Payment: payment.Name,
State: pp.PaymentStateCreated,
}
if product.IsRecharge {
transaction.Category = "Recharge"
transaction.Type = ""
transaction.Subtype = ""
transaction.Provider = ""
transaction.Tag = "User"
transaction.User = payment.User
transaction.State = pp.PaymentStatePaid
} else {
transaction.Category = ""
transaction.Type = provider.Category
transaction.Subtype = provider.Type
transaction.Provider = provider.Name
transaction.Tag = product.Tag
transaction.User = payment.User
}
if provider.Type == "Dummy" {
payment.State = pp.PaymentStatePaid
currency := payment.Currency
if currency == "" {
currency = "USD"
}
err = UpdateUserBalance(user.Owner, user.Name, payment.Price, currency, "en")
if err != nil {
return nil, nil, err
}
} else if provider.Type == "Balance" {
convertedPrice := ConvertCurrency(order.Price, order.Currency, user.BalanceCurrency)
if convertedPrice > user.Balance {
return nil, nil, fmt.Errorf("insufficient user balance")
}
transaction.Amount = -transaction.Amount
err = UpdateUserBalance(user.Owner, user.Name, -convertedPrice, user.BalanceCurrency, "en")
if err != nil {
return nil, nil, err
}
payment.State = pp.PaymentStatePaid
transaction.State = pp.PaymentStatePaid
}
affected, err := AddPayment(payment)
if err != nil {
return nil, nil, err
}
if !affected {
return nil, nil, fmt.Errorf("failed to add payment: %s", util.StructToJson(payment))
}
if product.IsRecharge || provider.Type == "Balance" {
affected, _, err = AddTransaction(transaction, "en", false)
if err != nil {
return nil, nil, err
}
if !affected {
return nil, nil, fmt.Errorf("failed to add transaction: %s", util.StructToJson(transaction))
}
}
order.Payment = payment.Name
if provider.Type == "Dummy" || provider.Type == "Balance" {
order.State = "Paid"
order.Message = "Payment successful"
order.EndTime = util.GetCurrentTime()
}
_, err = UpdateOrder(order.GetId(), order)
if err != nil {
return nil, nil, err
}
return payment, payResp.AttachInfo, nil
}
func CancelOrder(order *Order) (bool, error) {
if order.State != "Created" {
return false, fmt.Errorf("cannot cancel order in state: %s", order.State)
}
order.State = "Canceled"
order.Message = "Canceled by user"
order.EndTime = util.GetCurrentTime()
return UpdateOrder(order.GetId(), order)
}

View File

@@ -83,14 +83,17 @@ type Organization struct {
DisableSignin bool `json:"disableSignin"`
IpRestriction string `json:"ipRestriction"`
NavItems []string `xorm:"mediumtext" json:"navItems"`
UserNavItems []string `xorm:"mediumtext" json:"userNavItems"`
WidgetItems []string `xorm:"mediumtext" json:"widgetItems"`
MfaItems []*MfaItem `xorm:"varchar(300)" json:"mfaItems"`
MfaRememberInHours int `json:"mfaRememberInHours"`
AccountItems []*AccountItem `xorm:"mediumtext" json:"accountItems"`
OrgBalance float64 `json:"orgBalance"`
UserBalance float64 `json:"userBalance"`
OrgBalance float64 `json:"orgBalance"`
UserBalance float64 `json:"userBalance"`
BalanceCredit float64 `json:"balanceCredit"`
BalanceCurrency string `xorm:"varchar(100)" json:"balanceCurrency"`
}
func GetOrganizationCount(owner, name, field, value string) (int64, error) {
@@ -237,6 +240,7 @@ func UpdateOrganization(id string, organization *Organization, isGlobalAdmin boo
if !isGlobalAdmin {
organization.NavItems = org.NavItems
organization.UserNavItems = org.UserNavItems
organization.WidgetItems = org.WidgetItems
}
@@ -586,7 +590,7 @@ func (org *Organization) GetInitScore() (int, error) {
}
}
func UpdateOrganizationBalance(owner string, name string, balance float64, isOrgBalance bool, lang string) error {
func UpdateOrganizationBalance(owner string, name string, balance float64, currency string, isOrgBalance bool, lang string) error {
organization, err := getOrganization(owner, name)
if err != nil {
return err
@@ -595,12 +599,27 @@ func UpdateOrganizationBalance(owner string, name string, balance float64, isOrg
return fmt.Errorf(i18n.Translate(lang, "auth:the organization: %s is not found"), fmt.Sprintf("%s/%s", owner, name))
}
// Convert the balance amount from transaction currency to organization's balance currency
balanceCurrency := organization.BalanceCurrency
if balanceCurrency == "" {
balanceCurrency = "USD"
}
convertedBalance := ConvertCurrency(balance, currency, balanceCurrency)
var columns []string
var newBalance float64
if isOrgBalance {
organization.OrgBalance += balance
newBalance = AddPrices(organization.OrgBalance, convertedBalance)
// Check organization balance credit limit
if newBalance < organization.BalanceCredit {
return fmt.Errorf(i18n.Translate(lang, "general:Insufficient balance: new organization balance %v would be below credit limit %v"), newBalance, organization.BalanceCredit)
}
organization.OrgBalance = newBalance
columns = []string{"org_balance"}
} else {
organization.UserBalance += balance
// User balance is just a sum of all users' balances, no credit limit check here
// Individual user credit limits are checked in UpdateUserBalance
organization.UserBalance = AddPrices(organization.UserBalance, convertedBalance)
columns = []string{"user_balance"}
}

View File

@@ -384,6 +384,11 @@ func (a *Ormer) createTable() {
panic(err)
}
err = a.Engine.Sync2(new(Order))
if err != nil {
panic(err)
}
err = a.Engine.Sync2(new(Plan))
if err != nil {
panic(err)

View File

@@ -207,7 +207,11 @@ func notifyPayment(body []byte, owner string, paymentName string) (*Payment, *pp
}
if payment.IsRecharge {
err = UpdateUserBalance(payment.Owner, payment.User, payment.Price, "en")
currency := payment.Currency
if currency == "" {
currency = "USD"
}
err = UpdateUserBalance(payment.Owner, payment.User, payment.Price, currency, "en")
return payment, notifyResult, err
}
@@ -241,6 +245,32 @@ func NotifyPayment(body []byte, owner string, paymentName string) (*Payment, err
return nil, err
}
}
// Update order state based on payment status
order, err := GetOrderByPayment(owner, paymentName)
if err != nil {
return nil, err
}
if order != nil {
if payment.State == pp.PaymentStatePaid {
order.State = "Paid"
order.Message = "Payment successful"
order.EndTime = util.GetCurrentTime()
} else if payment.State == pp.PaymentStateError {
order.State = "PaymentFailed"
order.Message = payment.Message
} else if payment.State == pp.PaymentStateCanceled {
order.State = "Canceled"
order.Message = "Payment was cancelled"
} else if payment.State == pp.PaymentStateTimeout {
order.State = "Timeout"
order.Message = "Payment timed out"
}
_, err = UpdateOrder(order.GetId(), order)
if err != nil {
return nil, err
}
}
}
return payment, nil

View File

@@ -43,7 +43,6 @@ func UploadPermissions(owner string, path string) (bool, error) {
newPermissions := []*Permission{}
for index, line := range table {
line := line
if index == 0 || parseLineItem(&line, 0) == "" {
continue
}

View File

@@ -17,10 +17,6 @@ package object
import (
"fmt"
"github.com/casdoor/casdoor/idp"
"github.com/casdoor/casdoor/pp"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
)
@@ -168,190 +164,6 @@ func (product *Product) getProvider(providerName string) (*Provider, error) {
return provider, nil
}
func BuyProduct(id string, user *User, providerName, pricingName, planName, host, paymentEnv string, customPrice float64) (payment *Payment, attachInfo map[string]interface{}, err error) {
product, err := GetProduct(id)
if err != nil {
return nil, nil, err
}
if product == nil {
return nil, nil, fmt.Errorf("the product: %s does not exist", id)
}
if product.IsRecharge {
if customPrice <= 0 {
return nil, nil, fmt.Errorf("the custom price should bigger than zero")
} else {
product.Price = customPrice
}
}
provider, err := product.getProvider(providerName)
if err != nil {
return nil, nil, err
}
pProvider, err := GetPaymentProvider(provider)
if err != nil {
return nil, nil, err
}
owner := product.Owner
payerName := fmt.Sprintf("%s | %s", user.Name, user.DisplayName)
paymentName := fmt.Sprintf("payment_%v", util.GenerateTimeId())
originFrontend, originBackend := getOriginFromHost(host)
returnUrl := fmt.Sprintf("%s/payments/%s/%s/result", originFrontend, owner, paymentName)
notifyUrl := fmt.Sprintf("%s/api/notify-payment/%s/%s", originBackend, owner, paymentName)
// Create a subscription when pricing and plan are provided
// This allows both free users and paid users to subscribe to plans
if pricingName != "" && planName != "" {
plan, err := GetPlan(util.GetId(owner, planName))
if err != nil {
return nil, nil, err
}
if plan == nil {
return nil, nil, fmt.Errorf("the plan: %s does not exist", planName)
}
sub, err := NewSubscription(owner, user.Name, plan.Name, paymentName, plan.Period)
if err != nil {
return nil, nil, err
}
_, err = AddSubscription(sub)
if err != nil {
return nil, nil, err
}
returnUrl = fmt.Sprintf("%s/buy-plan/%s/%s/result?subscription=%s", originFrontend, owner, pricingName, sub.Name)
}
if product.SuccessUrl != "" {
returnUrl = fmt.Sprintf("%s?transactionOwner=%s&transactionName=%s", product.SuccessUrl, owner, paymentName)
}
// Create an order
payReq := &pp.PayReq{
ProviderName: providerName,
ProductName: product.Name,
PayerName: payerName,
PayerId: user.Id,
PayerEmail: user.Email,
PaymentName: paymentName,
ProductDisplayName: product.DisplayName,
ProductDescription: product.Description,
ProductImage: product.Image,
Price: product.Price,
Currency: product.Currency,
ReturnUrl: returnUrl,
NotifyUrl: notifyUrl,
PaymentEnv: paymentEnv,
}
// custom process for WeChat & WeChat Pay
if provider.Type == "WeChat Pay" {
payReq.PayerId, err = getUserExtraProperty(user, "WeChat", idp.BuildWechatOpenIdKey(provider.ClientId2))
if err != nil {
return nil, nil, err
}
} else if provider.Type == "Balance" {
payReq.PayerId = user.GetId()
}
payResp, err := pProvider.Pay(payReq)
if err != nil {
return nil, nil, err
}
// Create a Payment linked with Product and Order
payment = &Payment{
Owner: product.Owner,
Name: paymentName,
CreatedTime: util.GetCurrentTime(),
DisplayName: paymentName,
Provider: provider.Name,
Type: provider.Type,
ProductName: product.Name,
ProductDisplayName: product.DisplayName,
Detail: product.Detail,
Tag: product.Tag,
Currency: product.Currency,
Price: product.Price,
ReturnUrl: product.ReturnUrl,
IsRecharge: product.IsRecharge,
User: user.Name,
PayUrl: payResp.PayUrl,
SuccessUrl: returnUrl,
State: pp.PaymentStateCreated,
OutOrderId: payResp.OrderId,
}
transaction := &Transaction{
Owner: payment.Owner,
Name: payment.Name,
DisplayName: payment.DisplayName,
Provider: provider.Name,
Category: provider.Category,
Type: provider.Type,
ProductName: product.Name,
ProductDisplayName: product.DisplayName,
Detail: product.Detail,
Tag: product.Tag,
Currency: product.Currency,
Amount: payment.Price,
ReturnUrl: payment.ReturnUrl,
User: payment.User,
Application: owner,
Payment: payment.GetId(),
State: pp.PaymentStateCreated,
}
if provider.Type == "Dummy" {
payment.State = pp.PaymentStatePaid
err = UpdateUserBalance(user.Owner, user.Name, payment.Price, "en")
if err != nil {
return nil, nil, err
}
} else if provider.Type == "Balance" {
if product.Price > user.Balance {
return nil, nil, fmt.Errorf("insufficient user balance")
}
transaction.Amount = -transaction.Amount
err = UpdateUserBalance(user.Owner, user.Name, -product.Price, "en")
if err != nil {
return nil, nil, err
}
payment.State = pp.PaymentStatePaid
transaction.State = pp.PaymentStatePaid
}
affected, err := AddPayment(payment)
if err != nil {
return nil, nil, err
}
if !affected {
return nil, nil, fmt.Errorf("failed to add payment: %s", util.StructToJson(payment))
}
if product.IsRecharge || provider.Type == "Balance" {
affected, err = AddTransaction(transaction, "en")
if err != nil {
return nil, nil, err
}
if !affected {
return nil, nil, fmt.Errorf("failed to add transaction: %s", util.StructToJson(payment))
}
}
return payment, payResp.AttachInfo, nil
}
func ExtendProductWithProviders(product *Product) error {
if product == nil {
return nil

View File

@@ -13,7 +13,6 @@
// limitations under the License.
//go:build !skipCi
// +build !skipCi
package object

View File

@@ -345,6 +345,30 @@ func GetPaymentProvider(p *Provider) (pp.PaymentProvider, error) {
return nil, err
}
return pp, nil
} else if typ == "Polar" {
pp, err := pp.NewPolarPaymentProvider(p.ClientSecret)
if err != nil {
return nil, err
}
return pp, nil
} else if typ == "Paddle" {
pp, err := pp.NewPaddlePaymentProvider(p.ClientSecret)
if err != nil {
return nil, err
}
return pp, nil
} else if typ == "FastSpring" {
pp, err := pp.NewFastSpringPaymentProvider(p.ClientId, p.ClientSecret, p.Host)
if err != nil {
return nil, err
}
return pp, nil
} else if typ == "Lemon Squeezy" {
pp, err := pp.NewLemonSqueezyPaymentProvider(p.ClientId, p.ClientSecret)
if err != nil {
return nil, err
}
return pp, nil
} else {
return nil, fmt.Errorf("the payment provider type: %s is not supported", p.Type)
}

View File

@@ -43,7 +43,6 @@ func UploadRoles(owner string, path string) (bool, error) {
newRoles := []*Role{}
for index, line := range table {
line := line
if index == 0 || parseLineItem(&line, 0) == "" {
continue
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/casdoor/casdoor/i18n"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
"golang.org/x/crypto/ssh"
)
type TableColumn struct {
@@ -61,7 +62,8 @@ type Syncer struct {
IsReadOnly bool `json:"isReadOnly"`
IsEnabled bool `json:"isEnabled"`
Ormer *Ormer `xorm:"-" json:"-"`
Ormer *Ormer `xorm:"-" json:"-"`
SshClient *ssh.Client `xorm:"-" json:"-"`
}
func GetSyncerCount(owner, organization, field, value string) (int64, error) {
@@ -171,6 +173,9 @@ func UpdateSyncer(id string, syncer *Syncer, isGlobalAdmin bool, lang string) (b
return false, fmt.Errorf(i18n.Translate(lang, "auth:Unauthorized operation"))
}
// Close old syncer connections before updating
_ = s.Close()
session := ormer.Engine.ID(core.PK{owner, name}).AllCols()
if syncer.Password == "***" {
syncer.Password = s.Password
@@ -311,26 +316,30 @@ func TestSyncer(syncer Syncer) error {
syncer.Password = oldSyncer.Password
}
// For WeCom syncer, test by getting access token
if syncer.Type == "WeCom" {
_, err := syncer.getWecomAccessToken()
return err
}
// For Azure AD syncer, test by getting access token
if syncer.Type == "Azure AD" {
_, err := syncer.getAzureAdAccessToken()
return err
}
err = syncer.initAdapter()
if err != nil {
return err
}
err = syncer.Ormer.Engine.Ping()
if err != nil {
return err
}
return nil
provider := GetSyncerProvider(&syncer)
return provider.TestConnection()
}
func (syncer *Syncer) Close() error {
var err error
if syncer.Ormer != nil {
if syncer.Ormer.Engine != nil {
err = syncer.Ormer.Engine.Close()
syncer.Ormer.Engine = nil
}
if syncer.Ormer.Db != nil {
if dbErr := syncer.Ormer.Db.Close(); dbErr != nil && err == nil {
err = dbErr
}
syncer.Ormer.Db = nil
}
syncer.Ormer = nil
}
if syncer.SshClient != nil {
if sshErr := syncer.SshClient.Close(); sshErr != nil && err == nil {
err = sshErr
}
syncer.SshClient = nil
}
return err
}

View File

@@ -0,0 +1,319 @@
// 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 (
"crypto/tls"
"fmt"
"strings"
"github.com/casdoor/casdoor/util"
goldap "github.com/go-ldap/ldap/v3"
)
// ActiveDirectorySyncerProvider implements SyncerProvider for Active Directory LDAP-based syncers
type ActiveDirectorySyncerProvider struct {
Syncer *Syncer
}
// InitAdapter initializes the Active Directory syncer (no database adapter needed)
func (p *ActiveDirectorySyncerProvider) InitAdapter() error {
// Active Directory syncer doesn't need database adapter
return nil
}
// GetOriginalUsers retrieves all users from Active Directory via LDAP
func (p *ActiveDirectorySyncerProvider) GetOriginalUsers() ([]*OriginalUser, error) {
return p.getActiveDirectoryUsers()
}
// AddUser adds a new user to Active Directory (not supported for read-only LDAP)
func (p *ActiveDirectorySyncerProvider) AddUser(user *OriginalUser) (bool, error) {
// Active Directory syncer is typically read-only
return false, fmt.Errorf("adding users to Active Directory is not supported")
}
// UpdateUser updates an existing user in Active Directory (not supported for read-only LDAP)
func (p *ActiveDirectorySyncerProvider) UpdateUser(user *OriginalUser) (bool, error) {
// Active Directory syncer is typically read-only
return false, fmt.Errorf("updating users in Active Directory is not supported")
}
// TestConnection tests the Active Directory LDAP connection
func (p *ActiveDirectorySyncerProvider) TestConnection() error {
conn, err := p.getLdapConn()
if err != nil {
return err
}
defer conn.Close()
return nil
}
// Close closes any open connections (no-op for Active Directory LDAP-based syncer)
func (p *ActiveDirectorySyncerProvider) Close() error {
// Active Directory syncer doesn't maintain persistent connections
// LDAP connections are opened and closed per operation
return nil
}
// getLdapConn establishes an LDAP connection to Active Directory
func (p *ActiveDirectorySyncerProvider) getLdapConn() (*goldap.Conn, error) {
// syncer.Host should be the AD server hostname/IP
// syncer.Port should be the LDAP port (usually 389 or 636 for LDAPS)
// syncer.User should be the bind DN or username
// syncer.Password should be the bind password
host := p.Syncer.Host
if host == "" {
return nil, fmt.Errorf("host is required for Active Directory syncer")
}
port := p.Syncer.Port
if port == 0 {
port = 389 // Default LDAP port
}
user := p.Syncer.User
if user == "" {
return nil, fmt.Errorf("user (bind DN) is required for Active Directory syncer")
}
password := p.Syncer.Password
if password == "" {
return nil, fmt.Errorf("password is required for Active Directory syncer")
}
var conn *goldap.Conn
var err error
// Check if SSL is enabled (port 636 typically indicates LDAPS)
if port == 636 {
tlsConfig := &tls.Config{
InsecureSkipVerify: true, // TODO: Make this configurable
}
conn, err = goldap.DialTLS("tcp", fmt.Sprintf("%s:%d", host, port), tlsConfig)
} else {
conn, err = goldap.Dial("tcp", fmt.Sprintf("%s:%d", host, port))
}
if err != nil {
return nil, fmt.Errorf("failed to connect to Active Directory: %w", err)
}
// Bind with the provided credentials
err = conn.Bind(user, password)
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to bind to Active Directory: %w", err)
}
return conn, nil
}
// getActiveDirectoryUsers retrieves all users from Active Directory
func (p *ActiveDirectorySyncerProvider) getActiveDirectoryUsers() ([]*OriginalUser, error) {
conn, err := p.getLdapConn()
if err != nil {
return nil, err
}
defer conn.Close()
// Use the Database field to store the base DN for searching
baseDN := p.Syncer.Database
if baseDN == "" {
return nil, fmt.Errorf("database field (base DN) is required for Active Directory syncer")
}
// Search filter for user objects in Active Directory
// Filter for users: objectClass=user, objectCategory=person, and not disabled accounts
searchFilter := "(&(objectClass=user)(objectCategory=person))"
// Attributes to retrieve from Active Directory
attributes := []string{
"sAMAccountName", // Username
"userPrincipalName", // UPN (email-like format)
"displayName", // Display name
"givenName", // First name
"sn", // Last name (surname)
"mail", // Email address
"telephoneNumber", // Phone number
"mobile", // Mobile phone
"title", // Job title
"department", // Department
"company", // Company
"streetAddress", // Street address
"l", // City/Locality
"st", // State/Province
"postalCode", // Postal code
"co", // Country
"objectGUID", // Unique identifier
"whenCreated", // Creation time
"userAccountControl", // Account status
}
searchRequest := goldap.NewSearchRequest(
baseDN,
goldap.ScopeWholeSubtree,
goldap.NeverDerefAliases,
0, // No size limit
0, // No time limit
false, // Types only = false
searchFilter,
attributes,
nil,
)
sr, err := conn.Search(searchRequest)
if err != nil {
return nil, fmt.Errorf("failed to search Active Directory: %w", err)
}
originalUsers := []*OriginalUser{}
for _, entry := range sr.Entries {
originalUser := p.adEntryToOriginalUser(entry)
originalUsers = append(originalUsers, originalUser)
}
return originalUsers, nil
}
// adEntryToOriginalUser converts an Active Directory LDAP entry to Casdoor OriginalUser
func (p *ActiveDirectorySyncerProvider) adEntryToOriginalUser(entry *goldap.Entry) *OriginalUser {
user := &OriginalUser{
Address: []string{},
Properties: map[string]string{},
Groups: []string{},
}
// Get basic attributes
sAMAccountName := entry.GetAttributeValue("sAMAccountName")
userPrincipalName := entry.GetAttributeValue("userPrincipalName")
displayName := entry.GetAttributeValue("displayName")
givenName := entry.GetAttributeValue("givenName")
sn := entry.GetAttributeValue("sn")
mail := entry.GetAttributeValue("mail")
telephoneNumber := entry.GetAttributeValue("telephoneNumber")
mobile := entry.GetAttributeValue("mobile")
title := entry.GetAttributeValue("title")
department := entry.GetAttributeValue("department")
company := entry.GetAttributeValue("company")
streetAddress := entry.GetAttributeValue("streetAddress")
city := entry.GetAttributeValue("l")
state := entry.GetAttributeValue("st")
postalCode := entry.GetAttributeValue("postalCode")
country := entry.GetAttributeValue("co")
objectGUID := entry.GetAttributeValue("objectGUID")
whenCreated := entry.GetAttributeValue("whenCreated")
userAccountControlStr := entry.GetAttributeValue("userAccountControl")
// Set user fields
// Use sAMAccountName as the primary username
user.Name = sAMAccountName
// Use objectGUID as the unique ID if available, otherwise use sAMAccountName
if objectGUID != "" {
user.Id = objectGUID
} else {
user.Id = sAMAccountName
}
user.DisplayName = displayName
user.FirstName = givenName
user.LastName = sn
// If display name is empty, construct from first and last name
if user.DisplayName == "" && (user.FirstName != "" || user.LastName != "") {
user.DisplayName = strings.TrimSpace(fmt.Sprintf("%s %s", user.FirstName, user.LastName))
}
// Set email - prefer mail attribute, fallback to userPrincipalName
if mail != "" {
user.Email = mail
} else if userPrincipalName != "" {
user.Email = userPrincipalName
}
// Set phone - prefer mobile, fallback to telephoneNumber
if mobile != "" {
user.Phone = mobile
} else if telephoneNumber != "" {
user.Phone = telephoneNumber
}
user.Title = title
// Set affiliation/department
if department != "" {
user.Affiliation = department
}
// Construct location from city, state, country
locationParts := []string{}
if city != "" {
locationParts = append(locationParts, city)
}
if state != "" {
locationParts = append(locationParts, state)
}
if country != "" {
locationParts = append(locationParts, country)
}
if len(locationParts) > 0 {
user.Location = strings.Join(locationParts, ", ")
}
// Construct address
if streetAddress != "" {
addressParts := []string{streetAddress}
if city != "" {
addressParts = append(addressParts, city)
}
if state != "" {
addressParts = append(addressParts, state)
}
if postalCode != "" {
addressParts = append(addressParts, postalCode)
}
if country != "" {
addressParts = append(addressParts, country)
}
user.Address = []string{strings.Join(addressParts, ", ")}
}
// Store additional properties
if company != "" {
user.Properties["company"] = company
}
if userPrincipalName != "" {
user.Properties["userPrincipalName"] = userPrincipalName
}
// Set creation time
if whenCreated != "" {
user.CreatedTime = whenCreated
} else {
user.CreatedTime = util.GetCurrentTime()
}
// Parse userAccountControl to determine if account is disabled
// Bit 2 (value 2) indicates the account is disabled
if userAccountControlStr != "" {
userAccountControl := util.ParseInt(userAccountControlStr)
// Check if bit 2 is set (account disabled)
user.IsForbidden = (userAccountControl & 0x02) != 0
}
return user
}

View File

@@ -26,6 +26,46 @@ import (
"github.com/casdoor/casdoor/util"
)
// AzureAdSyncerProvider implements SyncerProvider for Azure AD API-based syncers
type AzureAdSyncerProvider struct {
Syncer *Syncer
}
// InitAdapter initializes the Azure AD syncer (no database adapter needed)
func (p *AzureAdSyncerProvider) InitAdapter() error {
// Azure AD syncer doesn't need database adapter
return nil
}
// GetOriginalUsers retrieves all users from Azure AD API
func (p *AzureAdSyncerProvider) GetOriginalUsers() ([]*OriginalUser, error) {
return p.getAzureAdOriginalUsers()
}
// AddUser adds a new user to Azure AD (not supported for read-only API)
func (p *AzureAdSyncerProvider) AddUser(user *OriginalUser) (bool, error) {
// Azure AD syncer is typically read-only
return false, fmt.Errorf("adding users to Azure AD is not supported")
}
// UpdateUser updates an existing user in Azure AD (not supported for read-only API)
func (p *AzureAdSyncerProvider) UpdateUser(user *OriginalUser) (bool, error) {
// Azure AD syncer is typically read-only
return false, fmt.Errorf("updating users in Azure AD is not supported")
}
// TestConnection tests the Azure AD API connection
func (p *AzureAdSyncerProvider) TestConnection() error {
_, err := p.getAzureAdAccessToken()
return err
}
// Close closes any open connections (no-op for Azure AD API-based syncer)
func (p *AzureAdSyncerProvider) Close() error {
// Azure AD syncer doesn't maintain persistent connections
return nil
}
type AzureAdAccessTokenResp struct {
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
@@ -55,22 +95,22 @@ type AzureAdUserListResp struct {
}
// getAzureAdAccessToken gets access token from Azure AD API using client credentials flow
func (syncer *Syncer) getAzureAdAccessToken() (string, error) {
func (p *AzureAdSyncerProvider) getAzureAdAccessToken() (string, error) {
// syncer.Host should be the tenant ID or tenant domain
// syncer.User should be the client ID (application ID)
// syncer.Password should be the client secret
tenantId := syncer.Host
tenantId := p.Syncer.Host
if tenantId == "" {
return "", fmt.Errorf("tenant ID (host field) is required for Azure AD syncer")
}
clientId := syncer.User
clientId := p.Syncer.User
if clientId == "" {
return "", fmt.Errorf("client ID (user field) is required for Azure AD syncer")
}
clientSecret := syncer.Password
clientSecret := p.Syncer.Password
if clientSecret == "" {
return "", fmt.Errorf("client secret (password field) is required for Azure AD syncer")
}
@@ -124,7 +164,7 @@ func (syncer *Syncer) getAzureAdAccessToken() (string, error) {
}
// getAzureAdUsers gets all users from Azure AD using Microsoft Graph API
func (syncer *Syncer) getAzureAdUsers(accessToken string) ([]*AzureAdUser, error) {
func (p *AzureAdSyncerProvider) getAzureAdUsers(accessToken string) ([]*AzureAdUser, error) {
allUsers := []*AzureAdUser{}
nextLink := "https://graph.microsoft.com/v1.0/users?$top=999"
@@ -173,7 +213,7 @@ func (syncer *Syncer) getAzureAdUsers(accessToken string) ([]*AzureAdUser, error
}
// azureAdUserToOriginalUser converts Azure AD user to Casdoor OriginalUser
func (syncer *Syncer) azureAdUserToOriginalUser(azureUser *AzureAdUser) *OriginalUser {
func (p *AzureAdSyncerProvider) azureAdUserToOriginalUser(azureUser *AzureAdUser) *OriginalUser {
user := &OriginalUser{
Id: azureUser.Id,
Name: azureUser.UserPrincipalName,
@@ -212,15 +252,15 @@ func (syncer *Syncer) azureAdUserToOriginalUser(azureUser *AzureAdUser) *Origina
}
// getAzureAdOriginalUsers is the main entry point for Azure AD syncer
func (syncer *Syncer) getAzureAdOriginalUsers() ([]*OriginalUser, error) {
func (p *AzureAdSyncerProvider) getAzureAdOriginalUsers() ([]*OriginalUser, error) {
// Get access token
accessToken, err := syncer.getAzureAdAccessToken()
accessToken, err := p.getAzureAdAccessToken()
if err != nil {
return nil, err
}
// Get all users from Azure AD
azureUsers, err := syncer.getAzureAdUsers(accessToken)
azureUsers, err := p.getAzureAdUsers(accessToken)
if err != nil {
return nil, err
}
@@ -228,7 +268,7 @@ func (syncer *Syncer) getAzureAdOriginalUsers() ([]*OriginalUser, error) {
// Convert Azure AD users to Casdoor OriginalUser
originalUsers := []*OriginalUser{}
for _, azureUser := range azureUsers {
originalUser := syncer.azureAdUserToOriginalUser(azureUser)
originalUser := p.azureAdUserToOriginalUser(azureUser)
originalUsers = append(originalUsers, originalUser)
}

View File

@@ -73,4 +73,6 @@ func addSyncerJob(syncer *Syncer) error {
func deleteSyncerJob(syncer *Syncer) {
clearCron(syncer.Name)
// Close any open connections when deleting the job
_ = syncer.Close()
}

166
object/syncer_database.go Normal file
View File

@@ -0,0 +1,166 @@
// 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 (
"context"
"database/sql"
"database/sql/driver"
"fmt"
"strings"
"github.com/go-sql-driver/mysql"
"golang.org/x/crypto/ssh"
)
// DatabaseSyncerProvider implements SyncerProvider for database-based syncers
type DatabaseSyncerProvider struct {
Syncer *Syncer
}
// InitAdapter initializes the database adapter
func (p *DatabaseSyncerProvider) InitAdapter() error {
if p.Syncer.Ormer != nil {
return nil
}
var dataSourceName string
if p.Syncer.DatabaseType == "mssql" {
dataSourceName = fmt.Sprintf("sqlserver://%s:%s@%s:%d?database=%s", p.Syncer.User, p.Syncer.Password, p.Syncer.Host, p.Syncer.Port, p.Syncer.Database)
} else if p.Syncer.DatabaseType == "postgres" {
sslMode := "disable"
if p.Syncer.SslMode != "" {
sslMode = p.Syncer.SslMode
}
dataSourceName = fmt.Sprintf("user=%s password=%s host=%s port=%d sslmode=%s dbname=%s", p.Syncer.User, p.Syncer.Password, p.Syncer.Host, p.Syncer.Port, sslMode, p.Syncer.Database)
} else {
dataSourceName = fmt.Sprintf("%s:%s@tcp(%s:%d)/", p.Syncer.User, p.Syncer.Password, p.Syncer.Host, p.Syncer.Port)
}
var db *sql.DB
var err error
if p.Syncer.SshType != "" && (p.Syncer.DatabaseType == "mysql" || p.Syncer.DatabaseType == "postgres" || p.Syncer.DatabaseType == "mssql") {
var dial *ssh.Client
if p.Syncer.SshType == "password" {
dial, err = DialWithPassword(p.Syncer.SshUser, p.Syncer.SshPassword, p.Syncer.SshHost, p.Syncer.SshPort)
} else {
dial, err = DialWithCert(p.Syncer.SshUser, p.Syncer.Owner+"/"+p.Syncer.Cert, p.Syncer.SshHost, p.Syncer.SshPort)
}
if err != nil {
return err
}
// Store SSH client for proper cleanup
p.Syncer.SshClient = dial
if p.Syncer.DatabaseType == "mysql" {
dataSourceName = fmt.Sprintf("%s:%s@%s(%s:%d)/", p.Syncer.User, p.Syncer.Password, p.Syncer.Owner+p.Syncer.Name, p.Syncer.Host, p.Syncer.Port)
mysql.RegisterDialContext(p.Syncer.Owner+p.Syncer.Name, (&ViaSSHDialer{Client: dial, Context: nil}).MysqlDial)
} else if p.Syncer.DatabaseType == "postgres" || p.Syncer.DatabaseType == "mssql" {
db = sql.OpenDB(dsnConnector{dsn: dataSourceName, driver: &ViaSSHDialer{Client: dial, Context: nil, DatabaseType: p.Syncer.DatabaseType}})
}
}
if !isCloudIntranet {
dataSourceName = strings.ReplaceAll(dataSourceName, "dbi.", "db.")
}
if db != nil {
p.Syncer.Ormer, err = NewAdapterFromDb(p.Syncer.DatabaseType, dataSourceName, p.Syncer.Database, db)
} else {
p.Syncer.Ormer, err = NewAdapter(p.Syncer.DatabaseType, dataSourceName, p.Syncer.Database)
}
return err
}
// GetOriginalUsers retrieves all users from the database
func (p *DatabaseSyncerProvider) GetOriginalUsers() ([]*OriginalUser, error) {
var results []map[string]sql.NullString
err := p.Syncer.Ormer.Engine.Table(p.Syncer.getTable()).Find(&results)
if err != nil {
return nil, err
}
// Memory leak problem handling
// https://github.com/casdoor/casdoor/issues/1256
users := p.Syncer.getOriginalUsersFromMap(results)
// Clear map contents to help garbage collection
for i := range results {
for k := range results[i] {
delete(results[i], k)
}
}
results = nil
return users, nil
}
// AddUser adds a new user to the database
func (p *DatabaseSyncerProvider) AddUser(user *OriginalUser) (bool, error) {
m := p.Syncer.getMapFromOriginalUser(user)
affected, err := p.Syncer.Ormer.Engine.Table(p.Syncer.getTable()).Insert(m)
if err != nil {
return false, err
}
return affected != 0, nil
}
// UpdateUser updates an existing user in the database
func (p *DatabaseSyncerProvider) UpdateUser(user *OriginalUser) (bool, error) {
key := p.Syncer.getTargetTablePrimaryKey()
m := p.Syncer.getMapFromOriginalUser(user)
pkValue := m[key]
delete(m, key)
affected, err := p.Syncer.Ormer.Engine.Table(p.Syncer.getTable()).Where(fmt.Sprintf("%s = ?", key), pkValue).Update(&m)
if err != nil {
return false, err
}
return affected != 0, nil
}
// TestConnection tests the database connection
func (p *DatabaseSyncerProvider) TestConnection() error {
err := p.InitAdapter()
if err != nil {
return err
}
err = p.Syncer.Ormer.Engine.Ping()
if err != nil {
return err
}
return nil
}
// Close closes the database connection and SSH tunnel
func (p *DatabaseSyncerProvider) Close() error {
return p.Syncer.Close()
}
type dsnConnector struct {
dsn string
driver driver.Driver
}
func (t dsnConnector) Connect(ctx context.Context) (driver.Conn, error) {
return t.driver.Open(t.dsn)
}
func (t dsnConnector) Driver() driver.Driver {
return t.driver
}

View File

@@ -0,0 +1,213 @@
// 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 (
"context"
"encoding/json"
"fmt"
"github.com/casdoor/casdoor/util"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/jwt"
admin "google.golang.org/api/admin/directory/v1"
"google.golang.org/api/option"
)
// GoogleWorkspaceSyncerProvider implements SyncerProvider for Google Workspace API-based syncers
type GoogleWorkspaceSyncerProvider struct {
Syncer *Syncer
}
// InitAdapter initializes the Google Workspace syncer (no database adapter needed)
func (p *GoogleWorkspaceSyncerProvider) InitAdapter() error {
// Google Workspace syncer doesn't need database adapter
return nil
}
// GetOriginalUsers retrieves all users from Google Workspace API
func (p *GoogleWorkspaceSyncerProvider) GetOriginalUsers() ([]*OriginalUser, error) {
return p.getGoogleWorkspaceOriginalUsers()
}
// AddUser adds a new user to Google Workspace (not supported for read-only API)
func (p *GoogleWorkspaceSyncerProvider) AddUser(user *OriginalUser) (bool, error) {
// Google Workspace syncer is typically read-only
return false, fmt.Errorf("adding users to Google Workspace is not supported")
}
// UpdateUser updates an existing user in Google Workspace (not supported for read-only API)
func (p *GoogleWorkspaceSyncerProvider) UpdateUser(user *OriginalUser) (bool, error) {
// Google Workspace syncer is typically read-only
return false, fmt.Errorf("updating users in Google Workspace is not supported")
}
// TestConnection tests the Google Workspace API connection
func (p *GoogleWorkspaceSyncerProvider) TestConnection() error {
_, err := p.getAdminService()
return err
}
// Close closes any open connections (no-op for Google Workspace API-based syncer)
func (p *GoogleWorkspaceSyncerProvider) Close() error {
// Google Workspace syncer doesn't maintain persistent connections
return nil
}
// getAdminService creates and returns a Google Workspace Admin SDK service
func (p *GoogleWorkspaceSyncerProvider) getAdminService() (*admin.Service, error) {
// syncer.Host should be the admin email (impersonation account)
// syncer.User should be the service account email or client_email
// syncer.Password should be the service account private key (JSON key file content)
adminEmail := p.Syncer.Host
if adminEmail == "" {
return nil, fmt.Errorf("admin email (host field) is required for Google Workspace syncer")
}
// Parse the service account credentials from the password field
serviceAccountKey := p.Syncer.Password
if serviceAccountKey == "" {
return nil, fmt.Errorf("service account key (password field) is required for Google Workspace syncer")
}
// Parse the JSON key
var serviceAccount struct {
Type string `json:"type"`
ClientEmail string `json:"client_email"`
PrivateKey string `json:"private_key"`
}
err := json.Unmarshal([]byte(serviceAccountKey), &serviceAccount)
if err != nil {
return nil, fmt.Errorf("failed to parse service account key: %v", err)
}
// Create JWT config for service account with domain-wide delegation
config := &jwt.Config{
Email: serviceAccount.ClientEmail,
PrivateKey: []byte(serviceAccount.PrivateKey),
Scopes: []string{
admin.AdminDirectoryUserReadonlyScope,
},
TokenURL: google.JWTTokenURL,
Subject: adminEmail, // Impersonate the admin user
}
client := config.Client(context.Background())
// Create Admin SDK service
service, err := admin.NewService(context.Background(), option.WithHTTPClient(client))
if err != nil {
return nil, fmt.Errorf("failed to create admin service: %v", err)
}
return service, nil
}
// getGoogleWorkspaceUsers gets all users from Google Workspace using Admin SDK API
func (p *GoogleWorkspaceSyncerProvider) getGoogleWorkspaceUsers(service *admin.Service) ([]*admin.User, error) {
allUsers := []*admin.User{}
pageToken := ""
// Get the customer ID (use "my_customer" for the domain)
customer := "my_customer"
for {
call := service.Users.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 users: %v", err)
}
allUsers = append(allUsers, resp.Users...)
// Handle pagination
if resp.NextPageToken == "" {
break
}
pageToken = resp.NextPageToken
}
return allUsers, nil
}
// googleWorkspaceUserToOriginalUser converts Google Workspace user to Casdoor OriginalUser
func (p *GoogleWorkspaceSyncerProvider) googleWorkspaceUserToOriginalUser(gwUser *admin.User) *OriginalUser {
user := &OriginalUser{
Id: gwUser.Id,
Name: gwUser.PrimaryEmail,
Email: gwUser.PrimaryEmail,
Avatar: gwUser.ThumbnailPhotoUrl,
Address: []string{},
Properties: map[string]string{},
Groups: []string{},
}
// Set name fields if Name is not nil
if gwUser.Name != nil {
user.DisplayName = gwUser.Name.FullName
user.FirstName = gwUser.Name.GivenName
user.LastName = gwUser.Name.FamilyName
}
// Set IsForbidden based on account status
user.IsForbidden = gwUser.Suspended
// Set IsAdmin
user.IsAdmin = gwUser.IsAdmin
// If display name is empty, construct from first and last name
if user.DisplayName == "" && (user.FirstName != "" || user.LastName != "") {
user.DisplayName = fmt.Sprintf("%s %s", user.FirstName, user.LastName)
}
// Set CreatedTime from Google or current time
if gwUser.CreationTime != "" {
user.CreatedTime = gwUser.CreationTime
} else {
user.CreatedTime = util.GetCurrentTime()
}
return user
}
// getGoogleWorkspaceOriginalUsers is the main entry point for Google Workspace syncer
func (p *GoogleWorkspaceSyncerProvider) getGoogleWorkspaceOriginalUsers() ([]*OriginalUser, error) {
// Get Admin SDK service
service, err := p.getAdminService()
if err != nil {
return nil, err
}
// Get all users from Google Workspace
gwUsers, err := p.getGoogleWorkspaceUsers(service)
if err != nil {
return nil, err
}
// Convert Google Workspace users to Casdoor OriginalUser
originalUsers := []*OriginalUser{}
for _, gwUser := range gwUsers {
originalUser := p.googleWorkspaceUserToOriginalUser(gwUser)
originalUsers = append(originalUsers, originalUser)
}
return originalUsers, nil
}

View File

@@ -0,0 +1,58 @@
// 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
// SyncerProvider defines the interface that all syncer implementations must satisfy.
// Different syncer types (Database, Keycloak, WeCom, Azure AD) implement this interface.
type SyncerProvider interface {
// InitAdapter initializes the connection to the external system
InitAdapter() error
// GetOriginalUsers retrieves all users from the external system
GetOriginalUsers() ([]*OriginalUser, error)
// AddUser adds a new user to the external system
AddUser(user *OriginalUser) (bool, error)
// UpdateUser updates an existing user in the external system
UpdateUser(user *OriginalUser) (bool, error)
// TestConnection tests the connection to the external system
TestConnection() error
// Close closes any open connections and releases resources
Close() error
}
// GetSyncerProvider returns the appropriate SyncerProvider implementation based on syncer type
func GetSyncerProvider(syncer *Syncer) SyncerProvider {
switch syncer.Type {
case "WeCom":
return &WecomSyncerProvider{Syncer: syncer}
case "Azure AD":
return &AzureAdSyncerProvider{Syncer: syncer}
case "Google Workspace":
return &GoogleWorkspaceSyncerProvider{Syncer: syncer}
case "Active Directory":
return &ActiveDirectorySyncerProvider{Syncer: syncer}
case "Keycloak":
return &KeycloakSyncerProvider{
DatabaseSyncerProvider: DatabaseSyncerProvider{Syncer: syncer},
}
default:
// Default to database syncer for "Database" type and any others
return &DatabaseSyncerProvider{Syncer: syncer}
}
}

31
object/syncer_keycloak.go Normal file
View File

@@ -0,0 +1,31 @@
// 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
// KeycloakSyncerProvider implements SyncerProvider for Keycloak database syncers
// Keycloak syncer extends DatabaseSyncerProvider with special handling for Keycloak schema
type KeycloakSyncerProvider struct {
DatabaseSyncerProvider
}
// GetOriginalUsers retrieves all users from Keycloak database
// This method overrides the base implementation to handle Keycloak-specific logic
func (p *KeycloakSyncerProvider) GetOriginalUsers() ([]*OriginalUser, error) {
// Use the base database implementation
return p.DatabaseSyncerProvider.GetOriginalUsers()
}
// Note: Keycloak-specific user mapping is handled in syncer_util.go
// via getOriginalUsersFromMap which checks syncer.Type == "Keycloak"

View File

@@ -15,62 +15,22 @@
package object
import (
"context"
"database/sql"
"database/sql/driver"
"fmt"
"strings"
"time"
"golang.org/x/crypto/ssh"
"github.com/casdoor/casdoor/util"
"github.com/go-sql-driver/mysql"
)
type OriginalUser = User
type Credential struct {
Value string `json:"value"`
Salt string `json:"salt"`
}
func (syncer *Syncer) getOriginalUsers() ([]*OriginalUser, error) {
// Handle WeCom syncer separately
if syncer.Type == "WeCom" {
return syncer.getWecomOriginalUsers()
}
// Handle Azure AD syncer separately
if syncer.Type == "Azure AD" {
return syncer.getAzureAdOriginalUsers()
}
var results []map[string]sql.NullString
err := syncer.Ormer.Engine.Table(syncer.getTable()).Find(&results)
if err != nil {
return nil, err
}
// Memory leak problem handling
// https://github.com/casdoor/casdoor/issues/1256
users := syncer.getOriginalUsersFromMap(results)
for _, m := range results {
for k := range m {
delete(m, k)
}
}
return users, nil
provider := GetSyncerProvider(syncer)
return provider.GetOriginalUsers()
}
func (syncer *Syncer) addUser(user *OriginalUser) (bool, error) {
m := syncer.getMapFromOriginalUser(user)
affected, err := syncer.Ormer.Engine.Table(syncer.getTable()).Insert(m)
if err != nil {
return false, err
}
return affected != 0, nil
provider := GetSyncerProvider(syncer)
return provider.AddUser(user)
}
func (syncer *Syncer) getCasdoorColumns() []string {
@@ -85,16 +45,8 @@ func (syncer *Syncer) getCasdoorColumns() []string {
}
func (syncer *Syncer) updateUser(user *OriginalUser) (bool, error) {
key := syncer.getTargetTablePrimaryKey()
m := syncer.getMapFromOriginalUser(user)
pkValue := m[key]
delete(m, key)
affected, err := syncer.Ormer.Engine.Table(syncer.getTable()).Where(fmt.Sprintf("%s = ?", key), pkValue).Update(&m)
if err != nil {
return false, err
}
return affected != 0, nil
provider := GetSyncerProvider(syncer)
return provider.UpdateUser(user)
}
func (syncer *Syncer) updateUserForOriginalFields(user *User, key string) (bool, error) {
@@ -139,80 +91,9 @@ func (syncer *Syncer) calculateHash(user *OriginalUser) string {
return util.GetMd5Hash(s)
}
type dsnConnector struct {
dsn string
driver driver.Driver
}
func (t dsnConnector) Connect(ctx context.Context) (driver.Conn, error) {
return t.driver.Open(t.dsn)
}
func (t dsnConnector) Driver() driver.Driver {
return t.driver
}
func (syncer *Syncer) initAdapter() error {
if syncer.Ormer != nil {
return nil
}
// WeCom syncer doesn't need database adapter
if syncer.Type == "WeCom" {
return nil
}
// Azure AD syncer doesn't need database adapter
if syncer.Type == "Azure AD" {
return nil
}
var dataSourceName string
if syncer.DatabaseType == "mssql" {
dataSourceName = fmt.Sprintf("sqlserver://%s:%s@%s:%d?database=%s", syncer.User, syncer.Password, syncer.Host, syncer.Port, syncer.Database)
} else if syncer.DatabaseType == "postgres" {
sslMode := "disable"
if syncer.SslMode != "" {
sslMode = syncer.SslMode
}
dataSourceName = fmt.Sprintf("user=%s password=%s host=%s port=%d sslmode=%s dbname=%s", syncer.User, syncer.Password, syncer.Host, syncer.Port, sslMode, syncer.Database)
} else {
dataSourceName = fmt.Sprintf("%s:%s@tcp(%s:%d)/", syncer.User, syncer.Password, syncer.Host, syncer.Port)
}
var db *sql.DB
var err error
if syncer.SshType != "" && (syncer.DatabaseType == "mysql" || syncer.DatabaseType == "postgres" || syncer.DatabaseType == "mssql") {
var dial *ssh.Client
if syncer.SshType == "password" {
dial, err = DialWithPassword(syncer.SshUser, syncer.SshPassword, syncer.SshHost, syncer.SshPort)
} else {
dial, err = DialWithCert(syncer.SshUser, syncer.Owner+"/"+syncer.Cert, syncer.SshHost, syncer.SshPort)
}
if err != nil {
return err
}
if syncer.DatabaseType == "mysql" {
dataSourceName = fmt.Sprintf("%s:%s@%s(%s:%d)/", syncer.User, syncer.Password, syncer.Owner+syncer.Name, syncer.Host, syncer.Port)
mysql.RegisterDialContext(syncer.Owner+syncer.Name, (&ViaSSHDialer{Client: dial, Context: nil}).MysqlDial)
} else if syncer.DatabaseType == "postgres" || syncer.DatabaseType == "mssql" {
db = sql.OpenDB(dsnConnector{dsn: dataSourceName, driver: &ViaSSHDialer{Client: dial, Context: nil, DatabaseType: syncer.DatabaseType}})
}
}
if !isCloudIntranet {
dataSourceName = strings.ReplaceAll(dataSourceName, "dbi.", "db.")
}
if db != nil {
syncer.Ormer, err = NewAdapterFromDb(syncer.DatabaseType, dataSourceName, syncer.Database, db)
} else {
syncer.Ormer, err = NewAdapter(syncer.DatabaseType, dataSourceName, syncer.Database)
}
return err
provider := GetSyncerProvider(syncer)
return provider.InitAdapter()
}
func RunSyncUsersJob() {

View File

@@ -12,7 +12,6 @@
// limitations under the License.
//go:build !skipCi
// +build !skipCi
package object

View File

@@ -26,6 +26,11 @@ import (
"github.com/casdoor/casdoor/util"
)
type Credential struct {
Value string `json:"value"`
Salt string `json:"salt"`
}
func (syncer *Syncer) getFullAvatarUrl(avatar string) string {
if syncer.AvatarBaseUrl == "" {
return avatar
@@ -241,25 +246,25 @@ func (syncer *Syncer) getOriginalUsersFromMap(results []map[string]sql.NullStrin
if syncer.Type == "Keycloak" {
// query and set password and password salt from credential table
sql := fmt.Sprintf("select * from credential where type = 'password' and user_id = '%s'", originalUser.Id)
credentialResult, _ := syncer.Ormer.Engine.QueryString(sql)
if len(credentialResult) > 0 {
credentialResult, err := syncer.Ormer.Engine.QueryString("select * from credential where type = 'password' and user_id = ?", originalUser.Id)
if err == nil && len(credentialResult) > 0 {
credential := Credential{}
_ = json.Unmarshal([]byte(credentialResult[0]["SECRET_DATA"]), &credential)
originalUser.Password = credential.Value
originalUser.PasswordSalt = credential.Salt
if err := json.Unmarshal([]byte(credentialResult[0]["SECRET_DATA"]), &credential); err == nil {
originalUser.Password = credential.Value
originalUser.PasswordSalt = credential.Salt
}
}
// query and set signup application from user group table
sql = fmt.Sprintf("select name from keycloak_group where id = "+
"(select group_id as gid from user_group_membership where user_id = '%s')", originalUser.Id)
groupResult, _ := syncer.Ormer.Engine.QueryString(sql)
if len(groupResult) > 0 {
groupResult, err := syncer.Ormer.Engine.QueryString("select name from keycloak_group where id = "+
"(select group_id as gid from user_group_membership where user_id = ?)", originalUser.Id)
if err == nil && len(groupResult) > 0 {
originalUser.SignupApplication = groupResult[0]["name"]
}
// create time
i, _ := strconv.ParseInt(originalUser.CreatedTime, 10, 64)
tm := time.Unix(i/int64(1000), 0)
originalUser.CreatedTime = tm.Format("2006-01-02T15:04:05+08:00")
if i, err := strconv.ParseInt(originalUser.CreatedTime, 10, 64); err == nil {
tm := time.Unix(i/int64(1000), 0)
originalUser.CreatedTime = tm.Format("2006-01-02T15:04:05+08:00")
}
// enable
value, ok := result["ENABLED"]
if ok {

View File

@@ -26,6 +26,46 @@ import (
"github.com/casdoor/casdoor/util"
)
// WecomSyncerProvider implements SyncerProvider for WeCom (WeChat Work) API-based syncers
type WecomSyncerProvider struct {
Syncer *Syncer
}
// InitAdapter initializes the WeCom syncer (no database adapter needed)
func (p *WecomSyncerProvider) InitAdapter() error {
// WeCom syncer doesn't need database adapter
return nil
}
// GetOriginalUsers retrieves all users from WeCom API
func (p *WecomSyncerProvider) GetOriginalUsers() ([]*OriginalUser, error) {
return p.getWecomUsers()
}
// AddUser adds a new user to WeCom (not supported for read-only API)
func (p *WecomSyncerProvider) AddUser(user *OriginalUser) (bool, error) {
// WeCom syncer is typically read-only
return false, fmt.Errorf("adding users to WeCom is not supported")
}
// UpdateUser updates an existing user in WeCom (not supported for read-only API)
func (p *WecomSyncerProvider) UpdateUser(user *OriginalUser) (bool, error) {
// WeCom syncer is typically read-only
return false, fmt.Errorf("updating users in WeCom is not supported")
}
// TestConnection tests the WeCom API connection
func (p *WecomSyncerProvider) TestConnection() error {
_, err := p.getWecomAccessToken()
return err
}
// Close closes any open connections (no-op for WeCom API-based syncer)
func (p *WecomSyncerProvider) Close() error {
// WeCom syncer doesn't maintain persistent connections
return nil
}
type WecomAccessTokenResp struct {
Errcode int `json:"errcode"`
Errmsg string `json:"errmsg"`
@@ -61,9 +101,9 @@ type WecomDeptListResp struct {
}
// getWecomAccessToken gets access token from WeCom API
func (syncer *Syncer) getWecomAccessToken() (string, error) {
func (p *WecomSyncerProvider) getWecomAccessToken() (string, error) {
apiUrl := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s",
url.QueryEscape(syncer.User), url.QueryEscape(syncer.Password))
url.QueryEscape(p.Syncer.User), url.QueryEscape(p.Syncer.Password))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -100,7 +140,7 @@ func (syncer *Syncer) getWecomAccessToken() (string, error) {
}
// getWecomDepartments gets all department IDs from WeCom API
func (syncer *Syncer) getWecomDepartments(accessToken string) ([]int, error) {
func (p *WecomSyncerProvider) getWecomDepartments(accessToken string) ([]int, error) {
apiUrl := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/department/list?access_token=%s",
url.QueryEscape(accessToken))
@@ -144,7 +184,7 @@ func (syncer *Syncer) getWecomDepartments(accessToken string) ([]int, error) {
}
// getWecomUsersFromDept gets users from a specific department
func (syncer *Syncer) getWecomUsersFromDept(accessToken string, deptId int) ([]*WecomUser, error) {
func (p *WecomSyncerProvider) getWecomUsersFromDept(accessToken string, deptId int) ([]*WecomUser, error) {
apiUrl := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/user/list?access_token=%s&department_id=%d",
url.QueryEscape(accessToken), deptId)
@@ -183,15 +223,15 @@ func (syncer *Syncer) getWecomUsersFromDept(accessToken string, deptId int) ([]*
}
// getWecomUsers gets all users from WeCom API
func (syncer *Syncer) getWecomUsers() ([]*OriginalUser, error) {
func (p *WecomSyncerProvider) getWecomUsers() ([]*OriginalUser, error) {
// Get access token
accessToken, err := syncer.getWecomAccessToken()
accessToken, err := p.getWecomAccessToken()
if err != nil {
return nil, err
}
// Get all departments
deptIds, err := syncer.getWecomDepartments(accessToken)
deptIds, err := p.getWecomDepartments(accessToken)
if err != nil {
return nil, err
}
@@ -199,7 +239,7 @@ func (syncer *Syncer) getWecomUsers() ([]*OriginalUser, error) {
// Get users from all departments (deduplicate by userid)
userMap := make(map[string]*WecomUser)
for _, deptId := range deptIds {
users, err := syncer.getWecomUsersFromDept(accessToken, deptId)
users, err := p.getWecomUsersFromDept(accessToken, deptId)
if err != nil {
return nil, err
}
@@ -215,7 +255,7 @@ func (syncer *Syncer) getWecomUsers() ([]*OriginalUser, error) {
// Convert WeCom users to Casdoor OriginalUser
originalUsers := []*OriginalUser{}
for _, wecomUser := range userMap {
originalUser := syncer.wecomUserToOriginalUser(wecomUser)
originalUser := p.wecomUserToOriginalUser(wecomUser)
originalUsers = append(originalUsers, originalUser)
}
@@ -223,7 +263,7 @@ func (syncer *Syncer) getWecomUsers() ([]*OriginalUser, error) {
}
// wecomUserToOriginalUser converts WeCom user to Casdoor OriginalUser
func (syncer *Syncer) wecomUserToOriginalUser(wecomUser *WecomUser) *OriginalUser {
func (p *WecomSyncerProvider) wecomUserToOriginalUser(wecomUser *WecomUser) *OriginalUser {
user := &OriginalUser{
Id: wecomUser.UserId,
Name: wecomUser.UserId,
@@ -263,8 +303,3 @@ func (syncer *Syncer) wecomUserToOriginalUser(wecomUser *WecomUser) *OriginalUse
return user
}
// getWecomOriginalUsers is the main entry point for WeCom syncer
func (syncer *Syncer) getWecomOriginalUsers() ([]*OriginalUser, error) {
return syncer.getWecomUsers()
}

View File

@@ -16,6 +16,7 @@ package object
import (
"fmt"
"strings"
"github.com/casdoor/casdoor/i18n"
"github.com/casdoor/casdoor/pp"
@@ -28,22 +29,20 @@ type Transaction struct {
Name string `xorm:"varchar(100) notnull pk" json:"name"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
// Transaction Provider Info
Provider string `xorm:"varchar(100)" json:"provider"`
Category string `xorm:"varchar(100)" json:"category"`
Type string `xorm:"varchar(100)" json:"type"`
// Product Info
ProductName string `xorm:"varchar(100)" json:"productName"`
ProductDisplayName string `xorm:"varchar(100)" json:"productDisplayName"`
Detail string `xorm:"varchar(255)" json:"detail"`
Tag string `xorm:"varchar(100)" json:"tag"`
Currency string `xorm:"varchar(100)" json:"currency"`
Amount float64 `json:"amount"`
ReturnUrl string `xorm:"varchar(1000)" json:"returnUrl"`
// User Info
User string `xorm:"varchar(100)" json:"user"`
Application string `xorm:"varchar(100)" json:"application"`
Payment string `xorm:"varchar(100)" json:"payment"`
Domain string `xorm:"varchar(1000)" json:"domain"`
Category string `xorm:"varchar(100)" json:"category"`
Type string `xorm:"varchar(100)" json:"type"`
Subtype string `xorm:"varchar(100)" json:"subtype"`
Provider string `xorm:"varchar(100)" json:"provider"`
User string `xorm:"varchar(100)" json:"user"`
Tag string `xorm:"varchar(100)" json:"tag"`
Amount float64 `json:"amount"`
Currency string `xorm:"varchar(100)" json:"currency"`
Payment string `xorm:"varchar(100)" json:"payment"`
State pp.PaymentState `xorm:"varchar(100)" json:"state"`
}
@@ -142,19 +141,32 @@ func UpdateTransaction(id string, transaction *Transaction, lang string) (bool,
return affected != 0, nil
}
func AddTransaction(transaction *Transaction, lang string) (bool, error) {
func AddTransaction(transaction *Transaction, lang string, dryRun bool) (bool, string, error) {
transactionId := strings.ReplaceAll(util.GenerateId(), "-", "")
transaction.Name = transactionId
// In dry run mode, only validate without making changes
if dryRun {
err := validateBalanceForTransaction(transaction, transaction.Amount, lang)
if err != nil {
return false, "", err
}
return true, "", nil
}
affected, err := ormer.Engine.Insert(transaction)
if err != nil {
return false, err
return false, "", err
}
if affected != 0 {
if err := updateBalanceForTransaction(transaction, transaction.Amount, lang); err != nil {
return false, err
return false, transactionId, err
}
}
return affected != 0, nil
return affected != 0, transactionId, nil
}
func DeleteTransaction(transaction *Transaction, lang string) (bool, error) {
@@ -176,19 +188,24 @@ func (transaction *Transaction) GetId() string {
}
func updateBalanceForTransaction(transaction *Transaction, amount float64, lang string) error {
if transaction.Category == "Organization" {
currency := transaction.Currency
if currency == "" {
currency = "USD"
}
if transaction.Tag == "Organization" {
// Update organization's own balance
return UpdateOrganizationBalance(transaction.Owner, transaction.Owner, amount, true, lang)
} else if transaction.Category == "User" {
return UpdateOrganizationBalance("admin", transaction.Owner, amount, currency, true, lang)
} else if transaction.Tag == "User" {
// Update user's balance
if transaction.User == "" {
return fmt.Errorf(i18n.Translate(lang, "general:User is required for User category transaction"))
}
if err := UpdateUserBalance(transaction.Owner, transaction.User, amount, lang); err != nil {
if err := UpdateUserBalance(transaction.Owner, transaction.User, amount, currency, lang); err != nil {
return err
}
// Update organization's user balance sum
return UpdateOrganizationBalance(transaction.Owner, transaction.Owner, amount, false, lang)
return UpdateOrganizationBalance("admin", transaction.Owner, amount, currency, false, lang)
}
return nil
}

View File

@@ -13,7 +13,6 @@
// limitations under the License.
//go:build !skipCi
// +build !skipCi
package object

View File

@@ -0,0 +1,130 @@
// 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/i18n"
)
func validateBalanceForTransaction(transaction *Transaction, amount float64, lang string) error {
currency := transaction.Currency
if currency == "" {
currency = "USD"
}
if transaction.Tag == "Organization" {
// Validate organization balance change
return validateOrganizationBalance("admin", transaction.Owner, amount, currency, true, lang)
} else if transaction.Tag == "User" {
// Validate user balance change
if transaction.User == "" {
return fmt.Errorf(i18n.Translate(lang, "general:User is required for User category transaction"))
}
if err := validateUserBalance(transaction.Owner, transaction.User, amount, currency, lang); err != nil {
return err
}
// Validate organization's user balance sum change
return validateOrganizationBalance("admin", transaction.Owner, amount, currency, false, lang)
}
return nil
}
func validateOrganizationBalance(owner string, name string, balance float64, currency string, isOrgBalance bool, lang string) error {
organization, err := getOrganization(owner, name)
if err != nil {
return err
}
if organization == nil {
return fmt.Errorf(i18n.Translate(lang, "auth:the organization: %s is not found"), fmt.Sprintf("%s/%s", owner, name))
}
// Convert the balance amount from transaction currency to organization's balance currency
balanceCurrency := organization.BalanceCurrency
if balanceCurrency == "" {
balanceCurrency = "USD"
}
convertedBalance := ConvertCurrency(balance, currency, balanceCurrency)
var newBalance float64
if isOrgBalance {
newBalance = AddPrices(organization.OrgBalance, convertedBalance)
// Check organization balance credit limit
if newBalance < organization.BalanceCredit {
return fmt.Errorf(i18n.Translate(lang, "general:Insufficient balance: new organization balance %v would be below credit limit %v"), newBalance, organization.BalanceCredit)
}
} else {
// User balance is just a sum of all users' balances, no credit limit check here
// Individual user credit limits are checked in validateUserBalance
newBalance = AddPrices(organization.UserBalance, convertedBalance)
}
// In validation mode, we don't actually update the balance
return nil
}
func validateUserBalance(owner string, name string, balance float64, currency string, lang string) error {
user, err := getUser(owner, name)
if err != nil {
return err
}
if user == nil {
return fmt.Errorf(i18n.Translate(lang, "general:The user: %s is not found"), fmt.Sprintf("%s/%s", owner, name))
}
// Convert the balance amount from transaction currency to user's balance currency
balanceCurrency := user.BalanceCurrency
var org *Organization
if balanceCurrency == "" {
// Get organization's balance currency as fallback
org, err = getOrganization("admin", owner)
if err == nil && org != nil && org.BalanceCurrency != "" {
balanceCurrency = org.BalanceCurrency
} else {
balanceCurrency = "USD"
}
}
convertedBalance := ConvertCurrency(balance, currency, balanceCurrency)
// Calculate new balance
newBalance := AddPrices(user.Balance, convertedBalance)
// Check balance credit limit
// User.BalanceCredit takes precedence over Organization.BalanceCredit
var balanceCredit float64
if user.BalanceCredit != 0 {
balanceCredit = user.BalanceCredit
} else {
// Get organization's balance credit as fallback
if org == nil {
org, err = getOrganization("admin", owner)
if err != nil {
return err
}
}
if org != nil {
balanceCredit = org.BalanceCredit
}
}
// Validate new balance against credit limit
if newBalance < balanceCredit {
return fmt.Errorf(i18n.Translate(lang, "general:Insufficient balance: new balance %v would be below credit limit %v"), newBalance, balanceCredit)
}
// In validation mode, we don't actually update the balance
return nil
}

View File

@@ -92,7 +92,9 @@ type User struct {
Karma int `json:"karma"`
Ranking int `json:"ranking"`
Balance float64 `json:"balance"`
BalanceCredit float64 `json:"balanceCredit"`
Currency string `xorm:"varchar(100)" json:"currency"`
BalanceCurrency string `xorm:"varchar(100)" json:"balanceCurrency"`
IsDefaultAvatar bool `json:"isDefaultAvatar"`
IsOnline bool `json:"isOnline"`
IsAdmin bool `json:"isAdmin"`
@@ -106,6 +108,7 @@ type User struct {
AccessKey string `xorm:"varchar(100)" json:"accessKey"`
AccessSecret string `xorm:"varchar(100)" json:"accessSecret"`
AccessToken string `xorm:"mediumtext" json:"accessToken"`
OriginalToken string `xorm:"mediumtext" json:"originalToken"`
CreatedIp string `xorm:"varchar(100)" json:"createdIp"`
LastSigninTime string `xorm:"varchar(100)" json:"lastSigninTime"`
@@ -663,6 +666,9 @@ func GetMaskedUser(user *User, isAdminOrSelf bool, errs ...error) (*User, error)
if user.AccessSecret != "" {
user.AccessSecret = "***"
}
if user.OriginalToken != "" {
user.OriginalToken = "***"
}
}
if user.ManagedAccounts != nil {
@@ -827,7 +833,7 @@ func UpdateUser(id string, user *User, columns []string, isAdmin bool) (bool, er
"owner", "display_name", "avatar", "first_name", "last_name",
"location", "address", "country_code", "region", "language", "affiliation", "title", "id_card_type", "id_card", "homepage", "bio", "tag", "language", "gender", "birthday", "education", "score", "karma", "ranking", "signup_application",
"is_admin", "is_forbidden", "is_deleted", "hash", "is_default_avatar", "properties", "webauthnCredentials", "managedAccounts", "face_ids", "mfaAccounts",
"signin_wrong_times", "last_change_password_time", "last_signin_wrong_time", "groups", "access_key", "access_secret", "mfa_phone_enabled", "mfa_email_enabled",
"signin_wrong_times", "last_change_password_time", "last_signin_wrong_time", "groups", "access_key", "access_secret", "mfa_phone_enabled", "mfa_email_enabled", "email_verified",
"github", "google", "qq", "wechat", "facebook", "dingtalk", "weibo", "gitee", "linkedin", "wecom", "lark", "gitlab", "adfs",
"baidu", "alipay", "casdoor", "infoflow", "apple", "azuread", "azureadb2c", "slack", "steam", "bilibili", "okta", "douyin", "kwai", "line", "amazon",
"auth0", "battlenet", "bitbucket", "box", "cloudfoundry", "dailymotion", "deezer", "digitalocean", "discord", "dropbox",
@@ -838,7 +844,7 @@ func UpdateUser(id string, user *User, columns []string, isAdmin bool) (bool, er
}
}
if isAdmin {
columns = append(columns, "name", "id", "email", "phone", "country_code", "type", "balance", "mfa_items", "register_type", "register_source")
columns = append(columns, "name", "id", "email", "phone", "country_code", "type", "balance", "balance_credit", "balance_currency", "mfa_items", "register_type", "register_source")
}
columns = append(columns, "updated_time")
@@ -870,6 +876,11 @@ func updateUser(id string, user *User, columns []string) (int64, error) {
return 0, err
}
// Ensure hash column is included in updates when columns are specified
if len(columns) > 0 && !util.InSlice(columns, "hash") {
columns = append(columns, "hash")
}
affected, err := ormer.Engine.ID(core.PK{owner, name}).Cols(columns...).Update(user)
if err != nil {
return 0, err
@@ -966,6 +977,14 @@ func AddUser(user *User, lang string) (bool, error) {
return false, fmt.Errorf(i18n.Translate(lang, "organization:adding a new user to the 'built-in' organization is currently disabled. Please note: all users in the 'built-in' organization are global administrators in Casdoor. Refer to the docs: https://casdoor.org/docs/basic/core-concepts#how-does-casdoor-manage-itself. If you still wish to create a user for the 'built-in' organization, go to the organization's settings page and enable the 'Has privilege consent' option."))
}
if user.BalanceCurrency == "" {
if organization.BalanceCurrency != "" {
user.BalanceCurrency = organization.BalanceCurrency
} else {
user.BalanceCurrency = "USD"
}
}
if organization.DefaultPassword != "" && user.Password == "123" {
user.Password = organization.DefaultPassword
}
@@ -978,6 +997,10 @@ func AddUser(user *User, lang string) (bool, error) {
user.CreatedTime = util.GetCurrentTime()
}
if user.UpdatedTime == "" {
user.UpdatedTime = user.CreatedTime
}
err = user.UpdateUserHash()
if err != nil {
return false, err
@@ -1038,6 +1061,14 @@ func AddUsers(users []*User) (bool, error) {
// this function is only used for syncer or batch upload, so no need to encrypt the password
// user.UpdateUserPassword(organization)
if user.CreatedTime == "" {
user.CreatedTime = util.GetCurrentTime()
}
if user.UpdatedTime == "" {
user.UpdatedTime = user.CreatedTime
}
err := user.UpdateUserHash()
if err != nil {
return false, err
@@ -1453,7 +1484,7 @@ func GenerateIdForNewUser(application *Application) (string, error) {
return res, nil
}
func UpdateUserBalance(owner string, name string, balance float64, lang string) error {
func UpdateUserBalance(owner string, name string, balance float64, currency string, lang string) error {
user, err := getUser(owner, name)
if err != nil {
return err
@@ -1461,7 +1492,48 @@ func UpdateUserBalance(owner string, name string, balance float64, lang string)
if user == nil {
return fmt.Errorf(i18n.Translate(lang, "general:The user: %s is not found"), fmt.Sprintf("%s/%s", owner, name))
}
user.Balance += balance
// Convert the balance amount from transaction currency to user's balance currency
balanceCurrency := user.BalanceCurrency
var org *Organization
if balanceCurrency == "" {
// Get organization's balance currency as fallback
org, err = getOrganization("admin", owner)
if err == nil && org != nil && org.BalanceCurrency != "" {
balanceCurrency = org.BalanceCurrency
} else {
balanceCurrency = "USD"
}
}
convertedBalance := ConvertCurrency(balance, currency, balanceCurrency)
// Calculate new balance
newBalance := AddPrices(user.Balance, convertedBalance)
// Check balance credit limit
// User.BalanceCredit takes precedence over Organization.BalanceCredit
var balanceCredit float64
if user.BalanceCredit != 0 {
balanceCredit = user.BalanceCredit
} else {
// Get organization's balance credit as fallback
if org == nil {
org, err = getOrganization("admin", owner)
if err != nil {
return err
}
}
if org != nil {
balanceCredit = org.BalanceCredit
}
}
// Validate new balance against credit limit
if newBalance < balanceCredit {
return fmt.Errorf(i18n.Translate(lang, "general:Insufficient balance: new balance %v would be below credit limit %v"), newBalance, balanceCredit)
}
user.Balance = newBalance
_, err = UpdateUser(user.GetId(), user, []string{"balance"}, true)
return err
}

View File

@@ -124,7 +124,6 @@ func chooseFaviconLinkBySizes(links []Link) *Link {
var chosenLink *Link
for _, link := range links {
link := link
if chosenLink == nil || compareSizes(link.Sizes, chosenLink.Sizes) > 0 {
chosenLink = &link
}

View File

@@ -43,6 +43,11 @@ func (user *User) UpdateUserHash() error {
}
func (user *User) UpdateUserPassword(organization *Organization) {
// Don't hash empty passwords (e.g., for OAuth users)
if user.Password == "" {
return
}
credManager := cred.GetCredManager(organization.PasswordType)
if credManager != nil {
// Use organization salt if available, otherwise generate a random salt for the user

View File

@@ -78,9 +78,15 @@ func parseListItem(lines *[]string, i int) []string {
func UploadUsers(owner string, path string, userObj *User, lang string) (bool, error) {
table := xlsx.ReadXlsxFile(path)
oldUserMap, err := getUserMap(owner)
if err != nil {
return false, err
if len(table) == 0 {
return false, fmt.Errorf("empty table")
}
for idx, row := range table[0] {
splitRow := strings.Split(row, "#")
if len(splitRow) > 1 {
table[0][idx] = splitRow[1]
}
}
uploadedUsers, err := StringArrayToStruct[User](table)
@@ -104,6 +110,11 @@ func UploadUsers(owner string, path string, userObj *User, lang string) (bool, e
return false, fmt.Errorf(i18n.Translate(lang, "auth:The organization: %s does not exist"), organizationName)
}
oldUserMap, err := getUserMap(organizationName)
if err != nil {
return false, err
}
newUsers := []*User{}
for _, user := range uploadedUsers {
if _, ok := oldUserMap[user.GetId()]; !ok {
@@ -146,7 +157,7 @@ func UploadUsers(owner string, path string, userObj *User, lang string) (bool, e
}
if len(newUsers) == 0 {
return false, nil
return false, fmt.Errorf("no users are modified")
}
return AddUsersInBatch(newUsers)

View File

@@ -30,6 +30,7 @@ import (
"github.com/go-webauthn/webauthn/webauthn"
jsoniter "github.com/json-iterator/go"
"github.com/xorm-io/core"
"golang.org/x/oauth2"
)
func GetUserByField(organizationName string, field string, value string) (*User, error) {
@@ -183,7 +184,12 @@ func getUserExtraProperty(user *User, providerType, key string) (string, error)
return extra[key], nil
}
func SetUserOAuthProperties(organization *Organization, user *User, providerType string, userInfo *idp.UserInfo, userMapping ...map[string]string) (bool, error) {
func SetUserOAuthProperties(organization *Organization, user *User, providerType string, userInfo *idp.UserInfo, token *oauth2.Token, userMapping ...map[string]string) (bool, error) {
// Store the original OAuth provider token if available
if token != nil && token.AccessToken != "" {
user.OriginalToken = token.AccessToken
}
if userInfo.Id != "" {
propertyName := fmt.Sprintf("oauth_%s_id", providerType)
setUserProperty(user, propertyName, userInfo.Id)
@@ -859,7 +865,7 @@ func StringArrayToStruct[T any](stringArray [][]string) ([]*T, error) {
instances := []*T{}
var err error
for _, m := range excelMap {
for idx, m := range excelMap {
instance := new(T)
reflectedInstance := reflect.ValueOf(instance).Elem()
@@ -886,7 +892,7 @@ func StringArrayToStruct[T any](stringArray [][]string) ([]*T, error) {
case reflect.Int:
intVal, err := strconv.Atoi(v)
if err != nil {
return nil, err
return nil, fmt.Errorf("line %d - column %s: %s", idx+1, fName, err.Error())
}
fv.SetInt(int64(intVal))
continue
@@ -914,7 +920,7 @@ func StringArrayToStruct[T any](stringArray [][]string) ([]*T, error) {
}
if err != nil {
return nil, err
return nil, fmt.Errorf("line %d: %s", idx, err.Error())
}
}
instances = append(instances, instance)

88
object/util.go Normal file
View File

@@ -0,0 +1,88 @@
// 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 "math"
// Fixed exchange rates (temporary implementation as per requirements)
// All rates represent how many units of the currency equal 1 USD
// Example: EUR: 0.92 means 1 USD = 0.92 EUR
var exchangeRates = map[string]float64{
"USD": 1.0,
"EUR": 0.92,
"GBP": 0.79,
"JPY": 149.50,
"CNY": 7.24,
"AUD": 1.52,
"CAD": 1.39,
"CHF": 0.88,
"HKD": 7.82,
"SGD": 1.34,
"INR": 83.12,
"KRW": 1319.50,
"BRL": 4.97,
"MXN": 17.09,
"ZAR": 18.15,
"RUB": 92.50,
"TRY": 32.15,
"NZD": 1.67,
"SEK": 10.35,
"NOK": 10.72,
"DKK": 6.87,
"PLN": 3.91,
"THB": 34.50,
"MYR": 4.47,
"IDR": 15750.00,
"PHP": 55.50,
"VND": 24500.00,
}
// GetExchangeRate returns the exchange rate from fromCurrency to toCurrency
func GetExchangeRate(fromCurrency, toCurrency string) float64 {
if fromCurrency == toCurrency {
return 1.0
}
// Default to USD if currency not found
fromRate, fromExists := exchangeRates[fromCurrency]
if !fromExists {
fromRate = 1.0
}
toRate, toExists := exchangeRates[toCurrency]
if !toExists {
toRate = 1.0
}
// Convert from source currency to USD, then from USD to target currency
// Example: EUR to JPY = (1/0.92) * 149.50 = USD/EUR * JPY/USD
return toRate / fromRate
}
// ConvertCurrency converts an amount from one currency to another using exchange rates
func ConvertCurrency(amount float64, fromCurrency, toCurrency string) float64 {
if fromCurrency == toCurrency {
return amount
}
rate := GetExchangeRate(fromCurrency, toCurrency)
converted := amount * rate
return math.Round(converted*1e8) / 1e8
}
func AddPrices(price1 float64, price2 float64) float64 {
res := price1 + price2
return math.Round(res*1e8) / 1e8
}

255
pp/fastspring.go Normal file
View File

@@ -0,0 +1,255 @@
// Copyright 2024 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 pp
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
type FastSpringPaymentProvider struct {
ApiUsername string
ApiPassword string
StorefrontPath string
}
func NewFastSpringPaymentProvider(apiUsername string, apiPassword string, storefrontPath string) (*FastSpringPaymentProvider, error) {
pp := &FastSpringPaymentProvider{
ApiUsername: apiUsername,
ApiPassword: apiPassword,
StorefrontPath: storefrontPath,
}
return pp, nil
}
type fastSpringSessionRequest struct {
Account *fastSpringAccount `json:"account,omitempty"`
Items []fastSpringItem `json:"items"`
Tags map[string]string `json:"tags,omitempty"`
}
type fastSpringAccount struct {
Contact *fastSpringContact `json:"contact,omitempty"`
}
type fastSpringContact struct {
Email string `json:"email,omitempty"`
First string `json:"first,omitempty"`
Last string `json:"last,omitempty"`
}
type fastSpringItem struct {
Product string `json:"product"`
Quantity int `json:"quantity"`
Pricing *fastSpringPricing `json:"pricing,omitempty"`
}
type fastSpringPricing struct {
Price map[string]float64 `json:"price,omitempty"`
}
type fastSpringSessionResponse struct {
ID string `json:"id"`
Account string `json:"account"`
AccountURL string `json:"accountUrl"`
}
type fastSpringOrderResponse struct {
ID string `json:"id"`
Reference string `json:"reference"`
Total float64 `json:"total"`
TotalDisplay string `json:"totalDisplay"`
TotalInPayoutCurrency float64 `json:"totalInPayoutCurrency"`
Currency string `json:"currency"`
PayoutCurrency string `json:"payoutCurrency"`
Completed bool `json:"completed"`
Changed int64 `json:"changed"`
Tags map[string]string `json:"tags"`
Items []fastSpringOrderItem `json:"items"`
}
type fastSpringOrderItem struct {
Product string `json:"product"`
Quantity int `json:"quantity"`
Display string `json:"display"`
Subtotal float64 `json:"subtotal"`
Discount float64 `json:"discount"`
Price float64 `json:"price"`
}
func (pp *FastSpringPaymentProvider) Pay(r *PayReq) (*PayResp, error) {
description := joinAttachString([]string{r.ProductName, r.ProductDisplayName, r.ProviderName})
// Create session request
sessionReq := fastSpringSessionRequest{
Items: []fastSpringItem{
{
Product: r.ProductName,
Quantity: 1,
Pricing: &fastSpringPricing{
Price: map[string]float64{
strings.ToUpper(r.Currency): r.Price,
},
},
},
},
Tags: map[string]string{
"payment_name": r.PaymentName,
"product_description": description,
"product_name": r.ProductName,
"product_display_name": r.ProductDisplayName,
"provider_name": r.ProviderName,
},
}
if r.PayerEmail != "" || r.PayerName != "" {
sessionReq.Account = &fastSpringAccount{
Contact: &fastSpringContact{
Email: r.PayerEmail,
First: r.PayerName,
},
}
}
jsonData, err := json.Marshal(sessionReq)
if err != nil {
return nil, err
}
// Create HTTP request to FastSpring API
req, err := http.NewRequest("POST", "https://api.fastspring.com/sessions", bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
req.SetBasicAuth(pp.ApiUsername, pp.ApiPassword)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("fastspring API error: %s", string(body))
}
var sessionResp fastSpringSessionResponse
if err := json.Unmarshal(body, &sessionResp); err != nil {
return nil, err
}
// Build checkout URL
checkoutURL := fmt.Sprintf("https://%s/session/%s", pp.StorefrontPath, sessionResp.ID)
payResp := &PayResp{
PayUrl: checkoutURL,
OrderId: sessionResp.ID,
}
return payResp, nil
}
func (pp *FastSpringPaymentProvider) Notify(body []byte, orderId string) (*NotifyResult, error) {
// Fetch order details from FastSpring API
req, err := http.NewRequest("GET", fmt.Sprintf("https://api.fastspring.com/orders/%s", orderId), nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(pp.ApiUsername, pp.ApiPassword)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusNotFound {
// Order not found - still pending
return &NotifyResult{PaymentStatus: PaymentStateCreated}, nil
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fastspring API error: %s", string(respBody))
}
var orderResp fastSpringOrderResponse
if err := json.Unmarshal(respBody, &orderResp); err != nil {
return nil, err
}
// Check if order is completed
if !orderResp.Completed {
return &NotifyResult{PaymentStatus: PaymentStateCreated}, nil
}
// Extract payment details from tags
var (
paymentName string
productName string
productDisplayName string
providerName string
)
if orderResp.Tags != nil {
paymentName = orderResp.Tags["payment_name"]
productName = orderResp.Tags["product_name"]
productDisplayName = orderResp.Tags["product_display_name"]
providerName = orderResp.Tags["provider_name"]
}
return &NotifyResult{
PaymentName: paymentName,
PaymentStatus: PaymentStatePaid,
ProductName: productName,
ProductDisplayName: productDisplayName,
ProviderName: providerName,
Price: orderResp.Total,
Currency: orderResp.Currency,
OrderId: orderId,
}, nil
}
func (pp *FastSpringPaymentProvider) GetInvoice(paymentName string, personName string, personIdCard string, personEmail string, personPhone string, invoiceType string, invoiceTitle string, invoiceTaxId string) (string, error) {
return "", nil
}
func (pp *FastSpringPaymentProvider) GetResponseError(err error) string {
if err == nil {
return "success"
}
return "fail"
}

194
pp/lemonsqueezy.go Normal file
View File

@@ -0,0 +1,194 @@
// Copyright 2024 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 pp
import (
"context"
"fmt"
"strconv"
"time"
"github.com/NdoleStudio/lemonsqueezy-go"
)
type LemonSqueezyPaymentProvider struct {
Client *lemonsqueezy.Client
StoreID int
}
func NewLemonSqueezyPaymentProvider(storeId string, apiKey string) (*LemonSqueezyPaymentProvider, error) {
client := lemonsqueezy.New(
lemonsqueezy.WithAPIKey(apiKey),
)
storeID, err := strconv.Atoi(storeId)
if err != nil {
return nil, fmt.Errorf("invalid store ID: %w", err)
}
pp := &LemonSqueezyPaymentProvider{
Client: client,
StoreID: storeID,
}
return pp, nil
}
func (pp *LemonSqueezyPaymentProvider) Pay(r *PayReq) (*PayResp, error) {
ctx := context.Background()
// Parse variant ID from the product name (expected to be the variant ID)
variantID, err := strconv.Atoi(r.ProductName)
if err != nil {
return nil, fmt.Errorf("invalid variant ID in product name: %w", err)
}
// Store product info in custom data for later retrieval
description := joinAttachString([]string{r.ProductName, r.ProductDisplayName, r.ProviderName})
customData := map[string]any{
"payment_name": r.PaymentName,
"product_description": description,
"product_name": r.ProductName,
"product_display_name": r.ProductDisplayName,
"provider_name": r.ProviderName,
"price": priceFloat64ToString(r.Price),
"currency": r.Currency,
}
// Create checkout attributes
attributes := &lemonsqueezy.CheckoutCreateAttributes{
ProductOptions: lemonsqueezy.CheckoutCreateProductOptions{
Name: r.ProductDisplayName,
Description: r.ProductDescription,
RedirectURL: r.ReturnUrl,
},
CheckoutData: lemonsqueezy.CheckoutCreateData{
Email: r.PayerEmail,
Name: r.PayerName,
Custom: customData,
},
}
// Create checkout
checkout, _, err := pp.Client.Checkouts.Create(ctx, pp.StoreID, variantID, attributes)
if err != nil {
return nil, err
}
if checkout == nil {
return nil, fmt.Errorf("lemonsqueezy checkout response is nil")
}
payResp := &PayResp{
PayUrl: checkout.Data.Attributes.URL,
OrderId: checkout.Data.ID,
}
return payResp, nil
}
func (pp *LemonSqueezyPaymentProvider) Notify(body []byte, orderId string) (*NotifyResult, error) {
ctx := context.Background()
// Get checkout status
checkout, _, err := pp.Client.Checkouts.Get(ctx, orderId)
if err != nil {
return nil, err
}
if checkout == nil {
return nil, fmt.Errorf("lemonsqueezy checkout not found for order: %s", orderId)
}
// Check if checkout has expired
if checkout.Data.Attributes.ExpiresAt != nil {
if time.Now().After(*checkout.Data.Attributes.ExpiresAt) {
return &NotifyResult{PaymentStatus: PaymentStateTimeout}, nil
}
}
// Extract payment details from custom data
var (
paymentName string
productName string
productDisplayName string
providerName string
price float64
currency string
)
if checkout.Data.Attributes.CheckoutData.Custom != nil {
if customData, ok := checkout.Data.Attributes.CheckoutData.Custom.(map[string]any); ok {
if v, ok := customData["payment_name"]; ok {
if str, ok := v.(string); ok {
paymentName = str
}
}
if v, ok := customData["product_name"]; ok {
if str, ok := v.(string); ok {
productName = str
}
}
if v, ok := customData["product_display_name"]; ok {
if str, ok := v.(string); ok {
productDisplayName = str
}
}
if v, ok := customData["provider_name"]; ok {
if str, ok := v.(string); ok {
providerName = str
}
}
if v, ok := customData["price"]; ok {
if str, ok := v.(string); ok {
price = priceStringToFloat64(str)
}
}
if v, ok := customData["currency"]; ok {
if str, ok := v.(string); ok {
currency = str
}
}
}
}
// Lemon Squeezy checkouts don't have a direct status field for payment completion.
// The checkout remains valid until it expires or is used.
// For proper payment status tracking, webhooks should be configured.
// Here we return PaymentStateCreated to indicate the checkout is still pending.
return &NotifyResult{
PaymentName: paymentName,
PaymentStatus: PaymentStateCreated,
ProductName: productName,
ProductDisplayName: productDisplayName,
ProviderName: providerName,
Price: price,
Currency: currency,
OrderId: orderId,
}, nil
}
func (pp *LemonSqueezyPaymentProvider) GetInvoice(paymentName string, personName string, personIdCard string, personEmail string, personPhone string, invoiceType string, invoiceTitle string, invoiceTaxId string) (string, error) {
return "", nil
}
func (pp *LemonSqueezyPaymentProvider) GetResponseError(err error) string {
if err == nil {
return "success"
}
return "fail"
}

223
pp/paddle.go Normal file
View File

@@ -0,0 +1,223 @@
// Copyright 2024 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 pp
import (
"context"
"fmt"
"strings"
"github.com/PaddleHQ/paddle-go-sdk"
"github.com/casdoor/casdoor/conf"
)
type PaddlePaymentProvider struct {
Client *paddle.SDK
}
func NewPaddlePaymentProvider(apiKey string) (*PaddlePaymentProvider, error) {
var client *paddle.SDK
var err error
if conf.GetConfigString("runmode") == "prod" {
client, err = paddle.New(apiKey)
} else {
client, err = paddle.NewSandbox(apiKey)
}
if err != nil {
return nil, err
}
pp := &PaddlePaymentProvider{
Client: client,
}
return pp, nil
}
func (pp *PaddlePaymentProvider) Pay(r *PayReq) (*PayResp, error) {
ctx := context.Background()
// Store product info in custom_data for later retrieval
description := joinAttachString([]string{r.ProductName, r.ProductDisplayName, r.ProviderName})
customData := paddle.CustomData{
"payment_name": r.PaymentName,
"product_description": description,
"product_name": r.ProductName,
"product_display_name": r.ProductDisplayName,
"provider_name": r.ProviderName,
}
// Convert price to amount string in cents (lowest denomination)
amountInCents := fmt.Sprintf("%d", priceFloat64ToInt64(r.Price))
// Map currency string to paddle CurrencyCode
currencyCode := paddle.CurrencyCode(strings.ToUpper(r.Currency))
// Create a non-catalog price and product for this transaction
items := []paddle.CreateTransactionItems{
*paddle.NewCreateTransactionItemsNonCatalogPriceAndProduct(&paddle.NonCatalogPriceAndProduct{
Quantity: 1,
Price: paddle.TransactionPriceCreateWithProduct{
Description: description,
Name: &r.ProductDisplayName,
TaxMode: paddle.TaxModeAccountSetting,
UnitPrice: paddle.Money{
Amount: amountInCents,
CurrencyCode: currencyCode,
},
Product: paddle.TransactionSubscriptionProductCreate{
Name: r.ProductDisplayName,
Description: &r.ProductDescription,
TaxCategory: paddle.TaxCategoryStandard,
ImageURL: &r.ProductImage,
CustomData: customData,
},
},
}),
}
checkoutSettings := &paddle.TransactionCheckout{
URL: &r.ReturnUrl,
}
req := &paddle.CreateTransactionRequest{
Items: items,
CustomData: customData,
Checkout: checkoutSettings,
}
res, err := pp.Client.CreateTransaction(ctx, req)
if err != nil {
return nil, err
}
if res == nil {
return nil, fmt.Errorf("paddle transaction response is nil")
}
// Get checkout URL from the transaction
checkoutURL := ""
if res.Checkout != nil && res.Checkout.URL != nil {
checkoutURL = *res.Checkout.URL
}
payResp := &PayResp{
PayUrl: checkoutURL,
OrderId: res.ID,
}
return payResp, nil
}
func (pp *PaddlePaymentProvider) Notify(body []byte, orderId string) (*NotifyResult, error) {
ctx := context.Background()
// Get transaction status
req := &paddle.GetTransactionRequest{
TransactionID: orderId,
}
res, err := pp.Client.GetTransaction(ctx, req)
if err != nil {
return nil, err
}
if res == nil {
return nil, fmt.Errorf("paddle transaction not found for order: %s", orderId)
}
// Map Paddle status to payment state
switch res.Status {
case paddle.TransactionStatusDraft, paddle.TransactionStatusReady:
return &NotifyResult{PaymentStatus: PaymentStateCreated}, nil
case paddle.TransactionStatusCompleted, paddle.TransactionStatusPaid:
// Payment successful, continue to extract payment details below
case paddle.TransactionStatusBilled:
// Billed but not yet paid
return &NotifyResult{PaymentStatus: PaymentStateCreated}, nil
case paddle.TransactionStatusCanceled:
return &NotifyResult{PaymentStatus: PaymentStateCanceled, NotifyMessage: "Transaction canceled"}, nil
case paddle.TransactionStatusPastDue:
return &NotifyResult{PaymentStatus: PaymentStateError, NotifyMessage: "Payment past due"}, nil
default:
return &NotifyResult{PaymentStatus: PaymentStateError, NotifyMessage: fmt.Sprintf("unexpected paddle transaction status: %v", res.Status)}, nil
}
// Extract payment details from transaction for successful payment
var (
paymentName string
productName string
productDisplayName string
providerName string
)
if res.CustomData != nil {
if v, ok := res.CustomData["payment_name"]; ok {
if str, ok := v.(string); ok {
paymentName = str
}
}
if v, ok := res.CustomData["product_name"]; ok {
if str, ok := v.(string); ok {
productName = str
}
}
if v, ok := res.CustomData["product_display_name"]; ok {
if str, ok := v.(string); ok {
productDisplayName = str
}
}
if v, ok := res.CustomData["provider_name"]; ok {
if str, ok := v.(string); ok {
providerName = str
}
}
}
// Get price from transaction details
var price float64
var currency string
if len(res.Details.LineItems) > 0 {
// Get the total amount from transaction details
price = priceStringToFloat64(res.Details.Totals.Total) / 100
currency = string(res.CurrencyCode)
}
return &NotifyResult{
PaymentName: paymentName,
PaymentStatus: PaymentStatePaid,
ProductName: productName,
ProductDisplayName: productDisplayName,
ProviderName: providerName,
Price: price,
Currency: currency,
OrderId: orderId,
}, nil
}
func (pp *PaddlePaymentProvider) GetInvoice(paymentName string, personName string, personIdCard string, personEmail string, personPhone string, invoiceType string, invoiceTitle string, invoiceTaxId string) (string, error) {
return "", nil
}
func (pp *PaddlePaymentProvider) GetResponseError(err error) string {
if err == nil {
return "success"
}
return "fail"
}

157
pp/polar.go Normal file
View File

@@ -0,0 +1,157 @@
// Copyright 2024 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 pp
import (
"context"
"fmt"
polargo "github.com/polarsource/polar-go"
"github.com/polarsource/polar-go/models/components"
)
type PolarPaymentProvider struct {
Client *polargo.Polar
}
func NewPolarPaymentProvider(accessToken string) (*PolarPaymentProvider, error) {
client := polargo.New(
polargo.WithSecurity(accessToken),
)
pp := &PolarPaymentProvider{
Client: client,
}
return pp, nil
}
func (pp *PolarPaymentProvider) Pay(r *PayReq) (*PayResp, error) {
ctx := context.Background()
// Store product info in metadata for later retrieval
description := joinAttachString([]string{r.ProductName, r.ProductDisplayName, r.ProviderName})
metadata := map[string]components.CheckoutCreateMetadata{
"payment_name": components.CreateCheckoutCreateMetadataStr(r.PaymentName),
"product_description": components.CreateCheckoutCreateMetadataStr(description),
"product_name": components.CreateCheckoutCreateMetadataStr(r.ProductName),
"product_display_name": components.CreateCheckoutCreateMetadataStr(r.ProductDisplayName),
"provider_name": components.CreateCheckoutCreateMetadataStr(r.ProviderName),
}
checkoutCreate := components.CheckoutCreate{
CustomerName: polargo.Pointer(r.PayerName),
CustomerEmail: polargo.Pointer(r.PayerEmail),
SuccessURL: polargo.Pointer(r.ReturnUrl),
Metadata: metadata,
Amount: polargo.Pointer(priceFloat64ToInt64(r.Price)),
}
res, err := pp.Client.Checkouts.Create(ctx, checkoutCreate)
if err != nil {
return nil, err
}
if res.Checkout == nil {
return nil, fmt.Errorf("polar checkout response is nil")
}
payResp := &PayResp{
PayUrl: res.Checkout.URL,
OrderId: res.Checkout.ID,
}
return payResp, nil
}
func (pp *PolarPaymentProvider) Notify(body []byte, orderId string) (*NotifyResult, error) {
ctx := context.Background()
// Get checkout session status
res, err := pp.Client.Checkouts.Get(ctx, orderId)
if err != nil {
return nil, err
}
if res.Checkout == nil {
return nil, fmt.Errorf("polar checkout not found for order: %s", orderId)
}
checkout := res.Checkout
// Map Polar status to payment state
switch checkout.Status {
case components.CheckoutStatusOpen:
return &NotifyResult{PaymentStatus: PaymentStateCreated}, nil
case components.CheckoutStatusSucceeded:
// Payment successful, continue to extract payment details below
case components.CheckoutStatusConfirmed:
// Payment confirmed but not yet succeeded
return &NotifyResult{PaymentStatus: PaymentStateCreated}, nil
case components.CheckoutStatusExpired:
return &NotifyResult{PaymentStatus: PaymentStateTimeout}, nil
case components.CheckoutStatusFailed:
return &NotifyResult{PaymentStatus: PaymentStateError, NotifyMessage: "Payment failed"}, nil
default:
return &NotifyResult{PaymentStatus: PaymentStateError, NotifyMessage: fmt.Sprintf("unexpected polar checkout status: %v", checkout.Status)}, nil
}
// Extract payment details from checkout for successful payment
var (
paymentName string
productName string
productDisplayName string
providerName string
)
if checkout.Metadata != nil {
if v, ok := checkout.Metadata["payment_name"]; ok && v.Str != nil {
paymentName = *v.Str
}
if v, ok := checkout.Metadata["product_name"]; ok && v.Str != nil {
productName = *v.Str
}
if v, ok := checkout.Metadata["product_display_name"]; ok && v.Str != nil {
productDisplayName = *v.Str
}
if v, ok := checkout.Metadata["provider_name"]; ok && v.Str != nil {
providerName = *v.Str
}
}
return &NotifyResult{
PaymentName: paymentName,
PaymentStatus: PaymentStatePaid,
ProductName: productName,
ProductDisplayName: productDisplayName,
ProviderName: providerName,
Price: priceInt64ToFloat64(checkout.TotalAmount),
Currency: checkout.Currency,
OrderId: orderId,
}, nil
}
func (pp *PolarPaymentProvider) GetInvoice(paymentName string, personName string, personIdCard string, personEmail string, personPhone string, invoiceType string, invoiceTitle string, invoiceTaxId string) (string, error) {
return "", nil
}
func (pp *PolarPaymentProvider) GetResponseError(err error) string {
if err == nil {
return "success"
}
return "fail"
}

View File

@@ -13,7 +13,6 @@
// limitations under the License.
//go:build !skipCi
// +build !skipCi
package radius

View File

@@ -199,7 +199,16 @@ func initAPI() {
beego.Router("/api/update-product", &controllers.ApiController{}, "POST:UpdateProduct")
beego.Router("/api/add-product", &controllers.ApiController{}, "POST:AddProduct")
beego.Router("/api/delete-product", &controllers.ApiController{}, "POST:DeleteProduct")
beego.Router("/api/buy-product", &controllers.ApiController{}, "POST:BuyProduct")
beego.Router("/api/get-orders", &controllers.ApiController{}, "GET:GetOrders")
beego.Router("/api/get-user-orders", &controllers.ApiController{}, "GET:GetUserOrders")
beego.Router("/api/get-order", &controllers.ApiController{}, "GET:GetOrder")
beego.Router("/api/update-order", &controllers.ApiController{}, "POST:UpdateOrder")
beego.Router("/api/add-order", &controllers.ApiController{}, "POST:AddOrder")
beego.Router("/api/delete-order", &controllers.ApiController{}, "POST:DeleteOrder")
beego.Router("/api/place-order", &controllers.ApiController{}, "POST:PlaceOrder")
beego.Router("/api/cancel-order", &controllers.ApiController{}, "POST:CancelOrder")
beego.Router("/api/pay-order", &controllers.ApiController{}, "POST:PayOrder")
beego.Router("/api/get-payments", &controllers.ApiController{}, "GET:GetPayments")
beego.Router("/api/get-user-payments", &controllers.ApiController{}, "GET:GetUserPayments")

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,60 @@ schemes:
- https
- http
paths:
/.well-known/{application}/jwks:
get:
tags:
- OIDC API
operationId: RootController.GetJwksByApplication
parameters:
- in: path
name: application
description: application name
required: true
type: string
responses:
"200":
description: ""
schema:
$ref: '#/definitions/jose.JSONWebKey'
/.well-known/{application}/openid-configuration:
get:
tags:
- OIDC API
description: Get Oidc Discovery for specific application
operationId: RootController.GetOidcDiscoveryByApplication
parameters:
- in: path
name: application
description: application name
required: true
type: string
responses:
"200":
description: ""
schema:
$ref: '#/definitions/object.OidcDiscovery'
/.well-known/{application}/webfinger:
get:
tags:
- OIDC API
operationId: RootController.GetWebFingerByApplication
parameters:
- in: path
name: application
description: application name
required: true
type: string
- in: query
name: resource
description: resource
required: true
type: string
responses:
"200":
description: ""
schema:
$ref: '#/definitions/object.WebFinger'
/.well-known/jwks:
get:
tags:
@@ -130,6 +184,24 @@ paths:
description: ""
schema:
$ref: '#/definitions/object.Enforcer'
/api/add-form:
post:
tags:
- Form API
description: add form
operationId: ApiController.AddForm
parameters:
- in: body
name: body
description: The details of the form
required: true
schema:
$ref: '#/definitions/object.Form'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/add-group:
post:
tags:
@@ -202,6 +274,24 @@ paths:
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/add-order:
post:
tags:
- Order API
description: add order
operationId: ApiController.AddOrder
parameters:
- in: body
name: body
description: The details of the order
required: true
schema:
$ref: '#/definitions/object.Order'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/add-organization:
post:
tags:
@@ -495,6 +585,10 @@ paths:
required: true
schema:
$ref: '#/definitions/object.Transaction'
- in: query
name: dryRun
description: 'Dry run mode: set to ''true'' or ''1'' to validate without committing'
type: string
responses:
"200":
description: The Response object
@@ -578,28 +672,6 @@ paths:
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/buy-product:
post:
tags:
- Product API
description: buy product
operationId: ApiController.BuyProduct
parameters:
- in: query
name: id
description: The id ( owner/name ) of the product
required: true
type: string
- in: query
name: providerName
description: The name of the provider
required: true
type: string
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/check-user-password:
post:
tags:
@@ -682,6 +754,24 @@ paths:
description: ""
schema:
$ref: '#/definitions/object.Enforcer'
/api/delete-form:
post:
tags:
- Form API
description: delete form
operationId: ApiController.DeleteForm
parameters:
- in: body
name: body
description: The details of the form
required: true
schema:
$ref: '#/definitions/object.Form'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/delete-group:
post:
tags:
@@ -765,6 +855,24 @@ paths:
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/delete-order:
post:
tags:
- Order API
description: delete order
operationId: ApiController.DeleteOrder
parameters:
- in: body
name: body
description: The details of the order
required: true
schema:
$ref: '#/definitions/object.Order'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/delete-organization:
post:
tags:
@@ -1392,10 +1500,10 @@ paths:
items:
$ref: '#/definitions/object.Enforcer'
/api/get-filtered-policies:
get:
post:
tags:
- Enforcer API
description: get filtered policies
description: get filtered policies with support for multiple filters via POST body
operationId: ApiController.GetFilteredPolicies
parameters:
- in: query
@@ -1403,19 +1511,14 @@ paths:
description: The id ( owner/name ) of enforcer
required: true
type: string
- in: query
name: ptype
description: Policy type, default is 'p'
type: string
- in: query
name: fieldIndex
description: Field index for filtering
type: integer
format: int64
- in: query
name: fieldValues
description: Field values for filtering, comma-separated
type: string
- in: body
name: body
description: Array of filter objects for multiple filters
required: true
schema:
type: array
items:
$ref: '#/definitions/object.Filter'
responses:
"200":
description: ""
@@ -1423,6 +1526,42 @@ paths:
type: array
items:
$ref: '#/definitions/xormadapter.CasbinRule'
/api/get-form:
get:
tags:
- Form API
description: get form
operationId: ApiController.GetForm
parameters:
- in: query
name: id
description: The id (owner/name) of form
required: true
type: string
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/object.Form'
/api/get-forms:
get:
tags:
- Form API
description: get forms
operationId: ApiController.GetForms
parameters:
- in: query
name: owner
description: The owner of form
required: true
type: string
responses:
"200":
description: The Response object
schema:
type: array
items:
$ref: '#/definitions/object.Form'
/api/get-global-certs:
get:
tags:
@@ -1436,6 +1575,19 @@ paths:
type: array
items:
$ref: '#/definitions/object.Cert'
/api/get-global-forms:
get:
tags:
- Form API
description: get global forms
operationId: ApiController.GetGlobalForms
responses:
"200":
description: The Response object
schema:
type: array
items:
$ref: '#/definitions/object.Form'
/api/get-global-providers:
get:
tags:
@@ -1633,6 +1785,42 @@ paths:
type: array
items:
$ref: '#/definitions/object.Model'
/api/get-order:
get:
tags:
- Order API
description: get order
operationId: ApiController.GetOrder
parameters:
- in: query
name: id
description: The id ( owner/name ) of the order
required: true
type: string
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/object.Order'
/api/get-orders:
get:
tags:
- Order API
description: get orders
operationId: ApiController.GetOrders
parameters:
- in: query
name: owner
description: The owner of orders
required: true
type: string
responses:
"200":
description: The Response object
schema:
type: array
items:
$ref: '#/definitions/object.Order'
/api/get-organization:
get:
tags:
@@ -1710,9 +1898,9 @@ paths:
/api/get-payment:
get:
tags:
- Verification API
- Payment API
description: get payment
operationId: ApiController.GetVerification
operationId: ApiController.GetPayment
parameters:
- in: query
name: id
@@ -1723,13 +1911,13 @@ paths:
"200":
description: The Response object
schema:
$ref: '#/definitions/object.Verification'
$ref: '#/definitions/object.Payment'
/api/get-payments:
get:
tags:
- Verification API
- Payment API
description: get payments
operationId: ApiController.GetVerifications
operationId: ApiController.GetPayments
parameters:
- in: query
name: owner
@@ -1742,7 +1930,7 @@ paths:
schema:
type: array
items:
$ref: '#/definitions/object.Verification'
$ref: '#/definitions/object.Payment'
/api/get-permission:
get:
tags:
@@ -2450,12 +2638,36 @@ paths:
responses:
"200":
description: '{int} int The count of filtered users for an organization'
/api/get-user-orders:
get:
tags:
- Order API
description: get orders for a user
operationId: ApiController.GetUserOrders
parameters:
- in: query
name: owner
description: The owner of orders
required: true
type: string
- in: query
name: user
description: The username of the user
required: true
type: string
responses:
"200":
description: The Response object
schema:
type: array
items:
$ref: '#/definitions/object.Order'
/api/get-user-payments:
get:
tags:
- Verification API
- Payment API
description: get payments for a user
operationId: ApiController.GetUserVerifications
operationId: ApiController.GetUserPayments
parameters:
- in: query
name: owner
@@ -2478,36 +2690,7 @@ paths:
schema:
type: array
items:
$ref: '#/definitions/object.Verification'
/api/get-user-transactions:
get:
tags:
- Transaction API
description: get transactions for a user
operationId: ApiController.GetUserTransaction
parameters:
- in: query
name: owner
description: The owner of transactions
required: true
type: string
- in: query
name: organization
description: The organization of the user
required: true
type: string
- in: query
name: user
description: The username of the user
required: true
type: string
responses:
"200":
description: The Response object
schema:
type: array
items:
$ref: '#/definitions/object.Transaction'
$ref: '#/definitions/object.Payment'
/api/get-users:
get:
tags:
@@ -2836,6 +3019,15 @@ paths:
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/metrics:
get:
tags:
- System API
description: get Prometheus metrics
operationId: ApiController.GetMetrics
responses:
"200":
description: '{string} Prometheus metrics in text format'
/api/mfa/setup/enable:
post:
tags:
@@ -2999,6 +3191,31 @@ paths:
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/send-invitation:
post:
tags:
- Invitation API
description: verify invitation
operationId: ApiController.VerifyInvitation
parameters:
- in: query
name: id
description: The id ( owner/name ) of the invitation
required: true
type: string
- in: body
name: body
description: The details of the invitation
required: true
schema:
type: array
items:
type: string
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/send-notification:
post:
tags:
@@ -3120,6 +3337,27 @@ paths:
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/sso-logout:
get:
tags:
- Login API
description: logout the current user from all applications
operationId: ApiController.SsoLogout
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
post:
tags:
- Login API
description: logout the current user from all applications
operationId: ApiController.SsoLogout
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/sync-ldap-users:
post:
tags:
@@ -3239,6 +3477,29 @@ paths:
description: ""
schema:
$ref: '#/definitions/object.Enforcer'
/api/update-form:
post:
tags:
- Form API
description: update form
operationId: ApiController.UpdateForm
parameters:
- in: query
name: id
description: The id (owner/name) of the form
required: true
type: string
- in: body
name: body
description: The details of the form
required: true
schema:
$ref: '#/definitions/object.Form'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/update-group:
post:
tags:
@@ -3326,6 +3587,29 @@ paths:
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/update-order:
post:
tags:
- Order API
description: update order
operationId: ApiController.UpdateOrder
parameters:
- in: query
name: id
description: The id ( owner/name ) of the order
required: true
type: string
- in: body
name: body
description: The details of the order
required: true
schema:
$ref: '#/definitions/object.Order'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/update-organization:
post:
tags:
@@ -3679,7 +3963,14 @@ paths:
- in: query
name: id
description: The id ( owner/name ) of the user
required: true
type: string
- in: query
name: userId
description: The userId (UUID) of the user
type: string
- in: query
name: owner
description: The owner of the user (required when using userId)
type: string
- in: body
name: body
@@ -3907,10 +4198,10 @@ paths:
schema:
$ref: '#/definitions/controllers.Response'
definitions:
187812.<nil>.string:
217289.<nil>.string:
title: string
type: object
187870.string.string:
217347.string.string:
title: string
type: object
Response:
@@ -4091,18 +4382,25 @@ definitions:
type: string
clientSecret:
type: string
codeResendTimeout:
type: integer
format: int64
createdTime:
type: string
defaultGroup:
type: string
description:
type: string
disableSignin:
type: boolean
displayName:
type: string
enableAutoSignin:
type: boolean
enableCodeSignin:
type: boolean
enableExclusiveSignin:
type: boolean
enableLinkWithEmail:
type: boolean
enablePassword:
@@ -4120,14 +4418,16 @@ definitions:
enableWebAuthn:
type: boolean
expireInHours:
type: integer
format: int64
type: number
format: double
failedSigninFrozenTime:
type: integer
format: int64
failedSigninLimit:
type: integer
format: int64
favicon:
type: string
footerHtml:
type: string
forcedRedirectOrigin:
@@ -4165,6 +4465,9 @@ definitions:
type: string
name:
type: string
order:
type: integer
format: int64
orgChoiceMode:
type: string
organization:
@@ -4182,12 +4485,14 @@ definitions:
items:
type: string
refreshExpireInHours:
type: integer
format: int64
type: number
format: double
samlAttributes:
type: array
items:
$ref: '#/definitions/object.SamlItem'
samlHashAlgorithm:
type: string
samlReplyUrl:
type: string
signinHtml:
@@ -4218,6 +4523,12 @@ definitions:
type: string
themeData:
$ref: '#/definitions/object.ThemeData'
title:
type: string
tokenAttributes:
type: array
items:
$ref: '#/definitions/object.JwtItem'
tokenFields:
type: array
items:
@@ -4308,6 +4619,51 @@ definitions:
format: double
name:
type: string
object.Filter:
title: Filter
type: object
properties:
fieldIndex:
type: integer
format: int64
fieldValues:
type: array
items:
type: string
ptype:
type: string
object.Form:
title: Form
type: object
properties:
createdTime:
type: string
displayName:
type: string
formItems:
type: array
items:
$ref: '#/definitions/object.FormItem'
name:
type: string
owner:
type: string
tag:
type: string
type:
type: string
object.FormItem:
title: FormItem
type: object
properties:
label:
type: string
name:
type: string
visible:
type: boolean
width:
type: string
object.GaugeVecInfo:
title: GaugeVecInfo
type: object
@@ -4453,6 +4809,14 @@ definitions:
format: int64
username:
type: string
object.JwtItem:
title: JwtItem
type: object
properties:
name:
type: string
value:
type: string
object.Ldap:
title: Ldap
type: object
@@ -4466,6 +4830,9 @@ definitions:
type: string
createdTime:
type: string
customAttributes:
additionalProperties:
type: string
defaultGroup:
type: string
enableSsl:
@@ -4513,8 +4880,15 @@ definitions:
type: string
address:
type: string
attributes:
additionalProperties:
type: string
cn:
type: string
country:
type: string
countryName:
type: string
displayName:
type: string
email:
@@ -4660,6 +5034,32 @@ definitions:
type: string
userinfo_endpoint:
type: string
object.Order:
title: Order
type: object
properties:
createdTime:
type: string
displayName:
type: string
endTime:
type: string
message:
type: string
name:
type: string
owner:
type: string
payment:
type: string
productName:
type: string
startTime:
type: string
state:
type: string
user:
type: string
object.Organization:
title: Organization
type: object
@@ -4668,6 +5068,11 @@ definitions:
type: array
items:
$ref: '#/definitions/object.AccountItem'
balanceCredit:
type: number
format: double
balanceCurrency:
type: string
countryCodes:
type: array
items:
@@ -4680,6 +5085,8 @@ definitions:
type: string
defaultPassword:
type: string
disableSignin:
type: boolean
displayName:
type: string
enableSoftDeletion:
@@ -4724,6 +5131,9 @@ definitions:
type: array
items:
type: string
orgBalance:
type: number
format: double
owner:
type: string
passwordExpireDays:
@@ -4749,6 +5159,13 @@ definitions:
$ref: '#/definitions/object.ThemeData'
useEmailAsUsername:
type: boolean
userBalance:
type: number
format: double
userNavItems:
type: array
items:
type: string
userTypes:
type: array
items:
@@ -5054,6 +5471,8 @@ definitions:
type: string
emailRegex:
type: string
enableProxy:
type: boolean
enableSignAuthnRequest:
type: boolean
endpoint:
@@ -5478,26 +5897,22 @@ definitions:
type: string
currency:
type: string
detail:
type: string
displayName:
type: string
domain:
type: string
name:
type: string
owner:
type: string
payment:
type: string
productDisplayName:
type: string
productName:
type: string
provider:
type: string
returnUrl:
type: string
state:
$ref: '#/definitions/pp.PaymentState'
subtype:
type: string
tag:
type: string
type:
@@ -5543,6 +5958,11 @@ definitions:
balance:
type: number
format: double
balanceCredit:
type: number
format: double
balanceCurrency:
type: string
battlenet:
type: string
bilibili:
@@ -5569,6 +5989,24 @@ definitions:
type: string
custom:
type: string
custom2:
type: string
custom3:
type: string
custom4:
type: string
custom5:
type: string
custom6:
type: string
custom7:
type: string
custom8:
type: string
custom9:
type: string
custom10:
type: string
dailymotion:
type: string
deezer:
@@ -5712,6 +6150,18 @@ definitions:
$ref: '#/definitions/object.MfaItem'
mfaPhoneEnabled:
type: boolean
mfaPushEnabled:
type: boolean
mfaPushProvider:
type: string
mfaPushReceiver:
type: string
mfaRadiusEnabled:
type: boolean
mfaRadiusProvider:
type: string
mfaRadiusUsername:
type: string
mfaRememberDeadline:
type: string
microsoftonline:
@@ -5732,6 +6182,8 @@ definitions:
type: string
onedrive:
type: string
originalToken:
type: string
oura:
type: string
owner:
@@ -5772,6 +6224,10 @@ definitions:
type: string
region:
type: string
registerSource:
type: string
registerType:
type: string
roles:
type: array
items:
@@ -5892,13 +6348,13 @@ definitions:
type: object
properties:
aliases:
$ref: '#/definitions/187812.<nil>.string'
$ref: '#/definitions/217289.<nil>.string'
links:
type: array
items:
$ref: '#/definitions/object.WebFingerLink'
properties:
$ref: '#/definitions/187870.string.string'
$ref: '#/definitions/217347.string.string'
subject:
type: string
object.WebFingerLink:
@@ -5974,6 +6430,9 @@ definitions:
sql.DB:
title: DB
type: object
ssh.Client:
title: Client
type: object
util.SystemInfo:
title: SystemInfo
type: object

View File

@@ -13,7 +13,6 @@
// limitations under the License.
//go:build !skipCi
// +build !skipCi
package sync

View File

@@ -13,7 +13,6 @@
// limitations under the License.
//go:build !skipCi
// +build !skipCi
package sync_v2

View File

@@ -13,7 +13,6 @@
// limitations under the License.
//go:build !skipCi
// +build !skipCi
package sync_v2

View File

@@ -21,6 +21,7 @@ func GetUploadXlsxPath(fileId string) string {
if err != nil {
panic(err)
}
defer file.Close()
return file.Name()
}

View File

@@ -13,7 +13,6 @@
// limitations under the License.
//go:build !skipCi
// +build !skipCi
package util

View File

@@ -55,7 +55,8 @@
"react-metamask-avatar": "^1.2.1",
"react-router-dom": "^5.3.3",
"react-scripts": "5.0.1",
"react-social-login-buttons": "^3.4.0"
"react-social-login-buttons": "^3.4.0",
"xlsx": "^0.18.5"
},
"scripts": {
"start": "cross-env PORT=7001 craco start",

View File

@@ -129,7 +129,7 @@ class AdapterListPage extends BaseListPage {
sorter: true,
render: (text, record, index) => {
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
<Switch disabled checkedChildren={i18next.t("general:ON")} unCheckedChildren={i18next.t("general:OFF")} checked={text} />
);
},
},

View File

@@ -38,8 +38,68 @@ import {setTwoToneColor} from "@ant-design/icons";
import * as ApplicationBackend from "./backend/ApplicationBackend";
import * as Cookie from "cookie";
// Ant Design locale imports
import enUS from "antd/locale/en_US";
import zhCN from "antd/locale/zh_CN";
import zhTW from "antd/locale/zh_TW";
import esES from "antd/locale/es_ES";
import frFR from "antd/locale/fr_FR";
import deDE from "antd/locale/de_DE";
import idID from "antd/locale/id_ID";
import jaJP from "antd/locale/ja_JP";
import koKR from "antd/locale/ko_KR";
import ruRU from "antd/locale/ru_RU";
import viVN from "antd/locale/vi_VN";
import ptBR from "antd/locale/pt_BR";
import itIT from "antd/locale/it_IT";
import msMY from "antd/locale/ms_MY";
import trTR from "antd/locale/tr_TR";
import arEG from "antd/locale/ar_EG";
import heIL from "antd/locale/he_IL";
import nlNL from "antd/locale/nl_NL";
import plPL from "antd/locale/pl_PL";
import fiFI from "antd/locale/fi_FI";
import svSE from "antd/locale/sv_SE";
import ukUA from "antd/locale/uk_UA";
import faIR from "antd/locale/fa_IR";
import csCZ from "antd/locale/cs_CZ";
import skSK from "antd/locale/sk_SK";
setTwoToneColor("rgb(87,52,211)");
function getAntdLocale(language) {
const localeMap = {
"en": enUS,
"zh": zhCN,
"zh-tw": zhTW,
"es": esES,
"fr": frFR,
"de": deDE,
"id": idID,
"ja": jaJP,
"ko": koKR,
"ru": ruRU,
"vi": viVN,
"pt": ptBR,
"it": itIT,
"ms": msMY,
"tr": trTR,
"ar": arEG,
"he": heIL,
"nl": nlNL,
"pl": plPL,
"fi": fiFI,
"sv": svSE,
"uk": ukUA,
"fa": faIR,
"cs": csCZ,
"sk": skSK,
"kk": ruRU, // Use Russian for Kazakh as antd doesn't have Kazakh
"az": trTR, // Use Turkish for Azerbaijani as they're similar
};
return localeMap[language] || enUS;
}
class App extends Component {
constructor(props) {
super(props);
@@ -98,11 +158,133 @@ class App extends Component {
}
}
shouldFlattenMenu() {
const organization = this.state.account?.organization;
const navItems = Setting.isLocalAdminUser(this.state.account) ? organization?.navItems : (organization?.userNavItems ?? []);
// If navItems is "all" or not configured, don't flatten
if (!Array.isArray(navItems) || navItems?.includes("all")) {
return false;
}
// Count how many valid menu items would be visible
// Filter out any invalid or non-existent menu items
const validMenuItems = [
"/", "/shortcuts", "/apps", // Home group
"/organizations", "/groups", "/users", "/invitations", // User Management
"/applications", "/providers", "/resources", "/certs", // Identity
"/roles", "/permissions", "/models", "/adapters", "/enforcers", // Authorization
"/sessions", "/records", "/tokens", "/verifications", // Logging & Auditing
"/products", "/orders", "/payments", "/plans", "/pricings", "/subscriptions", "/transactions", // Business
"/sysinfo", "/forms", "/syncers", "/webhooks", "/swagger", // Admin
];
const count = navItems.filter(item => validMenuItems.includes(item)).length;
return count <= Conf.MaxItemsForFlatMenu;
}
getSelectedMenuKeyForFlatMenu(uri) {
// For flattened menu, return the actual child path instead of parent group
if (uri === "/" || uri.includes("/shortcuts") || uri.includes("/apps")) {
if (uri === "/") {
return "/";
} else if (uri.includes("/shortcuts")) {
return "/shortcuts";
} else if (uri.includes("/apps")) {
return "/apps";
}
} else if (uri.includes("/organizations") || uri.includes("/trees") || uri.includes("/groups") || uri.includes("/users") || uri.includes("/invitations")) {
if (uri.includes("/organizations")) {
return "/organizations";
} else if (uri.includes("/groups")) {
return "/groups";
} else if (uri.includes("/users")) {
return "/users";
} else if (uri.includes("/invitations")) {
return "/invitations";
}
} else if (uri.includes("/applications") || uri.includes("/providers") || uri.includes("/resources") || uri.includes("/certs")) {
if (uri.includes("/applications")) {
return "/applications";
} else if (uri.includes("/providers")) {
return "/providers";
} else if (uri.includes("/resources")) {
return "/resources";
} else if (uri.includes("/certs")) {
return "/certs";
}
} else if (uri.includes("/roles") || uri.includes("/permissions") || uri.includes("/models") || uri.includes("/adapters") || uri.includes("/enforcers")) {
if (uri.includes("/roles")) {
return "/roles";
} else if (uri.includes("/permissions")) {
return "/permissions";
} else if (uri.includes("/models")) {
return "/models";
} else if (uri.includes("/adapters")) {
return "/adapters";
} else if (uri.includes("/enforcers")) {
return "/enforcers";
}
} else if (uri.includes("/records") || uri.includes("/tokens") || uri.includes("/sessions") || uri.includes("/verifications")) {
if (uri.includes("/sessions")) {
return "/sessions";
} else if (uri.includes("/records")) {
return "/records";
} else if (uri.includes("/tokens")) {
return "/tokens";
} else if (uri.includes("/verifications")) {
return "/verifications";
}
} else if (uri.includes("/products") || uri.includes("/orders") || uri.includes("/payments") || uri.includes("/plans") || uri.includes("/pricings") || uri.includes("/subscriptions") || uri.includes("/transactions")) {
if (uri.includes("/products")) {
return "/products";
} else if (uri.includes("/orders")) {
return "/orders";
} else if (uri.includes("/payments")) {
return "/payments";
} else if (uri.includes("/plans")) {
return "/plans";
} else if (uri.includes("/pricings")) {
return "/pricings";
} else if (uri.includes("/subscriptions")) {
return "/subscriptions";
} else if (uri.includes("/transactions")) {
return "/transactions";
}
} else if (uri.includes("/sysinfo") || uri.includes("/forms") || uri.includes("/syncers") || uri.includes("/webhooks")) {
if (uri.includes("/sysinfo")) {
return "/sysinfo";
} else if (uri.includes("/forms")) {
return "/forms";
} else if (uri.includes("/syncers")) {
return "/syncers";
} else if (uri.includes("/webhooks")) {
return "/webhooks";
}
} else if (uri.includes("/signup")) {
return "/signup";
} else if (uri.includes("/login")) {
return "/login";
} else if (uri.includes("/result")) {
return "/result";
}
return -1;
}
updateMenuKey() {
const uri = location.pathname;
this.setState({
uri: uri,
});
// Check if menu should be flattened and use appropriate key selection
if (this.shouldFlattenMenu()) {
const selectedKey = this.getSelectedMenuKeyForFlatMenu(uri);
this.setState({selectedMenuKey: selectedKey});
return;
}
// Original logic for grouped menu
if (uri === "/" || uri.includes("/shortcuts") || uri.includes("/apps")) {
this.setState({selectedMenuKey: "/home"});
} else if (uri.includes("/organizations") || uri.includes("/trees") || uri.includes("/groups") || uri.includes("/users") || uri.includes("/invitations")) {
@@ -111,9 +293,9 @@ class App extends Component {
this.setState({selectedMenuKey: "/identity"});
} else if (uri.includes("/roles") || uri.includes("/permissions") || uri.includes("/models") || uri.includes("/adapters") || uri.includes("/enforcers")) {
this.setState({selectedMenuKey: "/auth"});
} else if (uri.includes("/records") || uri.includes("/tokens") || uri.includes("/sessions")) {
} else if (uri.includes("/records") || uri.includes("/tokens") || uri.includes("/sessions") || uri.includes("/verifications")) {
this.setState({selectedMenuKey: "/logs"});
} else if (uri.includes("/products") || uri.includes("/payments") || uri.includes("/plans") || uri.includes("/pricings") || uri.includes("/subscriptions")) {
} else if (uri.includes("/product-store") || uri.includes("/products") || uri.includes("/orders") || uri.includes("/payments") || uri.includes("/plans") || uri.includes("/pricings") || uri.includes("/subscriptions") || uri.includes("/transactions")) {
this.setState({selectedMenuKey: "/business"});
} else if (uri.includes("/sysinfo") || uri.includes("/forms") || uri.includes("/syncers") || uri.includes("/webhooks")) {
this.setState({selectedMenuKey: "/admin"});
@@ -390,13 +572,15 @@ class App extends Component {
}
return (
<ConfigProvider theme={{
token: {
colorPrimary: themeData.colorPrimary,
borderRadius: themeData.borderRadius,
},
algorithm: Setting.getAlgorithm(this.state.themeAlgorithm),
}}>
<ConfigProvider
locale={getAntdLocale(Setting.getLanguage())}
theme={{
token: {
colorPrimary: themeData.colorPrimary,
borderRadius: themeData.borderRadius,
},
algorithm: Setting.getAlgorithm(this.state.themeAlgorithm),
}}>
<StyleProvider hashPriority="high" transformers={[legacyLogicalPropertiesTransformer]}>
<Layout id="parent-area">
<Content style={{display: "flex", justifyContent: "center"}}>
@@ -529,14 +713,16 @@ class App extends Component {
<link rel="icon" href={this.state.account.organization?.favicon} />
</Helmet>
}
<ConfigProvider theme={{
token: {
colorPrimary: this.state.themeData.colorPrimary,
colorInfo: this.state.themeData.colorPrimary,
borderRadius: this.state.themeData.borderRadius,
},
algorithm: Setting.getAlgorithm(this.state.themeAlgorithm),
}}>
<ConfigProvider
locale={getAntdLocale(Setting.getLanguage())}
theme={{
token: {
colorPrimary: this.state.themeData.colorPrimary,
colorInfo: this.state.themeData.colorPrimary,
borderRadius: this.state.themeData.borderRadius,
},
algorithm: Setting.getAlgorithm(this.state.themeAlgorithm),
}}>
<StyleProvider hashPriority="high" transformers={[legacyLogicalPropertiesTransformer]}>
{
this.renderPage()

View File

@@ -180,7 +180,7 @@ class ApplicationEditPage extends React.Component {
}
getGroups() {
GroupBackend.getGroups(this.state.organizationName)
GroupBackend.getGroups(this.state.owner)
.then((res) => {
if (res.status === "ok") {
this.setState({

View File

@@ -114,7 +114,7 @@ class BaseListPage extends React.Component {
ref={node => {
this.searchInput = node;
}}
placeholder={`Search ${dataIndex}`}
placeholder={i18next.t("general:Please input your search")}
value={selectedKeys[0]}
onChange={e => setSelectedKeys(e.target.value ? [e.target.value] : [])}
onPressEnter={() => this.handleSearch(selectedKeys, confirm, dataIndex)}
@@ -129,10 +129,10 @@ class BaseListPage extends React.Component {
size="small"
style={{width: 90}}
>
Search
{i18next.t("general:Search")}
</Button>
<Button onClick={() => this.handleReset(clearFilters)} size="small" style={{width: 90}}>
Reset
{i18next.t("general:Reset")}
</Button>
<Button
type="link"
@@ -145,7 +145,7 @@ class BaseListPage extends React.Component {
});
}}
>
Filter
{i18next.t("general:Filter")}
</Button>
</Space>
</div>

View File

@@ -34,3 +34,6 @@ export const CustomFooter = null;
// Blank or null to hide Ai Assistant button
export const AiAssistantUrl = "https://ai.casbin.com";
// Maximum number of navbar items before switching from flat to grouped menu
export const MaxItemsForFlatMenu = 7;

View File

@@ -61,8 +61,12 @@ import SessionListPage from "./SessionListPage";
import TokenListPage from "./TokenListPage";
import TokenEditPage from "./TokenEditPage";
import ProductListPage from "./ProductListPage";
import ProductStorePage from "./ProductStorePage";
import ProductEditPage from "./ProductEditPage";
import ProductBuyPage from "./ProductBuyPage";
import OrderListPage from "./OrderListPage";
import OrderEditPage from "./OrderEditPage";
import OrderPayPage from "./OrderPayPage";
import PaymentListPage from "./PaymentListPage";
import PaymentEditPage from "./PaymentEditPage";
import PaymentResultPage from "./PaymentResultPage";
@@ -98,8 +102,9 @@ import VerificationListPage from "./VerificationListPage";
function ManagementPage(props) {
const [menuVisible, setMenuVisible] = useState(false);
const navItems = props.account?.organization?.navItems;
const widgetItems = props.account?.organization?.widgetItems;
const organization = props.account?.organization;
const navItems = Setting.isLocalAdminUser(props.account) ? organization?.navItems : (organization?.userNavItems ?? []);
const widgetItems = organization?.widgetItems;
function logout() {
AuthBackend.logout()
@@ -186,6 +191,10 @@ function ManagementPage(props) {
return !Array.isArray(widgetItems) || !!widgetItems?.includes("all");
}
function isSpecialMenuItem(item) {
return item.key === "#" || item.key === "logo";
}
function renderWidgets() {
const widgets = [
Setting.getItem(<ThemeSelect themeAlgorithm={props.themeAlgorithm} onChange={props.setLogoAndThemeAlgorithm} />, "theme"),
@@ -271,77 +280,75 @@ function ManagementPage(props) {
Setting.getItem(<Link to="/">{i18next.t("general:Dashboard")}</Link>, "/"),
Setting.getItem(<Link to="/shortcuts">{i18next.t("general:Shortcuts")}</Link>, "/shortcuts"),
Setting.getItem(<Link to="/apps">{i18next.t("general:Apps")}</Link>, "/apps"),
]));
if (Setting.isLocalAdminUser(props.account) && Conf.ShowGithubCorner) {
res.push(Setting.getItem(<a href={"https://casdoor.com"}>
<span style={{fontWeight: "bold", backgroundColor: "rgba(87,52,211,0.4)", marginTop: "12px", paddingLeft: "5px", paddingRight: "5px", display: "flex", alignItems: "center", height: "40px", borderRadius: "5px"}}>
🚀 SaaS Hosting 🔥
</span>
</a>, "#"));
}
res.push(Setting.getItem(<Link style={{color: textColor}} to="/organizations">{i18next.t("general:User Management")}</Link>, "/orgs", <AppstoreTwoTone twoToneColor={twoToneColor} />, [
Setting.getItem(<Link to="/organizations">{i18next.t("general:Organizations")}</Link>, "/organizations"),
Setting.getItem(<Link to="/groups">{i18next.t("general:Groups")}</Link>, "/groups"),
Setting.getItem(<Link to="/users">{i18next.t("general:Users")}</Link>, "/users"),
Setting.getItem(<Link to="/invitations">{i18next.t("general:Invitations")}</Link>, "/invitations"),
]));
res.push(Setting.getItem(<Link style={{color: textColor}} to="/applications">{i18next.t("general:Identity")}</Link>, "/identity", <LockTwoTone twoToneColor={twoToneColor} />, [
Setting.getItem(<Link to="/applications">{i18next.t("general:Applications")}</Link>, "/applications"),
Setting.getItem(<Link to="/providers">{i18next.t("general:Providers")}</Link>, "/providers"),
Setting.getItem(<Link to="/resources">{i18next.t("general:Resources")}</Link>, "/resources"),
Setting.getItem(<Link to="/certs">{i18next.t("general:Certs")}</Link>, "/certs"),
]));
res.push(Setting.getItem(<Link style={{color: textColor}} to="/roles">{i18next.t("general:Authorization")}</Link>, "/auth", <SafetyCertificateTwoTone twoToneColor={twoToneColor} />, [
Setting.getItem(<Link to="/roles">{i18next.t("general:Roles")}</Link>, "/roles"),
Setting.getItem(<Link to="/permissions">{i18next.t("general:Permissions")}</Link>, "/permissions"),
Setting.getItem(<Link to="/models">{i18next.t("general:Models")}</Link>, "/models"),
Setting.getItem(<Link to="/adapters">{i18next.t("general:Adapters")}</Link>, "/adapters"),
Setting.getItem(<Link to="/enforcers">{i18next.t("general:Enforcers")}</Link>, "/enforcers"),
].filter(item => {
return Setting.isLocalAdminUser(props.account);
if (!Setting.isLocalAdminUser(props.account) && ["/models", "/adapters", "/enforcers"].includes(item.key)) {
return false;
} else {
return true;
}
})));
if (Setting.isLocalAdminUser(props.account)) {
if (Conf.ShowGithubCorner) {
res.push(Setting.getItem(<a href={"https://casdoor.com"}>
<span style={{fontWeight: "bold", backgroundColor: "rgba(87,52,211,0.4)", marginTop: "12px", paddingLeft: "5px", paddingRight: "5px", display: "flex", alignItems: "center", height: "40px", borderRadius: "5px"}}>
🚀 SaaS Hosting 🔥
</span>
</a>, "#"));
}
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sessions">{i18next.t("general:Logging & Auditing")}</Link>, "/logs", <WalletTwoTone twoToneColor={twoToneColor} />, [
Setting.getItem(<Link to="/sessions">{i18next.t("general:Sessions")}</Link>, "/sessions"),
Conf.CasvisorUrl ? Setting.getItem(<a target="_blank" rel="noreferrer" href={Conf.CasvisorUrl}>{i18next.t("general:Records")}</a>, "/records")
: Setting.getItem(<Link to="/records">{i18next.t("general:Records")}</Link>, "/records"),
Setting.getItem(<Link to="/tokens">{i18next.t("general:Tokens")}</Link>, "/tokens"),
Setting.getItem(<Link to="/verifications">{i18next.t("general:Verifications")}</Link>, "/verifications"),
]));
res.push(Setting.getItem(<Link style={{color: textColor}} to="/organizations">{i18next.t("general:User Management")}</Link>, "/orgs", <AppstoreTwoTone twoToneColor={twoToneColor} />, [
Setting.getItem(<Link to="/organizations">{i18next.t("general:Organizations")}</Link>, "/organizations"),
Setting.getItem(<Link to="/groups">{i18next.t("general:Groups")}</Link>, "/groups"),
Setting.getItem(<Link to="/users">{i18next.t("general:Users")}</Link>, "/users"),
Setting.getItem(<Link to="/invitations">{i18next.t("general:Invitations")}</Link>, "/invitations"),
]));
res.push(Setting.getItem(<Link style={{color: textColor}} to="/products">{i18next.t("general:Business & Payments")}</Link>, "/business", <DollarTwoTone twoToneColor={twoToneColor} />, [
Setting.getItem(<Link to="/product-store">{i18next.t("general:Product Store")}</Link>, "/product-store"),
Setting.getItem(<Link to="/products">{i18next.t("general:Products")}</Link>, "/products"),
Setting.getItem(<Link to="/orders">{i18next.t("general:Orders")}</Link>, "/orders"),
Setting.getItem(<Link to="/payments">{i18next.t("general:Payments")}</Link>, "/payments"),
Setting.getItem(<Link to="/plans">{i18next.t("general:Plans")}</Link>, "/plans"),
Setting.getItem(<Link to="/pricings">{i18next.t("general:Pricings")}</Link>, "/pricings"),
Setting.getItem(<Link to="/subscriptions">{i18next.t("general:Subscriptions")}</Link>, "/subscriptions"),
Setting.getItem(<Link to="/transactions">{i18next.t("general:Transactions")}</Link>, "/transactions"),
]));
res.push(Setting.getItem(<Link style={{color: textColor}} to="/applications">{i18next.t("general:Identity")}</Link>, "/identity", <LockTwoTone twoToneColor={twoToneColor} />, [
Setting.getItem(<Link to="/applications">{i18next.t("general:Applications")}</Link>, "/applications"),
Setting.getItem(<Link to="/providers">{i18next.t("general:Providers")}</Link>, "/providers"),
Setting.getItem(<Link to="/resources">{i18next.t("general:Resources")}</Link>, "/resources"),
Setting.getItem(<Link to="/certs">{i18next.t("general:Certs")}</Link>, "/certs"),
]));
res.push(Setting.getItem(<Link style={{color: textColor}} to="/roles">{i18next.t("general:Authorization")}</Link>, "/auth", <SafetyCertificateTwoTone twoToneColor={twoToneColor} />, [
Setting.getItem(<Link to="/roles">{i18next.t("general:Roles")}</Link>, "/roles"),
Setting.getItem(<Link to="/permissions">{i18next.t("general:Permissions")}</Link>, "/permissions"),
Setting.getItem(<Link to="/models">{i18next.t("general:Models")}</Link>, "/models"),
Setting.getItem(<Link to="/adapters">{i18next.t("general:Adapters")}</Link>, "/adapters"),
Setting.getItem(<Link to="/enforcers">{i18next.t("general:Enforcers")}</Link>, "/enforcers"),
].filter(item => {
if (!Setting.isLocalAdminUser(props.account) && ["/models", "/adapters", "/enforcers"].includes(item.key)) {
return false;
} else {
return true;
}
})));
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sessions">{i18next.t("general:Logging & Auditing")}</Link>, "/logs", <WalletTwoTone twoToneColor={twoToneColor} />, [
Setting.getItem(<Link to="/sessions">{i18next.t("general:Sessions")}</Link>, "/sessions"),
Conf.CasvisorUrl ? Setting.getItem(<a target="_blank" rel="noreferrer" href={Conf.CasvisorUrl}>{i18next.t("general:Records")}</a>, "/records")
: Setting.getItem(<Link to="/records">{i18next.t("general:Records")}</Link>, "/records"),
Setting.getItem(<Link to="/tokens">{i18next.t("general:Tokens")}</Link>, "/tokens"),
Setting.getItem(<Link to="/verifications">{i18next.t("general:Verifications")}</Link>, "/verifications"),
]));
res.push(Setting.getItem(<Link style={{color: textColor}} to="/products">{i18next.t("general:Business & Payments")}</Link>, "/business", <DollarTwoTone twoToneColor={twoToneColor} />, [
Setting.getItem(<Link to="/products">{i18next.t("general:Products")}</Link>, "/products"),
Setting.getItem(<Link to="/payments">{i18next.t("general:Payments")}</Link>, "/payments"),
Setting.getItem(<Link to="/plans">{i18next.t("general:Plans")}</Link>, "/plans"),
Setting.getItem(<Link to="/pricings">{i18next.t("general:Pricings")}</Link>, "/pricings"),
Setting.getItem(<Link to="/subscriptions">{i18next.t("general:Subscriptions")}</Link>, "/subscriptions"),
Setting.getItem(<Link to="/transactions">{i18next.t("general:Transactions")}</Link>, "/transactions"),
]));
if (Setting.isAdminUser(props.account)) {
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sysinfo">{i18next.t("general:Admin")}</Link>, "/admin", <SettingTwoTone twoToneColor={twoToneColor} />, [
Setting.getItem(<Link to="/sysinfo">{i18next.t("general:System Info")}</Link>, "/sysinfo"),
Setting.getItem(<Link to="/forms">{i18next.t("general:Forms")}</Link>, "/forms"),
Setting.getItem(<Link to="/syncers">{i18next.t("general:Syncers")}</Link>, "/syncers"),
Setting.getItem(<Link to="/webhooks">{i18next.t("general:Webhooks")}</Link>, "/webhooks"),
Setting.getItem(<a target="_blank" rel="noreferrer" href={Setting.isLocalhost() ? `${Setting.ServerUrl}/swagger` : "/swagger"}>{i18next.t("general:Swagger")}</a>, "/swagger")]));
} else {
res.push(Setting.getItem(<Link style={{color: textColor}} to="/syncers">{i18next.t("general:Admin")}</Link>, "/admin", <SettingTwoTone twoToneColor={twoToneColor} />, [
Setting.getItem(<Link to="/forms">{i18next.t("general:Forms")}</Link>, "/forms"),
Setting.getItem(<Link to="/syncers">{i18next.t("general:Syncers")}</Link>, "/syncers"),
Setting.getItem(<Link to="/webhooks">{i18next.t("general:Webhooks")}</Link>, "/webhooks")]));
}
if (Setting.isAdminUser(props.account)) {
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sysinfo">{i18next.t("general:Admin")}</Link>, "/admin", <SettingTwoTone twoToneColor={twoToneColor} />, [
Setting.getItem(<Link to="/sysinfo">{i18next.t("general:System Info")}</Link>, "/sysinfo"),
Setting.getItem(<Link to="/forms">{i18next.t("general:Forms")}</Link>, "/forms"),
Setting.getItem(<Link to="/syncers">{i18next.t("general:Syncers")}</Link>, "/syncers"),
Setting.getItem(<Link to="/webhooks">{i18next.t("general:Webhooks")}</Link>, "/webhooks"),
Setting.getItem(<a target="_blank" rel="noreferrer" href={Setting.isLocalhost() ? `${Setting.ServerUrl}/swagger` : "/swagger"}>{i18next.t("general:Swagger")}</a>, "/swagger")]));
} else {
res.push(Setting.getItem(<Link style={{color: textColor}} to="/syncers">{i18next.t("general:Admin")}</Link>, "/admin", <SettingTwoTone twoToneColor={twoToneColor} />, [
Setting.getItem(<Link to="/forms">{i18next.t("general:Forms")}</Link>, "/forms"),
Setting.getItem(<Link to="/syncers">{i18next.t("general:Syncers")}</Link>, "/syncers"),
Setting.getItem(<Link to="/webhooks">{i18next.t("general:Webhooks")}</Link>, "/webhooks")]));
}
if (navItemsIsAll()) {
@@ -363,10 +370,36 @@ function ManagementPage(props) {
return item;
});
return resFiltered.filter(item => {
if (item.key === "#" || item.key === "logo") {return true;}
const filteredResult = resFiltered.filter(item => {
if (isSpecialMenuItem(item)) {return true;}
return Array.isArray(item.children) && item.children.length > 0;
});
// Count total end items (leaf nodes)
let totalEndItems = 0;
filteredResult.forEach(item => {
if (Array.isArray(item.children)) {
totalEndItems += item.children.length;
}
});
// If total end items <= MaxItemsForFlatMenu, flatten the menu (show only one level)
if (totalEndItems <= Conf.MaxItemsForFlatMenu) {
const flattenedResult = [];
filteredResult.forEach(item => {
if (isSpecialMenuItem(item)) {
flattenedResult.push(item);
} else if (Array.isArray(item.children)) {
// Add children directly without parent group
item.children.forEach(child => {
flattenedResult.push(child);
});
}
});
return flattenedResult;
}
return filteredResult;
}
function renderLoginIfNotLoggedIn(component) {
@@ -425,9 +458,13 @@ function ManagementPage(props) {
<Route exact path="/sessions" render={(props) => renderLoginIfNotLoggedIn(<SessionListPage account={account} {...props} />)} />
<Route exact path="/tokens" render={(props) => renderLoginIfNotLoggedIn(<TokenListPage account={account} {...props} />)} />
<Route exact path="/tokens/:tokenName" render={(props) => renderLoginIfNotLoggedIn(<TokenEditPage account={account} {...props} />)} />
<Route exact path="/product-store" render={(props) => renderLoginIfNotLoggedIn(<ProductStorePage account={account} {...props} />)} />
<Route exact path="/products" render={(props) => renderLoginIfNotLoggedIn(<ProductListPage account={account} {...props} />)} />
<Route exact path="/products/:organizationName/:productName" render={(props) => renderLoginIfNotLoggedIn(<ProductEditPage account={account} {...props} />)} />
<Route exact path="/products/:organizationName/:productName/buy" render={(props) => renderLoginIfNotLoggedIn(<ProductBuyPage account={account} {...props} />)} />
<Route exact path="/orders" render={(props) => renderLoginIfNotLoggedIn(<OrderListPage account={account} {...props} />)} />
<Route exact path="/orders/:organizationName/:orderName" render={(props) => renderLoginIfNotLoggedIn(<OrderEditPage account={account} {...props} />)} />
<Route exact path="/orders/:organizationName/:orderName/pay" render={(props) => renderLoginIfNotLoggedIn(<OrderPayPage account={account} {...props} />)} />
<Route exact path="/payments" render={(props) => renderLoginIfNotLoggedIn(<PaymentListPage account={account} {...props} />)} />
<Route exact path="/payments/:organizationName/:paymentName" render={(props) => renderLoginIfNotLoggedIn(<PaymentEditPage account={account} {...props} />)} />
<Route exact path="/payments/:organizationName/:paymentName/result" render={(props) => renderLoginIfNotLoggedIn(<PaymentResultPage account={account} {...props} />)} />

307
web/src/OrderEditPage.js Normal file
View File

@@ -0,0 +1,307 @@
// 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.
import React from "react";
import {Button, Card, Col, Input, Row, Select} from "antd";
import * as OrderBackend from "./backend/OrderBackend";
import * as ProductBackend from "./backend/ProductBackend";
import * as UserBackend from "./backend/UserBackend";
import * as PaymentBackend from "./backend/PaymentBackend";
import * as Setting from "./Setting";
import i18next from "i18next";
const {Option} = Select;
class OrderEditPage extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
organizationName: props.organizationName !== undefined ? props.organizationName : props.match.params.organizationName,
orderName: props.match.params.orderName,
order: null,
products: [],
users: [],
payments: [],
mode: props.location.mode !== undefined ? props.location.mode : "edit",
};
}
UNSAFE_componentWillMount() {
this.getOrder();
this.getProducts();
this.getUsers();
this.getPayments();
}
getOrder() {
OrderBackend.getOrder(this.state.organizationName, this.state.orderName)
.then((res) => {
if (res.data === null) {
this.props.history.push("/404");
return;
}
this.setState({
order: res.data,
});
});
}
getProducts() {
ProductBackend.getProducts(this.state.organizationName)
.then((res) => {
if (res.status === "ok") {
this.setState({
products: res.data,
});
} else {
Setting.showMessage("error", `Failed to get products: ${res.msg}`);
}
});
}
getUsers() {
UserBackend.getUsers(this.state.organizationName)
.then((res) => {
if (res.status === "ok") {
this.setState({
users: res.data,
});
} else {
Setting.showMessage("error", `Failed to get users: ${res.msg}`);
}
});
}
getPayments() {
PaymentBackend.getPayments(this.state.organizationName)
.then((res) => {
if (res.status === "ok") {
this.setState({
payments: res.data,
});
} else {
Setting.showMessage("error", `Failed to get payments: ${res.msg}`);
}
});
}
parseOrderField(key, value) {
if ([""].includes(key)) {
value = Setting.myParseInt(value);
}
return value;
}
updateOrderField(key, value) {
value = this.parseOrderField(key, value);
const order = this.state.order;
order[key] = value;
this.setState({
order: order,
});
}
renderOrder() {
return (
<Card size="small" title={
<div>
{this.state.mode === "add" ? i18next.t("order:New Order") : i18next.t("order:Edit Order")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button onClick={() => this.submitOrderEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" onClick={() => this.submitOrderEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} onClick={() => this.deleteOrder()}>{i18next.t("general:Cancel")}</Button> : null}
</div>
} style={{marginLeft: "5px"}} type="inner">
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("general:Organization")}:
</Col>
<Col span={22} >
<Input value={this.state.order.owner} disabled />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("general:Name")}:
</Col>
<Col span={22} >
<Input value={this.state.order.name} onChange={e => {
this.updateOrderField("name", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("general:Display name")}:
</Col>
<Col span={22} >
<Input value={this.state.order.displayName} onChange={e => {
this.updateOrderField("displayName", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("order:Product")}:
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.order.productName} onChange={(value) => {
this.updateOrderField("productName", value);
}}>
{
this.state.products?.map((product, index) => <Option key={index} value={product.name}>{product.displayName}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("general:User")}:
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.order.user} onChange={(value) => {
this.updateOrderField("user", value);
}}>
{
this.state.users?.map((user, index) => <Option key={index} value={user.name}>{user.name}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("order:Payment")}:
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.order.payment} onChange={(value) => {
this.updateOrderField("payment", value);
}}>
<Option value="">{"(empty)"}</Option>
{
this.state.payments?.map((payment, index) => <Option key={index} value={payment.name}>{payment.name}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("general:State")}:
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.order.state} onChange={(value) => {
this.updateOrderField("state", value);
}}>
{
[
{id: "Created", name: "Created"},
{id: "Paid", name: "Paid"},
{id: "Delivered", name: "Delivered"},
{id: "Completed", name: "Completed"},
{id: "Canceled", name: "Canceled"},
{id: "Expired", name: "Expired"},
].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("general:Message")}:
</Col>
<Col span={22} >
<Input value={this.state.order.message} onChange={e => {
this.updateOrderField("message", e.target.value);
}} />
</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>
);
}
submitOrderEdit(exitAfterSave) {
const order = Setting.deepCopy(this.state.order);
OrderBackend.updateOrder(this.state.organizationName, this.state.orderName, order)
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully saved"));
this.setState({
orderName: this.state.order.name,
});
if (exitAfterSave) {
this.props.history.push("/orders");
} else {
this.props.history.push(`/orders/${this.state.order.owner}/${this.state.order.name}`);
}
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to save")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
deleteOrder() {
OrderBackend.deleteOrder(this.state.order)
.then((res) => {
if (res.status === "ok") {
this.props.history.push("/orders");
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
render() {
return (
<div>
{
this.state.order !== null ? this.renderOrder() : null
}
<div style={{marginTop: "20px", marginLeft: "40px"}}>
<Button size="large" onClick={() => this.submitOrderEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" size="large" onClick={() => this.submitOrderEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} size="large" onClick={() => this.deleteOrder()}>{i18next.t("general:Cancel")}</Button> : null}
</div>
</div>
);
}
}
export default OrderEditPage;

297
web/src/OrderListPage.js Normal file
View File

@@ -0,0 +1,297 @@
// 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.
import React from "react";
import {Link} from "react-router-dom";
import {Button, Table} from "antd";
import moment from "moment";
import * as Setting from "./Setting";
import * as OrderBackend from "./backend/OrderBackend";
import i18next from "i18next";
import BaseListPage from "./BaseListPage";
import PopconfirmModal from "./common/modal/PopconfirmModal";
class OrderListPage extends BaseListPage {
newOrder() {
const randomName = Setting.getRandomName();
const owner = Setting.getRequestOrganization(this.props.account);
return {
owner: owner,
name: `order_${randomName}`,
createdTime: moment().format(),
displayName: `New Order - ${randomName}`,
productName: "",
user: "",
payment: "",
state: "Created",
message: "",
startTime: moment().format(),
endTime: "",
};
}
addOrder() {
const newOrder = this.newOrder();
OrderBackend.addOrder(newOrder)
.then((res) => {
if (res.status === "ok") {
this.props.history.push({pathname: `/orders/${newOrder.owner}/${newOrder.name}`, mode: "add"});
Setting.showMessage("success", i18next.t("general:Successfully added"));
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to add")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
cancelOrder(order) {
OrderBackend.cancelOrder(order.owner, order.name)
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully canceled"));
this.fetch({
pagination: this.state.pagination,
});
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to cancel")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
deleteOrder(i) {
OrderBackend.deleteOrder(this.state.data[i])
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully deleted"));
this.fetch({
pagination: {
...this.state.pagination,
current: this.state.pagination.current > 1 && this.state.data.length === 1 ? this.state.pagination.current - 1 : this.state.pagination.current,
},
});
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
renderTable(orders) {
const columns = [
{
title: i18next.t("general:Name"),
dataIndex: "name",
key: "name",
width: "140px",
fixed: "left",
sorter: true,
...this.getColumnSearchProps("name"),
render: (text, record, index) => {
return (
<Link to={`/orders/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Organization"),
dataIndex: "owner",
key: "owner",
width: "150px",
sorter: true,
...this.getColumnSearchProps("owner"),
render: (text, record, index) => {
return (
<Link to={`/organizations/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Created time"),
dataIndex: "createdTime",
key: "createdTime",
width: "160px",
sorter: true,
render: (text, record, index) => {
return Setting.getFormattedDate(text);
},
},
{
title: i18next.t("general:Display name"),
dataIndex: "displayName",
key: "displayName",
width: "170px",
sorter: true,
...this.getColumnSearchProps("displayName"),
},
{
title: i18next.t("order:Product"),
dataIndex: "productName",
key: "productName",
width: "170px",
sorter: true,
...this.getColumnSearchProps("productName"),
render: (text, record, index) => {
if (text === "") {
return "(empty)";
}
return (
<Link to={`/products/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:User"),
dataIndex: "user",
key: "user",
width: "120px",
sorter: true,
...this.getColumnSearchProps("user"),
render: (text, record, index) => {
if (text === "") {
return "(empty)";
}
return (
<Link to={`/users/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:State"),
dataIndex: "state",
key: "state",
width: "120px",
sorter: true,
...this.getColumnSearchProps("state"),
},
{
title: i18next.t("order:Start time"),
dataIndex: "startTime",
key: "startTime",
width: "160px",
sorter: true,
render: (text, record, index) => {
return Setting.getFormattedDate(text);
},
},
{
title: i18next.t("order: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: "",
key: "op",
width: "240px",
fixed: (Setting.isMobile()) ? "false" : "right",
render: (text, record, index) => {
return (
<div style={{display: "flex", flexWrap: "wrap", gap: "8px"}}>
<Button onClick={() => this.props.history.push(`/orders/${record.owner}/${record.name}/pay`)} disabled={record.state !== "Created"}>
{i18next.t("order:Pay")}
</Button>
<Button danger onClick={() => this.cancelOrder(record)} disabled={record.state !== "Created"}>
{i18next.t("general:Cancel")}
</Button>
<Button type="primary" onClick={() => this.props.history.push(`/orders/${record.owner}/${record.name}`)}>{i18next.t("general:Edit")}</Button>
<PopconfirmModal
title={i18next.t("general:Sure to delete") + `: ${record.name} ?`}
onConfirm={() => this.deleteOrder(index)}
>
</PopconfirmModal>
</div>
);
},
},
];
const paginationProps = {
total: this.state.pagination.total,
showQuickJumper: true,
showSizeChanger: true,
showTotal: () => i18next.t("general:{total} in total").replace("{total}", this.state.pagination.total),
};
return (
<div>
<Table scroll={{x: "max-content"}} columns={columns} dataSource={orders} rowKey={(record) => `${record.owner}/${record.name}`} size="middle" bordered pagination={paginationProps}
title={() => (
<div>
{i18next.t("general:Orders")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button type="primary" size="small" onClick={this.addOrder.bind(this)}>{i18next.t("general:Add")}</Button>
</div>
)}
loading={this.state.loading}
onChange={this.handleTableChange}
/>
</div>
);
}
fetch = (params = {}) => {
const field = params.searchedColumn, value = params.searchText;
const sortField = params.sortField, sortOrder = params.sortOrder;
this.setState({loading: true});
OrderBackend.getOrders(Setting.getRequestOrganization(this.props.account), params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
.then((res) => {
this.setState({
loading: false,
});
if (res.status === "ok") {
this.setState({
data: res.data,
pagination: {
...params.pagination,
total: res.data2,
},
searchText: params.searchText,
searchedColumn: params.searchedColumn,
});
} else {
if (Setting.isResponseDenied(res)) {
this.setState({
isAuthorized: false,
});
} else {
Setting.showMessage("error", res.msg);
}
}
});
};
}
export default OrderListPage;

302
web/src/OrderPayPage.js Normal file
View File

@@ -0,0 +1,302 @@
// 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.
import React from "react";
import {Button, Descriptions, Spin} from "antd";
import i18next from "i18next";
import * as OrderBackend from "./backend/OrderBackend";
import * as ProductBackend from "./backend/ProductBackend";
import * as Setting from "./Setting";
class OrderPayPage extends React.Component {
constructor(props) {
super(props);
this.state = {
owner: props?.match?.params?.organizationName ?? props?.match?.params?.owner ?? null,
orderName: props?.match?.params?.orderName ?? null,
order: null,
product: null,
paymentEnv: "",
isProcessingPayment: false,
};
}
getPaymentEnv() {
let env = "";
const ua = navigator.userAgent.toLowerCase();
// Only support WeChat Pay in WeChat Browser for mobile devices
if (ua.indexOf("micromessenger") !== -1 && ua.indexOf("mobile") !== -1) {
env = "WechatBrowser";
}
this.setState({
paymentEnv: env,
});
}
componentDidMount() {
const params = new URLSearchParams(window.location.search);
if (params.get("created") === "1") {
Setting.showMessage("success", i18next.t("general:Successfully added"));
}
this.getOrder();
this.getPaymentEnv();
}
async getOrder() {
if (!this.state.owner || !this.state.orderName) {
return;
}
const res = await OrderBackend.getOrder(this.state.owner, this.state.orderName);
if (res.status === "ok") {
this.setState({
order: res.data,
}, () => {
this.getProduct();
});
} else {
Setting.showMessage("error", res.msg);
}
}
async getProduct() {
if (!this.state.order || !this.state.order.productName) {
return;
}
const res = await ProductBackend.getProduct(this.state.order.owner, this.state.order.productName);
if (res.status === "ok") {
this.setState({
product: res.data,
});
} else {
Setting.showMessage("error", res.msg);
}
}
getPrice(order) {
return `${Setting.getCurrencySymbol(order?.currency)}${order?.price} (${Setting.getCurrencyText(order)})`;
}
getProductPrice(product) {
return `${Setting.getCurrencySymbol(product?.currency)}${product?.price} (${Setting.getCurrencyText(product)})`;
}
// Call Wechat Pay via jsapi
onBridgeReady(attachInfo) {
const {WeixinJSBridge} = window;
this.setState({
isProcessingPayment: false,
});
WeixinJSBridge.invoke(
"getBrandWCPayRequest", {
"appId": attachInfo.appId,
"timeStamp": attachInfo.timeStamp,
"nonceStr": attachInfo.nonceStr,
"package": attachInfo.package,
"signType": attachInfo.signType,
"paySign": attachInfo.paySign,
},
function(res) {
if (res.err_msg === "get_brand_wcpay_request:ok") {
Setting.goToLink(attachInfo.payment.successUrl);
return;
}
if (res.err_msg === "get_brand_wcpay_request:cancel") {
Setting.showMessage("error", i18next.t("product:Payment cancelled"));
} else {
Setting.showMessage("error", i18next.t("product:Payment failed"));
}
}
);
}
// In WeChat browser, call this function to pay via jsapi
callWechatPay(attachInfo) {
const {WeixinJSBridge} = window;
if (typeof WeixinJSBridge === "undefined") {
document.addEventListener("WeixinJSBridgeReady", () => this.onBridgeReady(attachInfo), false);
} else {
this.onBridgeReady(attachInfo);
}
}
payOrder(provider) {
const {product, order} = this.state;
if (!product || !order) {
return;
}
this.setState({
isProcessingPayment: true,
});
OrderBackend.payOrder(order.owner, order.name, provider.name, this.state.paymentEnv)
.then((res) => {
if (res.status === "ok") {
const payment = res.data;
const attachInfo = res.data2;
let payUrl = payment.payUrl;
if (provider.type === "WeChat Pay") {
if (this.state.paymentEnv === "WechatBrowser") {
attachInfo.payment = payment;
this.callWechatPay(attachInfo);
return;
}
payUrl = `/qrcode/${payment.owner}/${payment.name}?providerName=${provider.name}&payUrl=${encodeURIComponent(payment.payUrl)}&successUrl=${encodeURIComponent(payment.successUrl)}`;
}
Setting.goToLink(payUrl);
} else {
Setting.showMessage("error", `${i18next.t("product:Payment failed")}: ${res.msg}`);
this.setState({
isProcessingPayment: false,
});
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
this.setState({
isProcessingPayment: false,
});
});
}
getPayButton(provider, onClick) {
const providerTypeMap = {
"Dummy": i18next.t("product:Dummy"),
"Alipay": i18next.t("product:Alipay"),
"WeChat Pay": i18next.t("product:WeChat Pay"),
"PayPal": i18next.t("product:PayPal"),
"Stripe": i18next.t("product:Stripe"),
"AirWallex": i18next.t("product:AirWallex"),
};
const text = providerTypeMap[provider.type] || provider.type;
return (
<Button style={{height: "50px", borderWidth: "2px"}} shape="round" icon={
<img style={{marginRight: "10px"}} width={36} height={36} src={Setting.getProviderLogoURL(provider)} alt={provider.displayName} />
} size={"large"} onClick={onClick}>
{text}
</Button>
);
}
renderProviderButton(provider) {
return (
<span key={provider.name} style={{width: "200px", marginRight: "20px", marginBottom: "10px"}}>
{this.getPayButton(provider, () => this.payOrder(provider))}
</span>
);
}
renderPaymentMethods() {
const {product} = this.state;
if (!product || !product.providerObjs || product.providerObjs.length === 0) {
return <div>{i18next.t("product:There is no payment channel for this product.")}</div>;
}
return product.providerObjs.map(provider => {
return this.renderProviderButton(provider);
});
}
render() {
const {order, product} = this.state;
if (!order || !product) {
return null;
}
const isSubscriptionOrder = order.pricingName && order.planName;
return (
<div className="login-content">
<Spin spinning={this.state.isProcessingPayment} size="large" tip={i18next.t("product:Processing payment...")} style={{paddingTop: "10%"}} >
<div style={{marginBottom: "20px"}}>
<Descriptions title={<span style={Setting.isMobile() ? {fontSize: 18} : {fontSize: 24}}>{i18next.t("order:Order Information")}</span>} bordered column={3}>
<Descriptions.Item label={i18next.t("order:Order ID")} span={3}>
<span style={{fontSize: 16}}>
{order.name}
</span>
</Descriptions.Item>
<Descriptions.Item label={i18next.t("order:Order Status")}>
<span style={{fontSize: 16}}>
{order.state}
</span>
</Descriptions.Item>
<Descriptions.Item label={i18next.t("general:Created time")}>
<span style={{fontSize: 16}}>
{Setting.getFormattedDate(order.createdTime)}
</span>
</Descriptions.Item>
<Descriptions.Item label={i18next.t("general:User")}>
<span style={{fontSize: 16}}>
{order.user}
</span>
</Descriptions.Item>
</Descriptions>
</div>
<div style={{marginBottom: "20px"}}>
<Descriptions title={<span style={Setting.isMobile() ? {fontSize: 18} : {fontSize: 24}}>{i18next.t("product:Product Information")}</span>} bordered column={3}>
<Descriptions.Item label={i18next.t("general:Name")} span={3}>
<span style={{fontSize: 20}}>
{Setting.getLanguageText(product?.displayName)}
</span>
</Descriptions.Item>
<Descriptions.Item label={i18next.t("product:Image")} span={3}>
<img src={product?.image} alt={Setting.getLanguageText(product?.displayName)} height={90} style={{marginBottom: "20px"}} />
</Descriptions.Item>
<Descriptions.Item label={i18next.t("product:Price")} span={3}>
<span style={{fontSize: 18, fontWeight: "bold"}}>
{this.getProductPrice(product)}
</span>
</Descriptions.Item>
<Descriptions.Item label={i18next.t("product:Detail")} span={3}>
<span style={{fontSize: 16}}>{Setting.getLanguageText(product?.detail)}</span>
</Descriptions.Item>
</Descriptions>
</div>
{isSubscriptionOrder && (
<div style={{marginBottom: "20px"}}>
<Descriptions title={<span style={Setting.isMobile() ? {fontSize: 18} : {fontSize: 24}}>{i18next.t("subscription:Subscription Information")}</span>} bordered column={3}>
<Descriptions.Item label={i18next.t("general:Plan")} span={3}>
<span style={{fontSize: 16}}>{order.planName}</span>
</Descriptions.Item>
<Descriptions.Item label={i18next.t("general:Pricing")} span={3}>
<span style={{fontSize: 16}}>{order.pricingName}</span>
</Descriptions.Item>
</Descriptions>
</div>
)}
<div>
<Descriptions title={<span style={Setting.isMobile() ? {fontSize: 18} : {fontSize: 24}}>{i18next.t("payment:Payment Information")}</span>} bordered column={3}>
<Descriptions.Item label={i18next.t("product:Price")} span={3}>
<span style={{fontSize: 28, color: "red", fontWeight: "bold"}}>
{this.getPrice(order)}
</span>
</Descriptions.Item>
<Descriptions.Item label={i18next.t("product:Pay")} span={3}>
{this.renderPaymentMethods()}
</Descriptions.Item>
</Descriptions>
</div>
</Spin>
</div>
);
}
}
export default OrderPayPage;

View File

@@ -559,6 +559,30 @@ class OrganizationEditPage extends React.Component {
<InputNumber value={this.state.organization.userBalance ?? 0} disabled />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
{Setting.getLabel(i18next.t("organization:Balance credit"), i18next.t("organization:Balance credit - Tooltip"))} :
</Col>
<Col span={4} >
<InputNumber value={this.state.organization.balanceCredit ?? 0} onChange={value => {
this.updateOrganizationField("balanceCredit", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
{Setting.getLabel(i18next.t("organization:Balance currency"), i18next.t("organization:Balance currency - Tooltip"))} :
</Col>
<Col span={4} >
<Select virtual={false} style={{width: "100%"}} value={this.state.organization.balanceCurrency || "USD"} onChange={(value => {
this.updateOrganizationField("balanceCurrency", value);
})}>
{
Setting.CurrencyOptions.map((item, index) => <Option key={index} value={item.id}>{Setting.getCurrencyWithFlag(item.id)}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
{Setting.getLabel(i18next.t("organization:Soft deletion"), i18next.t("organization:Soft deletion - Tooltip"))} :
@@ -611,7 +635,7 @@ class OrganizationEditPage extends React.Component {
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("organization:Navbar items"), i18next.t("organization:Navbar items - Tooltip"))} :
{Setting.getLabel(i18next.t("organization:Admin navbar items"), i18next.t("organization:Admin navbar items - Tooltip"))} :
</Col>
<Col span={22} >
<NavItemTree
@@ -624,6 +648,21 @@ class OrganizationEditPage extends React.Component {
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("organization:User navbar items"), i18next.t("organization:User navbar items - Tooltip"))} :
</Col>
<Col span={22} >
<NavItemTree
disabled={!Setting.isAdminUser(this.props.account)}
checkedKeys={this.state.organization.userNavItems ?? []}
defaultExpandedKeys={["all"]}
onCheck={(checked, _) => {
this.updateOrganizationField("userNavItems", checked);
}}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("organization:Widget items"), i18next.t("organization:Widget items - Tooltip"))} :
@@ -779,7 +818,7 @@ class OrganizationEditPage extends React.Component {
}
{this.state.mode !== "add" && this.state.transactions.length > 0 ? (
<Card size="small" title={i18next.t("transaction:Transactions")} style={{marginTop: "20px"}} type="inner">
<TransactionTable transactions={this.state.transactions} />
<TransactionTable transactions={this.state.transactions} includeUser={true} />
</Card>
) : null}
<div style={{marginTop: "20px", marginLeft: "40px"}}>

View File

@@ -51,6 +51,7 @@ class OrganizationListPage extends BaseListPage {
enableTour: true,
disableSignin: false,
mfaRememberInHours: DefaultMfaRememberInHours,
balanceCurrency: "USD",
accountItems: [
{name: "Organization", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "ID", visible: true, viewRule: "Public", modifyRule: "Immutable"},
@@ -81,6 +82,8 @@ class OrganizationListPage extends BaseListPage {
{name: "Karma", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Ranking", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Balance", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Balance credit", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Balance currency", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Signup application", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Register type", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Register source", visible: true, viewRule: "Public", modifyRule: "Admin"},
@@ -258,6 +261,26 @@ class OrganizationListPage extends BaseListPage {
return text ?? 0;
},
},
{
title: i18next.t("organization:Balance credit"),
dataIndex: "balanceCredit",
key: "balanceCredit",
width: "120px",
sorter: true,
render: (text, record, index) => {
return text ?? 0;
},
},
{
title: i18next.t("organization:Balance currency"),
dataIndex: "balanceCurrency",
key: "balanceCurrency",
width: "140px",
sorter: true,
render: (text, record, index) => {
return text || "USD";
},
},
{
title: i18next.t("organization:Soft deletion"),
dataIndex: "enableSoftDeletion",
@@ -266,7 +289,7 @@ class OrganizationListPage extends BaseListPage {
sorter: true,
render: (text, record, index) => {
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
<Switch disabled checkedChildren={i18next.t("general:ON")} unCheckedChildren={i18next.t("general:OFF")} checked={text} />
);
},
},

View File

@@ -233,9 +233,13 @@ class PaymentEditPage extends React.Component {
{Setting.getLabel(i18next.t("payment:Currency"), i18next.t("payment:Currency - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.payment.currency} onChange={e => {
<Select virtual={false} style={{width: "100%"}} value={this.state.payment.currency} disabled={true} onChange={(value => {
// this.updatePaymentField('currency', e.target.value);
}} />
})}>
{
Setting.CurrencyOptions.map((item, index) => <Option key={index} value={item.id}>{Setting.getCurrencyWithFlag(item.id)}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >

View File

@@ -204,6 +204,9 @@ class PaymentListPage extends BaseListPage {
width: "120px",
sorter: true,
...this.getColumnSearchProps("currency"),
render: (text, record, index) => {
return Setting.getCurrencyWithFlag(text);
},
},
{
title: i18next.t("general:State"),

View File

@@ -306,7 +306,7 @@ class PermissionListPage extends BaseListPage {
sorter: true,
render: (text, record, index) => {
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
<Switch disabled checkedChildren={i18next.t("general:ON")} unCheckedChildren={i18next.t("general:OFF")} checked={text} />
);
},
},

View File

@@ -150,6 +150,7 @@ class PlanEditPage extends React.Component {
this.updatePlanField("owner", owner);
this.getUsers(owner);
this.getRoles(owner);
this.getPaymentProviders(owner);
})}
options={this.state.organizations.map((organization) => Setting.getOption(organization.name, organization.name))
} />
@@ -229,34 +230,7 @@ class PlanEditPage extends React.Component {
this.updatePlanField("currency", value);
})}>
{
[
{id: "USD", name: "USD"},
{id: "CNY", name: "CNY"},
{id: "EUR", name: "EUR"},
{id: "JPY", name: "JPY"},
{id: "GBP", name: "GBP"},
{id: "AUD", name: "AUD"},
{id: "CAD", name: "CAD"},
{id: "CHF", name: "CHF"},
{id: "HKD", name: "HKD"},
{id: "SGD", name: "SGD"},
{id: "BRL", name: "BRL"},
{id: "PLN", name: "PLN"},
{id: "KRW", name: "KRW"},
{id: "INR", name: "INR"},
{id: "RUB", name: "RUB"},
{id: "MXN", name: "MXN"},
{id: "ZAR", name: "ZAR"},
{id: "TRY", name: "TRY"},
{id: "SEK", name: "SEK"},
{id: "NOK", name: "NOK"},
{id: "DKK", name: "DKK"},
{id: "THB", name: "THB"},
{id: "MYR", name: "MYR"},
{id: "TWD", name: "TWD"},
{id: "CZK", name: "CZK"},
{id: "HUF", name: "HUF"},
].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
Setting.CurrencyOptions.map((item, index) => <Option key={index} value={item.id}>{Setting.getCurrencyWithFlag(item.id)}</Option>)
}
</Select>
</Col>

View File

@@ -136,6 +136,9 @@ class PlanListPage extends BaseListPage {
width: "120px",
sorter: true,
...this.getColumnSearchProps("currency"),
render: (text, record, index) => {
return Setting.getCurrencyWithFlag(text);
},
},
{
title: i18next.t("plan:Price"),
@@ -187,7 +190,7 @@ class PlanListPage extends BaseListPage {
sorter: true,
render: (text, record, index) => {
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
<Switch disabled checkedChildren={i18next.t("general:ON")} unCheckedChildren={i18next.t("general:OFF")} checked={text} />
);
},
},

View File

@@ -181,7 +181,7 @@ class PricingListPage extends BaseListPage {
sorter: true,
render: (text, record, index) => {
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
<Switch disabled checkedChildren={i18next.t("general:ON")} unCheckedChildren={i18next.t("general:OFF")} checked={text} />
);
},
},

View File

@@ -18,6 +18,7 @@ import i18next from "i18next";
import * as ProductBackend from "./backend/ProductBackend";
import * as PlanBackend from "./backend/PlanBackend";
import * as PricingBackend from "./backend/PricingBackend";
import * as OrderBackend from "./backend/OrderBackend";
import * as Setting from "./Setting";
class ProductBuyPage extends React.Component {
@@ -118,136 +119,27 @@ class ProductBuyPage extends React.Component {
}
}
getCurrencySymbol(product) {
if (product?.currency === "USD") {
return "$";
} else if (product?.currency === "CNY") {
return "¥";
} else if (product?.currency === "EUR") {
return "€";
} else if (product?.currency === "JPY") {
return "¥";
} else if (product?.currency === "GBP") {
return "£";
} else if (product?.currency === "AUD") {
return "A$";
} else if (product?.currency === "CAD") {
return "C$";
} else if (product?.currency === "CHF") {
return "CHF";
} else if (product?.currency === "HKD") {
return "HK$";
} else if (product?.currency === "SGD") {
return "S$";
} else if (product?.currency === "BRL") {
return "R$";
} else if (product?.currency === "PLN") {
return "zł";
} else if (product?.currency === "KRW") {
return "₩";
} else if (product?.currency === "INR") {
return "₹";
} else if (product?.currency === "RUB") {
return "₽";
} else if (product?.currency === "MXN") {
return "$";
} else if (product?.currency === "ZAR") {
return "R";
} else if (product?.currency === "TRY") {
return "₺";
} else if (product?.currency === "SEK") {
return "kr";
} else if (product?.currency === "NOK") {
return "kr";
} else if (product?.currency === "DKK") {
return "kr";
} else if (product?.currency === "THB") {
return "฿";
} else if (product?.currency === "MYR") {
return "RM";
} else if (product?.currency === "TWD") {
return "NT$";
} else if (product?.currency === "CZK") {
return "Kč";
} else if (product?.currency === "HUF") {
return "Ft";
} else {
return "(Unknown currency)";
}
}
getPrice(product) {
return `${this.getCurrencySymbol(product)}${product?.price} (${Setting.getCurrencyText(product)})`;
return `${Setting.getCurrencySymbol(product?.currency)}${product?.price} (${Setting.getCurrencyText(product)})`;
}
// Call Weechat Pay via jsapi
onBridgeReady(attachInfo) {
const {WeixinJSBridge} = window;
// Setting.showMessage("success", "attachInfo is " + JSON.stringify(attachInfo));
this.setState({
isPlacingOrder: false,
});
WeixinJSBridge.invoke(
"getBrandWCPayRequest", {
"appId": attachInfo.appId,
"timeStamp": attachInfo.timeStamp,
"nonceStr": attachInfo.nonceStr,
"package": attachInfo.package,
"signType": attachInfo.signType,
"paySign": attachInfo.paySign,
},
function(res) {
if (res.err_msg === "get_brand_wcpay_request:ok") {
Setting.goToLink(attachInfo.payment.successUrl);
return ;
} else {
if (res.err_msg === "get_brand_wcpay_request:cancel") {
Setting.showMessage("error", i18next.t("product:Payment cancelled"));
} else {
Setting.showMessage("error", i18next.t("product:Payment failed"));
}
}
}
);
}
// In Wechat browser, call this function to pay via jsapi
callWechatPay(attachInfo) {
const {WeixinJSBridge} = window;
if (typeof WeixinJSBridge === "undefined") {
if (document.addEventListener) {
document.addEventListener("WeixinJSBridgeReady", () => this.onBridgeReady(attachInfo), false);
} else if (document.attachEvent) {
document.attachEvent("WeixinJSBridgeReady", () => this.onBridgeReady(attachInfo));
document.attachEvent("onWeixinJSBridgeReady", () => this.onBridgeReady(attachInfo));
}
} else {
this.onBridgeReady(attachInfo);
}
}
buyProduct(product, provider) {
placeOrder(product) {
this.setState({
isPlacingOrder: true,
});
ProductBackend.buyProduct(product.owner, product.name, provider.name, this.state.pricingName ?? "", this.state.planName ?? "", this.state.userName ?? "", this.state.paymentEnv, this.state.customPrice)
const productId = `${product.owner}/${product.name}`;
const pricingName = this.state.pricingName || "";
const planName = this.state.planName || "";
const customPrice = this.state.customPrice || 0;
OrderBackend.placeOrder(productId, pricingName, planName, this.state.userName ?? "", customPrice)
.then((res) => {
if (res.status === "ok") {
const payment = res.data;
const attachInfo = res.data2;
let payUrl = payment.payUrl;
if (provider.type === "WeChat Pay") {
if (this.state.paymentEnv === "WechatBrowser") {
attachInfo.payment = payment;
this.callWechatPay(attachInfo);
return ;
}
payUrl = `/qrcode/${payment.owner}/${payment.name}?providerName=${provider.name}&payUrl=${encodeURIComponent(payment.payUrl)}&successUrl=${encodeURIComponent(payment.successUrl)}`;
}
Setting.goToLink(payUrl);
const order = res.data;
// Redirect to order pay page
Setting.goToLink(`/orders/${order.owner}/${order.name}/pay?created=1`);
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to save")}: ${res.msg}`);
Setting.showMessage("error", `${i18next.t("product:Payment failed")}: ${res.msg}`);
this.setState({
isPlacingOrder: false,
});
@@ -255,49 +147,13 @@ class ProductBuyPage extends React.Component {
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
this.setState({
isPlacingOrder: false,
});
});
}
getPayButton(provider) {
let text = provider.type;
if (provider.type === "Dummy") {
text = i18next.t("product:Dummy");
} else if (provider.type === "Alipay") {
text = i18next.t("product:Alipay");
} else if (provider.type === "WeChat Pay") {
text = i18next.t("product:WeChat Pay");
} else if (provider.type === "PayPal") {
text = i18next.t("product:PayPal");
} else if (provider.type === "Stripe") {
text = i18next.t("product:Stripe");
} else if (provider.type === "AirWallex") {
text = i18next.t("product:AirWallex");
}
return (
<Button style={{height: "50px", borderWidth: "2px"}} shape="round" icon={
<img style={{marginRight: "10px"}} width={36} height={36} src={Setting.getProviderLogoURL(provider)} alt={provider.displayName} />
} size={"large"} >
{
text
}
</Button>
);
}
renderProviderButton(provider, product) {
return (
<span key={provider.name} style={{width: "200px", marginRight: "20px", marginBottom: "10px"}}>
<span style={{width: "200px", cursor: "pointer"}} onClick={() => this.buyProduct(product, provider)}>
{
this.getPayButton(provider)
}
</span>
</span>
);
}
renderPay(product) {
renderPlaceOrderButton(product) {
if (product === undefined || product === null) {
return null;
}
@@ -305,17 +161,32 @@ class ProductBuyPage extends React.Component {
if (product.state !== "Published") {
return i18next.t("product:This product is currently not in sale.");
}
if (product.providerObjs.length === 0) {
return i18next.t("product:There is no payment channel for this product.");
}
return product.providerObjs.map(provider => {
return this.renderProviderButton(provider, product);
});
return (
<div style={{display: "flex", justifyContent: "center", alignItems: "center"}}>
<Button
type="primary"
size="large"
style={{
height: "50px",
fontSize: "18px",
borderRadius: "30px",
paddingLeft: "60px",
paddingRight: "60px",
}}
onClick={() => this.placeOrder(product)}
disabled={this.state.isPlacingOrder}
loading={this.state.isPlacingOrder}
>
{i18next.t("order:Place Order")}
</Button>
</div>
);
}
render() {
const product = this.getProductObj();
const placeOrderButton = this.renderPlaceOrderButton(product);
if (product === null) {
return null;
@@ -357,10 +228,10 @@ class ProductBuyPage extends React.Component {
</React.Fragment>
)
}
<Descriptions.Item label={i18next.t("product:Pay")} span={3}>
{
this.renderPay(product)
}
<Descriptions.Item label={i18next.t("order:Place Order")} span={3}>
<div style={{display: "flex", justifyContent: "center", alignItems: "center", minHeight: "80px"}}>
{placeOrderButton}
</div>
</Descriptions.Item>
</Descriptions>
</Spin>

View File

@@ -206,34 +206,7 @@ class ProductEditPage extends React.Component {
this.updateProductField("currency", value);
})}>
{
[
{id: "USD", name: "USD"},
{id: "CNY", name: "CNY"},
{id: "EUR", name: "EUR"},
{id: "JPY", name: "JPY"},
{id: "GBP", name: "GBP"},
{id: "AUD", name: "AUD"},
{id: "CAD", name: "CAD"},
{id: "CHF", name: "CHF"},
{id: "HKD", name: "HKD"},
{id: "SGD", name: "SGD"},
{id: "BRL", name: "BRL"},
{id: "PLN", name: "PLN"},
{id: "KRW", name: "KRW"},
{id: "INR", name: "INR"},
{id: "RUB", name: "RUB"},
{id: "MXN", name: "MXN"},
{id: "ZAR", name: "ZAR"},
{id: "TRY", name: "TRY"},
{id: "SEK", name: "SEK"},
{id: "NOK", name: "NOK"},
{id: "DKK", name: "DKK"},
{id: "THB", name: "THB"},
{id: "MYR", name: "MYR"},
{id: "TWD", name: "TWD"},
{id: "CZK", name: "CZK"},
{id: "HUF", name: "HUF"},
].map((item, index) => <Option key={index} value={item.id}>{Setting.getCurrencyWithFlag(item.id)}</Option>)
Setting.CurrencyOptions.map((item, index) => <Option key={index} value={item.id}>{Setting.getCurrencyWithFlag(item.id)}</Option>)
}
</Select>
</Col>

146
web/src/ProductStorePage.js Normal file
View File

@@ -0,0 +1,146 @@
// Copyright 2022 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 {Button, Card, Col, Row, Tag, Typography} from "antd";
import * as Setting from "./Setting";
import * as ProductBackend from "./backend/ProductBackend";
import i18next from "i18next";
const {Text, Title} = Typography;
class ProductStorePage extends React.Component {
constructor(props) {
super(props);
this.state = {
products: [],
loading: true,
};
}
componentDidMount() {
this.getProducts();
}
getProducts() {
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});
ProductBackend.getProducts(owner, 1, pageSize, "state", "Published", "", "")
.then((res) => {
if (res.status === "ok") {
this.setState({
products: res.data,
loading: false,
});
} else {
Setting.showMessage("error", res.msg);
this.setState({loading: false});
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
this.setState({loading: false});
});
}
handleBuyProduct(product) {
this.props.history.push(`/products/${product.owner}/${product.name}/buy`);
}
renderProductCard(product) {
return (
<Col xs={24} sm={12} md={8} lg={6} key={`${product.owner}/${product.name}`} style={{marginBottom: "20px"}}>
<Card
hoverable
onClick={() => this.handleBuyProduct(product)}
style={{cursor: "pointer", height: "100%", display: "flex", flexDirection: "column"}}
cover={
<div style={{height: "200px", overflow: "hidden", display: "flex", alignItems: "center", justifyContent: "center", backgroundColor: "#f0f0f0"}}>
<img
alt={product.displayName}
src={product.image}
style={{width: "100%", height: "100%", objectFit: "contain"}}
/>
</div>
}
actions={[
<Button
key="buy"
type="primary"
onClick={(e) => {
e.stopPropagation();
this.handleBuyProduct(product);
}}
>
{i18next.t("product:Buy")}
</Button>,
]}
bodyStyle={{flex: 1, display: "flex", flexDirection: "column"}}
>
<div style={{flex: 1, display: "flex", flexDirection: "column"}}>
<Title level={5} ellipsis={{rows: 2}} style={{margin: "0 0 4px 0", minHeight: "44px"}}>
{Setting.getLanguageText(product.displayName)}
</Title>
<Text style={{display: "block", marginBottom: 4, minHeight: "40px"}} ellipsis={{rows: 2}}>
{Setting.getLanguageText(product.detail)}
</Text>
{product.tag && (
<Tag color="blue" style={{marginBottom: 4, display: "inline-block"}}>{product.tag}</Tag>
)}
<div style={{marginTop: "auto"}}>
<div style={{marginBottom: 4}}>
<Text strong style={{fontSize: "24px", color: "#ff4d4f"}}>
{Setting.getCurrencySymbol(product.currency)}{product.price}
</Text>
<Text type="secondary" style={{fontSize: "12px", marginLeft: 8}}>
{Setting.getCurrencyWithFlag(product.currency)}
</Text>
</div>
<div>
<Text type="secondary" style={{fontSize: "12px"}}>
{i18next.t("product:Sold")}: {product.sold}
</Text>
</div>
</div>
</div>
</Card>
</Col>
);
}
render() {
return (
<div>
<Row gutter={[16, 16]}>
{this.state.loading ? (
<Col span={24}>
<Card loading={true} />
</Col>
) : this.state.products.length === 0 ? (
<Col span={24}>
<Card>
<Text type="secondary">{i18next.t("general:No products available")}</Text>
</Card>
</Col>
) : (
this.state.products.map(product => this.renderProductCard(product))
)}
</Row>
</div>
);
}
}
export default ProductStorePage;

View File

@@ -1811,6 +1811,20 @@ class ProviderEditPage extends React.Component {
</Row>
) : null
}
{
(this.state.provider.type === "GC" || this.state.provider.type === "FastSpring") ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Host"), i18next.t("provider:Host - Tooltip"))} :
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={this.state.provider.host} onChange={e => {
this.updateProviderField("host", e.target.value);
}} />
</Col>
</Row>
) : null
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Provider URL"), i18next.t("provider:Provider URL - Tooltip"))} :

View File

@@ -192,7 +192,7 @@ class RecordListPage extends BaseListPage {
}
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
<Switch disabled checkedChildren={i18next.t("general:ON")} unCheckedChildren={i18next.t("general:OFF")} checked={text} />
);
},
},

View File

@@ -214,7 +214,7 @@ class RoleListPage extends BaseListPage {
sorter: true,
render: (text, record, index) => {
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
<Switch disabled checkedChildren={i18next.t("general:ON")} unCheckedChildren={i18next.t("general:OFF")} checked={text} />
);
},
},

View File

@@ -288,6 +288,22 @@ export const OtherProviderInfo = {
logo: `${StaticBaseUrl}/img/payment_gc.png`,
url: "https://gc.org",
},
"Polar": {
logo: `${StaticBaseUrl}/img/payment_polar.png`,
url: "https://polar.sh/",
},
"Paddle": {
logo: `${StaticBaseUrl}/img/payment_paddle.png`,
url: "https://www.paddle.com/",
},
"FastSpring": {
logo: `${StaticBaseUrl}/img/payment_fastspring.png`,
url: "https://fastspring.com/",
},
"Lemon Squeezy": {
logo: `${StaticBaseUrl}/img/payment_lemonsqueezy.png`,
url: "https://www.lemonsqueezy.com/",
},
},
Captcha: {
"Default": {
@@ -435,6 +451,120 @@ export const OtherProviderInfo = {
},
};
export const UserFields = ["owner", "name", "password", "display_name", "id", "type", "email", "phone", "country_code",
"is_admin", "homepage", "birthday", "gender", "password_type", "password_salt", "external_id", "avatar", "first_name", "last_name",
"avatar_type", "permanent_avatar", "email_verified", "region", "location", "address",
"affiliation", "title", "id_card_type", "id_card", "bio", "tag", "language",
"education", "score", "karma", "ranking", "balance", "currency", "is_default_avatar", "is_online",
"is_forbidden", "is_deleted", "signup_application", "hash", "pre_hash", "access_key", "access_secret", "access_token",
"created_ip", "last_signin_time", "last_signin_ip", "github", "google", "qq", "wechat", "facebook", "dingtalk",
"weibo", "gitee", "linkedin", "wecom", "lark", "gitlab", "adfs", "baidu", "alipay", "casdoor", "infoflow", "apple",
"azuread", "azureadb2c", "slack", "steam", "bilibili", "okta", "douyin", "kwai", "line", "amazon", "auth0",
"battlenet", "bitbucket", "box", "cloudfoundry", "dailymotion", "deezer", "digitalocean", "discord", "dropbox",
"eveonline", "fitbit", "gitea", "heroku", "influxcloud", "instagram", "intercom", "kakao", "lastfm", "mailru",
"meetup", "microsoftonline", "naver", "nextcloud", "onedrive", "oura", "patreon", "paypal", "salesforce", "shopify",
"soundcloud", "spotify", "strava", "stripe", "tiktok", "tumblr", "twitch", "twitter", "typetalk", "uber", "vk",
"wepay", "xero", "yahoo", "yammer", "yandex", "zoom", "metamask", "web3onboard", "custom", "webauthnCredentials",
"preferred_mfa_type", "recovery_codes", "totp_secret", "mfa_phone_enabled", "mfa_email_enabled", "invitation",
"invitation_code", "face_ids", "ldap", "properties", "roles", "permissions", "groups", "last_change_password_time",
"last_signin_wrong_time", "signin_wrong_times", "managedAccounts", "mfaAccounts", "need_update_password",
"created_time", "updated_time", "deleted_time",
"ip_whitelist"];
export const GetTranslatedUserItems = () => {
return [
{name: "Organization", label: i18next.t("general:Organization")},
{name: "ID", label: i18next.t("general:ID")},
{name: "Name", label: i18next.t("general:Name")},
{name: "Display name", label: i18next.t("general:Display name")},
{name: "First name", label: i18next.t("general:First name")},
{name: "Last name", label: i18next.t("general:Last name")},
{name: "Avatar", label: i18next.t("general:Avatar")},
{name: "User type", label: i18next.t("general:User type")},
{name: "Password", label: i18next.t("general:Password")},
{name: "Email", label: i18next.t("general:Email")},
{name: "Phone", label: i18next.t("general:Phone")},
{name: "Country code", label: i18next.t("user:Country code")},
{name: "Country/Region", label: i18next.t("user:Country/Region")},
{name: "Location", label: i18next.t("user:Location")},
{name: "Address", label: i18next.t("user:Address")},
{name: "Affiliation", label: i18next.t("user:Affiliation")},
{name: "Title", label: i18next.t("user:Title")},
{name: "ID card type", label: i18next.t("user:ID card type")},
{name: "ID card", label: i18next.t("user:ID card")},
{name: "ID card info", label: i18next.t("user:ID card info")},
{name: "Homepage", label: i18next.t("user:Homepage")},
{name: "Bio", label: i18next.t("user:Bio")},
{name: "Tag", label: i18next.t("user:Tag")},
{name: "Language", label: i18next.t("user:Language")},
{name: "Gender", label: i18next.t("user:Gender")},
{name: "Birthday", label: i18next.t("user:Birthday")},
{name: "Education", label: i18next.t("user:Education")},
{name: "Balance", label: i18next.t("user:Balance")},
{name: "Balance currency", label: i18next.t("organization:Balance currency")},
{name: "Balance credit", label: i18next.t("organization:Balance credit")},
{name: "Transactions", label: i18next.t("transaction:Transactions")},
{name: "Score", label: i18next.t("user:Score")},
{name: "Karma", label: i18next.t("user:Karma")},
{name: "Ranking", label: i18next.t("user:Ranking")},
{name: "Signup application", label: i18next.t("general:Signup application")},
{name: "API key", label: i18next.t("general:API key")},
{name: "Groups", label: i18next.t("general:Groups")},
{name: "Roles", label: i18next.t("general:Roles")},
{name: "Permissions", label: i18next.t("general:Permissions")},
{name: "3rd-party logins", label: i18next.t("user:3rd-party logins")},
{name: "Properties", label: i18next.t("user:Properties")},
{name: "Is online", label: i18next.t("user:Is online")},
{name: "Is admin", label: i18next.t("user:Is admin")},
{name: "Is forbidden", label: i18next.t("user:Is forbidden")},
{name: "Is deleted", label: i18next.t("user:Is deleted")},
{name: "Need update password", label: i18next.t("user:Need update password")},
{name: "IP whitelist", label: i18next.t("general:IP whitelist")},
{name: "Multi-factor authentication", label: i18next.t("user:Multi-factor authentication")},
{name: "WebAuthn credentials", label: i18next.t("user:WebAuthn credentials")},
{name: "Managed accounts", label: i18next.t("user:Managed accounts")},
{name: "Face ID", label: i18next.t("user:Face ID")},
{name: "MFA accounts", label: i18next.t("user:MFA accounts")},
{name: "MFA items", label: i18next.t("general:MFA items")},
];
};
export function getUserColumns() {
const items = GetTranslatedUserItems();
return UserFields.map(field => {
let transField = "";
if (field === "webauthnCredentials") {
transField = "WebAuthn credentials";
} else if (field === "region") {
transField = "Country/Region";
} else if (field === "mfaAccounts") {
transField = "MFA accounts";
} else if (field === "face_ids") {
transField = "Face ID";
} else if (field === "managedAccounts") {
transField = "Managed accounts";
} else {
transField = field.toLowerCase().split("_").join(" ");
transField = transField.charAt(0).toUpperCase() + transField.slice(1);
transField = transField.replace("ip", "IP")
.replace("Ip", "IP")
.replace("Id", "ID")
.replace("id", "ID");
}
if (transField === "Owner") {
transField = "Organization";
}
const transFieldItem = items.find(item => item.name === transField);
if (transFieldItem === undefined) {
const toTranslateList = ["general", "user", "organization"].map(ns => `${ns}:${transField}`);
const transResult = toTranslateList.map(item => i18next.t(item) === transField ? null : i18next.t(item))
.find(item => item !== null);
transField = transResult ? transResult : transField;
}
return `${transFieldItem ? transFieldItem.label : transField}#${field}`;
});
}
export function initCountries() {
const countries = require("i18n-iso-countries");
countries.registerLocale(require("i18n-iso-countries/langs/" + getLanguage() + ".json"));
@@ -1148,6 +1278,10 @@ export function getProviderTypeOptions(category) {
{id: "Stripe", name: "Stripe"},
{id: "AirWallex", name: "AirWallex"},
{id: "GC", name: "GC"},
{id: "Polar", name: "Polar"},
{id: "Paddle", name: "Paddle"},
{id: "FastSpring", name: "FastSpring"},
{id: "Lemon Squeezy", name: "Lemon Squeezy"},
]);
} else if (category === "Captcha") {
return ([
@@ -1544,6 +1678,35 @@ export function builtInObject(obj) {
return obj.owner === "built-in" && BuiltInObjects.includes(obj.name);
}
export const CurrencyOptions = [
{id: "USD", name: "USD"},
{id: "CNY", name: "CNY"},
{id: "EUR", name: "EUR"},
{id: "JPY", name: "JPY"},
{id: "GBP", name: "GBP"},
{id: "AUD", name: "AUD"},
{id: "CAD", name: "CAD"},
{id: "CHF", name: "CHF"},
{id: "HKD", name: "HKD"},
{id: "SGD", name: "SGD"},
{id: "BRL", name: "BRL"},
{id: "PLN", name: "PLN"},
{id: "KRW", name: "KRW"},
{id: "INR", name: "INR"},
{id: "RUB", name: "RUB"},
{id: "MXN", name: "MXN"},
{id: "ZAR", name: "ZAR"},
{id: "TRY", name: "TRY"},
{id: "SEK", name: "SEK"},
{id: "NOK", name: "NOK"},
{id: "DKK", name: "DKK"},
{id: "THB", name: "THB"},
{id: "MYR", name: "MYR"},
{id: "TWD", name: "TWD"},
{id: "CZK", name: "CZK"},
{id: "HUF", name: "HUF"},
];
export function getCurrencySymbol(currency) {
if (currency === "USD" || currency === "usd") {
return "$";
@@ -1636,15 +1799,19 @@ export function getCurrencyCountryCode(currency) {
}
export function getCurrencyWithFlag(currency) {
const translationKey = `currency:${currency}`;
const translatedText = i18next.t(translationKey);
const currencyText = translatedText === translationKey ? currency : translatedText;
const countryCode = getCurrencyCountryCode(currency);
if (!countryCode) {
return currency;
return currencyText;
}
return (
<span>
<img src={`${StaticBaseUrl}/flag-icons/${countryCode}.svg`} alt={`${currency} flag`} height={20} style={{marginRight: 5}} />
{currency}
{currencyText}
</span>
);
}
@@ -1661,6 +1828,14 @@ export function getFriendlyUserName(account) {
}
}
export function isAnonymousUserName(userName) {
if (!userName) {
return false;
}
return /^u-[0-9a-f]{8}$/i.test(userName);
}
export function getUserCommonFields() {
return ["Owner", "Name", "CreatedTime", "UpdatedTime", "DeletedTime", "Id", "Type", "Password", "PasswordSalt", "DisplayName", "FirstName", "LastName", "Avatar", "PermanentAvatar",
"Email", "EmailVerified", "Phone", "Location", "Address", "Affiliation", "Title", "IdCardType", "IdCard", "Homepage", "Bio", "Tag", "Region",

View File

@@ -143,6 +143,9 @@ class SubscriptionListPage extends BaseListPage {
key: "startTime",
width: "140px",
...this.getColumnSearchProps("startTime"),
render: (text, record, index) => {
return Setting.getFormattedDate(text);
},
},
{
title: i18next.t("subscription:End time"),
@@ -150,6 +153,9 @@ class SubscriptionListPage extends BaseListPage {
key: "endTime",
width: "140px",
...this.getColumnSearchProps("endTime"),
render: (text, record, index) => {
return Setting.getFormattedDate(text);
},
},
{
title: i18next.t("general:Plan"),

View File

@@ -61,10 +61,15 @@ class SyncerEditPage extends React.Component {
this.setState({
syncer: res.data,
});
if (res.data && res.data.organization) {
this.getCerts(res.data.organization);
}
});
}
getCerts(owner) {
// Load certificates for the given organization
CertBackend.getCerts(owner)
.then((res) => {
this.setState({
@@ -79,9 +84,6 @@ class SyncerEditPage extends React.Component {
this.setState({
organizations: res.data || [],
});
if (res.data) {
this.getCerts(`${res.data.owner}/${res.data.name}`);
}
});
}
@@ -96,6 +98,12 @@ class SyncerEditPage extends React.Component {
value = this.parseSyncerField(key, value);
const syncer = this.state.syncer;
if (key === "organization" && syncer["organization"] !== value) {
// the syncer changed the organization, reset the cert and reload certs
syncer["cert"] = "";
this.getCerts(value);
}
syncer[key] = value;
this.setState({
syncer: syncer,
@@ -320,11 +328,140 @@ class SyncerEditPage extends React.Component {
"values": [],
},
];
case "Google Workspace":
return [
{
"name": "id",
"type": "string",
"casdoorName": "Id",
"isHashed": true,
"values": [],
},
{
"name": "primaryEmail",
"type": "string",
"casdoorName": "Name",
"isHashed": true,
"values": [],
},
{
"name": "name.fullName",
"type": "string",
"casdoorName": "DisplayName",
"isHashed": true,
"values": [],
},
{
"name": "name.givenName",
"type": "string",
"casdoorName": "FirstName",
"isHashed": true,
"values": [],
},
{
"name": "name.familyName",
"type": "string",
"casdoorName": "LastName",
"isHashed": true,
"values": [],
},
{
"name": "suspended",
"type": "boolean",
"casdoorName": "IsForbidden",
"isHashed": true,
"values": [],
},
{
"name": "isAdmin",
"type": "boolean",
"casdoorName": "IsAdmin",
"isHashed": true,
"values": [],
},
];
case "Active Directory":
return [
{
"name": "objectGUID",
"type": "string",
"casdoorName": "Id",
"isHashed": true,
"values": [],
},
{
"name": "sAMAccountName",
"type": "string",
"casdoorName": "Name",
"isHashed": true,
"values": [],
},
{
"name": "displayName",
"type": "string",
"casdoorName": "DisplayName",
"isHashed": true,
"values": [],
},
{
"name": "givenName",
"type": "string",
"casdoorName": "FirstName",
"isHashed": true,
"values": [],
},
{
"name": "sn",
"type": "string",
"casdoorName": "LastName",
"isHashed": true,
"values": [],
},
{
"name": "mail",
"type": "string",
"casdoorName": "Email",
"isHashed": true,
"values": [],
},
{
"name": "mobile",
"type": "string",
"casdoorName": "Phone",
"isHashed": true,
"values": [],
},
{
"name": "title",
"type": "string",
"casdoorName": "Title",
"isHashed": true,
"values": [],
},
{
"name": "department",
"type": "string",
"casdoorName": "Affiliation",
"isHashed": true,
"values": [],
},
{
"name": "userAccountControl",
"type": "string",
"casdoorName": "IsForbidden",
"isHashed": true,
"values": [],
},
];
default:
return [];
}
}
needSshfields() {
return this.state.syncer.type === "Database" && (this.state.syncer.databaseType === "mysql" || this.state.syncer.databaseType === "mssql" || this.state.syncer.databaseType === "postgres");
}
renderSyncer() {
return (
<Card size="small" title={
@@ -372,14 +509,14 @@ class SyncerEditPage extends React.Component {
});
})}>
{
["Database", "Keycloak", "WeCom", "Azure AD"]
["Database", "Keycloak", "WeCom", "Azure AD", "Active Directory", "Google Workspace"]
.map((item, index) => <Option key={index} value={item}>{item}</Option>)
}
</Select>
</Col>
</Row>
{
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" ? null : (
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Active Directory" || this.state.syncer.type === "Google Workspace" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("syncer:Database type"), i18next.t("syncer:Database type - Tooltip"))} :
@@ -434,7 +571,7 @@ class SyncerEditPage extends React.Component {
this.state.syncer.type === "WeCom" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(this.state.syncer.type === "Azure AD" ? i18next.t("provider:Tenant ID") : i18next.t("provider:Host"), i18next.t("provider:Host - Tooltip"))} :
{Setting.getLabel(this.state.syncer.type === "Azure AD" ? i18next.t("provider:Tenant ID") : this.state.syncer.type === "Google Workspace" ? i18next.t("syncer:Admin Email") : this.state.syncer.type === "Active Directory" ? i18next.t("ldap:Server") : i18next.t("provider:Host"), i18next.t("provider:Host - Tooltip"))} :
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={this.state.syncer.host} onChange={e => {
@@ -445,10 +582,10 @@ class SyncerEditPage extends React.Component {
)
}
{
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" ? null : (
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Port"), i18next.t("provider:Port - Tooltip"))} :
{Setting.getLabel(this.state.syncer.type === "Active Directory" ? i18next.t("provider:LDAP port") : i18next.t("provider:Port"), i18next.t("provider:Port - Tooltip"))} :
</Col>
<Col span={22} >
<InputNumber value={this.state.syncer.port} onChange={value => {
@@ -458,41 +595,55 @@ class SyncerEditPage extends React.Component {
</Row>
)
}
{
this.state.syncer.type === "Google Workspace" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(
this.state.syncer.type === "WeCom" ? i18next.t("syncer:Corp ID") :
this.state.syncer.type === "Azure AD" ? i18next.t("provider:Client ID") :
this.state.syncer.type === "Active Directory" ? i18next.t("syncer:Bind DN") :
i18next.t("general:User"),
i18next.t("general:User - Tooltip")
)} :
</Col>
<Col span={22} >
<Input value={this.state.syncer.user} onChange={e => {
this.updateSyncerField("user", e.target.value);
}} />
</Col>
</Row>
)
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(
this.state.syncer.type === "WeCom" ? i18next.t("provider:Corp ID") :
this.state.syncer.type === "Azure AD" ? i18next.t("provider:Client ID") :
i18next.t("general:User"),
i18next.t("general:User - Tooltip")
)} :
</Col>
<Col span={22} >
<Input value={this.state.syncer.user} onChange={e => {
this.updateSyncerField("user", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(
this.state.syncer.type === "WeCom" ? i18next.t("provider:Corp Secret") :
this.state.syncer.type === "Azure AD" ? i18next.t("provider:Client Secret") :
i18next.t("general:Password"),
this.state.syncer.type === "WeCom" ? i18next.t("syncer:Corp secret") :
this.state.syncer.type === "Azure AD" ? i18next.t("provider:Client secret") :
this.state.syncer.type === "Google Workspace" ? i18next.t("syncer:Service account key") :
i18next.t("general:Password"),
i18next.t("general:Password - Tooltip")
)} :
</Col>
<Col span={22} >
<Input.Password value={this.state.syncer.password} onChange={e => {
this.updateSyncerField("password", e.target.value);
}} />
{
this.state.syncer.type === "Google Workspace" ? (
<Input.TextArea rows={4} value={this.state.syncer.password} onChange={e => {
this.updateSyncerField("password", e.target.value);
}} placeholder={i18next.t("syncer:Paste your Google Workspace service account JSON key here")} />
) : (
<Input.Password value={this.state.syncer.password} onChange={e => {
this.updateSyncerField("password", e.target.value);
}} />
)
}
</Col>
</Row>
{
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" ? null : (
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("syncer:Database"), i18next.t("syncer:Database - Tooltip"))} :
{Setting.getLabel(this.state.syncer.type === "Active Directory" ? i18next.t("syncer:Base DN") : i18next.t("syncer:Database"), i18next.t("syncer:Database - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.syncer.database} onChange={e => {
@@ -503,7 +654,7 @@ class SyncerEditPage extends React.Component {
)
}
{
this.state.syncer.databaseType === "mysql" || this.state.syncer.databaseType === "mssql" || this.state.syncer.databaseType === "postgres" ? (
this.needSshfields() ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:SSH type"), i18next.t("general:SSH type - Tooltip"))} :
@@ -521,7 +672,7 @@ class SyncerEditPage extends React.Component {
) : null
}
{
this.state.syncer.sshType && this.state.syncer.databaseType === "mysql" || this.state.syncer.databaseType === "mssql" || this.state.syncer.databaseType === "postgres" ? (
this.state.syncer.sshType && this.needSshfields() ? (
<React.Fragment>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
@@ -554,7 +705,7 @@ class SyncerEditPage extends React.Component {
</Col>
</Row>
{
this.state.syncer.sshType === "password" && (this.state.syncer.databaseType === "mysql" || this.state.syncer.databaseType === "mssql" || this.state.syncer.databaseType === "postgres") ?
this.state.syncer.sshType === "password" && this.needSshfields() ?
(
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
@@ -585,7 +736,7 @@ class SyncerEditPage extends React.Component {
) : null
}
{
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" ? null : (
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("syncer:Table"), i18next.t("syncer:Table - Tooltip"))} :

View File

@@ -227,7 +227,7 @@ class SyncerListPage extends BaseListPage {
sorter: true,
render: (text, record, index) => {
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
<Switch disabled checkedChildren={i18next.t("general:ON")} unCheckedChildren={i18next.t("general:OFF")} checked={text} />
);
},
},

View File

@@ -14,11 +14,15 @@
import React from "react";
import * as TransactionBackend from "./backend/TransactionBackend";
import * as Setting from "./Setting";
import * as OrganizationBackend from "./backend/OrganizationBackend";
import * as ApplicationBackend from "./backend/ApplicationBackend";
import {Button, Card, Col, Input, Row} from "antd";
import * as UserBackend from "./backend/UserBackend";
import * as Setting from "./Setting";
import {Button, Card, Col, Input, InputNumber, Row, Select} from "antd";
import i18next from "i18next";
const {Option} = Select;
class TransactionEditPage extends React.Component {
constructor(props) {
super(props);
@@ -26,15 +30,21 @@ class TransactionEditPage extends React.Component {
classes: props,
organizationName: props.organizationName !== undefined ? props.organizationName : props.match.params.organizationName,
transactionName: props.match.params.transactionName,
application: null,
transaction: null,
providers: [],
mode: props.location.mode !== undefined ? props.location.mode : "edit",
organizations: [],
applications: [],
users: [],
};
}
UNSAFE_componentWillMount() {
this.getTransaction();
if (this.state.mode === "recharge") {
this.getOrganizations();
this.getApplications(this.state.organizationName);
this.getUsers(this.state.organizationName);
}
}
getTransaction() {
@@ -45,15 +55,71 @@ class TransactionEditPage extends React.Component {
return;
}
if (res.status === "error") {
Setting.showMessage("error", res.msg);
return;
}
this.setState({
transaction: res.data,
});
Setting.scrollToDiv("invoice-area");
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
getOrganizations() {
const isGlobalAdmin = Setting.isAdminUser(this.props.account);
const owner = isGlobalAdmin ? "admin" : this.state.organizationName;
OrganizationBackend.getOrganizations(owner)
.then((res) => {
if (res.status === "ok") {
this.setState({
organizations: res.data || [],
});
} else {
Setting.showMessage("error", res.msg);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
getApplications(organizationName) {
const targetOrganizationName = organizationName || this.state.organizationName;
ApplicationBackend.getApplicationsByOrganization("admin", targetOrganizationName)
.then((res) => {
this.setState({
applications: res.data || [],
});
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
getUsers(organizationName) {
const targetOrganizationName = organizationName || this.state.organizationName;
UserBackend.getUsers(targetOrganizationName)
.then((res) => {
this.setState({
users: res.data || [],
});
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
submitTransactionEdit(exitAfterSave) {
if (this.state.transaction === null) {
return;
}
const transaction = Setting.deepCopy(this.state.transaction);
TransactionBackend.updateTransaction(this.state.transaction.owner, this.state.transactionName, transaction)
.then((res) => {
@@ -70,7 +136,7 @@ class TransactionEditPage extends React.Component {
}
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to save")}: ${res.msg}`);
this.updatePaymentField("name", this.state.transactionName);
this.updateTransactionField("name", this.state.transactionName);
}
})
.catch(error => {
@@ -79,6 +145,9 @@ class TransactionEditPage extends React.Component {
}
deleteTransaction() {
if (this.state.transaction === null) {
return;
}
TransactionBackend.deleteTransaction(this.state.transaction)
.then((res) => {
if (res.status === "ok") {
@@ -93,64 +162,69 @@ class TransactionEditPage extends React.Component {
}
parseTransactionField(key, value) {
if ([""].includes(key)) {
value = Setting.myParseInt(value);
if (["amount"].includes(key)) {
value = parseFloat(value);
if (isNaN(value)) {
value = 0;
}
}
return value;
}
getApplication() {
ApplicationBackend.getApplication("admin", this.state.applicationName)
.then((res) => {
if (res.data === null) {
this.props.history.push("/404");
return;
}
updateTransactionField(key, value) {
value = this.parseTransactionField(key, value);
if (res.status === "error") {
Setting.showMessage("error", res.msg);
return;
}
const application = res.data;
if (application.grantTypes === null || application.grantTypes === undefined || application.grantTypes.length === 0) {
application.grantTypes = ["authorization_code"];
}
if (application.tags === null || application.tags === undefined) {
application.tags = [];
}
this.setState({
application: application,
});
this.getCerts(application);
this.getSamlMetadata(application.enableSamlPostBinding);
});
const transaction = this.state.transaction;
transaction[key] = value;
this.setState({
transaction: transaction,
});
}
renderTransaction() {
const isRechargeMode = this.state.mode === "recharge";
const title = isRechargeMode ? i18next.t("transaction:Recharge") : (this.state.mode === "add" ? i18next.t("transaction:New Transaction") : i18next.t("transaction:Edit Transaction"));
return (
<Card size="small" title={
<div>
{this.state.mode === "add" ? i18next.t("transaction:New Transaction") : i18next.t("transaction:Edit Transaction")}&nbsp;&nbsp;&nbsp;&nbsp;
{title}&nbsp;&nbsp;&nbsp;&nbsp;
<Button onClick={() => this.submitTransactionEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" onClick={() => this.submitTransactionEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} onClick={() => this.deleteTransaction()}>{i18next.t("general:Cancel")}</Button> : null}
{(this.state.mode === "add" || isRechargeMode) ? <Button style={{marginLeft: "20px"}} onClick={() => this.deleteTransaction()}>{i18next.t("general:Cancel")}</Button> : null}
</div>
} style={(Setting.isMobile()) ? {margin: "5px"} : {}} type="inner">
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Organization"), i18next.t("general:Organization - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.transaction.owner} onChange={e => {
// this.updatePaymentField('organization', e.target.value);
}} />
</Col>
</Row>
{isRechargeMode ? (
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Organization"), i18next.t("general:Organization - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.transaction.owner}
onChange={(value) => {
this.updateTransactionField("owner", value);
this.updateTransactionField("application", "");
this.getApplications(value);
this.getUsers(value);
}}>
{
this.state.organizations.map((org, index) => <Option key={index} value={org.name}>{org.name}</Option>)
}
</Select>
</Col>
</Row>
) : (
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Organization"), i18next.t("general:Organization - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.transaction.owner} onChange={e => {
// this.updatePaymentField('organization', e.target.value);
}} />
</Col>
</Row>
)}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Name"), i18next.t("general:Name - Tooltip"))} :
@@ -161,13 +235,69 @@ class TransactionEditPage extends React.Component {
}} />
</Col>
</Row>
{isRechargeMode ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Application"), i18next.t("general:Application - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.transaction.application}
allowClear
onChange={(value) => {
this.updateTransactionField("application", value || "");
}}>
{
this.state.applications.map((app, index) => <Option key={index} value={app.name}>{app.name}</Option>)
}
</Select>
</Col>
</Row>
) : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Application"), i18next.t("general:Application - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.transaction.application} onChange={e => {
}} />
</Col>
</Row>
)}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Display name"), i18next.t("general:Display name - Tooltip"))} :
{Setting.getLabel(i18next.t("provider:Domain"), i18next.t("provider:Domain - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.transaction.displayName} onChange={e => {
this.updatePaymentField("displayName", e.target.value);
<Input disabled={true} value={this.state.transaction.domain} onChange={e => {
// this.updatePaymentField('domain', e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Category"), i18next.t("provider:Category - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.transaction.category} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Type"), i18next.t("payment:Type - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.transaction.type} onChange={e => {
// this.updatePaymentField('type', e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Subtype"), i18next.t("provider:Subtype - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.transaction.subtype} onChange={e => {
// this.updatePaymentField('subtype', e.target.value);
}} />
</Col>
</Row>
@@ -181,84 +311,27 @@ class TransactionEditPage extends React.Component {
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Category"), i18next.t("provider:Category - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.transaction.category} onChange={e => {
this.updatePaymentField("displayName", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Type"), i18next.t("payment:Type - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.transaction.type} onChange={e => {
// this.updatePaymentField('type', e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("payment:Product"), i18next.t("payment:Product - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.transaction.productName} onChange={e => {
// this.updatePaymentField('productName', e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("product:Detail"), i18next.t("product:Detail - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.transaction.detail} onChange={e => {
// this.updatePaymentField('currency', e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("user:Tag"), i18next.t("transaction:Tag - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.transaction.tag} onChange={e => {
// this.updatePaymentField('currency', e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("payment:Currency"), i18next.t("payment:Currency - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.transaction.currency} onChange={e => {
// this.updatePaymentField('currency', e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("transaction:Amount"), i18next.t("transaction:Amount - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.transaction.amount} onChange={e => {
// this.updatePaymentField('amount', e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("product:Return URL"), i18next.t("product:Return URL - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.transaction.user} onChange={e => {
// this.updatePaymentField('amount', e.target.value);
}} />
{isRechargeMode ? (
<Select virtual={false} style={{width: "100%"}}
value={this.state.transaction.tag}
onChange={(value) => {
this.updateTransactionField("tag", value);
if (value === "Organization") {
this.updateTransactionField("user", "");
}
}}>
<Option value="User">User</Option>
<Option value="Organization">Organization</Option>
</Select>
) : (
<Input disabled={true} value={this.state.transaction.tag} onChange={e => {
}} />
)}
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
@@ -266,19 +339,46 @@ class TransactionEditPage extends React.Component {
{Setting.getLabel(i18next.t("general:User"), i18next.t("general:User - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.transaction.user} onChange={e => {
// this.updatePaymentField('amount', e.target.value);
{isRechargeMode ? (
<Select virtual={false} style={{width: "100%"}}
value={this.state.transaction.user}
disabled={this.state.transaction.tag === "Organization"}
allowClear
onChange={(value) => {
this.updateTransactionField("user", value || "");
}}>
{
this.state.users.map((user, index) => <Option key={index} value={user.name}>{user.name}</Option>)
}
</Select>
) : (
<Input disabled={true} value={this.state.transaction.user} onChange={e => {
}} />
)}
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("transaction:Amount"), i18next.t("transaction:Amount - Tooltip"))} :
</Col>
<Col span={4} >
<InputNumber disabled={!isRechargeMode} value={this.state.transaction.amount ?? 0} onChange={value => {
this.updateTransactionField("amount", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Application"), i18next.t("general:Application - Tooltip"))} :
{Setting.getLabel(i18next.t("currency:Currency"), i18next.t("currency:Currency - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.transaction.application} onChange={e => {
// this.updatePaymentField('amount', e.target.value);
}} />
<Select virtual={false} style={{width: "100%"}} value={this.state.transaction.currency} disabled={!isRechargeMode} onChange={(value => {
this.updateTransactionField("currency", value);
})}>
{
Setting.CurrencyOptions.map((item, index) => <Option key={index} value={item.id}>{Setting.getCurrencyWithFlag(item.id)}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
@@ -309,13 +409,17 @@ class TransactionEditPage extends React.Component {
return (
<div>
{
this.state.transaction !== null ? this.renderTransaction() : null
this.state.transaction !== null ? (
<>
{this.renderTransaction()}
<div style={{marginTop: "20px", marginLeft: "40px"}}>
<Button size="large" onClick={() => this.submitTransactionEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" size="large" onClick={() => this.submitTransactionEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} size="large" onClick={() => this.deleteTransaction()}>{i18next.t("general:Cancel")}</Button> : null}
</div>
</>
) : null
}
<div style={{marginTop: "20px", marginLeft: "40px"}}>
<Button size="large" onClick={() => this.submitTransactionEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" size="large" onClick={() => this.submitTransactionEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} size="large" onClick={() => this.deleteTransaction()}>{i18next.t("general:Cancel")}</Button> : null}
</div>
</div>
);
}

View File

@@ -14,37 +14,30 @@
import BaseListPage from "./BaseListPage";
import i18next from "i18next";
import {Link} from "react-router-dom";
import * as Setting from "./Setting";
import * as Provider from "./auth/Provider";
import {Button, Table} from "antd";
import PopconfirmModal from "./common/modal/PopconfirmModal";
import React from "react";
import * as TransactionBackend from "./backend/TransactionBackend";
import moment from "moment/moment";
import {getTransactionTableColumns} from "./table/TransactionTableColumns";
class TransactionListPage extends BaseListPage {
newTransaction() {
const randomName = Setting.getRandomName();
const organizationName = Setting.getRequestOrganization(this.props.account);
return {
owner: organizationName,
name: `transaction_${randomName}`,
createdTime: moment().format(),
displayName: `New Transaction - ${randomName}`,
provider: "provider_pay_paypal",
application: "app-built-in",
domain: "https://ai-admin.casibase.com",
category: "",
type: "PayPal",
productName: "computer-1",
productDisplayName: "A notebook computer",
detail: "This is a computer with excellent CPU, memory and disk",
tag: "Promotion-1",
currency: "USD",
amount: 0,
returnUrl: "https://door.casdoor.com/transactions",
type: "chat_id",
subtype: "message_id",
provider: "provider_chatgpt",
user: "admin",
application: "",
payment: "payment_bhn1ra",
tag: "AI message",
amount: 0.1,
currency: "USD",
payment: "payment_paypal_001",
state: "Paid",
};
}
@@ -74,7 +67,8 @@ class TransactionListPage extends BaseListPage {
TransactionBackend.addTransaction(newTransaction)
.then((res) => {
if (res.status === "ok") {
this.props.history.push({pathname: `/transactions/${newTransaction.owner}/${newTransaction.name}`, mode: "add"});
const transactionId = res.data;
this.props.history.push({pathname: `/transactions/${newTransaction.owner}/${transactionId}`, mode: "add"});
Setting.showMessage("success", i18next.t("general:Successfully added"));
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to add")}: ${res.msg}`);
@@ -86,192 +80,54 @@ class TransactionListPage extends BaseListPage {
});
}
renderTable(transactions) {
const columns = [
{
title: i18next.t("general:Name"),
dataIndex: "name",
key: "name",
width: "180px",
fixed: "left",
sorter: true,
...this.getColumnSearchProps("name"),
render: (text, record, index) => {
return (
<Link to={`/transactions/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Organization"),
dataIndex: "owner",
key: "owner",
width: "120px",
fixed: "left",
sorter: true,
...this.getColumnSearchProps("owner"),
render: (text, record, index) => {
return (
<Link to={`/organizations/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Provider"),
dataIndex: "provider",
key: "provider",
width: "150px",
sorter: true,
...this.getColumnSearchProps("provider"),
render: (text, record, index) => {
return (
<Link to={`/providers/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:User"),
dataIndex: "user",
key: "user",
width: "120px",
sorter: true,
...this.getColumnSearchProps("user"),
render: (text, record, index) => {
return (
<Link to={`/users/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
rechargeTransaction() {
const organizationName = Setting.getRequestOrganization(this.props.account);
const newTransaction = {
owner: organizationName,
createdTime: moment().format(),
application: this.props.account.signupApplication || "",
domain: "",
category: "Recharge",
type: "",
subtype: "",
provider: "",
user: this.props.account.name || "",
tag: "User",
amount: 100,
currency: "USD",
payment: "",
state: "Paid",
};
TransactionBackend.addTransaction(newTransaction)
.then((res) => {
if (res.status === "ok") {
const transactionId = res.data;
this.props.history.push({pathname: `/transactions/${newTransaction.owner}/${transactionId}`, mode: "recharge"});
Setting.showMessage("success", i18next.t("general:Successfully added"));
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to add")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
{
title: i18next.t("general:Created time"),
dataIndex: "createdTime",
key: "createdTime",
width: "160px",
sorter: true,
render: (text, record, index) => {
return Setting.getFormattedDate(text);
},
renderTable(transactions) {
const columns = getTransactionTableColumns({
includeOrganization: true,
includeUser: true,
includeTag: true,
includeActions: true,
getColumnSearchProps: this.getColumnSearchProps,
account: this.props.account,
onEdit: (record, isAdmin) => {
this.props.history.push({pathname: `/transactions/${record.owner}/${record.name}`, mode: isAdmin ? "edit" : "view"});
},
{
title: i18next.t("provider:Type"),
dataIndex: "type",
key: "type",
width: "140px",
align: "center",
filterMultiple: false,
filters: Setting.getProviderTypeOptions("Payment").map((o) => {return {text: o.id, value: o.name};}),
sorter: true,
render: (text, record, index) => {
record.category = "Payment";
return Provider.getProviderLogoWidget(record);
},
onDelete: (index) => {
this.deleteTransaction(index);
},
{
title: i18next.t("payment:Product"),
dataIndex: "productDisplayName",
key: "productDisplayName",
// width: '160px',
sorter: true,
...this.getColumnSearchProps("productDisplayName"),
render: (text, record, index) => {
return (
<Link to={`/products/${record.owner}/${record.productName}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("payment:Currency"),
dataIndex: "currency",
key: "currency",
width: "120px",
sorter: true,
...this.getColumnSearchProps("currency"),
},
{
title: i18next.t("transaction:Amount"),
dataIndex: "amount",
key: "amount",
width: "120px",
sorter: true,
...this.getColumnSearchProps("amount"),
},
{
title: i18next.t("general:User"),
dataIndex: "user",
key: "user",
width: "120px",
sorter: true,
...this.getColumnSearchProps("user"),
},
{
title: i18next.t("general:Application"),
dataIndex: "application",
key: "application",
width: "120px",
sorter: true,
...this.getColumnSearchProps("application"),
render: (text, record, index) => {
return (
<Link to={`/applications/${record.owner}/${record.application}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Payment"),
dataIndex: "payment",
key: "payment",
width: "120px",
sorter: true,
...this.getColumnSearchProps("payment"),
render: (text, record, index) => {
return (
<Link to={`/payments/${record.owner}/${record.payment}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:State"),
dataIndex: "state",
key: "state",
width: "120px",
sorter: true,
...this.getColumnSearchProps("state"),
},
{
title: i18next.t("general:Action"),
dataIndex: "",
key: "op",
width: "240px",
fixed: (Setting.isMobile()) ? "false" : "right",
render: (text, record, index) => {
return (
<div>
<Button style={{marginTop: "10px", marginBottom: "10px", marginRight: "10px"}} type="primary" onClick={() => this.props.history.push(`/transactions/${record.owner}/${record.name}`)}>{i18next.t("general:Edit")}</Button>
<PopconfirmModal
title={i18next.t("general:Sure to delete") + `: ${record.name} ?`}
onConfirm={() => this.deleteTransaction(index)}
>
</PopconfirmModal>
</div>
);
},
},
];
});
const paginationProps = {
total: this.state.pagination.total,
@@ -283,12 +139,17 @@ class TransactionListPage extends BaseListPage {
return (
<div>
<Table scroll={{x: "max-content"}} columns={columns} dataSource={transactions} rowKey={(record) => `${record.owner}/${record.name}`} size="middle" bordered pagination={paginationProps}
title={() => (
<div>
{i18next.t("general:Transactions")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button type="primary" size="small" onClick={this.addTransaction.bind(this)}>{i18next.t("general:Add")}</Button>
</div>
)}
title={() => {
const isAdmin = Setting.isLocalAdminUser(this.props.account);
return (
<div>
{i18next.t("general:Transactions")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button size="small" disabled={!isAdmin} onClick={this.addTransaction.bind(this)}>{i18next.t("general:Add")}</Button>
&nbsp;&nbsp;
<Button type="primary" size="small" disabled={!isAdmin} onClick={this.rechargeTransaction.bind(this)}>{i18next.t("transaction:Recharge")}</Button>
</div>
);
}}
loading={this.state.loading}
onChange={this.handleTableChange}
/>

View File

@@ -316,11 +316,6 @@ class UserEditPage extends React.Component {
}
}
let isKeysGenerated = false;
if (this.state.user.accessKey !== "" && this.state.user.accessKey !== "") {
isKeysGenerated = true;
}
if (accountItem.name === "Organization") {
return (
<Row style={{marginTop: "10px"}} >
@@ -465,7 +460,7 @@ class UserEditPage extends React.Component {
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Email"), i18next.t("general:Email - Tooltip"))} :
</Col>
<Col style={{paddingRight: "20px"}} span={11} >
<Col style={{paddingRight: "20px"}} span={5} >
<Input
value={this.state.user.email}
style={{width: "280Px"}}
@@ -475,7 +470,7 @@ class UserEditPage extends React.Component {
}}
/>
</Col>
<Col span={Setting.isMobile() ? 22 : 11} >
<Col span={Setting.isMobile() ? 22 : 5} >
{/* backend auto get the current user, so admin can not edit. Just self can reset*/}
{this.isSelf() ? <ResetModal application={this.state.application} disabled={disabled} buttonText={i18next.t("user:Reset Email...")} destType={"email"} /> : null}
</Col>
@@ -487,7 +482,7 @@ class UserEditPage extends React.Component {
<Col style={{marginTop: "5px"}} span={Setting.isMobile() ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Phone"), i18next.t("general:Phone - Tooltip"))} :
</Col>
<Col style={{paddingRight: "20px"}} span={11} >
<Col style={{paddingRight: "20px"}} span={5} >
<Input.Group compact style={{width: "280Px"}}>
<CountryCodeSelect
style={{width: "30%"}}
@@ -506,7 +501,7 @@ class UserEditPage extends React.Component {
}} />
</Input.Group>
</Col>
<Col span={Setting.isMobile() ? 24 : 11} >
<Col span={Setting.isMobile() ? 24 : 5} >
{this.isSelf() ? (<ResetModal application={this.state.application} countryCode={this.getCountryCode()} disabled={disabled} buttonText={i18next.t("user:Reset Phone...")} destType={"phone"} />) : null}
</Col>
</Row>
@@ -750,6 +745,47 @@ class UserEditPage extends React.Component {
</Col>
</Row>
);
} else if (accountItem.name === "Balance credit") {
return (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("user:Balance credit"), i18next.t("user:Balance credit - Tooltip"))} :
</Col>
<Col span={22} >
<InputNumber value={this.state.user.balanceCredit ?? 0} onChange={value => {
this.updateUserField("balanceCredit", value);
}} />
</Col>
</Row>
);
} else if (accountItem.name === "Balance currency") {
return (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("user:Balance currency"), i18next.t("user:Balance currency - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.user.balanceCurrency || "USD"} onChange={(value => {
this.updateUserField("balanceCurrency", value);
})}>
{
Setting.CurrencyOptions.map((item, index) => <Option key={index} value={item.id}>{Setting.getCurrencyWithFlag(item.id)}</Option>)
}
</Select>
</Col>
</Row>
);
} else if (accountItem.name === "Transactions") {
return (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Transactions"), i18next.t("general:Transactions"))} :
</Col>
<Col span={22}>
<TransactionTable transactions={this.state.transactions} hideTag={true} />
</Col>
</Row>
);
} else if (accountItem.name === "Score") {
return (
<Row style={{marginTop: "20px"}} >
@@ -835,7 +871,7 @@ class UserEditPage extends React.Component {
</Col>
<Col span={22} >
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 1}>
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Access key"), i18next.t("general:Access key - Tooltip"))} :
</Col>
<Col span={22} >
@@ -843,16 +879,18 @@ class UserEditPage extends React.Component {
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 1}>
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Access secret"), i18next.t("general:Access secret - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.user.accessSecret} disabled={true} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Row style={{marginTop: "20px", marginBottom: "20px"}} >
<Col span={22} >
<Button onClick={() => this.addUserKeys()}>{i18next.t(isKeysGenerated ? "general:update" : "general:generate")}</Button>
<Button type="primary" onClick={() => this.addUserKeys()}>
{i18next.t("general:Generate")}
</Button>
</Col>
</Row>
</Col>
@@ -1189,19 +1227,6 @@ class UserEditPage extends React.Component {
</Col>
</Row>
);
} else if (accountItem.name === "Transactions") {
return (
this.state.mode !== "add" && this.state.transactions.length > 0 ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("transaction:Transactions"), i18next.t("transaction:Transactions"))} :
</Col>
<Col span={22}>
<TransactionTable transactions={this.state.transactions} />
</Col>
</Row>
) : null
);
}
}

View File

@@ -14,7 +14,7 @@
import React from "react";
import {Link} from "react-router-dom";
import {Button, Space, Switch, Table, Upload} from "antd";
import {Button, Modal, Space, Switch, Table, Upload} from "antd";
import {UploadOutlined} from "@ant-design/icons";
import moment from "moment";
import * as OrganizationBackend from "./backend/OrganizationBackend";
@@ -24,6 +24,7 @@ import i18next from "i18next";
import BaseListPage from "./BaseListPage";
import PopconfirmModal from "./common/modal/PopconfirmModal";
import AccountAvatar from "./account/AccountAvatar";
import * as XLSX from "xlsx";
class UserListPage extends BaseListPage {
constructor(props) {
@@ -87,6 +88,7 @@ class UserListPage extends BaseListPage {
signupApplication: this.state.organization.defaultApplication,
registerType: "Add User",
registerSource: `${this.props.account.owner}/${this.props.account.name}`,
balanceCurrency: this.state.organization.balanceCurrency || "USD",
};
}
@@ -148,19 +150,27 @@ class UserListPage extends BaseListPage {
}
uploadFile(info) {
const {status, response: res} = info.file;
if (status === "done") {
if (res.status === "ok") {
Setting.showMessage("success", "Users uploaded successfully, refreshing the page");
const {pagination} = this.state;
this.fetch({pagination});
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to upload")}: ${res.msg}`);
}
const {status, msg} = info;
if (status === "ok") {
Setting.showMessage("success", "Users uploaded successfully, refreshing the page");
const {pagination} = this.state;
this.fetch({pagination});
} else if (status === "error") {
Setting.showMessage("error", i18next.t("general:Failed to upload"));
Setting.showMessage("error", `${i18next.t("general:Failed to upload")}: ${msg}`);
}
this.setState({uploadJsonData: [], uploadColumns: [], showUploadModal: false});
}
generateDownloadTemplate() {
const userObj = {};
const items = Setting.getUserColumns();
items.forEach((item) => {
userObj[item] = null;
});
const worksheet = XLSX.utils.json_to_sheet([userObj]);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
XLSX.writeFile(workbook, "import-user.xlsx", {compression: true});
}
getOrganization(organizationName) {
@@ -177,23 +187,82 @@ class UserListPage extends BaseListPage {
}
renderUpload() {
const uploadThis = this;
const props = {
name: "file",
accept: ".xlsx",
method: "post",
action: `${Setting.ServerUrl}/api/upload-users`,
withCredentials: true,
onChange: (info) => {
this.uploadFile(info);
showUploadList: false,
beforeUpload: (file) => {
const reader = new FileReader();
reader.onload = (e) => {
const binary = e.target.result;
try {
const workbook = XLSX.read(binary, {type: "array"});
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
Setting.showMessage("error", i18next.t("general:No sheets found in file"));
return;
}
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
this.setState({uploadJsonData: jsonData, file: file});
const columns = Setting.getUserColumns().map(el => {
return {title: el.split("#")[0], dataIndex: el, key: el};
});
this.setState({uploadColumns: columns}, () => {this.setState({showUploadModal: true});});
} catch (err) {
Setting.showMessage("error", `${i18next.t("general:Failed to upload")}: ${err.message}`);
}
};
reader.onerror = (error) => {
Setting.showMessage("error", `${i18next.t("general:Failed to upload")}: ${error?.message || error}`);
};
reader.readAsArrayBuffer(file);
return false;
},
};
return (
<Upload {...props}>
<Button icon={<UploadOutlined />} id="upload-button" size="small">
{i18next.t("user:Upload (.xlsx)")}
</Button>
</Upload>
<>
<Upload {...props}>
<Button icon={<UploadOutlined />} id="upload-button" size="small">
{i18next.t("user:Upload (.xlsx)")}
</Button>
</Upload>
<Modal title={i18next.t("user:Upload (.xlsx)")}
width={"100%"}
closable={true}
open={this.state.showUploadModal}
okText={i18next.t("general:Click to Upload")}
onOk = {() => {
const formData = new FormData();
formData.append("file", this.state.file);
fetch(`${Setting.ServerUrl}/api/upload-users`, {
method: "post",
body: formData,
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
})
.then((res) => res.json())
.then((res) => {uploadThis.uploadFile(res);})
.catch((error) => {
Setting.showMessage("error", `${i18next.t("general:Failed to upload")}: ${error.message}`);
});
}}
cancelText={i18next.t("general:Cancel")}
onCancel={() => {this.setState({showUploadModal: false, uploadJsonData: [], uploadColumns: []});}}
>
<div style={{marginRight: "34px"}}>
<Table scroll={{x: "max-content"}} dataSource={this.state.uploadJsonData} columns={this.state.uploadColumns} />
</div>
</Modal>
</>
);
}
@@ -375,6 +444,26 @@ class UserListPage extends BaseListPage {
return text ?? 0;
},
},
{
title: i18next.t("user:Balance credit"),
dataIndex: "balanceCredit",
key: "balanceCredit",
width: "120px",
sorter: true,
render: (text, record, index) => {
return text ?? 0;
},
},
{
title: i18next.t("user:Balance currency"),
dataIndex: "balanceCurrency",
key: "balanceCurrency",
width: "140px",
sorter: true,
render: (text, record, index) => {
return text || "USD";
},
},
{
title: i18next.t("user:Is admin"),
dataIndex: "isAdmin",
@@ -383,7 +472,7 @@ class UserListPage extends BaseListPage {
sorter: true,
render: (text, record, index) => {
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
<Switch disabled checkedChildren={i18next.t("general:ON")} unCheckedChildren={i18next.t("general:OFF")} checked={text} />
);
},
},
@@ -395,7 +484,7 @@ class UserListPage extends BaseListPage {
sorter: true,
render: (text, record, index) => {
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
<Switch disabled checkedChildren={i18next.t("general:ON")} unCheckedChildren={i18next.t("general:OFF")} checked={text} />
);
},
},
@@ -407,7 +496,7 @@ class UserListPage extends BaseListPage {
sorter: true,
render: (text, record, index) => {
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
<Switch disabled checkedChildren={i18next.t("general:ON")} unCheckedChildren={i18next.t("general:OFF")} checked={text} />
);
},
},
@@ -462,6 +551,7 @@ class UserListPage extends BaseListPage {
<div>
{i18next.t("general:Users")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button style={{marginRight: "15px"}} type="primary" size="small" onClick={this.addUser.bind(this)}>{i18next.t("general:Add")} </Button>
<Button style={{marginRight: "15px"}} type="primary" size="small" onClick={this.generateDownloadTemplate}>{i18next.t("general:Download template")} </Button>
{
this.renderUpload()
}

View File

@@ -154,7 +154,7 @@ class VerificationListPage extends BaseListPage {
sorter: true,
render: (text, record, index) => {
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
<Switch disabled checkedChildren={i18next.t("general:ON")} unCheckedChildren={i18next.t("general:OFF")} checked={text} />
);
},
},

View File

@@ -175,7 +175,7 @@ class WebhookListPage extends BaseListPage {
sorter: true,
render: (text, record, index) => {
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
<Switch disabled checkedChildren={i18next.t("general:ON")} unCheckedChildren={i18next.t("general:OFF")} checked={text} />
);
},
},
@@ -187,7 +187,7 @@ class WebhookListPage extends BaseListPage {
sorter: true,
render: (text, record, index) => {
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
<Switch disabled checkedChildren={i18next.t("general:ON")} unCheckedChildren={i18next.t("general:OFF")} checked={text} />
);
},
},
@@ -200,7 +200,7 @@ class WebhookListPage extends BaseListPage {
fixed: (Setting.isMobile()) ? "false" : "right",
render: (text, record, index) => {
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
<Switch disabled checkedChildren={i18next.t("general:ON")} unCheckedChildren={i18next.t("general:OFF")} checked={text} />
);
},
},

View File

@@ -290,6 +290,10 @@ const authInfo = {
scope: "users.read%20tweet.read",
endpoint: "https://twitter.com/i/oauth2/authorize",
},
Telegram: {
scope: "",
endpoint: "https://core.telegram.org/widgets/login",
},
Typetalk: {
scope: "my",
endpoint: "https://typetalk.com/oauth2/authorize",

View File

@@ -57,7 +57,7 @@ const renderFormItem = (signupItem) => {
rules: [
{
required: signupItem.required,
message: i18next.t(`signup:Please input your ${signupItem.label || signupItem.name}!`),
message: i18next.t("signup:Please input your {label}!").replace("{label}", signupItem.label || signupItem.name),
},
],
};
@@ -655,7 +655,7 @@ class SignupPage extends React.Component {
}
} else if (signupItem.name === "Password") {
return (
<Popover placement={window.innerWidth >= 960 ? "right" : "top"} content={this.state.passwordPopover} open={this.state.passwordPopoverOpen}>
<Popover placement={"top"} content={this.state.passwordPopover} open={this.state.passwordPopoverOpen}>
<Form.Item
name="password"
className="signup-password"

View File

@@ -0,0 +1,111 @@
// 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.
import * as Setting from "../Setting";
export function getOrders(owner, page = "", pageSize = "", field = "", value = "", sortField = "", sortOrder = "") {
return fetch(`${Setting.ServerUrl}/api/get-orders?owner=${owner}&p=${page}&pageSize=${pageSize}&field=${field}&value=${value}&sortField=${sortField}&sortOrder=${sortOrder}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function payOrder(owner, name, providerName, paymentEnv = "") {
return fetch(`${Setting.ServerUrl}/api/pay-order?id=${owner}/${encodeURIComponent(name)}&providerName=${encodeURIComponent(providerName)}&paymentEnv=${paymentEnv}`, {
method: "POST",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function getUserOrders(owner, user) {
return fetch(`${Setting.ServerUrl}/api/get-user-orders?owner=${owner}&user=${user}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function getOrder(owner, name) {
return fetch(`${Setting.ServerUrl}/api/get-order?id=${owner}/${encodeURIComponent(name)}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function updateOrder(owner, name, order) {
const newOrder = Setting.deepCopy(order);
return fetch(`${Setting.ServerUrl}/api/update-order?id=${owner}/${encodeURIComponent(name)}`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newOrder),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function addOrder(order) {
const newOrder = Setting.deepCopy(order);
return fetch(`${Setting.ServerUrl}/api/add-order`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newOrder),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function deleteOrder(order) {
const newOrder = Setting.deepCopy(order);
return fetch(`${Setting.ServerUrl}/api/delete-order`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newOrder),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function placeOrder(productId, pricingName = "", planName = "", userName = "", customPrice = 0) {
return fetch(`${Setting.ServerUrl}/api/place-order?productId=${encodeURIComponent(productId)}&pricingName=${encodeURIComponent(pricingName)}&planName=${encodeURIComponent(planName)}&userName=${encodeURIComponent(userName)}&customPrice=${customPrice}`, {
method: "POST",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function cancelOrder(owner, name) {
return fetch(`${Setting.ServerUrl}/api/cancel-order?id=${owner}/${encodeURIComponent(name)}`, {
method: "POST",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}

View File

@@ -69,13 +69,3 @@ export function deleteProduct(product) {
},
}).then(res => res.json());
}
export function buyProduct(owner, name, providerName, pricingName = "", planName = "", userName = "", paymentEnv = "", customPrice = 0) {
return fetch(`${Setting.ServerUrl}/api/buy-product?id=${owner}/${encodeURIComponent(name)}&providerName=${providerName}&pricingName=${pricingName}&planName=${planName}&userName=${userName}&paymentEnv=${paymentEnv}&customPrice=${customPrice}`, {
method: "POST",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}

Some files were not shown because too many files have changed in this diff Show More