Compare commits

...

10 Commits

36 changed files with 1057 additions and 132 deletions

View File

@@ -51,22 +51,14 @@ COPY --from=FRONT --chown=$USER:$USER /web/build ./web/build
ENTRYPOINT ["/server"]
FROM debian:latest AS db
RUN apt update \
&& apt install -y \
mariadb-server \
mariadb-client \
&& rm -rf /var/lib/apt/lists/*
FROM db AS ALLINONE
FROM debian:latest AS ALLINONE
LABEL MAINTAINER="https://casdoor.org/"
ARG TARGETOS
ARG TARGETARCH
ENV BUILDX_ARCH="${TARGETOS:-linux}_${TARGETARCH:-amd64}"
RUN apt update
RUN apt install -y ca-certificates && update-ca-certificates
RUN apt install -y ca-certificates lsof && update-ca-certificates
WORKDIR /
COPY --from=BACK /go/src/casdoor/server_${BUILDX_ARCH} ./server

View File

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

View File

@@ -1,8 +1,10 @@
#!/bin/bash
if [ "${MYSQL_ROOT_PASSWORD}" = "" ] ;then MYSQL_ROOT_PASSWORD=123456 ;fi
service mariadb start
if [ -z "${driverName:-}" ]; then
export driverName=sqlite
fi
if [ -z "${dataSourceName:-}" ]; then
export dataSourceName="file:casdoor.db?cache=shared"
fi
mysqladmin -u root password ${MYSQL_ROOT_PASSWORD}
exec /server --createDatabase=true
exec /server

2
go.mod
View File

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

8
go.sum
View File

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

View File

@@ -216,6 +216,16 @@ func handleSearch(w ldap.ResponseWriter, m *ldap.Message) {
e.AddAttribute("mobile", message.AttributeValue(user.Phone))
e.AddAttribute("sn", message.AttributeValue(user.LastName))
e.AddAttribute("givenName", message.AttributeValue(user.FirstName))
// Add POSIX attributes for Linux machine login support
e.AddAttribute("loginShell", getAttribute("loginShell", user))
e.AddAttribute("gecos", getAttribute("gecos", user))
// Add SSH public key if available
sshKey := getAttribute("sshPublicKey", user)
if sshKey != "" {
e.AddAttribute("sshPublicKey", sshKey)
}
// Add objectClass for posixAccount
e.AddAttribute("objectClass", "posixAccount")
for _, group := range user.Groups {
e.AddAttribute(ldapMemberOfAttr, message.AttributeValue(group))
}

View File

@@ -83,6 +83,45 @@ var ldapAttributesMapping = map[string]FieldRelation{
return message.AttributeValue(getUserPasswordWithType(user))
},
},
"loginShell": {
userField: "loginShell",
notSearchable: true,
fieldMapper: func(user *object.User) message.AttributeValue {
// Check user properties first, otherwise return default shell
if user.Properties != nil {
if shell, ok := user.Properties["loginShell"]; ok && shell != "" {
return message.AttributeValue(shell)
}
}
return message.AttributeValue("/bin/bash")
},
},
"gecos": {
userField: "gecos",
notSearchable: true,
fieldMapper: func(user *object.User) message.AttributeValue {
// GECOS field typically contains full name and other user info
// Format: Full Name,Room Number,Work Phone,Home Phone,Other
gecos := user.DisplayName
if gecos == "" {
gecos = user.Name
}
return message.AttributeValue(gecos)
},
},
"sshPublicKey": {
userField: "sshPublicKey",
notSearchable: true,
fieldMapper: func(user *object.User) message.AttributeValue {
// Return SSH public key from user properties
if user.Properties != nil {
if sshKey, ok := user.Properties["sshPublicKey"]; ok && sshKey != "" {
return message.AttributeValue(sshKey)
}
}
return message.AttributeValue("")
},
},
}
const ldapMemberOfAttr = "memberOf"

View File

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

View File

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

View File

@@ -32,6 +32,7 @@ type AccountItem struct {
ViewRule string `json:"viewRule"`
ModifyRule string `json:"modifyRule"`
Regex string `json:"regex"`
Tab string `json:"tab"`
}
type ThemeData struct {
@@ -88,6 +89,7 @@ type Organization struct {
MfaItems []*MfaItem `xorm:"varchar(300)" json:"mfaItems"`
MfaRememberInHours int `json:"mfaRememberInHours"`
AccountMenu string `xorm:"varchar(20)" json:"accountMenu"`
AccountItems []*AccountItem `xorm:"mediumtext" json:"accountItems"`
OrgBalance float64 `json:"orgBalance"`

View File

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

View File

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

86
object/sms_pnvs.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

121
object/syncer_group.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -918,6 +918,8 @@ func StringArrayToStruct[T any](stringArray [][]string) ([]*T, error) {
err = setReflectAttr[[]MfaAccount](&fv, v)
case reflect.TypeOf([]webauthn.Credential{}):
err = setReflectAttr[[]webauthn.Credential](&fv, v)
case reflect.TypeOf(map[string]string{}):
err = setReflectAttr[map[string]string](&fv, v)
}
if err != nil {

View File

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

View File

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

View File

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

View File

@@ -678,6 +678,16 @@ 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:Account menu"), i18next.t("organization:Account menu - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.organization.accountMenu || "Horizontal"} onChange={(value => {this.updateOrganizationField("accountMenu", value);})}
options={[{value: "Horizontal", label: i18next.t("general:Horizontal")}, {value: "Vertical", label: i18next.t("general:Vertical")}].map(item => Setting.getOption(item.label, item.value))}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("organization:Account items"), i18next.t("organization:Account items - Tooltip"))} :

View File

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

View File

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

View File

@@ -13,7 +13,10 @@
// limitations under the License.
import React from "react";
import {Button, Card, Col, Form, Input, InputNumber, List, Result, Row, Select, Space, Spin, Switch, Tag, Tooltip} from "antd";
import {
Button, Card, Col, Form, Input, InputNumber, Layout, List,
Menu, Result, Row, Select, Space, Spin, Switch, Tabs, Tag, Tooltip
} from "antd";
import {withRouter} from "react-router-dom";
import {TotpMfaType} from "./auth/MfaSetupPage";
import * as GroupBackend from "./backend/GroupBackend";
@@ -46,6 +49,8 @@ import MfaAccountTable from "./table/MfaAccountTable";
import MfaTable from "./table/MfaTable";
import TransactionTable from "./table/TransactionTable";
import * as TransactionBackend from "./backend/TransactionBackend";
import {Content, Header} from "antd/es/layout/layout";
import Sider from "antd/es/layout/Sider";
const {Option} = Select;
@@ -67,6 +72,8 @@ class UserEditPage extends React.Component {
idCardInfo: ["ID card front", "ID card back", "ID card with person"],
openFaceRecognitionModal: false,
transactions: [],
activeMenuKey: window.location.hash?.slice(1) || "",
menuMode: "Horizontal",
};
}
@@ -175,6 +182,7 @@ class UserEditPage extends React.Component {
}
this.setState({
menuMode: res.data?.organizationObj?.accountMenu ?? "Horizontal",
application: res.data,
});
});
@@ -1333,6 +1341,152 @@ class UserEditPage extends React.Component {
);
}
isAccountItemVisible(item) {
if (!item.visible) {
return false;
}
const isAdmin = Setting.isLocalAdminUser(this.props.account);
if (item.viewRule === "Self") {
if (!this.isSelfOrAdmin()) {
return false;
}
} else if (item.viewRule === "Admin") {
if (!isAdmin) {
return false;
}
}
return true;
}
getAccountItemsByTab(tab) {
const accountItems = this.getUserOrganization()?.accountItems || [];
return accountItems.filter(item => {
if (!this.isAccountItemVisible(item)) {
return false;
}
const itemTab = item.tab || "";
return itemTab === tab;
});
}
getUniqueTabs() {
const accountItems = this.getUserOrganization()?.accountItems || [];
const tabs = new Set();
accountItems.forEach(item => {
if (this.isAccountItemVisible(item)) {
tabs.add(item.tab || "");
}
});
return Array.from(tabs).sort((a, b) => {
// Empty string (default tab) comes first
if (a === "") {
return -1;
}
if (b === "") {
return 1;
}
return a.localeCompare(b);
});
}
renderUserForm() {
const tabs = this.getUniqueTabs();
// If there are no tabs or only one tab (default), render without tab navigation
if (tabs.length === 0 || (tabs.length === 1 && tabs[0] === "")) {
const accountItems = this.getAccountItemsByTab("");
return (
<Form>
{accountItems.map(accountItem => (
<React.Fragment key={accountItem.name}>
<Form.Item name={accountItem.name}
validateTrigger="onChange"
rules={[
{
pattern: accountItem.regex ? new RegExp(accountItem.regex, "g") : null,
message: i18next.t("user:This field value doesn't match the pattern rule"),
},
]}
style={{margin: 0}}>
{this.renderAccountItem(accountItem)}
</Form.Item>
</React.Fragment>
))}
</Form>
);
}
// Render with tabs
const activeKey = this.state.activeMenuKey || tabs[0] || "";
return (
<Layout style={{background: "inherit"}}>
{
this.state.menuMode === "Vertical" ? null : (
<Header style={{background: "inherit", padding: "0px"}}>
<Tabs
onChange={(key) => {
this.setState({activeMenuKey: key});
window.location.hash = key;
}}
type="card"
activeKey={activeKey}
items={tabs.map(tab => ({
label: tab === "" ? i18next.t("user:Default") : tab,
key: tab,
}))}
/>
</Header>
)
}
<Layout style={{background: "inherit", maxHeight: "70vh", overflow: "auto"}}>
{
this.state.menuMode === "Vertical" ? (
<Sider width={200} style={{background: "inherit", position: "sticky", top: 0}}>
<Menu
mode="vertical"
selectedKeys={[activeKey]}
onClick={({key}) => {
this.setState({activeMenuKey: key});
window.location.hash = key;
}}
style={{marginBottom: "20px", height: "100%"}}
items={tabs.map(tab => ({
label: tab === "" ? i18next.t("user:Default") : tab,
key: tab,
}))}
/>
</Sider>) : null
}
<Content style={{padding: "15px"}}>
<Form>
{this.getAccountItemsByTab(activeKey).map(accountItem => (
<React.Fragment key={accountItem.name}>
<Form.Item name={accountItem.name}
validateTrigger="onChange"
rules={[
{
pattern: accountItem.regex ? new RegExp(accountItem.regex, "g") : null,
message: i18next.t("user:This field value doesn't match the pattern rule"),
},
]}
style={{margin: 0}}>
{this.renderAccountItem(accountItem)}
</Form.Item>
</React.Fragment>
))}
</Form>
</Content>
</Layout>
</Layout>
);
}
renderUser() {
return (
<div>
@@ -1346,42 +1500,7 @@ class UserEditPage extends React.Component {
</div>
)
} style={(Setting.isMobile()) ? {margin: "5px"} : {}} type="inner">
<Form>
{
this.getUserOrganization()?.accountItems?.map(accountItem => {
if (!accountItem.visible) {
return null;
}
const isAdmin = Setting.isLocalAdminUser(this.props.account);
if (accountItem.viewRule === "Self") {
if (!this.isSelfOrAdmin()) {
return null;
}
} else if (accountItem.viewRule === "Admin") {
if (!isAdmin) {
return null;
}
}
return (
<React.Fragment key={accountItem.name}>
<Form.Item name={accountItem.name}
validateTrigger="onChange"
rules={[
{
pattern: accountItem.regex ? new RegExp(accountItem.regex, "g") : null,
message: i18next.t("user:This field value doesn't match the pattern rule"),
},
]}
style={{margin: 0}}>
{this.renderAccountItem(accountItem)}
</Form.Item>
</React.Fragment>
);
})
}
</Form>
{this.renderUserForm()}
</Card>
</div>
);

View File

@@ -38,7 +38,7 @@ class AccountTable extends React.Component {
}
addRow(table) {
const row = {name: Setting.getNewRowNameForTable(table, "Please select an account item"), visible: true, viewRule: "Public", modifyRule: "Self"};
const row = {name: Setting.getNewRowNameForTable(table, "Please select an account item"), visible: true, viewRule: "Public", modifyRule: "Self", tab: ""};
if (table === undefined) {
table = [];
}
@@ -93,6 +93,19 @@ class AccountTable extends React.Component {
);
},
},
{
title: i18next.t("general:Tab"),
dataIndex: "tab",
key: "tab",
width: "150px",
render: (text, record, index) => {
return (
<Input value={text} placeholder={i18next.t("user:Default")} onChange={e => {
this.updateField(table, index, "tab", e.target.value);
}} />
);
},
},
{
title: i18next.t("signup:Regex"),
dataIndex: "regex",