Compare commits

...

26 Commits

Author SHA1 Message Date
Yang Luo
df8e9fceea feat: disable /forget API when "Forgot Password?" signin item is hidden (#4977) 2026-02-04 23:04:00 +08:00
Yang Luo
d674f0c33d feat: update Swagger docs 2026-02-03 21:34:38 +08:00
buzaslan129
1e1b5273d9 feat: expose get-all-* Casbin endpoints in Swagger (#4952) 2026-02-03 21:32:50 +08:00
IsAurora6
cf5e88915c feat: The order.products display is divided into two parts. Remove the "startTime" and "endTime", and add an updateTime field. (#4968) 2026-02-03 21:12:27 +08:00
Yang Luo
c8973e6c9e feat: add Cloud PNVS SMS provider (#4964) 2026-02-03 02:00:08 +08:00
Yang Luo
87ea451561 feat: support group sync in Google Workspace syncer (#4962) 2026-02-03 01:58:28 +08:00
Yang Luo
8f32779b42 feat: fix invitation code group assignment for OAuth provider signup (#4961) 2026-02-03 01:23:36 +08:00
Yang Luo
aba471b4e8 feat: install lsof in ALLINONE Docker image (#4958) 2026-02-02 23:51:49 +08:00
DacongDA
72b70c3b03 feat: use sqlite DB instead of mariadb for all-in-one Docker image (#4949) 2026-02-02 00:13:14 +08:00
DacongDA
a1c56894c7 feat: add tabs to user edit page (#4945) 2026-02-01 14:01:28 +08:00
Yang Luo
a9ae9394c7 feat: add Linux machine login via LDAP with POSIX attributes (#4944) 2026-01-31 22:37:29 +08:00
Yang Luo
5f0fa5f23e feat: fix properties field xlsx import issue in user list page (#4943) 2026-01-31 01:49:36 +08:00
Yang Luo
f99aa047a9 feat: add Org.AccountItems.Tab field to have tabs in user edit page (#4892) 2026-01-30 21:56:35 +08:00
Yang Luo
1d22b7ebd0 feat: prevent duplicate webhook events from redundant payment notifications (#4936) 2026-01-30 21:56:09 +08:00
IsAurora6
d147053329 feat: Optimize the display of the products column on the order and payment, adjust the color of the “Add to Cart” button. (#4933) 2026-01-30 14:03:15 +08:00
IsAurora6
0f8cd92be4 feat: resolve returnUrl redirection failure of UserEditPage (#4931) 2026-01-29 09:37:47 +08:00
DacongDA
7ea6f1296d feat: fix i18n/generate.go bug in handling "\" (#4930) 2026-01-28 23:35:23 +08:00
Yang Luo
db8c649f5e feat: include payment status in notify-payment webhook payload (#4929) 2026-01-28 19:59:10 +08:00
DacongDA
a06d003589 feat: make codeChallenge dynamic for custom OAuth provider (#4924) 2026-01-28 17:56:28 +08:00
Jacob
33298e44d4 feat(ldap-sync): support syncing phone country code and formatting mobile number (#4919) 2026-01-28 14:09:52 +08:00
IsAurora6
f4d86f8d92 feat: fix incorrect clearing of the returnUrl path parameter in redirects (#4920) 2026-01-28 10:51:44 +08:00
Yang Luo
af4337a1ae feat: add multi-address support to user edit page (#4916) 2026-01-27 21:46:41 +08:00
IsAurora6
81e650df65 feat: Optimize the display of the order price column and improve parameter passing in the OrderPay view mode. (#4912) 2026-01-27 12:17:15 +08:00
Yang Luo
fcea1e4c07 feat: add SCIM 2.0 syncer (#4909) 2026-01-27 01:47:50 +08:00
Yang Luo
639a8a47b1 feat: add Okta syncer (#4908) 2026-01-27 01:19:39 +08:00
Yang Luo
43f61d4426 feat: add Lark syncer (#4897) 2026-01-27 01:00:19 +08:00
89 changed files with 4288 additions and 520 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

@@ -739,6 +739,7 @@ func (c *ApiController) Login() {
} else if provider.Category == "OAuth" || provider.Category == "Web3" {
// OAuth
idpInfo := object.FromProviderToIdpInfo(c.Ctx, provider)
idpInfo.CodeVerifier = authForm.CodeVerifier
var idProvider idp.IdProvider
idProvider, err = idp.GetIdProvider(idpInfo, authForm.RedirectUri)
if err != nil {
@@ -866,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 != "" {
@@ -936,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}
}
@@ -955,6 +967,16 @@ func (c *ApiController) Login() {
c.ResponseError(fmt.Sprintf(c.T("auth:Failed to create user, user information is invalid: %s"), util.StructToJson(user)))
return
}
// Increment invitation usage count
if invitation != nil {
invitation.UsedCount += 1
_, err = object.UpdateInvitation(invitation.GetId(), invitation, c.GetAcceptLanguage())
if err != nil {
c.ResponseError(err.Error())
return
}
}
}
// sync info from 3rd-party if possible

View File

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

View File

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

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

View File

@@ -46,6 +46,7 @@ type AuthForm struct {
State string `json:"state"`
RedirectUri string `json:"redirectUri"`
Method string `json:"method"`
CodeVerifier string `json:"codeVerifier"`
EmailCode string `json:"emailCode"`
PhoneCode string `json:"phoneCode"`

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

@@ -19,6 +19,7 @@ import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/casdoor/casdoor/util"
@@ -47,7 +48,11 @@ func getAllI18nStringsFrontend(fileContent string) []string {
}
for _, match := range matches {
res = append(res, match[1])
target, err := strconv.Unquote("\"" + match[1] + "\"")
if err != nil {
target = match[1]
}
res = append(res, target)
}
return res
}
@@ -61,7 +66,12 @@ func getAllI18nStringsBackend(fileContent string, isObjectPackage bool) []string
}
for _, match := range matches {
match := strings.SplitN(match[1], ",", 2)
res = append(res, match[1][2:])
target, err := strconv.Unquote("\"" + match[1][2:] + "\"")
if err != nil {
target = match[1][2:]
}
res = append(res, target)
}
} else {
matches := reI18nBackendController.FindAllStringSubmatch(fileContent, -1)
@@ -69,7 +79,11 @@ func getAllI18nStringsBackend(fileContent string, isObjectPackage bool) []string
return res
}
for _, match := range matches {
res = append(res, match[1][1:])
target, err := strconv.Unquote("\"" + match[1][1:] + "\"")
if err != nil {
target = match[1][1:]
}
res = append(res, target)
}
}

View File

@@ -83,8 +83,8 @@
"The user is forbidden to sign in, please contact the administrator": "Dem Benutzer ist der Zugang verboten, bitte kontaktieren Sie den Administrator",
"The user: %s doesn't exist in LDAP server": "Der Benutzer: %s existiert nicht im LDAP-Server",
"The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline.": "Der Benutzername darf nur alphanumerische Zeichen, Unterstriche oder Bindestriche enthalten, keine aufeinanderfolgenden Bindestriche oder Unterstriche haben und darf nicht mit einem Bindestrich oder Unterstrich beginnen oder enden.",
"The value \\\"%s\\\" for account field \\\"%s\\\" doesn't match the account item regex": "Der Wert \\\"%s\\\" für das Kontenfeld \\\"%s\\\" stimmt nicht mit dem Kontenelement-Regex überein",
"The value \\\"%s\\\" for signup field \\\"%s\\\" doesn't match the signup item regex of the application \\\"%s\\\"": "Der Wert \\\"%s\\\" für das Registrierungsfeld \\\"%s\\\" stimmt nicht mit dem Registrierungselement-Regex der Anwendung \\\"%s\\\" überein",
"The value \"%s\" for account field \"%s\" doesn't match the account item regex": "Der Wert \"%s\" für das Kontenfeld \"%s\" stimmt nicht mit dem Kontenelement-Regex überein",
"The value \"%s\" for signup field \"%s\" doesn't match the signup item regex of the application \"%s\"": "Der Wert \"%s\" für das Registrierungsfeld \"%s\" stimmt nicht mit dem Registrierungselement-Regex der Anwendung \"%s\" überein",
"Username already exists": "Benutzername existiert bereits",
"Username cannot be an email address": "Benutzername kann keine E-Mail-Adresse sein",
"Username cannot contain white spaces": "Benutzername darf keine Leerzeichen enthalten",
@@ -94,7 +94,7 @@
"Username supports email format. Also The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline. Also pay attention to the email format.": "Benutzername unterstützt E-Mail-Format. Der Benutzername darf nur alphanumerische Zeichen, Unterstriche oder Bindestriche enthalten, keine aufeinanderfolgenden Bindestriche oder Unterstriche haben und darf nicht mit einem Bindestrich oder Unterstrich beginnen oder enden. Achten Sie auch auf das E-Mail-Format.",
"You have entered the wrong password or code too many times, please wait for %d minutes and try again": "Sie haben zu oft das falsche Passwort oder den falschen Code eingegeben. Bitte warten Sie %d Minuten und versuchen Sie es erneut",
"Your IP address: %s has been banned according to the configuration of: ": "Ihre IP-Adresse: %s wurde laut Konfiguration gesperrt von: ",
"Your password has expired. Please reset your password by clicking \\\"Forgot password\\\"": "Ihr Passwort ist abgelaufen. Bitte setzen Sie Ihr Passwort zurück, indem Sie auf \\\"Passwort vergessen\\\" klicken",
"Your password has expired. Please reset your password by clicking \"Forgot password\"": "Ihr Passwort ist abgelaufen. Bitte setzen Sie Ihr Passwort zurück, indem Sie auf \"Passwort vergessen\" klicken",
"Your region is not allow to signup by phone": "Ihre Region ist nicht berechtigt, sich telefonisch anzumelden",
"password or code is incorrect": "Passwort oder Code ist falsch",
"password or code is incorrect, you have %s remaining chances": "Das Passwort oder der Code ist falsch. Du hast noch %s Versuche übrig",
@@ -137,7 +137,7 @@
"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.": "Das Hinzufügen eines neuen Benutzers zur 'eingebauten' Organisation ist derzeit deaktiviert. Bitte beachten Sie: Alle Benutzer in der 'eingebauten' Organisation sind globale Administratoren in Casdoor. Siehe die Docs: https://casdoor.org/docs/basic/core-concepts#how -does-casdoor-manage-sich selbst. Wenn Sie immer noch einen Benutzer für die 'eingebaute' Organisation erstellen möchten, gehen Sie auf die Einstellungsseite der Organisation und aktivieren Sie die Option 'Habt Berechtigungszustimmung'."
},
"permission": {
"The permission: \\\"%s\\\" doesn't exist": "Die Berechtigung: \\\"%s\\\" existiert nicht"
"The permission: \"%s\" doesn't exist": "Die Berechtigung: \"%s\" existiert nicht"
},
"provider": {
"Invalid application id": "Ungültige Anwendungs-ID",
@@ -196,8 +196,8 @@
"Unknown type": "Unbekannter Typ",
"Wrong verification code!": "Falscher Bestätigungscode!",
"You should verify your code in %d min!": "Du solltest deinen Code in %d Minuten verifizieren!",
"please add a SMS provider to the \\\"Providers\\\" list for the application: %s": "Bitte fügen Sie einen SMS-Anbieter zur \\\"Providers\\\"-Liste für die Anwendung hinzu: %s",
"please add an Email provider to the \\\"Providers\\\" list for the application: %s": "Bitte fügen Sie einen E-Mail-Anbieter zur \\\"Providers\\\"-Liste für die Anwendung hinzu: %s",
"please add a SMS provider to the \"Providers\" list for the application: %s": "Bitte fügen Sie einen SMS-Anbieter zur \"Providers\"-Liste für die Anwendung hinzu: %s",
"please add an Email provider to the \"Providers\" list for the application: %s": "Bitte fügen Sie einen E-Mail-Anbieter zur \"Providers\"-Liste für die Anwendung hinzu: %s",
"the user does not exist, please sign up first": "Der Benutzer existiert nicht, bitte zuerst anmelden"
},
"webauthn": {

View File

@@ -83,8 +83,8 @@
"The user is forbidden to sign in, please contact the administrator": "The user is forbidden to sign in, please contact the administrator",
"The user: %s doesn't exist in LDAP server": "The user: %s doesn't exist in LDAP server",
"The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline.": "The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline.",
"The value \\\"%s\\\" for account field \\\"%s\\\" doesn't match the account item regex": "The value \\\"%s\\\" for account field \\\"%s\\\" doesn't match the account item regex",
"The value \\\"%s\\\" for signup field \\\"%s\\\" doesn't match the signup item regex of the application \\\"%s\\\"": "The value \\\"%s\\\" for signup field \\\"%s\\\" doesn't match the signup item regex of the application \\\"%s\\\"",
"The value \"%s\" for account field \"%s\" doesn't match the account item regex": "The value \"%s\" for account field \"%s\" doesn't match the account item regex",
"The value \"%s\" for signup field \"%s\" doesn't match the signup item regex of the application \"%s\"": "The value \"%s\" for signup field \"%s\" doesn't match the signup item regex of the application \"%s\"",
"Username already exists": "Username already exists",
"Username cannot be an email address": "Username cannot be an email address",
"Username cannot contain white spaces": "Username cannot contain white spaces",
@@ -94,7 +94,7 @@
"Username supports email format. Also The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline. Also pay attention to the email format.": "Username supports email format. Also The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline. Also pay attention to the email format.",
"You have entered the wrong password or code too many times, please wait for %d minutes and try again": "You have entered the wrong password or code too many times, please wait for %d minutes and try again",
"Your IP address: %s has been banned according to the configuration of: ": "Your IP address: %s has been banned according to the configuration of: ",
"Your password has expired. Please reset your password by clicking \\\"Forgot password\\\"": "Your password has expired. Please reset your password by clicking \\\"Forgot password\\\"",
"Your password has expired. Please reset your password by clicking \"Forgot password\"": "Your password has expired. Please reset your password by clicking \"Forgot password\"",
"Your region is not allow to signup by phone": "Your region is not allow to signup by phone",
"password or code is incorrect": "password or code is incorrect",
"password or code is incorrect, you have %s remaining chances": "password or code is incorrect, you have %s remaining chances",
@@ -137,7 +137,7 @@
"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.": "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."
},
"permission": {
"The permission: \\\"%s\\\" doesn't exist": "The permission: \\\"%s\\\" doesn't exist"
"The permission: \"%s\" doesn't exist": "The permission: \"%s\" doesn't exist"
},
"provider": {
"Invalid application id": "Invalid application id",
@@ -188,6 +188,7 @@
"verification": {
"Invalid captcha provider.": "Invalid captcha provider.",
"Phone number is invalid in your region %s": "Phone number is invalid in your region %s",
"The forgot password feature is disabled": "The forgot password feature is disabled",
"The verification code has already been used!": "The verification code has already been used!",
"The verification code has not been sent yet!": "The verification code has not been sent yet!",
"Turing test failed.": "Turing test failed.",
@@ -196,8 +197,8 @@
"Unknown type": "Unknown type",
"Wrong verification code!": "Wrong verification code!",
"You should verify your code in %d min!": "You should verify your code in %d min!",
"please add a SMS provider to the \\\"Providers\\\" list for the application: %s": "please add a SMS provider to the \\\"Providers\\\" list for the application: %s",
"please add an Email provider to the \\\"Providers\\\" list for the application: %s": "please add an Email provider to the \\\"Providers\\\" list for the application: %s",
"please add a SMS provider to the \"Providers\" list for the application: %s": "please add a SMS provider to the \"Providers\" list for the application: %s",
"please add an Email provider to the \"Providers\" list for the application: %s": "please add an Email provider to the \"Providers\" list for the application: %s",
"the user does not exist, please sign up first": "the user does not exist, please sign up first"
},
"webauthn": {

View File

@@ -83,8 +83,8 @@
"The user is forbidden to sign in, please contact the administrator": "El usuario no está autorizado a iniciar sesión, por favor contacte al administrador",
"The user: %s doesn't exist in LDAP server": "El usuario: %s no existe en el servidor LDAP",
"The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline.": "El nombre de usuario solo puede contener caracteres alfanuméricos, guiones bajos o guiones, no puede tener guiones o subrayados consecutivos, y no puede comenzar ni terminar con un guión o subrayado.",
"The value \\\"%s\\\" for account field \\\"%s\\\" doesn't match the account item regex": "El valor \\\"%s\\\" para el campo de cuenta \\\"%s\\\" no coincide con la expresión regular del elemento de cuenta",
"The value \\\"%s\\\" for signup field \\\"%s\\\" doesn't match the signup item regex of the application \\\"%s\\\"": "El valor \\\"%s\\\" para el campo de registro \\\"%s\\\" no coincide con la expresión regular del elemento de registro de la aplicación \\\"%s\\\"",
"The value \"%s\" for account field \"%s\" doesn't match the account item regex": "El valor \"%s\" para el campo de cuenta \"%s\" no coincide con la expresión regular del elemento de cuenta",
"The value \"%s\" for signup field \"%s\" doesn't match the signup item regex of the application \"%s\"": "El valor \"%s\" para el campo de registro \"%s\" no coincide con la expresión regular del elemento de registro de la aplicación \"%s\"",
"Username already exists": "El nombre de usuario ya existe",
"Username cannot be an email address": "Nombre de usuario no puede ser una dirección de correo electrónico",
"Username cannot contain white spaces": "Nombre de usuario no puede contener espacios en blanco",
@@ -94,7 +94,7 @@
"Username supports email format. Also The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline. Also pay attention to the email format.": "El nombre de usuario admite formato de correo electrónico. Además, el nombre de usuario solo puede contener caracteres alfanuméricos, guiones bajos o guiones, no puede tener guiones bajos o guiones consecutivos y no puede comenzar ni terminar con un guión o guión bajo. También preste atención al formato del correo electrónico.",
"You have entered the wrong password or code too many times, please wait for %d minutes and try again": "Has ingresado la contraseña o código incorrecto demasiadas veces, por favor espera %d minutos e intenta de nuevo",
"Your IP address: %s has been banned according to the configuration of: ": "Su dirección IP: %s ha sido bloqueada según la configuración de: ",
"Your password has expired. Please reset your password by clicking \\\"Forgot password\\\"": "Su contraseña ha expirado. Restablezca su contraseña haciendo clic en \\\"Olvidé mi contraseña\\\"",
"Your password has expired. Please reset your password by clicking \"Forgot password\"": "Su contraseña ha expirado. Restablezca su contraseña haciendo clic en \"Olvidé mi contraseña\"",
"Your region is not allow to signup by phone": "Tu región no está permitida para registrarse por teléfono",
"password or code is incorrect": "contraseña o código incorrecto",
"password or code is incorrect, you have %s remaining chances": "Contraseña o código incorrecto, tienes %s intentos restantes",
@@ -137,7 +137,7 @@
"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.": "La adición de un nuevo usuario a la organización 'integrada' está actualmente deshabilitada. Tenga en cuenta: todos los usuarios de la organización 'integrada' son administradores globales en Casdoor. Consulte los docs: https://casdoor.org/docs/basic/core-concepts#how -does-casdoor-manage-itself. Si todavía desea crear un usuario para la organización 'integrada', vaya a la página de configuración de la organización y habilite la opción 'Tiene consentimiento de privilegios'."
},
"permission": {
"The permission: \\\"%s\\\" doesn't exist": "El permiso: \\\"%s\\\" no existe"
"The permission: \"%s\" doesn't exist": "El permiso: \"%s\" no existe"
},
"provider": {
"Invalid application id": "Identificación de aplicación no válida",
@@ -196,8 +196,8 @@
"Unknown type": "Tipo desconocido",
"Wrong verification code!": "¡Código de verificación incorrecto!",
"You should verify your code in %d min!": "¡Deberías verificar tu código en %d minutos!",
"please add a SMS provider to the \\\"Providers\\\" list for the application: %s": "agregue un proveedor de SMS a la lista \\\"Proveedores\\\" para la aplicación: %s",
"please add an Email provider to the \\\"Providers\\\" list for the application: %s": "agregue un proveedor de correo electrónico a la lista \\\"Proveedores\\\" para la aplicación: %s",
"please add a SMS provider to the \"Providers\" list for the application: %s": "agregue un proveedor de SMS a la lista \"Proveedores\" para la aplicación: %s",
"please add an Email provider to the \"Providers\" list for the application: %s": "agregue un proveedor de correo electrónico a la lista \"Proveedores\" para la aplicación: %s",
"the user does not exist, please sign up first": "El usuario no existe, por favor regístrese primero"
},
"webauthn": {

View File

@@ -83,8 +83,8 @@
"The user is forbidden to sign in, please contact the administrator": "L'utilisateur est interdit de se connecter, veuillez contacter l'administrateur",
"The user: %s doesn't exist in LDAP server": "L'utilisateur : %s n'existe pas sur le serveur LDAP",
"The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline.": "Le nom d'utilisateur ne peut contenir que des caractères alphanumériques, des traits soulignés ou des tirets, ne peut pas avoir de tirets ou de traits soulignés consécutifs et ne peut pas commencer ou se terminer par un tiret ou un trait souligné.",
"The value \\\"%s\\\" for account field \\\"%s\\\" doesn't match the account item regex": "La valeur \\\"%s\\\" pour le champ de compte \\\"%s\\\" ne correspond pas à l'expression régulière de l'élément de compte",
"The value \\\"%s\\\" for signup field \\\"%s\\\" doesn't match the signup item regex of the application \\\"%s\\\"": "La valeur \\\"%s\\\" pour le champ d'inscription \\\"%s\\\" ne correspond pas à l'expression régulière de l'élément d'inscription de l'application \\\"%s\\\"",
"The value \"%s\" for account field \"%s\" doesn't match the account item regex": "La valeur \"%s\" pour le champ de compte \"%s\" ne correspond pas à l'expression régulière de l'élément de compte",
"The value \"%s\" for signup field \"%s\" doesn't match the signup item regex of the application \"%s\"": "La valeur \"%s\" pour le champ d'inscription \"%s\" ne correspond pas à l'expression régulière de l'élément d'inscription de l'application \"%s\"",
"Username already exists": "Nom d'utilisateur existe déjà",
"Username cannot be an email address": "Nom d'utilisateur ne peut pas être une adresse e-mail",
"Username cannot contain white spaces": "Nom d'utilisateur ne peut pas contenir d'espaces blancs",
@@ -94,7 +94,7 @@
"Username supports email format. Also The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline. Also pay attention to the email format.": "Le nom d'utilisateur prend en charge le format e-mail. De plus, il ne peut contenir que des caractères alphanumériques, des tirets bas ou des traits d'union, ne peut pas avoir de traits d'union ou de tirets bas consécutifs, et ne peut pas commencer ou se terminer par un trait d'union ou un tiret bas. Faites également attention au format de l'e-mail.",
"You have entered the wrong password or code too many times, please wait for %d minutes and try again": "Vous avez entré le mauvais mot de passe ou code plusieurs fois, veuillez attendre %d minutes et réessayer",
"Your IP address: %s has been banned according to the configuration of: ": "Votre adresse IP : %s a été bannie selon la configuration de : ",
"Your password has expired. Please reset your password by clicking \\\"Forgot password\\\"": "Votre mot de passe a expiré. Veuillez le réinitialiser en cliquant sur \\\"Mot de passe oublié\\\"",
"Your password has expired. Please reset your password by clicking \"Forgot password\"": "Votre mot de passe a expiré. Veuillez le réinitialiser en cliquant sur \"Mot de passe oublié\"",
"Your region is not allow to signup by phone": "Votre région n'est pas autorisée à s'inscrire par téléphone",
"password or code is incorrect": "mot de passe ou code incorrect",
"password or code is incorrect, you have %s remaining chances": "Le mot de passe ou le code est incorrect, il vous reste %s chances",
@@ -137,7 +137,7 @@
"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.": "L'ajout d'un nouvel utilisateur à l'organisation « built-in » (intégrée) est actuellement désactivé. Veuillez noter : Tous les utilisateurs de l'organisation « built-in » sont des administrateurs globaux dans Casdoor. Consulter la documentation : https://casdoor.org/docs/basic/core-concepts#how-does-casdoor-manage-itself. Si vous souhaitez encore créer un utilisateur pour l'organisation « built-in », accédez à la page des paramètres de l'organisation et activez l'option « A le consentement aux privilèges »."
},
"permission": {
"The permission: \\\"%s\\\" doesn't exist": "La permission : \\\"%s\\\" n'existe pas"
"The permission: \"%s\" doesn't exist": "La permission : \"%s\" n'existe pas"
},
"provider": {
"Invalid application id": "Identifiant d'application invalide",
@@ -196,8 +196,8 @@
"Unknown type": "Type inconnu",
"Wrong verification code!": "Mauvais code de vérification !",
"You should verify your code in %d min!": "Vous devriez vérifier votre code en %d min !",
"please add a SMS provider to the \\\"Providers\\\" list for the application: %s": "veuillez ajouter un fournisseur SMS à la liste \\\"Providers\\\" pour l'application : %s",
"please add an Email provider to the \\\"Providers\\\" list for the application: %s": "veuillez ajouter un fournisseur d'e-mail à la liste \\\"Providers\\\" pour l'application : %s",
"please add a SMS provider to the \"Providers\" list for the application: %s": "veuillez ajouter un fournisseur SMS à la liste \"Providers\" pour l'application : %s",
"please add an Email provider to the \"Providers\" list for the application: %s": "veuillez ajouter un fournisseur d'e-mail à la liste \"Providers\" pour l'application : %s",
"the user does not exist, please sign up first": "L'utilisateur n'existe pas, veuillez vous inscrire d'abord"
},
"webauthn": {

View File

@@ -83,8 +83,8 @@
"The user is forbidden to sign in, please contact the administrator": "ユーザーはサインインできません。管理者に連絡してください",
"The user: %s doesn't exist in LDAP server": "ユーザー「%s」は LDAP サーバーに存在しません",
"The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline.": "ユーザー名には英数字、アンダースコア、ハイフンしか含めることができません。連続したハイフンまたはアンダースコアは不可であり、ハイフンまたはアンダースコアで始まるまたは終わることもできません。",
"The value \\\"%s\\\" for account field \\\"%s\\\" doesn't match the account item regex": "アカウントフィールド「%s」の値「%s」がアカウント項目の正規表現に一致しません",
"The value \\\"%s\\\" for signup field \\\"%s\\\" doesn't match the signup item regex of the application \\\"%s\\\"": "アプリケーション「%s」のサインアップ項目「%s」の値「%s」が正規表現に一致しません",
"The value \"%s\" for account field \"%s\" doesn't match the account item regex": "アカウントフィールド「%s」の値「%s」がアカウント項目の正規表現に一致しません",
"The value \"%s\" for signup field \"%s\" doesn't match the signup item regex of the application \"%s\"": "アプリケーション「%s」のサインアップ項目「%s」の値「%s」が正規表現に一致しません",
"Username already exists": "ユーザー名はすでに存在しています",
"Username cannot be an email address": "ユーザー名には電子メールアドレスを使用できません",
"Username cannot contain white spaces": "ユーザ名にはスペースを含めることはできません",
@@ -94,7 +94,7 @@
"Username supports email format. Also The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline. Also pay attention to the email format.": "ユーザー名はメール形式もサポートします。ユーザー名は英数字、アンダースコア、またはハイフンのみを含め、連続するハイフンやアンダースコアは使用できません。また、ハイフンまたはアンダースコアで始まったり終わったりすることもできません。メール形式にも注意してください。",
"You have entered the wrong password or code too many times, please wait for %d minutes and try again": "あなたは間違ったパスワードまたはコードを何度も入力しました。%d 分間待ってから再度お試しください",
"Your IP address: %s has been banned according to the configuration of: ": "あなたの IP アドレス「%s」は設定によりアクセスが禁止されています: ",
"Your password has expired. Please reset your password by clicking \\\"Forgot password\\\"": "パスワードの有効期限が切れています。「パスワードを忘れた方はこちら」をクリックしてリセットしてください",
"Your password has expired. Please reset your password by clicking \"Forgot password\"": "パスワードの有効期限が切れています。「パスワードを忘れた方はこちら」をクリックしてリセットしてください",
"Your region is not allow to signup by phone": "あなたの地域は電話でサインアップすることができません",
"password or code is incorrect": "パスワードまたはコードが正しくありません",
"password or code is incorrect, you have %s remaining chances": "パスワードまたはコードが間違っています。あと %s 回の試行機会があります",
@@ -137,7 +137,7 @@
"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.": "「built-in」組み込み組織への新しいユーザーの追加は現在無効になっています。注意「built-in」組織のすべてのユーザーは、Casdoor のグローバル管理者です。ドキュメントを参照してくださいhttps://casdoor.org/docs/basic/core-concepts#how-does-casdoor-manage-itself。「built-in」組織のユーザーを作成したい場合は、組織の設定ページに移動し、「特権同意を持つ」オプションを有効にしてください。"
},
"permission": {
"The permission: \\\"%s\\\" doesn't exist": "権限「%s」は存在しません"
"The permission: \"%s\" doesn't exist": "権限「%s」は存在しません"
},
"provider": {
"Invalid application id": "アプリケーションIDが無効です",
@@ -196,8 +196,8 @@
"Unknown type": "不明なタイプ",
"Wrong verification code!": "誤った検証コードです!",
"You should verify your code in %d min!": "あなたは%d分であなたのコードを確認する必要があります",
"please add a SMS provider to the \\\"Providers\\\" list for the application: %s": "アプリケーション「%s」の「Providers」リストに SMS プロバイダを追加してください",
"please add an Email provider to the \\\"Providers\\\" list for the application: %s": "アプリケーション「%s」の「Providers」リストにメールプロバイダを追加してください",
"please add a SMS provider to the \"Providers\" list for the application: %s": "アプリケーション「%s」の「Providers」リストに SMS プロバイダを追加してください",
"please add an Email provider to the \"Providers\" list for the application: %s": "アプリケーション「%s」の「Providers」リストにメールプロバイダを追加してください",
"the user does not exist, please sign up first": "ユーザーは存在しません。まず登録してください"
},
"webauthn": {

View File

@@ -83,8 +83,8 @@
"The user is forbidden to sign in, please contact the administrator": "Użytkownikowi zabroniono logowania, skontaktuj się z administratorem",
"The user: %s doesn't exist in LDAP server": "Użytkownik: %s nie istnieje w serwerze LDAP",
"The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline.": "Nazwa użytkownika może zawierać tylko znaki alfanumeryczne, podkreślenia lub myślniki, nie może mieć kolejnych myślników lub podkreśleń i nie może zaczynać się ani kończyć myślnikiem lub podkreśleniem.",
"The value \\\"%s\\\" for account field \\\"%s\\\" doesn't match the account item regex": "Wartość \\\"%s\\\" dla pola konta \\\"%s\\\" nie pasuje do wyrażenia regularnego elementu konta",
"The value \\\"%s\\\" for signup field \\\"%s\\\" doesn't match the signup item regex of the application \\\"%s\\\"": "Wartość \\\"%s\\\" dla pola rejestracji \\\"%s\\\" nie pasuje do wyrażenia regularnego elementu rejestracji aplikacji \\\"%s\\\"",
"The value \"%s\" for account field \"%s\" doesn't match the account item regex": "Wartość \"%s\" dla pola konta \"%s\" nie pasuje do wyrażenia regularnego elementu konta",
"The value \"%s\" for signup field \"%s\" doesn't match the signup item regex of the application \"%s\"": "Wartość \"%s\" dla pola rejestracji \"%s\" nie pasuje do wyrażenia regularnego elementu rejestracji aplikacji \"%s\"",
"Username already exists": "Nazwa użytkownika już istnieje",
"Username cannot be an email address": "Nazwa użytkownika nie może być adresem email",
"Username cannot contain white spaces": "Nazwa użytkownika nie może zawierać spacji",
@@ -94,7 +94,7 @@
"Username supports email format. Also The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline. Also pay attention to the email format.": "Nazwa użytkownika obsługuje format email. Również nazwa użytkownika może zawierać tylko znaki alfanumeryczne, podkreślenia lub myślniki, nie może mieć kolejnych myślników lub podkreśleń i nie może zaczynać się ani kończyć myślnikiem lub podkreśleniem. Zwróć też uwagę na format email.",
"You have entered the wrong password or code too many times, please wait for %d minutes and try again": "Wprowadziłeś złe hasło lub kod zbyt wiele razy, poczekaj %d minut i spróbuj ponownie",
"Your IP address: %s has been banned according to the configuration of: ": "Twój adres IP: %s został zablokowany zgodnie z konfiguracją: ",
"Your password has expired. Please reset your password by clicking \\\"Forgot password\\\"": "Twoje hasło wygasło. Zresetuj hasło klikając \\\"Zapomniałem hasła\\\"",
"Your password has expired. Please reset your password by clicking \"Forgot password\"": "Twoje hasło wygasło. Zresetuj hasło klikając \"Zapomniałem hasła\"",
"Your region is not allow to signup by phone": "Twój region nie pozwala na rejestrację przez telefon",
"password or code is incorrect": "hasło lub kod jest nieprawidłowe",
"password or code is incorrect, you have %s remaining chances": "hasło lub kod jest nieprawidłowe, masz jeszcze %s prób",
@@ -137,7 +137,7 @@
"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.": "Dodawanie nowego użytkownika do organizacji „built-in' (wbudowanej) jest obecnie wyłączone. Należy zauważyć, że wszyscy użytkownicy w organizacji „built-in' są globalnymi administratorami w Casdoor. Zobacz dokumentację: https://casdoor.org/docs/basic/core-concepts#how-does-casdoor-manage-itself. Jeśli nadal chcesz utworzyć użytkownika dla organizacji „built-in', przejdź do strony ustawień organizacji i włącz opcję „Ma zgodę na uprawnienia'."
},
"permission": {
"The permission: \\\"%s\\\" doesn't exist": "Uprawnienie: \\\"%s\\\" nie istnieje"
"The permission: \"%s\" doesn't exist": "Uprawnienie: \"%s\" nie istnieje"
},
"provider": {
"Invalid application id": "Nieprawidłowe id aplikacji",
@@ -196,8 +196,8 @@
"Unknown type": "Nieznany typ",
"Wrong verification code!": "Zły kod weryfikacyjny!",
"You should verify your code in %d min!": "Powinieneś zweryfikować swój kod w ciągu %d min!",
"please add a SMS provider to the \\\"Providers\\\" list for the application: %s": "proszę dodać dostawcę SMS do listy \\\"Providers\\\" dla aplikacji: %s",
"please add an Email provider to the \\\"Providers\\\" list for the application: %s": "proszę dodać dostawcę email do listy \\\"Providers\\\" dla aplikacji: %s",
"please add a SMS provider to the \"Providers\" list for the application: %s": "proszę dodać dostawcę SMS do listy \"Providers\" dla aplikacji: %s",
"please add an Email provider to the \"Providers\" list for the application: %s": "proszę dodać dostawcę email do listy \"Providers\" dla aplikacji: %s",
"the user does not exist, please sign up first": "użytkownik nie istnieje, najpierw się zarejestruj"
},
"webauthn": {

View File

@@ -83,8 +83,8 @@
"The user is forbidden to sign in, please contact the administrator": "O usuário está proibido de entrar, entre em contato com o administrador",
"The user: %s doesn't exist in LDAP server": "O usuário: %s não existe no servidor LDAP",
"The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline.": "O nome de usuário pode conter apenas caracteres alfanuméricos, sublinhados ou hífens, não pode ter hífens ou sublinhados consecutivos e não pode começar ou terminar com hífen ou sublinhado.",
"The value \\\"%s\\\" for account field \\\"%s\\\" doesn't match the account item regex": "O valor \\\"%s\\\" para o campo de conta \\\"%s\\\" não corresponde à expressão regular definida",
"The value \\\"%s\\\" for signup field \\\"%s\\\" doesn't match the signup item regex of the application \\\"%s\\\"": "O valor \\\"%s\\\" para o campo de registro \\\"%s\\\" não corresponde à expressão regular definida na aplicação \\\"%s\\\"",
"The value \"%s\" for account field \"%s\" doesn't match the account item regex": "O valor \"%s\" para o campo de conta \"%s\" não corresponde à expressão regular definida",
"The value \"%s\" for signup field \"%s\" doesn't match the signup item regex of the application \"%s\"": "O valor \"%s\" para o campo de registro \"%s\" não corresponde à expressão regular definida na aplicação \"%s\"",
"Username already exists": "O nome de usuário já existe",
"Username cannot be an email address": "O nome de usuário não pode ser um endereço de e-mail",
"Username cannot contain white spaces": "O nome de usuário não pode conter espaços em branco",
@@ -94,7 +94,7 @@
"Username supports email format. Also The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline. Also pay attention to the email format.": "O nome de usuário suporta o formato de e-mail. Além disso, pode conter apenas caracteres alfanuméricos, sublinhados ou hífens, não pode ter hífens ou sublinhados consecutivos e não pode começar ou terminar com hífen ou sublinhado. Também preste atenção ao formato do e-mail.",
"You have entered the wrong password or code too many times, please wait for %d minutes and try again": "Você digitou a senha ou o código incorretos muitas vezes. Aguarde %d minutos e tente novamente",
"Your IP address: %s has been banned according to the configuration of: ": "Seu endereço IP: %s foi bloqueado de acordo com a configuração de: ",
"Your password has expired. Please reset your password by clicking \\\"Forgot password\\\"": "Sua senha expirou. Por favor, redefina-a clicando em \\\"Esqueci a senha\\\"",
"Your password has expired. Please reset your password by clicking \"Forgot password\"": "Sua senha expirou. Por favor, redefina-a clicando em \"Esqueci a senha\"",
"Your region is not allow to signup by phone": "Sua região não permite cadastro por telefone",
"password or code is incorrect": "Senha ou código incorreto",
"password or code is incorrect, you have %s remaining chances": "Senha ou código incorreto, você tem %s tentativas restantes",
@@ -137,7 +137,7 @@
"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.": "A adição de novos usuários à organização 'built-in' está desativada. Observe que todos os usuários nessa organização são administradores globais no Casdoor. Consulte a documentação: https://casdoor.org/docs/basic/core-concepts#how-does-casdoor-manage-itself. Se ainda desejar criar um usuário, vá até a página de configurações da organização e habilite a opção 'Possui consentimento de privilégios'."
},
"permission": {
"The permission: \\\"%s\\\" doesn't exist": "A permissão: \\\"%s\\\" não existe"
"The permission: \"%s\" doesn't exist": "A permissão: \"%s\" não existe"
},
"provider": {
"Invalid application id": "ID de aplicativo inválido",
@@ -196,8 +196,8 @@
"Unknown type": "Tipo desconhecido",
"Wrong verification code!": "Código de verificação incorreto!",
"You should verify your code in %d min!": "Você deve verificar seu código em %d minuto(s)!",
"please add a SMS provider to the \\\"Providers\\\" list for the application: %s": "Adicione um provedor de SMS à lista \\\"Providers\\\" do aplicativo: %s",
"please add an Email provider to the \\\"Providers\\\" list for the application: %s": "Adicione um provedor de e-mail à lista \\\"Providers\\\" do aplicativo: %s",
"please add a SMS provider to the \"Providers\" list for the application: %s": "Adicione um provedor de SMS à lista \"Providers\" do aplicativo: %s",
"please add an Email provider to the \"Providers\" list for the application: %s": "Adicione um provedor de e-mail à lista \"Providers\" do aplicativo: %s",
"the user does not exist, please sign up first": "O usuário não existe, cadastre-se primeiro"
},
"webauthn": {

View File

@@ -83,8 +83,8 @@
"The user is forbidden to sign in, please contact the administrator": "Kullanıcı giriş yapmaktan men edildi, lütfen yönetici ile iletişime geçin",
"The user: %s doesn't exist in LDAP server": "Kullanıcı: %s LDAP sunucusunda mevcut değil",
"The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline.": "Kullanıcı adı yalnızca alfanümerik karakterler, alt çizgi veya tire içerebilir, ardışık tire veya alt çizgi içeremez ve tire veya alt çizgi ile başlayamaz veya bitemez.",
"The value \\\"%s\\\" for account field \\\"%s\\\" doesn't match the account item regex": "Hesap alanı \\\"%s\\\" için \\\"%s\\\" değeri, hesap öğesi regex'iyle eşleşmiyor",
"The value \\\"%s\\\" for signup field \\\"%s\\\" doesn't match the signup item regex of the application \\\"%s\\\"": "Kayıt alanı \\\"%s\\\" için \\\"%s\\\" değeri, \\\"%s\\\" uygulamasının kayıt öğesi regex'iyle eşleşmiyor",
"The value \"%s\" for account field \"%s\" doesn't match the account item regex": "Hesap alanı \"%s\" için \"%s\" değeri, hesap öğesi regex'iyle eşleşmiyor",
"The value \"%s\" for signup field \"%s\" doesn't match the signup item regex of the application \"%s\"": "Kayıt alanı \"%s\" için \"%s\" değeri, \"%s\" uygulamasının kayıt öğesi regex'iyle eşleşmiyor",
"Username already exists": "Kullanıcı adı zaten var",
"Username cannot be an email address": "Kullanıcı adı e-posta adresi olamaz",
"Username cannot contain white spaces": "Kullanıcı adı boşluk içeremez",
@@ -94,7 +94,7 @@
"Username supports email format. Also The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline. Also pay attention to the email format.": "Kullanıcı adı e-posta biçimini destekler. Ayrıca kullanıcı adı yalnızca alfanümerik karakterler, alt çizgiler veya tireler içerebilir, ardışık tireler veya alt çizgiler olamaz ve tire veya alt çizgi ile başlayıp bitemez. Ayrıca e-posta biçimine dikkat edin.",
"You have entered the wrong password or code too many times, please wait for %d minutes and try again": "Çok fazla hatalı şifre veya kod girdiniz, lütfen %d dakika bekleyin ve tekrar deneyin",
"Your IP address: %s has been banned according to the configuration of: ": "IP adresiniz: %s, yapılandırmaya göre yasaklandı: ",
"Your password has expired. Please reset your password by clicking \\\"Forgot password\\\"": "Şifrenizin süresi doldu. Lütfen \\\"Şifremi unuttum\\\"a tıklayarak şifrenizi sıfırlayın",
"Your password has expired. Please reset your password by clicking \"Forgot password\"": "Şifrenizin süresi doldu. Lütfen \"Şifremi unuttum\"a tıklayarak şifrenizi sıfırlayın",
"Your region is not allow to signup by phone": "Bölgeniz telefonla kayıt yapmaya izin verilmiyor",
"password or code is incorrect": "şifre veya kod yanlış",
"password or code is incorrect, you have %s remaining chances": "şifre veya kod yanlış, %s hakkınız kaldı",
@@ -137,7 +137,7 @@
"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.": "'built-in' (yerleşik) organizasyona yeni bir kullanıcı ekleme şu anda devre dışı bırakılmıştır. Not: 'built-in' organizasyonundaki tüm kullanıcılar Casdoor'da genel yöneticilerdir. Belgelere bakın: https://casdoor.org/docs/basic/core-concepts#how-does-casdoor-manage-itself. Hala 'built-in' organizasyonu için bir kullanıcı oluşturmak isterseniz, organizasyonun ayarları sayfasına gidin ve 'Yetki onayı var' seçeneğini etkinleştirin."
},
"permission": {
"The permission: \\\"%s\\\" doesn't exist": "İzin: \\\"%s\\\" mevcut değil"
"The permission: \"%s\" doesn't exist": "İzin: \"%s\" mevcut değil"
},
"provider": {
"Invalid application id": "Geçersiz uygulama id",
@@ -196,8 +196,8 @@
"Unknown type": "Bilinmeyen tür",
"Wrong verification code!": "Yanlış doğrulama kodu!",
"You should verify your code in %d min!": "Kodunuzu %d dakika içinde doğrulamalısınız!",
"please add a SMS provider to the \\\"Providers\\\" list for the application: %s": "lütfen uygulama için \\\"Sağlayıcılar\\\" listesine bir SMS sağlayıcı ekleyin: %s",
"please add an Email provider to the \\\"Providers\\\" list for the application: %s": "lütfen uygulama için \\\"Sağlayıcılar\\\" listesine bir E-posta sağlayıcı ekleyin: %s",
"please add a SMS provider to the \"Providers\" list for the application: %s": "lütfen uygulama için \"Sağlayıcılar\" listesine bir SMS sağlayıcı ekleyin: %s",
"please add an Email provider to the \"Providers\" list for the application: %s": "lütfen uygulama için \"Sağlayıcılar\" listesine bir E-posta sağlayıcı ekleyin: %s",
"the user does not exist, please sign up first": "kullanıcı mevcut değil, lütfen önce kaydolun"
},
"webauthn": {

View File

@@ -83,8 +83,8 @@
"The user is forbidden to sign in, please contact the administrator": "Користувачу заборонено вхід, зверніться до адміністратора",
"The user: %s doesn't exist in LDAP server": "Користувач: %s не існує на сервері LDAP",
"The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline.": "Ім’я користувача може містити лише буквено-цифрові символи, підкреслення або дефіси, не може мати послідовні дефіси або підкреслення та не може починатися або закінчуватися дефісом або підкресленням.",
"The value \\\"%s\\\" for account field \\\"%s\\\" doesn't match the account item regex": "Värdet \\\"%s\\\" för kontofältet \\\"%s\\\" matchar inte kontots regex",
"The value \\\"%s\\\" for signup field \\\"%s\\\" doesn't match the signup item regex of the application \\\"%s\\\"": "Värdet \\\"%s\\\" för registreringsfältet \\\"%s\\\" matchar inte registreringsfältets regex för applikationen \\\"%s\\\"",
"The value \"%s\" for account field \"%s\" doesn't match the account item regex": "Värdet \"%s\" för kontofältet \"%s\" matchar inte kontots regex",
"The value \"%s\" for signup field \"%s\" doesn't match the signup item regex of the application \"%s\"": "Värdet \"%s\" för registreringsfältet \"%s\" matchar inte registreringsfältets regex för applikationen \"%s\"",
"Username already exists": "Ім’я користувача вже існує",
"Username cannot be an email address": "Ім’я користувача не може бути email-адресою",
"Username cannot contain white spaces": "Ім’я користувача не може містити пробіли",
@@ -94,7 +94,7 @@
"Username supports email format. Also The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline. Also pay attention to the email format.": "Ім’я користувача підтримує формат email. Також може містити лише буквено-цифрові символи, підкреслення або дефіси, не може мати послідовні дефіси або підкреслення та не може починатися або закінчуватися дефісом або підкресленням. Зверніть увагу на формат email.",
"You have entered the wrong password or code too many times, please wait for %d minutes and try again": "Ви ввели неправильний пароль або код забагато разів, зачекайте %d хвилин і спробуйте знову",
"Your IP address: %s has been banned according to the configuration of: ": "Ваша IP-адреса: %s заблокована відповідно до конфігурації: ",
"Your password has expired. Please reset your password by clicking \\\"Forgot password\\\"": "Ditt lösenord har gått ut. Återställ det genom att klicka på \\\"Glömt lösenord\\\"",
"Your password has expired. Please reset your password by clicking \"Forgot password\"": "Ditt lösenord har gått ut. Återställ det genom att klicka på \"Glömt lösenord\"",
"Your region is not allow to signup by phone": "У вашому регіоні реєстрація за телефоном недоступна",
"password or code is incorrect": "пароль або код неправильний",
"password or code is incorrect, you have %s remaining chances": "пароль або код неправильний, у вас залишилось %s спроб",
@@ -137,7 +137,7 @@
"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.": "Додавання нового користувача до організації «built-in» (вбудованої) на даний момент вимкнено. Зауважте: усі користувачі в організації «built-in» є глобальними адміністраторами в Casdoor. Дивіться документацію: https://casdoor.org/docs/basic/core-concepts#how-does-casdoor-manage-itself. Якщо ви все ще хочете створити користувача для організації «built-in», перейдіть на сторінку налаштувань організації та увімкніть опцію «Має згоду на привілеї»."
},
"permission": {
"The permission: \\\"%s\\\" doesn't exist": "Behörigheten: \\\"%s\\\" finns inte"
"The permission: \"%s\" doesn't exist": "Behörigheten: \"%s\" finns inte"
},
"provider": {
"Invalid application id": "Недійсний id додатка",
@@ -196,8 +196,8 @@
"Unknown type": "Невідомий тип",
"Wrong verification code!": "Неправильний код підтвердження!",
"You should verify your code in %d min!": "Ви маєте підтвердити код за %d хв!",
"please add a SMS provider to the \\\"Providers\\\" list for the application: %s": "lägg till en SMS-leverantör i listan \\\"Leverantörer\\\" för applikationen: %s",
"please add an Email provider to the \\\"Providers\\\" list for the application: %s": "lägg till en e-postleverantör i listan \\\"Leverantörer\\\" för applikationen: %s",
"please add a SMS provider to the \"Providers\" list for the application: %s": "lägg till en SMS-leverantör i listan \"Leverantörer\" för applikationen: %s",
"please add an Email provider to the \"Providers\" list for the application: %s": "lägg till en e-postleverantör i listan \"Leverantörer\" för applikationen: %s",
"the user does not exist, please sign up first": "користувача не існує, спочатку зареєструйтесь"
},
"webauthn": {

View File

@@ -83,8 +83,8 @@
"The user is forbidden to sign in, please contact the administrator": "Người dùng bị cấm đăng nhập, vui lòng liên hệ với quản trị viên",
"The user: %s doesn't exist in LDAP server": "Người dùng: %s không tồn tại trên máy chủ LDAP",
"The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline.": "Tên người dùng chỉ có thể chứa các ký tự chữ và số, gạch dưới hoặc gạch ngang, không được có hai ký tự gạch dưới hoặc gạch ngang liền kề và không được bắt đầu hoặc kết thúc bằng dấu gạch dưới hoặc gạch ngang.",
"The value \\\"%s\\\" for account field \\\"%s\\\" doesn't match the account item regex": "Giá trị \\\"%s\\\" cho trường tài khoản \\\"%s\\\" không khớp với biểu thức chính quy của mục tài khoản",
"The value \\\"%s\\\" for signup field \\\"%s\\\" doesn't match the signup item regex of the application \\\"%s\\\"": "Giá trị \\\"%s\\\" cho trường đăng ký \\\"%s\\\" không khớp với biểu thức chính quy của mục đăng ký trong ứng dụng \\\"%s\\\"",
"The value \"%s\" for account field \"%s\" doesn't match the account item regex": "Giá trị \"%s\" cho trường tài khoản \"%s\" không khớp với biểu thức chính quy của mục tài khoản",
"The value \"%s\" for signup field \"%s\" doesn't match the signup item regex of the application \"%s\"": "Giá trị \"%s\" cho trường đăng ký \"%s\" không khớp với biểu thức chính quy của mục đăng ký trong ứng dụng \"%s\"",
"Username already exists": "Tên đăng nhập đã tồn tại",
"Username cannot be an email address": "Tên người dùng không thể là địa chỉ email",
"Username cannot contain white spaces": "Tên người dùng không thể chứa khoảng trắng",
@@ -94,7 +94,7 @@
"Username supports email format. Also The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline. Also pay attention to the email format.": "Tên người dùng hỗ trợ định dạng email. Ngoài ra, tên người dùng chỉ có thể chứa ký tự chữ và số, gạch dưới hoặc gạch ngang, không được có gạch ngang hoặc gạch dưới liên tiếp và không được bắt đầu hoặc kết thúc bằng gạch ngang hoặc gạch dưới. Đồng thời lưu ý định dạng email.",
"You have entered the wrong password or code too many times, please wait for %d minutes and try again": "Bạn đã nhập sai mật khẩu hoặc mã quá nhiều lần, vui lòng đợi %d phút và thử lại",
"Your IP address: %s has been banned according to the configuration of: ": "Địa chỉ IP của bạn: %s đã bị cấm theo cấu hình của: ",
"Your password has expired. Please reset your password by clicking \\\"Forgot password\\\"": "Mật khẩu của bạn đã hết hạn. Vui lòng đặt lại mật khẩu bằng cách nhấp vào \\\"Quên mật khẩu\\\"",
"Your password has expired. Please reset your password by clicking \"Forgot password\"": "Mật khẩu của bạn đã hết hạn. Vui lòng đặt lại mật khẩu bằng cách nhấp vào \"Quên mật khẩu\"",
"Your region is not allow to signup by phone": "Vùng của bạn không được phép đăng ký bằng điện thoại",
"password or code is incorrect": "mật khẩu hoặc mã không đúng",
"password or code is incorrect, you have %s remaining chances": "Mật khẩu hoặc mã không chính xác, bạn còn %s lần cơ hội",
@@ -137,7 +137,7 @@
"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.": "Thêm người dùng mới vào tổ chức 'built-in' (tích hợp sẵn) hiện đang bị vô hiệu hóa. Lưu ý: Tất cả người dùng trong tổ chức 'built-in' đều là quản trị viên toàn cầu trong Casdoor. Xem tài liệu: https://casdoor.org/docs/basic/core-concepts#how-does-casdoor-manage-itself. Nếu bạn vẫn muốn tạo người dùng cho tổ chức 'built-in', hãy truy cập trang cài đặt của tổ chức và bật tùy chọn 'Có đồng ý quyền đặc biệt'."
},
"permission": {
"The permission: \\\"%s\\\" doesn't exist": "Quyền: \\\"%s\\\" không tồn tại"
"The permission: \"%s\" doesn't exist": "Quyền: \"%s\" không tồn tại"
},
"provider": {
"Invalid application id": "Sai ID ứng dụng",
@@ -196,8 +196,8 @@
"Unknown type": "Loại không xác định",
"Wrong verification code!": "Mã xác thực sai!",
"You should verify your code in %d min!": "Bạn nên kiểm tra mã của mình trong %d phút!",
"please add a SMS provider to the \\\"Providers\\\" list for the application: %s": "vui lòng thêm nhà cung cấp SMS vào danh sách \\\"Providers\\\" cho ứng dụng: %s",
"please add an Email provider to the \\\"Providers\\\" list for the application: %s": "vui lòng thêm nhà cung cấp Email vào danh sách \\\"Providers\\\" cho ứng dụng: %s",
"please add a SMS provider to the \"Providers\" list for the application: %s": "vui lòng thêm nhà cung cấp SMS vào danh sách \"Providers\" cho ứng dụng: %s",
"please add an Email provider to the \"Providers\" list for the application: %s": "vui lòng thêm nhà cung cấp Email vào danh sách \"Providers\" cho ứng dụng: %s",
"the user does not exist, please sign up first": "Người dùng không tồn tại, vui lòng đăng ký trước"
},
"webauthn": {

View File

@@ -83,8 +83,8 @@
"The user is forbidden to sign in, please contact the administrator": "该用户被禁止登录,请联系管理员",
"The user: %s doesn't exist in LDAP server": "用户: %s 在LDAP服务器中未找到",
"The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline.": "用户名只能包含字母数字字符、下划线或连字符,不能有连续的连字符或下划线,也不能以连字符或下划线开头或结尾",
"The value \\\"%s\\\" for account field \\\"%s\\\" doesn't match the account item regex": "值 \\\"%s\\\"在账户信息字段\\\"%s\\\" 中与应用的账户项正则表达式不匹配",
"The value \\\"%s\\\" for signup field \\\"%s\\\" doesn't match the signup item regex of the application \\\"%s\\\"": "值\\\"%s\\\"在注册字段\\\"%s\\\"中与应用\\\"%s\\\"的注册项正则表达式不匹配",
"The value \"%s\" for account field \"%s\" doesn't match the account item regex": "值 \"%s\"在账户信息字段\"%s\" 中与应用的账户项正则表达式不匹配",
"The value \"%s\" for signup field \"%s\" doesn't match the signup item regex of the application \"%s\"": "值\"%s\"在注册字段\"%s\"中与应用\"%s\"的注册项正则表达式不匹配",
"Username already exists": "用户名已存在",
"Username cannot be an email address": "用户名不可以是邮箱地址",
"Username cannot contain white spaces": "用户名禁止包含空格",
@@ -94,7 +94,7 @@
"Username supports email format. Also The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline. Also pay attention to the email format.": "用户名支持电子邮件格式。此外,用户名只能包含字母数字字符、下划线或连字符,不能包含连续的连字符或下划线,也不能以连字符或下划线开头或结尾。同时请注意电子邮件格式。",
"You have entered the wrong password or code too many times, please wait for %d minutes and try again": "密码错误次数已达上限,请在 %d 分后重试",
"Your IP address: %s has been banned according to the configuration of: ": "您的IP地址%s 根据以下配置已被禁止: ",
"Your password has expired. Please reset your password by clicking \\\"Forgot password\\\"": "您的密码已过期。请点击 \\\"忘记密码\\\" 以重置密码",
"Your password has expired. Please reset your password by clicking \"Forgot password\"": "您的密码已过期。请点击 \"忘记密码\" 以重置密码",
"Your region is not allow to signup by phone": "所在地区不支持手机号注册",
"password or code is incorrect": "密码错误",
"password or code is incorrect, you have %s remaining chances": "密码错误,您还有 %s 次尝试的机会",
@@ -137,7 +137,7 @@
"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.": "目前,向'built-in'组织添加新用户的功能已禁用。请注意:'built-in'组织中的所有用户均为Casdoor的全局管理员。请参阅文档https://casdoor.org/docs/basic/core-concepts#how-does-casdoor-manage-itself。如果您仍希望为built-in组织创建用户请转到该组织的设置页面并启用“特权同意”选项。"
},
"permission": {
"The permission: \\\"%s\\\" doesn't exist": "权限: \\\"%s\\\" 不存在"
"The permission: \"%s\" doesn't exist": "权限: \"%s\" 不存在"
},
"provider": {
"Invalid application id": "无效的应用ID",
@@ -188,6 +188,7 @@
"verification": {
"Invalid captcha provider.": "非法的验证码提供商",
"Phone number is invalid in your region %s": "您所在地区的电话号码无效 %s",
"The forgot password feature is disabled": "忘记密码功能已被禁用",
"The verification code has already been used!": "验证码已使用过!",
"The verification code has not been sent yet!": "验证码未发送!",
"Turing test failed.": "验证码还未发送",
@@ -196,8 +197,8 @@
"Unknown type": "未知类型",
"Wrong verification code!": "验证码错误!",
"You should verify your code in %d min!": "请在 %d 分钟内输入正确验证码",
"please add a SMS provider to the \\\"Providers\\\" list for the application: %s": "请添加一个SMS提供商到应用 %s 的 \\\"提供商 \\\"列表",
"please add an Email provider to the \\\"Providers\\\" list for the application: %s": "请添加一个Email提供商到应用 %s 的 \\\"提供商 \\\"列表",
"please add a SMS provider to the \"Providers\" list for the application: %s": "请添加一个SMS提供商到应用 %s 的 \"提供商 \"列表",
"please add an Email provider to the \"Providers\" list for the application: %s": "请添加一个Email提供商到应用 %s 的 \"提供商 \"列表",
"the user does not exist, please sign up first": "用户不存在,请先注册"
},
"webauthn": {

View File

@@ -31,11 +31,12 @@ type CustomIdProvider struct {
Client *http.Client
Config *oauth2.Config
UserInfoURL string
TokenURL string
AuthURL string
UserMapping map[string]string
Scopes []string
UserInfoURL string
TokenURL string
AuthURL string
UserMapping map[string]string
Scopes []string
CodeVerifier string
}
func NewCustomIdProvider(idpInfo *ProviderInfo, redirectUrl string) *CustomIdProvider {
@@ -53,6 +54,7 @@ func NewCustomIdProvider(idpInfo *ProviderInfo, redirectUrl string) *CustomIdPro
idp.UserInfoURL = idpInfo.UserInfoURL
idp.UserMapping = idpInfo.UserMapping
idp.CodeVerifier = idpInfo.CodeVerifier
return idp
}
@@ -62,7 +64,11 @@ func (idp *CustomIdProvider) SetHttpClient(client *http.Client) {
func (idp *CustomIdProvider) GetToken(code string) (*oauth2.Token, error) {
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, idp.Client)
return idp.Config.Exchange(ctx, code)
var oauth2Opts []oauth2.AuthCodeOption
if idp.CodeVerifier != "" {
oauth2Opts = append(oauth2Opts, oauth2.VerifierOption(idp.CodeVerifier))
}
return idp.Config.Exchange(ctx, code, oauth2Opts...)
}
func getNestedValue(data map[string]interface{}, path string) (interface{}, error) {

View File

@@ -87,8 +87,9 @@ import (
)
type GothIdProvider struct {
Provider goth.Provider
Session goth.Session
Provider goth.Provider
Session goth.Session
CodeVerifier string
}
func NewGothIdProvider(providerType string, clientId string, clientSecret string, clientId2 string, clientSecret2 string, redirectUrl string, hostUrl string) (*GothIdProvider, error) {
@@ -448,7 +449,13 @@ func (idp *GothIdProvider) GetToken(code string) (*oauth2.Token, error) {
value = url.Values{}
value.Add("code", code)
if idp.Provider.Name() == "twitterv2" || idp.Provider.Name() == "fitbit" {
value.Add("oauth_verifier", "casdoor-verifier")
// Use dynamic code verifier if provided, otherwise fall back to static one
verifier := idp.CodeVerifier
if verifier == "" {
verifier = "casdoor-verifier"
}
// RFC 7636 PKCE uses 'code_verifier' parameter
value.Add("code_verifier", verifier)
}
}
accessToken, err := idp.Session.Authorize(idp.Provider, value)

View File

@@ -45,6 +45,7 @@ type ProviderInfo struct {
HostUrl string
RedirectUrl string
DisableSsl bool
CodeVerifier string
TokenURL string
AuthURL string
@@ -128,7 +129,9 @@ func GetIdProvider(idpInfo *ProviderInfo, redirectUrl string) (IdProvider, error
case "Web3Onboard":
return NewWeb3OnboardIdProvider(), nil
case "Twitter":
return NewTwitterIdProvider(idpInfo.ClientId, idpInfo.ClientSecret, redirectUrl), nil
provider := NewTwitterIdProvider(idpInfo.ClientId, idpInfo.ClientSecret, redirectUrl)
provider.CodeVerifier = idpInfo.CodeVerifier
return provider, nil
case "Telegram":
return NewTelegramIdProvider(idpInfo.ClientId, idpInfo.ClientSecret, redirectUrl), nil
default:

View File

@@ -28,8 +28,9 @@ import (
)
type TwitterIdProvider struct {
Client *http.Client
Config *oauth2.Config
Client *http.Client
Config *oauth2.Config
CodeVerifier string
}
func NewTwitterIdProvider(clientId string, clientSecret string, redirectUrl string) *TwitterIdProvider {
@@ -84,7 +85,12 @@ func (idp *TwitterIdProvider) GetToken(code string) (*oauth2.Token, error) {
params := url.Values{}
// params.Add("client_id", idp.Config.ClientID)
params.Add("redirect_uri", idp.Config.RedirectURL)
params.Add("code_verifier", "casdoor-verifier")
// Use dynamic code verifier if provided, otherwise fall back to static one
verifier := idp.CodeVerifier
if verifier == "" {
verifier = "casdoor-verifier"
}
params.Add("code_verifier", verifier)
params.Add("code", code)
params.Add("grant_type", "authorization_code")
req, err := http.NewRequest("POST", "https://api.twitter.com/2/oauth2/token", strings.NewReader(params.Encode()))

View File

@@ -75,6 +75,8 @@
{"name": "Country code", "visible": true, "viewRule": "Public", "modifyRule": "Admin"},
{"name": "Country/Region", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Location", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Address", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Addresses", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Affiliation", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Title", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "ID card type", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
@@ -236,6 +238,7 @@
"phone": "",
"countryCode": "",
"address": [],
"addresses": [],
"affiliation": "",
"tag": "",
"score": 2000,

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

@@ -61,6 +61,8 @@ func getBuiltInAccountItems() []*AccountItem {
{Name: "Country code", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "Country/Region", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Location", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Address", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Addresses", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Affiliation", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Title", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "ID card type", Visible: true, ViewRule: "Public", ModifyRule: "Self"},

View File

@@ -26,10 +26,35 @@ import (
"github.com/casdoor/casdoor/i18n"
"github.com/casdoor/casdoor/util"
goldap "github.com/go-ldap/ldap/v3"
"github.com/nyaruka/phonenumbers"
"github.com/thanhpk/randstr"
"golang.org/x/text/encoding/unicode"
)
// formatUserPhone processes phone number for a user based on their CountryCode
func formatUserPhone(u *User) {
if u.Phone == "" {
return
}
// 1. Normalize hint (e.g., "China" -> "CN") for the parser
countryHint := u.CountryCode
if strings.EqualFold(countryHint, "China") {
countryHint = "CN"
}
if len(countryHint) != 2 {
countryHint = "" // Only 2-letter codes are valid hints
}
// 2. Try parsing (Strictly using countryHint from LDAP)
num, err := phonenumbers.Parse(u.Phone, countryHint)
if err == nil && num != nil && phonenumbers.IsValidNumber(num) {
// Store a clean national number (digits only, without country prefix)
u.Phone = fmt.Sprint(num.GetNationalNumber())
}
}
type LdapConn struct {
Conn *goldap.Conn
IsAD bool
@@ -289,6 +314,7 @@ func AutoAdjustLdapUser(users []LdapUser) []LdapUser {
Mobile: util.ReturnAnyNotEmpty(user.Mobile, user.MobileTelephoneNumber, user.TelephoneNumber),
Address: util.ReturnAnyNotEmpty(user.Address, user.PostalAddress, user.RegisteredAddress),
Country: util.ReturnAnyNotEmpty(user.Country, user.CountryName),
CountryName: user.CountryName,
Attributes: user.Attributes,
}
}
@@ -361,6 +387,7 @@ func SyncLdapUsers(owner string, syncUsers []LdapUser, ldapId string) (existUser
Avatar: organization.DefaultAvatar,
Email: syncUser.Email,
Phone: syncUser.Mobile,
CountryCode: syncUser.Country,
Address: []string{syncUser.Address},
Region: util.ReturnAnyNotEmpty(syncUser.Country, syncUser.CountryName),
Affiliation: affiliation,
@@ -369,6 +396,7 @@ func SyncLdapUsers(owner string, syncUsers []LdapUser, ldapId string) (existUser
Ldap: syncUser.Uuid,
Properties: syncUser.Attributes,
}
formatUserPhone(newUser)
if ldap.DefaultGroup != "" {
newUser.Groups = []string{ldap.DefaultGroup}

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

@@ -50,7 +50,8 @@ type Payment struct {
InvoiceRemark string `xorm:"varchar(100)" json:"invoiceRemark"`
InvoiceUrl string `xorm:"varchar(255)" json:"invoiceUrl"`
// Order Info
Order string `xorm:"varchar(100)" json:"order"` // Internal order name
Order string `xorm:"varchar(100)" json:"order"` // Internal order name
OrderObj *Order `xorm:"-" json:"orderObj,omitempty"`
OutOrderId string `xorm:"varchar(100)" json:"outOrderId"` // External payment provider's order ID
PayUrl string `xorm:"varchar(2000)" json:"payUrl"`
SuccessUrl string `xorm:"varchar(2000)" json:"successUrl"` // `successUrl` is redirected from `payUrl` after pay success
@@ -70,6 +71,11 @@ func GetPayments(owner string) ([]*Payment, error) {
return nil, err
}
err = ExtendPaymentWithOrder(payments)
if err != nil {
return nil, err
}
return payments, nil
}
@@ -80,6 +86,11 @@ func GetUserPayments(owner, user string) ([]*Payment, error) {
return nil, err
}
err = ExtendPaymentWithOrder(payments)
if err != nil {
return nil, err
}
return payments, nil
}
@@ -91,9 +102,49 @@ func GetPaginationPayments(owner string, offset, limit int, field, value, sortFi
return nil, err
}
err = ExtendPaymentWithOrder(payments)
if err != nil {
return nil, err
}
return payments, nil
}
func ExtendPaymentWithOrder(payments []*Payment) error {
ownerOrdersMap := make(map[string][]string)
for _, payment := range payments {
if payment.Order != "" {
ownerOrdersMap[payment.Owner] = append(ownerOrdersMap[payment.Owner], payment.Order)
}
}
ordersMap := make(map[string]*Order)
for owner, orderNames := range ownerOrdersMap {
if len(orderNames) == 0 {
continue
}
var orders []*Order
err := ormer.Engine.In("name", orderNames).Find(&orders, &Order{Owner: owner})
if err != nil {
return err
}
for _, order := range orders {
ordersMap[util.GetId(order.Owner, order.Name)] = order
}
}
for _, payment := range payments {
if payment.Order != "" {
orderId := util.GetId(payment.Owner, payment.Order)
if order, ok := ordersMap[orderId]; ok {
payment.OrderObj = order
}
}
}
return nil
}
func getPayment(owner string, name string) (*Payment, error) {
if owner == "" || name == "" {
return nil, nil
@@ -210,18 +261,29 @@ func NotifyPayment(body []byte, owner string, paymentName string, lang string) (
}
// Check if payment is already in a terminal state to prevent duplicate processing
if payment.State == pp.PaymentStatePaid || payment.State == pp.PaymentStateError ||
payment.State == pp.PaymentStateCanceled || payment.State == pp.PaymentStateTimeout {
if pp.IsTerminalState(payment.State) {
return payment, nil
}
// Determine the new payment state
var newState pp.PaymentState
var newMessage string
if err != nil {
payment.State = pp.PaymentStateError
payment.Message = err.Error()
newState = pp.PaymentStateError
newMessage = err.Error()
} else {
payment.State = notifyResult.PaymentStatus
payment.Message = notifyResult.NotifyMessage
newState = notifyResult.PaymentStatus
newMessage = notifyResult.NotifyMessage
}
// Check if the payment state would actually change
// This prevents duplicate webhook events when providers send redundant notifications
if payment.State == newState {
return payment, nil
}
payment.State = newState
payment.Message = newMessage
_, err = UpdatePayment(payment.GetId(), payment)
if err != nil {
return nil, err
@@ -239,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

@@ -80,7 +80,7 @@ func NewRecord(ctx *context.Context) (*casvisorsdk.Record, error) {
return nil, err
}
if action != "buy-product" {
if action != "buy-product" && action != "notify-payment" {
resp.Data = 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)
@@ -49,6 +66,12 @@ func GetSyncerProvider(syncer *Syncer) SyncerProvider {
return &ActiveDirectorySyncerProvider{Syncer: syncer}
case "DingTalk":
return &DingtalkSyncerProvider{Syncer: syncer}
case "Lark":
return &LarkSyncerProvider{Syncer: syncer}
case "Okta":
return &OktaSyncerProvider{Syncer: syncer}
case "SCIM":
return &SCIMSyncerProvider{Syncer: syncer}
case "Keycloak":
return &KeycloakSyncerProvider{
DatabaseSyncerProvider: DatabaseSyncerProvider{Syncer: syncer},

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
}

428
object/syncer_lark.go Normal file
View File

@@ -0,0 +1,428 @@
// 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 (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/casdoor/casdoor/util"
)
// LarkSyncerProvider implements SyncerProvider for Lark API-based syncers
type LarkSyncerProvider struct {
Syncer *Syncer
}
// InitAdapter initializes the Lark syncer (no database adapter needed)
func (p *LarkSyncerProvider) InitAdapter() error {
// Lark syncer doesn't need database adapter
return nil
}
// GetOriginalUsers retrieves all users from Lark API
func (p *LarkSyncerProvider) GetOriginalUsers() ([]*OriginalUser, error) {
return p.getLarkUsers()
}
// AddUser adds a new user to Lark (not supported for read-only API)
func (p *LarkSyncerProvider) AddUser(user *OriginalUser) (bool, error) {
// Lark syncer is typically read-only
return false, fmt.Errorf("adding users to Lark is not supported")
}
// UpdateUser updates an existing user in Lark (not supported for read-only API)
func (p *LarkSyncerProvider) UpdateUser(user *OriginalUser) (bool, error) {
// Lark syncer is typically read-only
return false, fmt.Errorf("updating users in Lark is not supported")
}
// TestConnection tests the Lark API connection
func (p *LarkSyncerProvider) TestConnection() error {
_, err := p.getLarkAccessToken()
return err
}
// Close closes any open connections (no-op for Lark API-based syncer)
func (p *LarkSyncerProvider) Close() error {
// Lark syncer doesn't maintain persistent connections
return nil
}
type LarkAccessTokenResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
Expire int `json:"expire"`
}
type LarkUser struct {
UserId string `json:"user_id"`
UnionId string `json:"union_id"`
OpenId string `json:"open_id"`
Name string `json:"name"`
EnName string `json:"en_name"`
Email string `json:"email"`
Mobile string `json:"mobile"`
Gender int `json:"gender"`
Avatar *LarkAvatar `json:"avatar"`
Status *LarkStatus `json:"status"`
DepartmentIds []string `json:"department_ids"`
JobTitle string `json:"job_title"`
}
type LarkAvatar struct {
Avatar72 string `json:"avatar_72"`
Avatar240 string `json:"avatar_240"`
Avatar640 string `json:"avatar_640"`
AvatarOrigin string `json:"avatar_origin"`
}
type LarkStatus struct {
IsFrozen bool `json:"is_frozen"`
IsResigned bool `json:"is_resigned"`
IsActivated bool `json:"is_activated"`
IsExited bool `json:"is_exited"`
}
type LarkUserListResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Items []*LarkUser `json:"items"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
}
type LarkDeptListResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Items []struct {
DepartmentId string `json:"department_id"`
} `json:"items"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
}
// getLarkDomain returns the Lark API domain based on whether global endpoint is used
func (p *LarkSyncerProvider) getLarkDomain() string {
// syncer.Host can be used to specify custom endpoint
// If empty, default to global endpoint (larksuite.com)
if p.Syncer.Host != "" {
return p.Syncer.Host
}
return "https://open.larksuite.com"
}
// getLarkAccessToken gets access token from Lark API
func (p *LarkSyncerProvider) getLarkAccessToken() (string, error) {
// syncer.User should be the app_id
// syncer.Password should be the app_secret
appId := p.Syncer.User
if appId == "" {
return "", fmt.Errorf("app_id (user field) is required for Lark syncer")
}
appSecret := p.Syncer.Password
if appSecret == "" {
return "", fmt.Errorf("app_secret (password field) is required for Lark syncer")
}
domain := p.getLarkDomain()
apiUrl := fmt.Sprintf("%s/open-apis/auth/v3/tenant_access_token/internal", domain)
postData := map[string]string{
"app_id": appId,
"app_secret": appSecret,
}
data, err := p.postJSON(apiUrl, postData)
if err != nil {
return "", err
}
var tokenResp LarkAccessTokenResp
err = json.Unmarshal(data, &tokenResp)
if err != nil {
return "", err
}
if tokenResp.Code != 0 {
return "", fmt.Errorf("failed to get access token: code=%d, msg=%s",
tokenResp.Code, tokenResp.Msg)
}
return tokenResp.TenantAccessToken, nil
}
// getLarkDepartments gets all department IDs from Lark API
func (p *LarkSyncerProvider) getLarkDepartments(accessToken string) ([]string, error) {
domain := p.getLarkDomain()
allDeptIds := []string{"0"} // Start with root department
pageToken := ""
for {
apiUrl := fmt.Sprintf("%s/open-apis/contact/v3/departments?parent_department_id=0&fetch_child=true&page_size=50", domain)
if pageToken != "" {
apiUrl += fmt.Sprintf("&page_token=%s", pageToken)
}
data, err := p.getWithAuth(apiUrl, accessToken)
if err != nil {
return nil, err
}
var deptResp LarkDeptListResp
err = json.Unmarshal(data, &deptResp)
if err != nil {
return nil, err
}
if deptResp.Code != 0 {
return nil, fmt.Errorf("failed to get departments: code=%d, msg=%s",
deptResp.Code, deptResp.Msg)
}
for _, dept := range deptResp.Data.Items {
allDeptIds = append(allDeptIds, dept.DepartmentId)
}
if !deptResp.Data.HasMore {
break
}
pageToken = deptResp.Data.PageToken
}
return allDeptIds, nil
}
// getLarkUsersFromDept gets users from a specific department
func (p *LarkSyncerProvider) getLarkUsersFromDept(accessToken string, deptId string) ([]*LarkUser, error) {
domain := p.getLarkDomain()
allUsers := []*LarkUser{}
pageToken := ""
for {
apiUrl := fmt.Sprintf("%s/open-apis/contact/v3/users/find_by_department?department_id=%s&page_size=50", domain, deptId)
if pageToken != "" {
apiUrl += fmt.Sprintf("&page_token=%s", pageToken)
}
data, err := p.getWithAuth(apiUrl, accessToken)
if err != nil {
return nil, err
}
var userResp LarkUserListResp
err = json.Unmarshal(data, &userResp)
if err != nil {
return nil, err
}
if userResp.Code != 0 {
return nil, fmt.Errorf("failed to get users from dept %s: code=%d, msg=%s",
deptId, userResp.Code, userResp.Msg)
}
allUsers = append(allUsers, userResp.Data.Items...)
if !userResp.Data.HasMore {
break
}
pageToken = userResp.Data.PageToken
}
return allUsers, nil
}
// postJSON sends a POST request with JSON body
func (p *LarkSyncerProvider) postJSON(url string, data interface{}) ([]byte, error) {
jsonData, err := json.Marshal(data)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return respData, nil
}
// getWithAuth sends a GET request with authorization header
func (p *LarkSyncerProvider) getWithAuth(url string, accessToken string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return data, nil
}
// getLarkUsers gets all users from Lark API
func (p *LarkSyncerProvider) getLarkUsers() ([]*OriginalUser, error) {
// Get access token
accessToken, err := p.getLarkAccessToken()
if err != nil {
return nil, err
}
// Get all departments
deptIds, err := p.getLarkDepartments(accessToken)
if err != nil {
return nil, err
}
// Get users from all departments (deduplicate by user_id)
userMap := make(map[string]*LarkUser)
for _, deptId := range deptIds {
users, err := p.getLarkUsersFromDept(accessToken, deptId)
if err != nil {
return nil, err
}
for _, user := range users {
// Deduplicate users by user_id
if _, exists := userMap[user.UserId]; !exists {
userMap[user.UserId] = user
}
}
}
// Convert Lark users to Casdoor OriginalUser
originalUsers := []*OriginalUser{}
for _, larkUser := range userMap {
originalUser := p.larkUserToOriginalUser(larkUser)
originalUsers = append(originalUsers, originalUser)
}
return originalUsers, nil
}
// larkUserToOriginalUser converts Lark user to Casdoor OriginalUser
func (p *LarkSyncerProvider) larkUserToOriginalUser(larkUser *LarkUser) *OriginalUser {
// Use user_id as name, fallback to union_id or open_id
userName := larkUser.UserId
if userName == "" && larkUser.UnionId != "" {
userName = larkUser.UnionId
}
if userName == "" && larkUser.OpenId != "" {
userName = larkUser.OpenId
}
user := &OriginalUser{
Id: larkUser.UserId,
Name: userName,
DisplayName: larkUser.Name,
Email: larkUser.Email,
Phone: larkUser.Mobile,
Title: larkUser.JobTitle,
Address: []string{},
Properties: map[string]string{},
Groups: []string{},
}
// Set avatar if available
if larkUser.Avatar != nil {
if larkUser.Avatar.Avatar240 != "" {
user.Avatar = larkUser.Avatar.Avatar240
} else if larkUser.Avatar.Avatar72 != "" {
user.Avatar = larkUser.Avatar.Avatar72
}
}
// Set gender
switch larkUser.Gender {
case 1:
user.Gender = "Male"
case 2:
user.Gender = "Female"
default:
user.Gender = ""
}
// Set IsForbidden based on status
// User is forbidden if frozen, resigned, not activated, or exited
if larkUser.Status != nil {
if larkUser.Status.IsFrozen || larkUser.Status.IsResigned || !larkUser.Status.IsActivated || larkUser.Status.IsExited {
user.IsForbidden = true
} else {
user.IsForbidden = false
}
}
// Set CreatedTime to current time if not set
if user.CreatedTime == "" {
user.CreatedTime = util.GetCurrentTime()
}
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
}

310
object/syncer_okta.go Normal file
View File

@@ -0,0 +1,310 @@
// 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"
"io"
"net/http"
"strings"
"time"
"github.com/casdoor/casdoor/util"
)
// OktaSyncerProvider implements SyncerProvider for Okta API-based syncers
type OktaSyncerProvider struct {
Syncer *Syncer
}
// InitAdapter initializes the Okta syncer (no database adapter needed)
func (p *OktaSyncerProvider) InitAdapter() error {
// Okta syncer doesn't need database adapter
return nil
}
// GetOriginalUsers retrieves all users from Okta API
func (p *OktaSyncerProvider) GetOriginalUsers() ([]*OriginalUser, error) {
return p.getOktaOriginalUsers()
}
// AddUser adds a new user to Okta (not supported for read-only API)
func (p *OktaSyncerProvider) AddUser(user *OriginalUser) (bool, error) {
// Okta syncer is typically read-only
return false, fmt.Errorf("adding users to Okta is not supported")
}
// UpdateUser updates an existing user in Okta (not supported for read-only API)
func (p *OktaSyncerProvider) UpdateUser(user *OriginalUser) (bool, error) {
// Okta syncer is typically read-only
return false, fmt.Errorf("updating users in Okta is not supported")
}
// TestConnection tests the Okta API connection
func (p *OktaSyncerProvider) TestConnection() error {
// Try to fetch first page of users to verify connection
_, _, err := p.getOktaUsers("")
return err
}
// Close closes any open connections (no-op for Okta API-based syncer)
func (p *OktaSyncerProvider) Close() error {
// Okta syncer doesn't maintain persistent connections
return nil
}
// OktaUser represents a user from Okta API
type OktaUser struct {
Id string `json:"id"`
Status string `json:"status"`
Created string `json:"created"`
Profile struct {
Login string `json:"login"`
Email string `json:"email"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
DisplayName string `json:"displayName"`
MobilePhone string `json:"mobilePhone"`
PrimaryPhone string `json:"primaryPhone"`
StreetAddress string `json:"streetAddress"`
City string `json:"city"`
State string `json:"state"`
ZipCode string `json:"zipCode"`
CountryCode string `json:"countryCode"`
PostalAddress string `json:"postalAddress"`
PreferredLanguage string `json:"preferredLanguage"`
Locale string `json:"locale"`
Timezone string `json:"timezone"`
Title string `json:"title"`
Department string `json:"department"`
Organization string `json:"organization"`
} `json:"profile"`
}
// parseLinkHeader parses the HTTP Link header
// Format: <https://example.com/api/v1/users?after=xyz>; rel="next"
func parseLinkHeader(header string) map[string]string {
links := make(map[string]string)
parts := strings.Split(header, ",")
for _, part := range parts {
section := strings.Split(strings.TrimSpace(part), ";")
if len(section) != 2 {
continue
}
url := strings.Trim(strings.TrimSpace(section[0]), "<>")
rel := strings.TrimSpace(section[1])
if strings.HasPrefix(rel, "rel=\"") && strings.HasSuffix(rel, "\"") {
relValue := rel[5 : len(rel)-1]
links[relValue] = url
}
}
return links
}
// getOktaUsers retrieves users from Okta API with pagination support
// Returns users and the next page link (if any)
func (p *OktaSyncerProvider) getOktaUsers(nextLink string) ([]*OktaUser, string, error) {
// syncer.Host should be the Okta domain (e.g., "dev-12345.okta.com" or full URL)
// syncer.Password should be the API token
domain := p.Syncer.Host
if domain == "" {
return nil, "", fmt.Errorf("Okta domain (host field) is required for Okta syncer")
}
apiToken := p.Syncer.Password
if apiToken == "" {
return nil, "", fmt.Errorf("API token (password field) is required for Okta syncer")
}
// Construct API URL
var apiUrl string
if nextLink != "" {
apiUrl = nextLink
} else {
// Remove https:// or http:// prefix if present in domain
domain = strings.TrimPrefix(strings.TrimPrefix(domain, "https://"), "http://")
apiUrl = fmt.Sprintf("https://%s/api/v1/users?limit=200", domain)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", apiUrl, nil)
if err != nil {
return nil, "", err
}
req.Header.Set("Authorization", "SSWS "+apiToken)
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, "", fmt.Errorf("failed to get users from Okta: status=%d, body=%s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
var users []*OktaUser
err = json.Unmarshal(body, &users)
if err != nil {
return nil, "", err
}
// Parse Link header for next page
// Link header format: <https://...>; rel="next"
nextPageLink := ""
linkHeader := resp.Header.Get("Link")
if linkHeader != "" {
links := parseLinkHeader(linkHeader)
if next, ok := links["next"]; ok {
nextPageLink = next
}
}
return users, nextPageLink, nil
}
// oktaUserToOriginalUser converts Okta user to Casdoor OriginalUser
func (p *OktaSyncerProvider) oktaUserToOriginalUser(oktaUser *OktaUser) *OriginalUser {
user := &OriginalUser{
Id: oktaUser.Id,
Name: oktaUser.Profile.Login,
DisplayName: oktaUser.Profile.DisplayName,
FirstName: oktaUser.Profile.FirstName,
LastName: oktaUser.Profile.LastName,
Email: oktaUser.Profile.Email,
Phone: oktaUser.Profile.MobilePhone,
Title: oktaUser.Profile.Title,
Language: oktaUser.Profile.PreferredLanguage,
Address: []string{},
Properties: map[string]string{},
Groups: []string{},
}
// Build address from street, city, state, zip
if oktaUser.Profile.StreetAddress != "" {
user.Address = append(user.Address, oktaUser.Profile.StreetAddress)
}
if oktaUser.Profile.City != "" {
user.Address = append(user.Address, oktaUser.Profile.City)
}
if oktaUser.Profile.State != "" {
user.Address = append(user.Address, oktaUser.Profile.State)
}
if oktaUser.Profile.ZipCode != "" {
user.Address = append(user.Address, oktaUser.Profile.ZipCode)
}
// Store additional properties
if oktaUser.Profile.Department != "" {
user.Properties["department"] = oktaUser.Profile.Department
}
if oktaUser.Profile.Organization != "" {
user.Properties["organization"] = oktaUser.Profile.Organization
}
if oktaUser.Profile.Timezone != "" {
user.Properties["timezone"] = oktaUser.Profile.Timezone
}
// Set IsForbidden based on status
// Okta status values: STAGED, PROVISIONED, ACTIVE, RECOVERY, PASSWORD_EXPIRED, LOCKED_OUT, SUSPENDED, DEPROVISIONED
if oktaUser.Status == "SUSPENDED" || oktaUser.Status == "DEPROVISIONED" || oktaUser.Status == "LOCKED_OUT" {
user.IsForbidden = true
} else {
user.IsForbidden = false
}
// 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))
}
// If email is empty, use login as email (typically login is an email)
if user.Email == "" && oktaUser.Profile.Login != "" {
user.Email = oktaUser.Profile.Login
}
// If mobile phone is empty, try primary phone
if user.Phone == "" && oktaUser.Profile.PrimaryPhone != "" {
user.Phone = oktaUser.Profile.PrimaryPhone
}
// Set CreatedTime to current time if not set
if user.CreatedTime == "" {
user.CreatedTime = util.GetCurrentTime()
}
return user
}
// getOktaOriginalUsers is the main entry point for Okta syncer
func (p *OktaSyncerProvider) getOktaOriginalUsers() ([]*OriginalUser, error) {
allUsers := []*OktaUser{}
nextLink := ""
// Fetch all users with pagination
for {
users, next, err := p.getOktaUsers(nextLink)
if err != nil {
return nil, err
}
allUsers = append(allUsers, users...)
// If there's no next link, we've fetched all users
if next == "" {
break
}
nextLink = next
}
// Convert Okta users to Casdoor OriginalUser
originalUsers := []*OriginalUser{}
for _, oktaUser := range allUsers {
originalUser := p.oktaUserToOriginalUser(oktaUser)
originalUsers = append(originalUsers, originalUser)
}
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
}

349
object/syncer_scim.go Normal file
View File

@@ -0,0 +1,349 @@
// 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"
"io"
"net/http"
"strings"
"time"
"github.com/casdoor/casdoor/util"
)
// SCIMSyncerProvider implements SyncerProvider for SCIM 2.0 API-based syncers
type SCIMSyncerProvider struct {
Syncer *Syncer
}
// InitAdapter initializes the SCIM syncer (no database adapter needed)
func (p *SCIMSyncerProvider) InitAdapter() error {
// SCIM syncer doesn't need database adapter
return nil
}
// GetOriginalUsers retrieves all users from SCIM API
func (p *SCIMSyncerProvider) GetOriginalUsers() ([]*OriginalUser, error) {
return p.getSCIMUsers()
}
// AddUser adds a new user to SCIM (not supported for read-only API)
func (p *SCIMSyncerProvider) AddUser(user *OriginalUser) (bool, error) {
// SCIM syncer is typically read-only
return false, fmt.Errorf("adding users to SCIM is not supported")
}
// UpdateUser updates an existing user in SCIM (not supported for read-only API)
func (p *SCIMSyncerProvider) UpdateUser(user *OriginalUser) (bool, error) {
// SCIM syncer is typically read-only
return false, fmt.Errorf("updating users in SCIM is not supported")
}
// TestConnection tests the SCIM API connection
func (p *SCIMSyncerProvider) TestConnection() error {
// Test by trying to fetch users with a limit of 1
endpoint := p.buildSCIMEndpoint()
endpoint = fmt.Sprintf("%s?startIndex=1&count=1", endpoint)
req, err := p.createSCIMRequest("GET", endpoint, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("SCIM connection test failed: status=%d, body=%s", resp.StatusCode, string(body))
}
return nil
}
// Close closes any open connections (no-op for SCIM API-based syncer)
func (p *SCIMSyncerProvider) Close() error {
// SCIM syncer doesn't maintain persistent connections
return nil
}
// SCIMName represents a SCIM user name structure
type SCIMName struct {
FamilyName string `json:"familyName"`
GivenName string `json:"givenName"`
Formatted string `json:"formatted"`
}
// SCIMEmail represents a SCIM user email structure
type SCIMEmail struct {
Value string `json:"value"`
Type string `json:"type"`
Primary bool `json:"primary"`
}
// SCIMPhoneNumber represents a SCIM user phone number structure
type SCIMPhoneNumber struct {
Value string `json:"value"`
Type string `json:"type"`
Primary bool `json:"primary"`
}
// SCIMAddress represents a SCIM user address structure
type SCIMAddress struct {
StreetAddress string `json:"streetAddress"`
Locality string `json:"locality"`
Region string `json:"region"`
PostalCode string `json:"postalCode"`
Country string `json:"country"`
Formatted string `json:"formatted"`
Type string `json:"type"`
Primary bool `json:"primary"`
}
// SCIMUser represents a SCIM 2.0 user resource
type SCIMUser struct {
ID string `json:"id"`
ExternalID string `json:"externalId"`
UserName string `json:"userName"`
Name SCIMName `json:"name"`
DisplayName string `json:"displayName"`
NickName string `json:"nickName"`
ProfileURL string `json:"profileUrl"`
Title string `json:"title"`
UserType string `json:"userType"`
PreferredLan string `json:"preferredLanguage"`
Locale string `json:"locale"`
Timezone string `json:"timezone"`
Active bool `json:"active"`
Emails []SCIMEmail `json:"emails"`
PhoneNumbers []SCIMPhoneNumber `json:"phoneNumbers"`
Addresses []SCIMAddress `json:"addresses"`
}
// SCIMListResponse represents a SCIM list response
type SCIMListResponse struct {
TotalResults int `json:"totalResults"`
ItemsPerPage int `json:"itemsPerPage"`
StartIndex int `json:"startIndex"`
Resources []*SCIMUser `json:"Resources"`
}
// buildSCIMEndpoint builds the SCIM API endpoint URL
func (p *SCIMSyncerProvider) buildSCIMEndpoint() string {
// syncer.Host should be the SCIM server URL (e.g., https://example.com/scim/v2)
host := strings.TrimSuffix(p.Syncer.Host, "/")
return fmt.Sprintf("%s/Users", host)
}
// createSCIMRequest creates an HTTP request with proper authentication
func (p *SCIMSyncerProvider) createSCIMRequest(method, url string, body io.Reader) (*http.Request, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
}
// Set SCIM headers
req.Header.Set("Content-Type", "application/scim+json")
req.Header.Set("Accept", "application/scim+json")
// Add authentication
// syncer.User should be the authentication token or username
// syncer.Password should be the password or API key
if p.Syncer.User != "" && p.Syncer.Password != "" {
// Try Basic Auth
req.SetBasicAuth(p.Syncer.User, p.Syncer.Password)
} else if p.Syncer.Password != "" {
// Try Bearer token (assuming password field contains the token)
req.Header.Set("Authorization", "Bearer "+p.Syncer.Password)
} else if p.Syncer.User != "" {
// Try Bearer token (assuming user field contains the token)
req.Header.Set("Authorization", "Bearer "+p.Syncer.User)
}
return req, nil
}
// getSCIMUsers retrieves all users from SCIM API with pagination
func (p *SCIMSyncerProvider) getSCIMUsers() ([]*OriginalUser, error) {
allUsers := []*SCIMUser{}
startIndex := 1
count := 100 // Fetch 100 users per page
for {
endpoint := p.buildSCIMEndpoint()
endpoint = fmt.Sprintf("%s?startIndex=%d&count=%d", endpoint, startIndex, count)
req, err := p.createSCIMRequest("GET", endpoint, nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to get users: status=%d, body=%s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var listResp SCIMListResponse
err = json.Unmarshal(body, &listResp)
if err != nil {
return nil, err
}
allUsers = append(allUsers, listResp.Resources...)
// Check if we've fetched all users
if len(allUsers) >= listResp.TotalResults {
break
}
// Move to the next page
startIndex += count
}
// Convert SCIM users to Casdoor OriginalUser
originalUsers := []*OriginalUser{}
for _, scimUser := range allUsers {
originalUser := p.scimUserToOriginalUser(scimUser)
originalUsers = append(originalUsers, originalUser)
}
return originalUsers, nil
}
// scimUserToOriginalUser converts SCIM user to Casdoor OriginalUser
func (p *SCIMSyncerProvider) scimUserToOriginalUser(scimUser *SCIMUser) *OriginalUser {
user := &OriginalUser{
Id: scimUser.ID,
ExternalId: scimUser.ExternalID,
Name: scimUser.UserName,
DisplayName: scimUser.DisplayName,
FirstName: scimUser.Name.GivenName,
LastName: scimUser.Name.FamilyName,
Title: scimUser.Title,
Language: scimUser.PreferredLan,
Address: []string{},
Properties: map[string]string{},
Groups: []string{},
}
// If display name is from name structure
if user.DisplayName == "" && scimUser.Name.Formatted != "" {
user.DisplayName = scimUser.Name.Formatted
}
// If display name is still 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))
}
// Extract primary email or first email
if len(scimUser.Emails) > 0 {
for _, email := range scimUser.Emails {
if email.Primary {
user.Email = email.Value
break
}
}
// If no primary email, use the first one
if user.Email == "" && len(scimUser.Emails) > 0 {
user.Email = scimUser.Emails[0].Value
}
}
// Extract primary phone or first phone
if len(scimUser.PhoneNumbers) > 0 {
for _, phone := range scimUser.PhoneNumbers {
if phone.Primary {
user.Phone = phone.Value
break
}
}
// If no primary phone, use the first one
if user.Phone == "" && len(scimUser.PhoneNumbers) > 0 {
user.Phone = scimUser.PhoneNumbers[0].Value
}
}
// Extract primary address or first address
if len(scimUser.Addresses) > 0 {
for _, addr := range scimUser.Addresses {
if addr.Primary {
if addr.Formatted != "" {
user.Address = []string{addr.Formatted}
} else {
user.Address = []string{addr.StreetAddress, addr.Locality, addr.Region, addr.PostalCode, addr.Country}
}
user.Location = addr.Locality
user.Region = addr.Region
break
}
}
// If no primary address, use the first one
if len(user.Address) == 0 && len(scimUser.Addresses) > 0 {
addr := scimUser.Addresses[0]
if addr.Formatted != "" {
user.Address = []string{addr.Formatted}
} else {
user.Address = []string{addr.StreetAddress, addr.Locality, addr.Region, addr.PostalCode, addr.Country}
}
user.Location = addr.Locality
user.Region = addr.Region
}
}
// Set IsForbidden based on Active status
user.IsForbidden = !scimUser.Active
// Set CreatedTime to current time if not set
if user.CreatedTime == "" {
user.CreatedTime = util.GetCurrentTime()
}
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
}

198
object/syncer_scim_test.go Normal file
View File

@@ -0,0 +1,198 @@
// 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"
)
func TestSCIMUserToOriginalUser(t *testing.T) {
provider := &SCIMSyncerProvider{
Syncer: &Syncer{
Host: "https://example.com/scim/v2",
User: "testuser",
Password: "testtoken",
},
}
// Test case 1: Full SCIM user with all fields
scimUser := &SCIMUser{
ID: "user-123",
ExternalID: "ext-123",
UserName: "john.doe",
DisplayName: "John Doe",
Name: SCIMName{
GivenName: "John",
FamilyName: "Doe",
Formatted: "John Doe",
},
Title: "Software Engineer",
PreferredLan: "en-US",
Active: true,
Emails: []SCIMEmail{
{Value: "john.doe@example.com", Primary: true, Type: "work"},
{Value: "john@personal.com", Primary: false, Type: "home"},
},
PhoneNumbers: []SCIMPhoneNumber{
{Value: "+1-555-1234", Primary: true, Type: "work"},
{Value: "+1-555-5678", Primary: false, Type: "mobile"},
},
Addresses: []SCIMAddress{
{
StreetAddress: "123 Main St",
Locality: "San Francisco",
Region: "CA",
PostalCode: "94102",
Country: "USA",
Formatted: "123 Main St, San Francisco, CA 94102, USA",
Primary: true,
Type: "work",
},
},
}
originalUser := provider.scimUserToOriginalUser(scimUser)
// Verify basic fields
if originalUser.Id != "user-123" {
t.Errorf("Expected Id to be 'user-123', got '%s'", originalUser.Id)
}
if originalUser.ExternalId != "ext-123" {
t.Errorf("Expected ExternalId to be 'ext-123', got '%s'", originalUser.ExternalId)
}
if originalUser.Name != "john.doe" {
t.Errorf("Expected Name to be 'john.doe', got '%s'", originalUser.Name)
}
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.Title != "Software Engineer" {
t.Errorf("Expected Title to be 'Software Engineer', got '%s'", originalUser.Title)
}
if originalUser.Language != "en-US" {
t.Errorf("Expected Language to be 'en-US', got '%s'", originalUser.Language)
}
// Verify primary email is selected
if originalUser.Email != "john.doe@example.com" {
t.Errorf("Expected Email to be 'john.doe@example.com', got '%s'", originalUser.Email)
}
// Verify primary phone is selected
if originalUser.Phone != "+1-555-1234" {
t.Errorf("Expected Phone to be '+1-555-1234', got '%s'", originalUser.Phone)
}
// Verify address fields
if originalUser.Location != "San Francisco" {
t.Errorf("Expected Location to be 'San Francisco', got '%s'", originalUser.Location)
}
if originalUser.Region != "CA" {
t.Errorf("Expected Region to be 'CA', got '%s'", originalUser.Region)
}
// Verify active status is inverted to IsForbidden
if originalUser.IsForbidden != false {
t.Errorf("Expected IsForbidden to be false for active user, got %v", originalUser.IsForbidden)
}
// Test case 2: Inactive SCIM user
inactiveUser := &SCIMUser{
ID: "user-456",
UserName: "jane.doe",
Active: false,
}
inactiveOriginalUser := provider.scimUserToOriginalUser(inactiveUser)
if inactiveOriginalUser.IsForbidden != true {
t.Errorf("Expected IsForbidden to be true for inactive user, got %v", inactiveOriginalUser.IsForbidden)
}
// Test case 3: SCIM user with no primary email/phone (should use first)
noPrimaryUser := &SCIMUser{
ID: "user-789",
UserName: "bob.smith",
Emails: []SCIMEmail{
{Value: "bob@example.com", Primary: false, Type: "work"},
{Value: "bob@personal.com", Primary: false, Type: "home"},
},
PhoneNumbers: []SCIMPhoneNumber{
{Value: "+1-555-9999", Primary: false, Type: "work"},
},
}
noPrimaryOriginalUser := provider.scimUserToOriginalUser(noPrimaryUser)
if noPrimaryOriginalUser.Email != "bob@example.com" {
t.Errorf("Expected first email when no primary, got '%s'", noPrimaryOriginalUser.Email)
}
if noPrimaryOriginalUser.Phone != "+1-555-9999" {
t.Errorf("Expected first phone when no primary, got '%s'", noPrimaryOriginalUser.Phone)
}
// Test case 4: Display name construction from first/last name when empty
noDisplayNameUser := &SCIMUser{
ID: "user-101",
UserName: "alice.jones",
Name: SCIMName{
GivenName: "Alice",
FamilyName: "Jones",
},
}
noDisplayNameOriginalUser := provider.scimUserToOriginalUser(noDisplayNameUser)
if noDisplayNameOriginalUser.DisplayName != "Alice Jones" {
t.Errorf("Expected DisplayName to be constructed as 'Alice Jones', got '%s'", noDisplayNameOriginalUser.DisplayName)
}
}
func TestSCIMBuildEndpoint(t *testing.T) {
tests := []struct {
host string
expected string
}{
{"https://example.com/scim/v2", "https://example.com/scim/v2/Users"},
{"https://example.com/scim/v2/", "https://example.com/scim/v2/Users"},
{"http://localhost:8080/scim", "http://localhost:8080/scim/Users"},
}
for _, test := range tests {
provider := &SCIMSyncerProvider{
Syncer: &Syncer{Host: test.host},
}
endpoint := provider.buildSCIMEndpoint()
if endpoint != test.expected {
t.Errorf("For host '%s', expected endpoint '%s', got '%s'", test.host, test.expected, endpoint)
}
}
}
func TestGetSyncerProviderSCIM(t *testing.T) {
syncer := &Syncer{
Type: "SCIM",
Host: "https://example.com/scim/v2",
}
provider := GetSyncerProvider(syncer)
if _, ok := provider.(*SCIMSyncerProvider); !ok {
t.Errorf("Expected SCIMSyncerProvider for type 'SCIM', got %T", provider)
}
}

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

@@ -58,60 +58,61 @@ type User struct {
UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
DeletedTime string `xorm:"varchar(100)" json:"deletedTime"`
Id string `xorm:"varchar(100) index" json:"id"`
ExternalId string `xorm:"varchar(100) index" json:"externalId"`
Type string `xorm:"varchar(100)" json:"type"`
Password string `xorm:"varchar(150)" json:"password"`
PasswordSalt string `xorm:"varchar(100)" json:"passwordSalt"`
PasswordType string `xorm:"varchar(100)" json:"passwordType"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
FirstName string `xorm:"varchar(100)" json:"firstName"`
LastName string `xorm:"varchar(100)" json:"lastName"`
Avatar string `xorm:"text" json:"avatar"`
AvatarType string `xorm:"varchar(100)" json:"avatarType"`
PermanentAvatar string `xorm:"varchar(500)" json:"permanentAvatar"`
Email string `xorm:"varchar(100) index" json:"email"`
EmailVerified bool `json:"emailVerified"`
Phone string `xorm:"varchar(100) index" json:"phone"`
CountryCode string `xorm:"varchar(6)" json:"countryCode"`
Region string `xorm:"varchar(100)" json:"region"`
Location string `xorm:"varchar(100)" json:"location"`
Address []string `json:"address"`
Affiliation string `xorm:"varchar(100)" json:"affiliation"`
Title string `xorm:"varchar(100)" json:"title"`
IdCardType string `xorm:"varchar(100)" json:"idCardType"`
IdCard string `xorm:"varchar(100) index" json:"idCard"`
RealName string `xorm:"varchar(100)" json:"realName"`
IsVerified bool `json:"isVerified"`
Homepage string `xorm:"varchar(100)" json:"homepage"`
Bio string `xorm:"varchar(100)" json:"bio"`
Tag string `xorm:"varchar(100)" json:"tag"`
Language string `xorm:"varchar(100)" json:"language"`
Gender string `xorm:"varchar(100)" json:"gender"`
Birthday string `xorm:"varchar(100)" json:"birthday"`
Education string `xorm:"varchar(100)" json:"education"`
Score int `json:"score"`
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"`
IsForbidden bool `json:"isForbidden"`
IsDeleted bool `json:"isDeleted"`
SignupApplication string `xorm:"varchar(100)" json:"signupApplication"`
Hash string `xorm:"varchar(100)" json:"hash"`
PreHash string `xorm:"varchar(100)" json:"preHash"`
RegisterType string `xorm:"varchar(100)" json:"registerType"`
RegisterSource string `xorm:"varchar(100)" json:"registerSource"`
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"`
OriginalRefreshToken string `xorm:"mediumtext" json:"originalRefreshToken"`
Id string `xorm:"varchar(100) index" json:"id"`
ExternalId string `xorm:"varchar(100) index" json:"externalId"`
Type string `xorm:"varchar(100)" json:"type"`
Password string `xorm:"varchar(150)" json:"password"`
PasswordSalt string `xorm:"varchar(100)" json:"passwordSalt"`
PasswordType string `xorm:"varchar(100)" json:"passwordType"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
FirstName string `xorm:"varchar(100)" json:"firstName"`
LastName string `xorm:"varchar(100)" json:"lastName"`
Avatar string `xorm:"text" json:"avatar"`
AvatarType string `xorm:"varchar(100)" json:"avatarType"`
PermanentAvatar string `xorm:"varchar(500)" json:"permanentAvatar"`
Email string `xorm:"varchar(100) index" json:"email"`
EmailVerified bool `json:"emailVerified"`
Phone string `xorm:"varchar(100) index" json:"phone"`
CountryCode string `xorm:"varchar(6)" json:"countryCode"`
Region string `xorm:"varchar(100)" json:"region"`
Location string `xorm:"varchar(100)" json:"location"`
Address []string `json:"address"`
Addresses []*Address `xorm:"addresses blob" json:"addresses"`
Affiliation string `xorm:"varchar(100)" json:"affiliation"`
Title string `xorm:"varchar(100)" json:"title"`
IdCardType string `xorm:"varchar(100)" json:"idCardType"`
IdCard string `xorm:"varchar(100) index" json:"idCard"`
RealName string `xorm:"varchar(100)" json:"realName"`
IsVerified bool `json:"isVerified"`
Homepage string `xorm:"varchar(100)" json:"homepage"`
Bio string `xorm:"varchar(100)" json:"bio"`
Tag string `xorm:"varchar(100)" json:"tag"`
Language string `xorm:"varchar(100)" json:"language"`
Gender string `xorm:"varchar(100)" json:"gender"`
Birthday string `xorm:"varchar(100)" json:"birthday"`
Education string `xorm:"varchar(100)" json:"education"`
Score int `json:"score"`
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"`
IsForbidden bool `json:"isForbidden"`
IsDeleted bool `json:"isDeleted"`
SignupApplication string `xorm:"varchar(100)" json:"signupApplication"`
Hash string `xorm:"varchar(100)" json:"hash"`
PreHash string `xorm:"varchar(100)" json:"preHash"`
RegisterType string `xorm:"varchar(100)" json:"registerType"`
RegisterSource string `xorm:"varchar(100)" json:"registerSource"`
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"`
OriginalRefreshToken string `xorm:"mediumtext" json:"originalRefreshToken"`
CreatedIp string `xorm:"varchar(100)" json:"createdIp"`
LastSigninTime string `xorm:"varchar(100)" json:"lastSigninTime"`
@@ -274,6 +275,16 @@ type MfaAccount struct {
Origin string `xorm:"varchar(100)" json:"origin"`
}
type Address struct {
Tag string `xorm:"varchar(100)" json:"tag"`
Line1 string `xorm:"varchar(100)" json:"line1"`
Line2 string `xorm:"varchar(100)" json:"line2"`
City string `xorm:"varchar(100)" json:"city"`
State string `xorm:"varchar(100)" json:"state"`
ZipCode string `xorm:"varchar(100)" json:"zipCode"`
Region string `xorm:"varchar(100)" json:"region"`
}
type FaceId struct {
Name string `xorm:"varchar(100) notnull pk" json:"name"`
FaceIdData []float64 `json:"faceIdData"`

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

@@ -24,6 +24,12 @@ const (
PaymentStateError PaymentState = "Error"
)
// IsTerminalState checks if a payment state is terminal (cannot transition to other states)
func IsTerminalState(state PaymentState) bool {
return state == PaymentStatePaid || state == PaymentStateError ||
state == PaymentStateCanceled || state == PaymentStateTimeout
}
const (
PaymentEnvWechatBrowser = "WechatBrowser"
)

View File

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

View File

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

View File

@@ -420,7 +420,17 @@ class App extends Component {
}
if (query !== "") {
window.history.replaceState({}, document.title, this.getUrlWithoutQuery());
const returnUrl = params.get("returnUrl");
let newUrl;
if (returnUrl) {
const newParams = new URLSearchParams();
newParams.set("returnUrl", returnUrl);
newUrl = window.location.pathname + "?" + newParams.toString();
} else {
newUrl = this.getUrlWithoutQuery();
}
window.history.replaceState({}, document.title, newUrl);
}
AuthBackend.getAccount(query)

View File

@@ -182,29 +182,15 @@ class CartListPage extends BaseListPage {
);
},
},
{
title: i18next.t("payment:Currency"),
dataIndex: "currency",
key: "currency",
width: "120px",
sorter: true,
render: (text, record, index) => {
return Setting.getCurrencyWithFlag(text);
},
},
{
title: i18next.t("product:Price"),
dataIndex: "price",
key: "price",
width: "120px",
width: "160px",
sorter: true,
render: (text, record) => {
const subtotal = record.price * record.quantity;
return (
<span>
{Setting.getCurrencySymbol(record.currency)}{subtotal.toFixed(2)}
</span>
);
const subtotal = (record.price * record.quantity).toFixed(2);
return Setting.getPriceDisplay(subtotal, record.currency);
},
},
{
@@ -304,7 +290,7 @@ class CartListPage extends BaseListPage {
<div style={{display: "flex", alignItems: "center", fontSize: "18px", fontWeight: "bold"}}>
{i18next.t("product:Total Price")}:&nbsp;
<span style={{color: "red", fontSize: "28px"}}>
{Setting.getCurrencySymbol(currency)}{total.toFixed(2)} ({Setting.getCurrencyText({currency: currency})})
{Setting.getCurrencySymbol(currency)}{total.toFixed(2)} ({Setting.getCurrencyText(currency)})
</span>
</div>
<Button

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: "",
};
}
@@ -138,22 +136,14 @@ class OrderListPage extends BaseListPage {
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:Products"),
dataIndex: "products",
key: "products",
...this.getColumnSearchProps("products"),
render: (text, record, index) => {
const products = record?.products || [];
if (products.length === 0) {
const productInfos = record?.productInfos || [];
if (productInfos.length === 0) {
return `(${i18next.t("general:empty")})`;
}
return (
@@ -161,22 +151,37 @@ class OrderListPage extends BaseListPage {
<List
size="small"
locale={{emptyText: " "}}
dataSource={products}
dataSource={productInfos}
style={{
paddingTop: 8,
paddingBottom: 8,
}}
renderItem={(productName, i) => {
renderItem={(productInfo, i) => {
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}/${productName}`)} />
</Tooltip>
<Link to={`/products/${record.owner}/${productName}`}>
{productName}
</Link>
</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>
);
}}
@@ -186,20 +191,23 @@ class OrderListPage extends BaseListPage {
},
},
{
title: i18next.t("order:Payment"),
dataIndex: "payment",
key: "payment",
width: "140px",
title: i18next.t("order:Price"),
dataIndex: "price",
key: "price",
width: "160px",
sorter: true,
...this.getColumnSearchProps("payment"),
...this.getColumnSearchProps("price"),
render: (text, record, index) => {
if (text === "") {
return "(empty)";
}
return (
<Link to={`/payments/${record.owner}/${text}`}>
{text}
const price = (record.price || 0).toFixed(2);
const currency = record.currency || "USD";
const priceDisplay = Setting.getPriceDisplay(price, currency);
return record.payment ? (
<Link to={`/payments/${record.owner}/${record.payment}`}>
{priceDisplay}
</Link>
) : (
<span>{priceDisplay}</span>
);
},
},
@@ -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: "",
@@ -262,19 +247,20 @@ class OrderListPage extends BaseListPage {
const isAdmin = Setting.isLocalAdminUser(this.props.account);
return (
<div style={{display: "flex", flexWrap: "wrap", gap: "8px"}}>
<Button onClick={() => this.props.history.push(`/orders/${record.owner}/${record.name}/pay` + (record.state === "Created" ? "" : "?view=true"))}>
<Button onClick={() => this.props.history.push(`/orders/${record.owner}/${record.name}/pay`)}>
{record.state === "Created" ? i18next.t("order:Pay") : i18next.t("general:Detail")}
</Button>
<Button danger onClick={() => this.cancelOrder(record)} disabled={record.state !== "Created" || !isAdmin}>
{i18next.t("general:Cancel")}
</Button>
<Button type="primary" onClick={() => this.props.history.push({pathname: `/orders/${record.owner}/${record.name}`, mode: isAdmin ? "edit" : "view"})}>{isAdmin ? i18next.t("general:Edit") : i18next.t("general:View")}</Button>
<PopconfirmModal
disabled={!isAdmin}
title={i18next.t("general:Sure to delete") + `: ${record.name} ?`}
onConfirm={() => this.deleteOrder(index)}
>
</PopconfirmModal>
{isAdmin && (
<PopconfirmModal
title={i18next.t("general:Sure to delete") + `: ${record.name} ?`}
onConfirm={() => this.deleteOrder(index)}
>
</PopconfirmModal>
)}
</div>
);
},

View File

@@ -22,7 +22,6 @@ import * as Setting from "./Setting";
class OrderPayPage extends React.Component {
constructor(props) {
super(props);
const params = new URLSearchParams(window.location.search);
this.state = {
owner: props?.match?.params?.organizationName ?? props?.match?.params?.owner ?? null,
orderName: props?.match?.params?.orderName ?? null,
@@ -31,7 +30,7 @@ class OrderPayPage extends React.Component {
productInfos: [],
paymentEnv: "",
isProcessingPayment: false,
isViewMode: params.get("view") === "true",
isViewMode: false,
};
}
@@ -61,6 +60,7 @@ class OrderPayPage extends React.Component {
this.setState({
order: res.data,
productInfos: res.data?.productInfos,
isViewMode: res.data?.state !== "Created",
}, () => {
this.getProduct();
});
@@ -90,12 +90,12 @@ class OrderPayPage extends React.Component {
}
getPrice(order) {
return `${Setting.getCurrencySymbol(order?.currency)}${order?.price} (${Setting.getCurrencyText(order)})`;
return `${Setting.getCurrencySymbol(order?.currency)}${order?.price} (${Setting.getCurrencyText(order?.currency)})`;
}
getProductPrice(product) {
const price = product.price * (product.quantity ?? 1);
return `${Setting.getCurrencySymbol(this.state.order?.currency)}${price.toFixed(2)} (${Setting.getCurrencyText(this.state.order)})`;
return `${Setting.getCurrencySymbol(this.state.order?.currency)}${price.toFixed(2)} (${Setting.getCurrencyText(this.state.order?.currency)})`;
}
// Call Wechat Pay via jsapi
@@ -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;
}
@@ -275,13 +286,13 @@ class OrderPayPage extends React.Component {
<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")}</span>} bordered column={3}>
<Descriptions.Item label={i18next.t("order:ID")} span={3}>
<Descriptions title={<span style={Setting.isMobile() ? {fontSize: 18} : {fontSize: 24}}>{i18next.t("general:Order")}</span>} bordered column={3}>
<Descriptions.Item label={i18next.t("general:ID")} span={3}>
<span style={{fontSize: 16}}>
{order.name}
</span>
</Descriptions.Item>
<Descriptions.Item label={i18next.t("order:Status")}>
<Descriptions.Item label={i18next.t("general:Status")}>
<span style={{fontSize: 16}}>
{order.state}
</span>
@@ -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

@@ -74,8 +74,7 @@ class PaymentEditPage extends React.Component {
goToViewOrder() {
const payment = this.state.payment;
if (payment && payment.order) {
const viewUrl = `/orders/${payment.owner}/${payment.order}/pay?view=true`;
this.props.history.push(viewUrl);
this.props.history.push(`/orders/${payment.owner}/${payment.order}/pay`);
} else {
Setting.showMessage("error", i18next.t("order:Order not found"));
}

View File

@@ -180,8 +180,8 @@ class PaymentListPage extends BaseListPage {
key: "products",
...this.getColumnSearchProps("products"),
render: (text, record, index) => {
const products = record?.products || [];
if (products.length === 0) {
const productInfos = record?.orderObj?.productInfos || [];
if (productInfos.length === 0) {
return `(${i18next.t("general:empty")})`;
}
return (
@@ -189,21 +189,26 @@ class PaymentListPage extends BaseListPage {
<List
size="small"
locale={{emptyText: " "}}
dataSource={products}
dataSource={productInfos}
style={{
paddingTop: 8,
paddingBottom: 8,
}}
renderItem={(productName, i) => {
renderItem={(productInfo, i) => {
const price = productInfo.price * (productInfo.quantity || 1);
const currency = record.currency || "USD";
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}/${productName}`)} />
<Button style={{marginRight: "5px"}} icon={<EditOutlined />} size="small" onClick={() => Setting.goToLinkSoft(this, `/products/${record.owner}/${productInfo.name}`)} />
</Tooltip>
<Link to={`/products/${record.owner}/${productName}`}>
{productName}
<Link to={`/products/${record.owner}/${productInfo.name}`}>
{productInfo.displayName || productInfo.name}
</Link>
<span style={{marginLeft: "8px", color: "#666"}}>
{Setting.getPriceDisplay(price, currency)}
</span>
</div>
</List.Item>
);
@@ -217,19 +222,11 @@ class PaymentListPage extends BaseListPage {
title: i18next.t("product:Price"),
dataIndex: "price",
key: "price",
width: "120px",
width: "160px",
sorter: true,
...this.getColumnSearchProps("price"),
},
{
title: i18next.t("payment:Currency"),
dataIndex: "currency",
key: "currency",
width: "120px",
sorter: true,
...this.getColumnSearchProps("currency"),
render: (text, record, index) => {
return Setting.getCurrencyWithFlag(text);
return Setting.getPriceDisplay(record.price, record.currency);
},
},
{

View File

@@ -150,8 +150,7 @@ class PaymentResultPage extends React.Component {
goToViewOrder() {
const payment = this.state.payment;
if (payment && payment.order) {
const viewUrl = `/orders/${payment.owner}/${payment.order}/pay?view=true`;
this.props.history.push(viewUrl);
this.props.history.push(`/orders/${payment.owner}/${payment.order}/pay`);
} else {
Setting.showMessage("error", i18next.t("order:Order not found"));
}
@@ -178,7 +177,7 @@ class PaymentResultPage extends React.Component {
<Result
status="success"
title={`${i18next.t("payment:Recharged successfully")}`}
subTitle={`${i18next.t("payment:You have successfully recharged")} ${payment.price} ${Setting.getCurrencyText(payment)}, ${i18next.t("payment:Your current balance is")} ${this.state.user?.balance} ${Setting.getCurrencyText(payment)}`}
subTitle={`${i18next.t("payment:You have successfully recharged")} ${payment.price} ${Setting.getCurrencyText(payment?.currency)}, ${i18next.t("payment:Your current balance is")} ${this.state.user?.balance} ${Setting.getCurrencyText(payment?.currency)}`}
extra={[
<Button type="primary" key="viewOrder" onClick={() => {
this.goToViewOrder();

View File

@@ -129,23 +129,15 @@ class PlanListPage extends BaseListPage {
sorter: true,
...this.getColumnSearchProps("displayName"),
},
{
title: i18next.t("payment:Currency"),
dataIndex: "currency",
key: "currency",
width: "120px",
sorter: true,
...this.getColumnSearchProps("currency"),
render: (text, record, index) => {
return Setting.getCurrencyWithFlag(text);
},
},
{
title: i18next.t("plan:Price"),
dataIndex: "price",
key: "price",
width: "130px",
width: "160px",
...this.getColumnSearchProps("price"),
render: (text, record, index) => {
return Setting.getPriceDisplay(record.price, record.currency);
},
},
{
title: i18next.t("plan:Period"),

View File

@@ -134,7 +134,7 @@ class ProductBuyPage extends React.Component {
}
getPrice(product) {
return `${Setting.getCurrencySymbol(product?.currency)}${product?.price} (${Setting.getCurrencyText(product)})`;
return `${Setting.getCurrencySymbol(product?.currency)}${product?.price} (${Setting.getCurrencyText(product?.currency)})`;
}
addToCart(product) {
@@ -301,7 +301,7 @@ class ProductBuyPage extends React.Component {
onChange={(e) => {this.setState({customPrice: e});}}
disabled={disableCustom}
/>
<span style={{fontSize: 16}}>{Setting.getCurrencyText(product)}</span>
<span style={{fontSize: 16}}>{Setting.getCurrencyText(product?.currency)}</span>
</Space>
</Space>
);
@@ -340,7 +340,7 @@ class ProductBuyPage extends React.Component {
{i18next.t("order:Place Order")}
</Button>
<Button
type="primary"
type="default"
size="large"
style={{
height: "50px",

View File

@@ -152,24 +152,16 @@ class ProductListPage extends BaseListPage {
sorter: true,
...this.getColumnSearchProps("tag"),
},
{
title: i18next.t("payment:Currency"),
dataIndex: "currency",
key: "currency",
width: "120px",
sorter: true,
...this.getColumnSearchProps("currency"),
render: (text, record, index) => {
return Setting.getCurrencyWithFlag(text);
},
},
{
title: i18next.t("product:Price"),
dataIndex: "price",
key: "price",
width: "120px",
width: "160px",
sorter: true,
...this.getColumnSearchProps("price"),
render: (text, record, index) => {
return Setting.getPriceDisplay(record.price, record.currency);
},
},
{
title: i18next.t("product:Quantity"),

View File

@@ -162,7 +162,7 @@ class ProductStorePage extends React.Component {
{!product.isRecharge && (
<Button
key="add"
type="primary"
type="default"
onClick={(e) => {
e.stopPropagation();
this.addToCart(product);

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"},
@@ -1868,6 +1869,17 @@ export function getCurrencyCountryCode(currency) {
return currencyToCountry[currency?.toUpperCase()] || null;
}
export function getCurrencyFlag(currency) {
const countryCode = getCurrencyCountryCode(currency);
if (!countryCode) {
return null;
}
return (
<img src={`${StaticBaseUrl}/flag-icons/${countryCode}.svg`} alt={`${currency} flag`} height={20} style={{marginRight: 5}} />
);
}
export function getCurrencyWithFlag(currency) {
const translationKey = `currency:${currency}`;
const translatedText = i18next.t(translationKey);
@@ -1886,6 +1898,16 @@ export function getCurrencyWithFlag(currency) {
);
}
export function getPriceDisplay(price, currency) {
const priceValue = price || 0;
const currencyValue = currency || "USD";
return (
<>
{getCurrencyFlag(currencyValue)} {getCurrencySymbol(currencyValue)}{priceValue} ({getCurrencyText(currencyValue)})
</>
);
}
export function getFriendlyUserName(account) {
if (account.firstName !== "" && account.lastName !== "") {
return `${account.firstName}, ${account.lastName}`;
@@ -2013,58 +2035,58 @@ export function getDefaultInvitationHtmlEmailContent() {
</html>`;
}
export function getCurrencyText(product) {
if (product?.currency === "USD") {
export function getCurrencyText(currency) {
if (currency === "USD") {
return i18next.t("currency:USD");
} else if (product?.currency === "CNY") {
} else if (currency === "CNY") {
return i18next.t("currency:CNY");
} else if (product?.currency === "EUR") {
} else if (currency === "EUR") {
return i18next.t("currency:EUR");
} else if (product?.currency === "JPY") {
} else if (currency === "JPY") {
return i18next.t("currency:JPY");
} else if (product?.currency === "GBP") {
} else if (currency === "GBP") {
return i18next.t("currency:GBP");
} else if (product?.currency === "AUD") {
} else if (currency === "AUD") {
return i18next.t("currency:AUD");
} else if (product?.currency === "CAD") {
} else if (currency === "CAD") {
return i18next.t("currency:CAD");
} else if (product?.currency === "CHF") {
} else if (currency === "CHF") {
return i18next.t("currency:CHF");
} else if (product?.currency === "HKD") {
} else if (currency === "HKD") {
return i18next.t("currency:HKD");
} else if (product?.currency === "SGD") {
} else if (currency === "SGD") {
return i18next.t("currency:SGD");
} else if (product?.currency === "BRL") {
} else if (currency === "BRL") {
return i18next.t("currency:BRL");
} else if (product?.currency === "PLN") {
} else if (currency === "PLN") {
return i18next.t("currency:PLN");
} else if (product?.currency === "KRW") {
} else if (currency === "KRW") {
return i18next.t("currency:KRW");
} else if (product?.currency === "INR") {
} else if (currency === "INR") {
return i18next.t("currency:INR");
} else if (product?.currency === "RUB") {
} else if (currency === "RUB") {
return i18next.t("currency:RUB");
} else if (product?.currency === "MXN") {
} else if (currency === "MXN") {
return i18next.t("currency:MXN");
} else if (product?.currency === "ZAR") {
} else if (currency === "ZAR") {
return i18next.t("currency:ZAR");
} else if (product?.currency === "TRY") {
} else if (currency === "TRY") {
return i18next.t("currency:TRY");
} else if (product?.currency === "SEK") {
} else if (currency === "SEK") {
return i18next.t("currency:SEK");
} else if (product?.currency === "NOK") {
} else if (currency === "NOK") {
return i18next.t("currency:NOK");
} else if (product?.currency === "DKK") {
} else if (currency === "DKK") {
return i18next.t("currency:DKK");
} else if (product?.currency === "THB") {
} else if (currency === "THB") {
return i18next.t("currency:THB");
} else if (product?.currency === "MYR") {
} else if (currency === "MYR") {
return i18next.t("currency:MYR");
} else if (product?.currency === "TWD") {
} else if (currency === "TWD") {
return i18next.t("currency:TWD");
} else if (product?.currency === "CZK") {
} else if (currency === "CZK") {
return i18next.t("currency:CZK");
} else if (product?.currency === "HUF") {
} else if (currency === "HUF") {
return i18next.t("currency:HUF");
} else {
return "(Unknown currency)";

View File

@@ -512,6 +512,204 @@ class SyncerEditPage extends React.Component {
"values": [],
},
];
case "Lark":
return [
{
"name": "user_id",
"type": "string",
"casdoorName": "Id",
"isHashed": true,
"values": [],
},
{
"name": "name",
"type": "string",
"casdoorName": "DisplayName",
"isHashed": true,
"values": [],
},
{
"name": "email",
"type": "string",
"casdoorName": "Email",
"isHashed": true,
"values": [],
},
{
"name": "mobile",
"type": "string",
"casdoorName": "Phone",
"isHashed": true,
"values": [],
},
{
"name": "avatar",
"type": "string",
"casdoorName": "Avatar",
"isHashed": true,
"values": [],
},
{
"name": "job_title",
"type": "string",
"casdoorName": "Title",
"isHashed": true,
"values": [],
},
{
"name": "gender",
"type": "number",
"casdoorName": "Gender",
"isHashed": true,
"values": [],
},
];
case "Okta":
return [
{
"name": "id",
"type": "string",
"casdoorName": "Id",
"isHashed": true,
"values": [],
},
{
"name": "profile.login",
"type": "string",
"casdoorName": "Name",
"isHashed": true,
"values": [],
},
{
"name": "profile.displayName",
"type": "string",
"casdoorName": "DisplayName",
"isHashed": true,
"values": [],
},
{
"name": "profile.firstName",
"type": "string",
"casdoorName": "FirstName",
"isHashed": true,
"values": [],
},
{
"name": "profile.lastName",
"type": "string",
"casdoorName": "LastName",
"isHashed": true,
"values": [],
},
{
"name": "profile.email",
"type": "string",
"casdoorName": "Email",
"isHashed": true,
"values": [],
},
{
"name": "profile.mobilePhone",
"type": "string",
"casdoorName": "Phone",
"isHashed": true,
"values": [],
},
{
"name": "profile.title",
"type": "string",
"casdoorName": "Title",
"isHashed": true,
"values": [],
},
{
"name": "profile.preferredLanguage",
"type": "string",
"casdoorName": "Language",
"isHashed": true,
"values": [],
},
{
"name": "status",
"type": "string",
"casdoorName": "IsForbidden",
"isHashed": true,
"values": [],
},
];
case "SCIM":
return [
{
"name": "id",
"type": "string",
"casdoorName": "Id",
"isHashed": true,
"values": [],
},
{
"name": "userName",
"type": "string",
"casdoorName": "Name",
"isHashed": true,
"values": [],
},
{
"name": "displayName",
"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": "emails",
"type": "string",
"casdoorName": "Email",
"isHashed": true,
"values": [],
},
{
"name": "phoneNumbers",
"type": "string",
"casdoorName": "Phone",
"isHashed": true,
"values": [],
},
{
"name": "title",
"type": "string",
"casdoorName": "Title",
"isHashed": true,
"values": [],
},
{
"name": "preferredLanguage",
"type": "string",
"casdoorName": "Language",
"isHashed": true,
"values": [],
},
{
"name": "active",
"type": "boolean",
"casdoorName": "IsForbidden",
"isHashed": true,
"values": [],
},
];
default:
return [];
}
@@ -568,14 +766,14 @@ class SyncerEditPage extends React.Component {
});
})}>
{
["Database", "Keycloak", "WeCom", "Azure AD", "Active Directory", "Google Workspace", "DingTalk"]
["Database", "Keycloak", "WeCom", "Azure AD", "Active Directory", "Google Workspace", "DingTalk", "Lark", "Okta", "SCIM"]
.map((item, index) => <Option key={index} value={item}>{item}</Option>)
}
</Select>
</Col>
</Row>
{
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Active Directory" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" ? null : (
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Active Directory" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" ? 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"))} :
@@ -627,10 +825,10 @@ class SyncerEditPage extends React.Component {
)
}
{
this.state.syncer.type === "WeCom" || this.state.syncer.type === "DingTalk" ? null : (
this.state.syncer.type === "WeCom" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" ? 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") : 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"))} :
{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") : this.state.syncer.type === "SCIM" ? i18next.t("syncer:SCIM Server URL") : i18next.t("provider:Host"), i18next.t("provider:Host - Tooltip"))} :
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={this.state.syncer.host} onChange={e => {
@@ -641,7 +839,7 @@ class SyncerEditPage extends React.Component {
)
}
{
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" ? null : (
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(this.state.syncer.type === "Active Directory" ? i18next.t("provider:LDAP port") : i18next.t("provider:Port"), i18next.t("provider:Port - Tooltip"))} :
@@ -661,9 +859,11 @@ class SyncerEditPage extends React.Component {
{Setting.getLabel(
this.state.syncer.type === "WeCom" ? i18next.t("syncer:Corp ID") :
this.state.syncer.type === "DingTalk" ? i18next.t("provider:App Key") :
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"),
this.state.syncer.type === "Lark" ? i18next.t("provider:App ID") :
this.state.syncer.type === "Azure AD" ? i18next.t("provider:Client ID") :
this.state.syncer.type === "Active Directory" ? i18next.t("syncer:Bind DN") :
this.state.syncer.type === "SCIM" ? i18next.t("syncer:Username (optional)") :
i18next.t("general:User"),
i18next.t("general:User - Tooltip")
)} :
</Col>
@@ -680,9 +880,11 @@ class SyncerEditPage extends React.Component {
{Setting.getLabel(
this.state.syncer.type === "WeCom" ? i18next.t("syncer:Corp secret") :
this.state.syncer.type === "DingTalk" ? i18next.t("provider:App 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"),
this.state.syncer.type === "Lark" ? i18next.t("provider:App secret") :
this.state.syncer.type === "Azure AD" ? i18next.t("provider:Client secret") :
this.state.syncer.type === "Google Workspace" ? i18next.t("syncer:Service account key") :
this.state.syncer.type === "SCIM" ? i18next.t("syncer:API Token / Password") :
i18next.t("general:Password"),
i18next.t("general:Password - Tooltip")
)} :
</Col>
@@ -701,7 +903,7 @@ class SyncerEditPage extends React.Component {
</Col>
</Row>
{
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" ? null : (
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(this.state.syncer.type === "Active Directory" ? i18next.t("syncer:Base DN") : i18next.t("syncer:Database"), i18next.t("syncer:Database - Tooltip"))} :
@@ -797,7 +999,7 @@ class SyncerEditPage extends React.Component {
) : null
}
{
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" ? null : (
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" ? 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

@@ -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";
@@ -33,6 +36,7 @@ import SamlWidget from "./common/SamlWidget";
import RegionSelect from "./common/select/RegionSelect";
import WebAuthnCredentialTable from "./table/WebauthnCredentialTable";
import ManagedAccountTable from "./table/ManagedAccountTable";
import AddressTable from "./table/AddressTable";
import PropertyTable from "./table/propertyTable";
import {CountryCodeSelect} from "./common/select/CountryCodeSelect";
import PopconfirmModal from "./common/modal/PopconfirmModal";
@@ -45,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;
@@ -66,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",
};
}
@@ -174,6 +182,7 @@ class UserEditPage extends React.Component {
}
this.setState({
menuMode: res.data?.organizationObj?.accountMenu ?? "Horizontal",
application: res.data,
});
});
@@ -597,6 +606,16 @@ class UserEditPage extends React.Component {
</Row>
</React.Fragment>
);
} else if (accountItem.name === "Addresses") {
return (
<AddressTable
title={i18next.t("user:Addresses")}
table={this.state.user.addresses}
onUpdateTable={(value) => {
this.updateUserField("addresses", value);
}}
/>
);
} else if (accountItem.name === "Affiliation") {
return (
(this.state.application === null || this.state.user === null) ? null : (
@@ -1322,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>
@@ -1335,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>
);
@@ -1410,29 +1540,24 @@ class UserEditPage extends React.Component {
organizationName: this.state.user.owner,
userName: this.state.user.name,
});
if (this.props.history !== undefined) {
if (exitAfterSave) {
const userListUrl = sessionStorage.getItem("userListUrl");
if (userListUrl !== null) {
this.props.history.push(userListUrl);
} else {
if (Setting.isLocalAdminUser(this.props.account)) {
this.props.history.push("/users");
} else {
this.props.history.push("/");
}
}
if (exitAfterSave) {
if (this.state.returnUrl) {
window.location.href = this.state.returnUrl;
return;
}
const userListUrl = sessionStorage.getItem("userListUrl");
if (userListUrl !== null) {
this.props.history.push(userListUrl);
} else {
if (location.pathname !== "/account") {
this.props.history.push(`/users/${this.state.user.owner}/${this.state.user.name}`);
if (Setting.isLocalAdminUser(this.props.account)) {
this.props.history.push("/users");
} else {
this.props.history.push("/");
}
}
} else {
if (exitAfterSave) {
if (this.state.returnUrl) {
window.location.href = this.state.returnUrl;
}
if (location.pathname !== "/account") {
this.props.history.push(`/users/${this.state.user.owner}/${this.state.user.name}`);
}
}
} else {

View File

@@ -17,6 +17,7 @@ import {Spin} from "antd";
import {withRouter} from "react-router-dom";
import * as AuthBackend from "./AuthBackend";
import * as Util from "./Util";
import * as Provider from "./Provider";
import {authConfig} from "./Auth";
import * as Setting from "../Setting";
import i18next from "i18next";
@@ -146,6 +147,9 @@ class AuthCallback extends React.Component {
const redirectUri = `${window.location.origin}/callback`;
// Retrieve the code verifier for PKCE if it exists
const codeVerifier = Provider.getCodeVerifier(params.get("state"));
const body = {
type: this.getResponseType(),
application: applicationName,
@@ -156,8 +160,14 @@ class AuthCallback extends React.Component {
state: applicationName,
redirectUri: redirectUri,
method: method,
codeVerifier: codeVerifier, // Include PKCE code verifier
};
// Clean up the stored code verifier after using it
if (codeVerifier) {
Provider.clearCodeVerifier(params.get("state"));
}
if (this.getResponseType() === "cas") {
// user is using casdoor as cas sso server, and wants the ticket to be acquired
AuthBackend.loginCas(body, {"service": casService}).then((res) => {

View File

@@ -14,9 +14,52 @@
import React from "react";
import {Tooltip} from "antd";
import CryptoJS from "crypto-js";
import * as Util from "./Util";
import * as Setting from "../Setting";
// PKCE helper functions
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
function base64UrlEncode(buffer) {
const base64 = btoa(String.fromCharCode.apply(null, buffer));
return base64
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
function generateCodeChallenge(verifier) {
// Convert verifier to UTF-8 bytes and compute SHA-256 hash
const hash = CryptoJS.SHA256(CryptoJS.enc.Utf8.parse(verifier));
const base64Hash = CryptoJS.enc.Base64.stringify(hash);
return base64Hash
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
function storeCodeVerifier(state, verifier) {
localStorage.setItem("pkce_verifier", `${state}#${verifier}`);
}
export function getCodeVerifier(state) {
const verifierStore = localStorage.getItem("pkce_verifier");
const [storedState, verifier] = verifierStore ? verifierStore.split("#") : [null, null];
if (storedState !== state) {
return null;
}
return verifier;
}
export function clearCodeVerifier(state) {
localStorage.removeItem("pkce_verifier");
}
const authInfo = {
Google: {
scope: "profile+email",
@@ -402,7 +445,11 @@ export function getAuthUrl(application, provider, method, code) {
applicationName = `${application.name}-org-${application.organization}`;
}
const state = Util.getStateFromQueryParams(applicationName, provider.name, method, isShortState);
const codeChallenge = "P3S-a7dr8bgM4bF6vOyiKkKETDl16rcAzao9F8UIL1Y"; // SHA256(Base64-URL-encode("casdoor-verifier"))
// Generate PKCE code verifier and challenge dynamically
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
storeCodeVerifier(state, codeVerifier);
if (provider.type === "AzureAD") {
if (provider.domain !== "") {

View File

@@ -100,7 +100,7 @@
"Order - Tooltip": "Je kleiner der Wert, desto höher rangiert er auf der Apps-Seite",
"Org choice mode": "Organisationsauswahlmodus",
"Org choice mode - Tooltip": "Organisationsauswahlmodus",
"Please enable \\\"Signin session\\\" first before enabling \\\"Auto signin\\\"": "Bitte aktivieren Sie zuerst \\\"Anmeldesitzung\\\", bevor Sie \\\"Automatische Anmeldung\\\" aktivieren.",
"Please enable \"Signin session\" first before enabling \"Auto signin\"": "Bitte aktivieren Sie zuerst \"Anmeldesitzung\", bevor Sie \"Automatische Anmeldung\" aktivieren.",
"Please input your application!": "Bitte geben Sie Ihre Anwendung ein!",
"Please input your organization!": "Bitte geben Sie Ihre Organisation ein!",
"Please select a HTML file": "Bitte wählen Sie eine HTML-Datei aus",
@@ -1269,6 +1269,17 @@
"Address": "Adresse",
"Address - Tooltip": "Wohnadresse",
"Address line": "Adresszeile",
"Addresses": "Adressen",
"Addresses - Tooltip": "Mehrere Adressen des Benutzers",
"Tag": "Tag",
"Line 1": "Zeile 1",
"Line 2": "Zeile 2",
"City": "Stadt",
"State": "Bundesland",
"Zip code": "Postleitzahl",
"Home": "Zuhause",
"Work": "Arbeit",
"Other": "Andere",
"Affiliation": "Zugehörigkeit",
"Affiliation - Tooltip": "Arbeitgeber, wie Firmenname oder Organisationsname",
"Balance": "Guthaben",

View File

@@ -100,7 +100,7 @@
"Order - Tooltip": "The smaller the value, the higher it ranks in the Apps page",
"Org choice mode": "Org choice mode",
"Org choice mode - Tooltip": "Method used to select the organization to log in",
"Please enable \\\"Signin session\\\" first before enabling \\\"Auto signin\\\"": "Please enable \\\"Signin session\\\" first before enabling \\\"Auto signin\\\"",
"Please enable \"Signin session\" first before enabling \"Auto signin\"": "Please enable \"Signin session\" first before enabling \"Auto signin\"",
"Please input your application!": "Please input your application!",
"Please input your organization!": "Please input your organization!",
"Please select a HTML file": "Please select a HTML file",
@@ -956,6 +956,9 @@
"Copy": "Copy",
"Corp ID": "Corp ID",
"Corp Secret": "Corp Secret",
"SCIM Server URL": "SCIM Server URL",
"Username (optional)": "Username (optional)",
"API Token / Password": "API Token / Password",
"DB test": "DB test",
"DB test - Tooltip": "DB test - Tooltip",
"Disable SSL": "Disable SSL",
@@ -1301,6 +1304,17 @@
"Address": "Address",
"Address - Tooltip": "Residential address",
"Address line": "Address line",
"Addresses": "Addresses",
"Addresses - Tooltip": "Multiple addresses of the user",
"Tag": "Tag",
"Line 1": "Line 1",
"Line 2": "Line 2",
"City": "City",
"State": "State",
"Zip code": "Zip code",
"Home": "Home",
"Work": "Work",
"Other": "Other",
"Affiliation": "Affiliation",
"Affiliation - Tooltip": "Employer, such as company name or organization name",
"Balance": "Balance",

View File

@@ -100,7 +100,7 @@
"Order - Tooltip": "Cuanto menor sea el valor, más alto se clasificará en la página de Aplicaciones",
"Org choice mode": "Modo de selección de organización",
"Org choice mode - Tooltip": "Modo de elección de organización",
"Please enable \\\"Signin session\\\" first before enabling \\\"Auto signin\\\"": "Por favor, habilita \\\"Sesión de inicio de sesión\\\" primero antes de habilitar \\\"Inicio de sesión automático\\\"",
"Please enable \"Signin session\" first before enabling \"Auto signin\"": "Por favor, habilita \"Sesión de inicio de sesión\" primero antes de habilitar \"Inicio de sesión automático\"",
"Please input your application!": "¡Por favor, ingrese su solicitud!",
"Please input your organization!": "¡Por favor, ingrese su organización!",
"Please select a HTML file": "Por favor, seleccione un archivo HTML",
@@ -1269,6 +1269,17 @@
"Address": "Dirección",
"Address - Tooltip": "Dirección residencial",
"Address line": "Línea de dirección",
"Addresses": "Direcciones",
"Addresses - Tooltip": "Múltiples direcciones del usuario",
"Tag": "Etiqueta",
"Line 1": "Línea 1",
"Line 2": "Línea 2",
"City": "Ciudad",
"State": "Estado",
"Zip code": "Código postal",
"Home": "Casa",
"Work": "Trabajo",
"Other": "Otro",
"Affiliation": "Afiliación",
"Affiliation - Tooltip": "Empleador, como el nombre de una empresa u organización",
"Balance": "Saldo",

View File

@@ -100,7 +100,7 @@
"Order - Tooltip": "Plus la valeur est petite, plus elle est classée haut dans la page Applications",
"Org choice mode": "Mode de choix d'organisation",
"Org choice mode - Tooltip": "Mode de choix d'organisation",
"Please enable \\\"Signin session\\\" first before enabling \\\"Auto signin\\\"": "Veuillez activer \\\"Session de connexion\\\" avant d'activer \\\"Connexion automatique\\\"",
"Please enable \"Signin session\" first before enabling \"Auto signin\"": "Veuillez activer \"Session de connexion\" avant d'activer \"Connexion automatique\"",
"Please input your application!": "Veuillez saisir votre application !",
"Please input your organization!": "Veuillez saisir votre organisation !",
"Please select a HTML file": "Veuillez sélectionner un fichier HTML",
@@ -1269,6 +1269,17 @@
"Address": "Adresse",
"Address - Tooltip": "Adresse résidentielle",
"Address line": "Adresse",
"Addresses": "Adresses",
"Addresses - Tooltip": "Plusieurs adresses de l'utilisateur",
"Tag": "Étiquette",
"Line 1": "Ligne 1",
"Line 2": "Ligne 2",
"City": "Ville",
"State": "État",
"Zip code": "Code postal",
"Home": "Domicile",
"Work": "Travail",
"Other": "Autre",
"Affiliation": "Affiliation",
"Affiliation - Tooltip": "Employeur, tel que le nom de l'entreprise ou de l'organisation",
"Balance": "Solde",

View File

@@ -100,7 +100,7 @@
"Order - Tooltip": "値が小さいほど、アプリページで上位にランク付けされます",
"Org choice mode": "組織選択モード",
"Org choice mode - Tooltip": "組織選択モード",
"Please enable \\\"Signin session\\\" first before enabling \\\"Auto signin\\\"": "\\\"自動サインイン\\\"を有効にする前に、まず\\\"サインインセッション\\\"を有効にしてください",
"Please enable \"Signin session\" first before enabling \"Auto signin\"": "\"自動サインイン\"を有効にする前に、まず\"サインインセッション\"を有効にしてください",
"Please input your application!": "あなたの申請を入力してください!",
"Please input your organization!": "あなたの組織を入力してください!",
"Please select a HTML file": "HTMLファイルを選択してください",
@@ -1269,6 +1269,17 @@
"Address": "住所",
"Address - Tooltip": "住所",
"Address line": "住所",
"Addresses": "住所一覧",
"Addresses - Tooltip": "ユーザーの複数の住所",
"Tag": "タグ",
"Line 1": "住所1行目",
"Line 2": "住所2行目",
"City": "都市",
"State": "都道府県",
"Zip code": "郵便番号",
"Home": "自宅",
"Work": "職場",
"Other": "その他",
"Affiliation": "所属",
"Affiliation - Tooltip": "企業名や団体名などの雇用主",
"Balance": "残高",

View File

@@ -100,7 +100,7 @@
"Order - Tooltip": "Im mniejsza wartość, tym wyżej jest rangowana na stronie Aplikacji",
"Org choice mode": "Tryb wyboru organizacji",
"Org choice mode - Tooltip": "Tryb wyboru organizacji - Tooltip",
"Please enable \\\"Signin session\\\" first before enabling \\\"Auto signin\\\"": "Najpierw włącz \\\"sesję logowania\\\", zanim włączysz \\\"automatyczne logowanie\\\"",
"Please enable \"Signin session\" first before enabling \"Auto signin\"": "Najpierw włącz \"sesję logowania\", zanim włączysz \"automatyczne logowanie\"",
"Please input your application!": "Proszę wprowadzić swoją aplikację!",
"Please input your organization!": "Proszę wprowadzić swoją organizację!",
"Please select a HTML file": "Proszę wybrać plik HTML",
@@ -1269,6 +1269,17 @@
"Address": "Adres",
"Address - Tooltip": "Adres zamieszkania",
"Address line": "Linia adresu",
"Addresses": "Adresy",
"Addresses - Tooltip": "Wiele adresów użytkownika",
"Tag": "Tag",
"Line 1": "Linia 1",
"Line 2": "Linia 2",
"City": "Miasto",
"State": "Stan",
"Zip code": "Kod pocztowy",
"Home": "Dom",
"Work": "Praca",
"Other": "Inne",
"Affiliation": "Przynależność",
"Affiliation - Tooltip": "Pracodawca, np. nazwa firmy lub organizacji",
"Balance": "Saldo",

View File

@@ -100,7 +100,7 @@
"Order - Tooltip": "Quanto menor o valor, maior a classificação na página de Aplicativos",
"Org choice mode": "Modo de escolha da organização",
"Org choice mode - Tooltip": "Modo de escolha de organização",
"Please enable \\\"Signin session\\\" first before enabling \\\"Auto signin\\\"": "Por favor, habilite a \\\"Sessão de login\\\" primeiro antes de habilitar o \\\"Login automático\\\"",
"Please enable \"Signin session\" first before enabling \"Auto signin\"": "Por favor, habilite a \"Sessão de login\" primeiro antes de habilitar o \"Login automático\"",
"Please input your application!": "Por favor, insira o nome da sua aplicação!",
"Please input your organization!": "Por favor, insira o nome da sua organização!",
"Please select a HTML file": "Por favor, selecione um arquivo HTML",
@@ -1269,6 +1269,17 @@
"Address": "Endereço",
"Address - Tooltip": "Endereço residencial",
"Address line": "Linha de endereço",
"Addresses": "Endereços",
"Addresses - Tooltip": "Múltiplos endereços do usuário",
"Tag": "Etiqueta",
"Line 1": "Linha 1",
"Line 2": "Linha 2",
"City": "Cidade",
"State": "Estado",
"Zip code": "Código postal",
"Home": "Casa",
"Work": "Trabalho",
"Other": "Outro",
"Affiliation": "Afiliação",
"Affiliation - Tooltip": "Empregador, como nome da empresa ou organização",
"Balance": "Saldo",

View File

@@ -100,7 +100,7 @@
"Order - Tooltip": "Değer ne kadar küçükse, Uygulamalar sayfasında o kadar yüksek sıralanır",
"Org choice mode": "Organizasyon seçim modu",
"Org choice mode - Tooltip": "Organizasyon seçim modu",
"Please enable \\\"Signin session\\\" first before enabling \\\"Auto signin\\\"": "Lütfen \\\"Oturum açma oturumu\\\"nu etkinleştirmeden önce \\\"Otomatik oturum açma\\\"yı etkinleştirin",
"Please enable \"Signin session\" first before enabling \"Auto signin\"": "Lütfen \"Oturum açma oturumu\"nu etkinleştirmeden önce \"Otomatik oturum açma\"yı etkinleştirin",
"Please input your application!": "Lütfen uygulamanızı girin!",
"Please input your organization!": "Lütfen organizasyonunuzu girin!",
"Please select a HTML file": "Lütfen bir HTML dosyası seçin",
@@ -1269,6 +1269,17 @@
"Address": "Adres",
"Address - Tooltip": "Ev adresi",
"Address line": "Adres satırı",
"Addresses": "Adresler",
"Addresses - Tooltip": "Kullanıcının birden fazla adresi",
"Tag": "Etiket",
"Line 1": "Satır 1",
"Line 2": "Satır 2",
"City": "Şehir",
"State": "Eyalet",
"Zip code": "Posta kodu",
"Home": "Ev",
"Work": "İş",
"Other": "Diğer",
"Affiliation": "İlişki",
"Affiliation - Tooltip": "İşveren, örneğin şirket adı veya organizasyon adı",
"Balance": "Bakiye",

View File

@@ -100,7 +100,7 @@
"Order - Tooltip": "Чим менше значення, тим вище воно ранжується на сторінці програм",
"Org choice mode": "Режим вибору організації",
"Org choice mode - Tooltip": "Режим вибору організації підказка",
"Please enable \\\"Signin session\\\" first before enabling \\\"Auto signin\\\"": "Спочатку увімкніть \\\"Сесію входу\\\", перш ніж увімкнути \\\"Автоматичний вхід\\\"",
"Please enable \"Signin session\" first before enabling \"Auto signin\"": "Спочатку увімкніть \"Сесію входу\", перш ніж увімкнути \"Автоматичний вхід\"",
"Please input your application!": "Будь ласка, введіть свою заявку!",
"Please input your organization!": "Будь ласка, введіть вашу організацію!",
"Please select a HTML file": "Виберіть файл HTML",
@@ -1269,6 +1269,17 @@
"Address": "Адреса",
"Address - Tooltip": "Адреса місця проживання",
"Address line": "Адреса (рядок)",
"Addresses": "Адреси",
"Addresses - Tooltip": "Декілька адрес користувача",
"Tag": "Тег",
"Line 1": "Рядок 1",
"Line 2": "Рядок 2",
"City": "Місто",
"State": "Область",
"Zip code": "Поштовий індекс",
"Home": "Дім",
"Work": "Робота",
"Other": "Інше",
"Affiliation": "Приналежність",
"Affiliation - Tooltip": "Роботодавець, наприклад назва компанії чи організації",
"Balance": "Баланс",

View File

@@ -100,7 +100,7 @@
"Order - Tooltip": "Giá trị càng nhỏ, xếp hạng càng cao trong trang Ứng dụng",
"Org choice mode": "Chế độ chọn tổ chức",
"Org choice mode - Tooltip": "Chế độ chọn tổ chức",
"Please enable \\\"Signin session\\\" first before enabling \\\"Auto signin\\\"": "Vui lòng kích hoạt \\\"Phiên đăng nhập\\\" trước khi kích hoạt \\\"Đăng nhập tự động\\\"",
"Please enable \"Signin session\" first before enabling \"Auto signin\"": "Vui lòng kích hoạt \"Phiên đăng nhập\" trước khi kích hoạt \"Đăng nhập tự động\"",
"Please input your application!": "Vui lòng nhập ứng dụng của bạn!",
"Please input your organization!": "Vui lòng nhập tổ chức của bạn!",
"Please select a HTML file": "Vui lòng chọn tệp HTML",
@@ -1269,6 +1269,17 @@
"Address": "Địa chỉ",
"Address - Tooltip": "Địa chỉ cư trú",
"Address line": "Dòng địa chỉ",
"Addresses": "Các địa chỉ",
"Addresses - Tooltip": "Nhiều địa chỉ của người dùng",
"Tag": "Thẻ",
"Line 1": "Dòng 1",
"Line 2": "Dòng 2",
"City": "Thành phố",
"State": "Tiểu bang",
"Zip code": "Mã bưu điện",
"Home": "Nhà",
"Work": "Làm việc",
"Other": "Khác",
"Affiliation": "Liên kết",
"Affiliation - Tooltip": "Nhà tuyển dụng, chẳng hạn như tên công ty hoặc tổ chức",
"Balance": "Số dư",

View File

@@ -100,7 +100,7 @@
"Order - Tooltip": "数值越小,在应用列表页面中排序越靠前",
"Org choice mode": "组织选择模式",
"Org choice mode - Tooltip": "采用什么方式选择要登录的组织",
"Please enable \\\"Signin session\\\" first before enabling \\\"Auto signin\\\"": "开启 \\\"保持登录会话\\\" 后才能开启 \\\"自动登录\\\"",
"Please enable \"Signin session\" first before enabling \"Auto signin\"": "开启 \"保持登录会话\" 后才能开启 \"自动登录\"",
"Please input your application!": "请输入你的应用",
"Please input your organization!": "请输入你的组织",
"Please select a HTML file": "请选择一个HTML文件",
@@ -941,6 +941,9 @@
"Copy": "复制",
"Corp ID": "企业 ID",
"Corp Secret": "企业密钥",
"SCIM Server URL": "SCIM 服务器 URL",
"Username (optional)": "用户名(可选)",
"API Token / Password": "API 令牌 / 密码",
"DB test": "DB test",
"DB test - Tooltip": "DB test - Tooltip",
"Disable SSL": "禁用SSL",
@@ -1284,6 +1287,17 @@
"Address": "地址",
"Address - Tooltip": "居住地址",
"Address line": "地址",
"Addresses": "地址列表",
"Addresses - Tooltip": "用户的多个地址",
"Tag": "标签",
"Line 1": "地址行 1",
"Line 2": "地址行 2",
"City": "城市",
"State": "州/省",
"Zip code": "邮政编码",
"Home": "家庭",
"Work": "工作",
"Other": "其他",
"Affiliation": "工作单位",
"Affiliation - Tooltip": "工作单位,如公司、组织名称",
"Balance": "余额",

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",

View File

@@ -0,0 +1,232 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {DeleteOutlined, DownOutlined, UpOutlined} from "@ant-design/icons";
import {Button, Col, Input, Row, Select, Table, Tooltip} from "antd";
import * as Setting from "../Setting";
import i18next from "i18next";
import RegionSelect from "../common/select/RegionSelect";
const {Option} = Select;
class AddressTable extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
addresses: this.props.table !== null ? this.props.table.map((item, index) => {
item.key = index;
return item;
}) : [],
};
}
count = this.props.table?.length ?? 0;
updateTable(table) {
this.setState({
addresses: table,
});
this.props.onUpdateTable([...table].map((item) => {
const newItem = Setting.deepCopy(item);
delete newItem.key;
return newItem;
}));
}
updateField(table, index, key, value) {
table[index][key] = value;
this.updateTable(table);
}
addRow(table) {
const row = {key: this.count, tag: "", line1: "", line2: "", city: "", state: "", zipCode: "", region: ""};
if (table === undefined || table === null) {
table = [];
}
this.count += 1;
table = Setting.addRow(table, row);
this.updateTable(table);
}
deleteRow(table, i) {
table = Setting.deleteRow(table, i);
this.updateTable(table);
}
upRow(table, i) {
table = Setting.swapRow(table, i - 1, i);
this.updateTable(table);
}
downRow(table, i) {
table = Setting.swapRow(table, i, i + 1);
this.updateTable(table);
}
renderTable(table) {
const columns = [
{
title: i18next.t("user:Tag"),
dataIndex: "tag",
key: "tag",
width: "100px",
render: (text, record, index) => {
return (
<Select virtual={false} style={{width: "100%"}}
value={text}
onChange={value => {
this.updateField(table, index, "tag", value);
}} >
<Option value="Home">{i18next.t("user:Home")}</Option>
<Option value="Work">{i18next.t("user:Work")}</Option>
<Option value="Other">{i18next.t("user:Other")}</Option>
</Select>
);
},
},
{
title: i18next.t("user:Line 1"),
dataIndex: "line1",
key: "line1",
width: "150px",
render: (text, record, index) => {
return (
<Input value={text} onChange={e => {
this.updateField(table, index, "line1", e.target.value);
}} />
);
},
},
{
title: i18next.t("user:Line 2"),
dataIndex: "line2",
key: "line2",
width: "150px",
render: (text, record, index) => {
return (
<Input value={text} onChange={e => {
this.updateField(table, index, "line2", e.target.value);
}} />
);
},
},
{
title: i18next.t("user:City"),
dataIndex: "city",
key: "city",
width: "120px",
render: (text, record, index) => {
return (
<Input value={text} onChange={e => {
this.updateField(table, index, "city", e.target.value);
}} />
);
},
},
{
title: i18next.t("user:State"),
dataIndex: "state",
key: "state",
width: "100px",
render: (text, record, index) => {
return (
<Input value={text} onChange={e => {
this.updateField(table, index, "state", e.target.value);
}} />
);
},
},
{
title: i18next.t("user:Zip code"),
dataIndex: "zipCode",
key: "zipCode",
width: "100px",
render: (text, record, index) => {
return (
<Input value={text} onChange={e => {
this.updateField(table, index, "zipCode", e.target.value);
}} />
);
},
},
{
title: i18next.t("user:Region"),
dataIndex: "region",
key: "region",
width: "150px",
render: (text, record, index) => {
return (
<RegionSelect
value={text}
onChange={value => {
this.updateField(table, index, "region", value);
}}
/>
);
},
},
{
title: i18next.t("general:Action"),
key: "action",
width: "100px",
render: (text, record, index) => {
return (
<div>
<Tooltip placement="bottomLeft" title={i18next.t("general:Up")}>
<Button style={{marginRight: "5px"}} disabled={index === 0} icon={<UpOutlined />} size="small" onClick={() => this.upRow(table, index)} />
</Tooltip>
<Tooltip placement="topLeft" title={i18next.t("general:Down")}>
<Button style={{marginRight: "5px"}} disabled={index === table.length - 1} icon={<DownOutlined />} size="small" onClick={() => this.downRow(table, index)} />
</Tooltip>
<Tooltip placement="topLeft" title={i18next.t("general:Delete")}>
<Button icon={<DeleteOutlined />} size="small" onClick={() => this.deleteRow(table, index)} />
</Tooltip>
</div>
);
},
},
];
return (
<Table scroll={{x: "max-content"}} rowKey="key" columns={columns} dataSource={table} size="middle" bordered pagination={false}
title={() => (
<div>
{this.props.title}&nbsp;&nbsp;&nbsp;&nbsp;
<Button style={{marginRight: "5px"}} type="primary" size="small" onClick={() => this.addRow(table)}>{i18next.t("general:Add")}</Button>
</div>
)}
/>
);
}
render() {
return (
<div>
<Row style={{marginTop: "20px"}} >
<Col span={24}>
{
this.renderTable(this.state.addresses)
}
</Col>
</Row>
</div>
);
}
}
export default AddressTable;

View File

@@ -275,22 +275,12 @@ export function getTransactionTableColumns(options = {}) {
title: i18next.t("transaction:Amount"),
dataIndex: "amount",
key: "amount",
width: "120px",
width: "180px",
sorter: getSorter("amount"),
...(getColumnSearchProps ? getColumnSearchProps("amount") : {}),
fixed: (Setting.isMobile()) ? "false" : "right",
});
columns.push({
title: i18next.t("payment:Currency"),
dataIndex: "currency",
key: "currency",
width: "120px",
sorter: getSorter("currency"),
...(getColumnSearchProps ? getColumnSearchProps("currency") : {}),
fixed: (Setting.isMobile()) ? "false" : "right",
render: (text, record, index) => {
return Setting.getCurrencyWithFlag(text);
return Setting.getPriceDisplay(record.amount, record.currency);
},
});