Compare commits

..

12 Commits

Author SHA1 Message Date
DacongDA
c181006661 feat: cache theme in signup page (#3568) 2025-02-09 15:12:35 +08:00
Cutsin
2e83e49492 feat: fix bug due to null characters in descriptor when creating a payment intent (#3567) 2025-02-08 19:35:51 +08:00
Coki
5661942175 feat: add CLI version cache and proxy support (#3565)
* feat: add CLI version cache mechanism

* feat: add /api/refresh-engines to allowed endpoints in demo mode

* feat: add proxy support for cli downloader

* feat: add SafeGoroutine for CLIDownloader initialization

* refactor: optimize code structure
2025-02-08 19:34:19 +08:00
Coki
7f9f7c6468 feat: add CLI tools auto-downloader and updater (#3559)
* feat: add CLI downloader feature

* feat: add CLI refresh endpoint and scheduler

* feat: improve binary names mapping for different platforms and architectures

* fix: format binary names in getBinaryNames function

* fix: change file permission notation to octal in cli_downloader.go

* feat: add isDemoMode check for CLI downloader features
2025-02-07 19:22:56 +08:00
Cutsin
b7a818e2d3 feat: support AirWallex payment provider (#3558)
* feat: support AirWallex payment provider

* chore: add some information due to AirWallex's risk control policy
2025-02-07 19:19:30 +08:00
DacongDA
1a8cfe4ee6 feat: can fetch SAML metadata from URL (#3560) 2025-02-06 23:50:39 +08:00
hsluoyz
b3526de675 feat: add checkOrgMasterVerificationCode() 2025-02-06 23:46:22 +08:00
Mayank
3b9e08b70d feat: Fix reset password flow for shared application (#3556) 2025-02-06 18:03:23 +08:00
IZUMI-Zu
cfc6015aca feat: rename Casdoor app URL to authenticator (#3553) 2025-02-05 23:08:06 +08:00
hsluoyz
1600a6799a feat: return error for updateUsedByCode() 2025-02-05 13:40:41 +08:00
DacongDA
ca60cc3a33 feat: show SAML cert parse error better in frontend (#3551) 2025-02-05 10:06:02 +08:00
DacongDA
df295717f0 feat: can define what Casdoor pages an org admin can see via Organization.NavItems (#3539)
* feat: support define what Casdoor pages an org admin can see

* feat: remove useless code

* fix: fix NavItemNodes i18next invalid

* fix: only global admin can edit navItems

* fix: move navItem tree to extra file
2025-02-03 00:40:21 +08:00
52 changed files with 1228 additions and 37 deletions

View File

@@ -99,6 +99,7 @@ p, *, *, GET, /api/get-all-objects, *, *
p, *, *, GET, /api/get-all-actions, *, *
p, *, *, GET, /api/get-all-roles, *, *
p, *, *, GET, /api/run-casbin-command, *, *
p, *, *, POST, /api/refresh-engines, *, *
p, *, *, GET, /api/get-invitation-info, *, *
p, *, *, GET, /api/faceid-signin-begin, *, *
`
@@ -156,7 +157,7 @@ func IsAllowed(subOwner string, subName string, method string, urlPath string, o
func isAllowedInDemoMode(subOwner string, subName string, method string, urlPath string, objOwner string, objName string) bool {
if method == "POST" {
if strings.HasPrefix(urlPath, "/api/login") || urlPath == "/api/logout" || urlPath == "/api/signup" || urlPath == "/api/callback" || urlPath == "/api/send-verification-code" || urlPath == "/api/send-email" || urlPath == "/api/verify-captcha" || urlPath == "/api/verify-code" || urlPath == "/api/check-user-password" || strings.HasPrefix(urlPath, "/api/mfa/") || urlPath == "/api/webhook" || urlPath == "/api/get-qrcode" {
if strings.HasPrefix(urlPath, "/api/login") || urlPath == "/api/logout" || urlPath == "/api/signup" || urlPath == "/api/callback" || urlPath == "/api/send-verification-code" || urlPath == "/api/send-email" || urlPath == "/api/verify-captcha" || urlPath == "/api/verify-code" || urlPath == "/api/check-user-password" || strings.HasPrefix(urlPath, "/api/mfa/") || urlPath == "/api/webhook" || urlPath == "/api/get-qrcode" || urlPath == "/api/refresh-engines" {
return true
} else if urlPath == "/api/update-user" {
// Allow ordinary users to update their own information

View File

@@ -910,11 +910,20 @@ func (c *ApiController) Login() {
return
}
err = mfaUtil.Verify(authForm.Passcode)
passed, err := c.checkOrgMasterVerificationCode(user, authForm.Passcode)
if err != nil {
c.ResponseError(err.Error())
return
}
if !passed {
err = mfaUtil.Verify(authForm.Passcode)
if err != nil {
c.ResponseError(err.Error())
return
}
}
c.SetSession("verificationCodeType", "")
} else if authForm.RecoveryCode != "" {
err = object.MfaRecover(user, authForm.RecoveryCode)

View File

@@ -23,9 +23,68 @@ import (
"os/exec"
"sort"
"strings"
"sync"
"time"
)
type CLIVersionInfo struct {
Version string
BinaryPath string
BinaryTime time.Time
}
var (
cliVersionCache = make(map[string]*CLIVersionInfo)
cliVersionMutex sync.RWMutex
)
// getCLIVersion
// @Title getCLIVersion
// @Description Get CLI version with cache mechanism
// @Param language string The language of CLI (go/java/rust etc.)
// @Return string The version string of CLI
// @Return error Error if CLI execution fails
func getCLIVersion(language string) (string, error) {
binaryName := fmt.Sprintf("casbin-%s-cli", language)
binaryPath, err := exec.LookPath(binaryName)
if err != nil {
return "", fmt.Errorf("executable file not found: %v", err)
}
fileInfo, err := os.Stat(binaryPath)
if err != nil {
return "", fmt.Errorf("failed to get binary info: %v", err)
}
cliVersionMutex.RLock()
if info, exists := cliVersionCache[language]; exists {
if info.BinaryPath == binaryPath && info.BinaryTime == fileInfo.ModTime() {
cliVersionMutex.RUnlock()
return info.Version, nil
}
}
cliVersionMutex.RUnlock()
cmd := exec.Command(binaryName, "--version")
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("failed to get CLI version: %v", err)
}
version := strings.TrimSpace(string(output))
cliVersionMutex.Lock()
cliVersionCache[language] = &CLIVersionInfo{
Version: version,
BinaryPath: binaryPath,
BinaryTime: fileInfo.ModTime(),
}
cliVersionMutex.Unlock()
return version, nil
}
func processArgsToTempFiles(args []string) ([]string, []string, error) {
tempFiles := []string{}
newArgs := []string{}
@@ -93,6 +152,16 @@ func (c *ApiController) RunCasbinCommand() {
return
}
if len(args) > 0 && args[0] == "--version" {
version, err := getCLIVersion(language)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(version)
return
}
tempFiles, processedArgs, err := processArgsToTempFiles(args)
defer func() {
for _, file := range tempFiles {

View File

@@ -0,0 +1,519 @@
package controllers
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/beego/beego"
"github.com/casdoor/casdoor/proxy"
"github.com/casdoor/casdoor/util"
)
const (
javaCliRepo = "https://api.github.com/repos/jcasbin/casbin-java-cli/releases/latest"
goCliRepo = "https://api.github.com/repos/casbin/casbin-go-cli/releases/latest"
rustCliRepo = "https://api.github.com/repos/casbin-rs/casbin-rust-cli/releases/latest"
downloadFolder = "bin"
)
type ReleaseInfo struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
} `json:"assets"`
}
// @Title getBinaryNames
// @Description Get binary names for different platforms and architectures
// @Success 200 {map[string]string} map[string]string "Binary names map"
func getBinaryNames() map[string]string {
const (
golang = "go"
java = "java"
rust = "rust"
)
arch := runtime.GOARCH
archMap := map[string]struct{ goArch, rustArch string }{
"amd64": {"x86_64", "x86_64"},
"arm64": {"arm64", "aarch64"},
}
archNames, ok := archMap[arch]
if !ok {
archNames = struct{ goArch, rustArch string }{arch, arch}
}
switch runtime.GOOS {
case "windows":
return map[string]string{
golang: fmt.Sprintf("casbin-go-cli_Windows_%s.zip", archNames.goArch),
java: "casbin-java-cli.jar",
rust: fmt.Sprintf("casbin-rust-cli-%s-pc-windows-gnu", archNames.rustArch),
}
case "darwin":
return map[string]string{
golang: fmt.Sprintf("casbin-go-cli_Darwin_%s.tar.gz", archNames.goArch),
java: "casbin-java-cli.jar",
rust: fmt.Sprintf("casbin-rust-cli-%s-apple-darwin", archNames.rustArch),
}
case "linux":
return map[string]string{
golang: fmt.Sprintf("casbin-go-cli_Linux_%s.tar.gz", archNames.goArch),
java: "casbin-java-cli.jar",
rust: fmt.Sprintf("casbin-rust-cli-%s-unknown-linux-gnu", archNames.rustArch),
}
default:
return nil
}
}
// @Title getFinalBinaryName
// @Description Get final binary name for specific language
// @Param lang string true "Language type (go/java/rust)"
// @Success 200 {string} string "Final binary name"
func getFinalBinaryName(lang string) string {
switch lang {
case "go":
if runtime.GOOS == "windows" {
return "casbin-go-cli.exe"
}
return "casbin-go-cli"
case "java":
return "casbin-java-cli.jar"
case "rust":
if runtime.GOOS == "windows" {
return "casbin-rust-cli.exe"
}
return "casbin-rust-cli"
default:
return ""
}
}
// @Title getLatestCLIURL
// @Description Get latest CLI download URL from GitHub
// @Param repoURL string true "GitHub repository URL"
// @Param language string true "Language type"
// @Success 200 {string} string "Download URL and version"
func getLatestCLIURL(repoURL string, language string) (string, string, error) {
client := proxy.GetHttpClient(repoURL)
resp, err := client.Get(repoURL)
if err != nil {
return "", "", fmt.Errorf("failed to fetch release info: %v", err)
}
defer resp.Body.Close()
var release ReleaseInfo
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", "", err
}
binaryNames := getBinaryNames()
if binaryNames == nil {
return "", "", fmt.Errorf("unsupported OS: %s", runtime.GOOS)
}
binaryName := binaryNames[language]
for _, asset := range release.Assets {
if asset.Name == binaryName {
return asset.URL, release.TagName, nil
}
}
return "", "", fmt.Errorf("no suitable binary found for OS: %s, language: %s", runtime.GOOS, language)
}
// @Title extractGoCliFile
// @Description Extract the Go CLI file
// @Param filePath string true "The file path"
// @Success 200 {string} string "The extracted file path"
// @router /extractGoCliFile [post]
func extractGoCliFile(filePath string) error {
tempDir := filepath.Join(downloadFolder, "temp")
if err := os.MkdirAll(tempDir, 0o755); err != nil {
return err
}
defer os.RemoveAll(tempDir)
if runtime.GOOS == "windows" {
if err := unzipFile(filePath, tempDir); err != nil {
return err
}
} else {
if err := untarFile(filePath, tempDir); err != nil {
return err
}
}
execName := "casbin-go-cli"
if runtime.GOOS == "windows" {
execName += ".exe"
}
var execPath string
err := filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error {
if info.Name() == execName {
execPath = path
return nil
}
return nil
})
if err != nil {
return err
}
finalPath := filepath.Join(downloadFolder, execName)
if err := os.Rename(execPath, finalPath); err != nil {
return err
}
return os.Remove(filePath)
}
// @Title unzipFile
// @Description Unzip the file
// @Param zipPath string true "The zip file path"
// @Param destDir string true "The destination directory"
// @Success 200 {string} string "The extracted file path"
// @router /unzipFile [post]
func unzipFile(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
fpath := filepath.Join(destDir, f.Name)
if f.FileInfo().IsDir() {
os.MkdirAll(fpath, os.ModePerm)
continue
}
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
return err
}
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
outFile.Close()
return err
}
_, err = io.Copy(outFile, rc)
outFile.Close()
rc.Close()
if err != nil {
return err
}
}
return nil
}
// @Title untarFile
// @Description Untar the file
// @Param tarPath string true "The tar file path"
// @Param destDir string true "The destination directory"
// @Success 200 {string} string "The extracted file path"
// @router /untarFile [post]
func untarFile(tarPath, destDir string) error {
file, err := os.Open(tarPath)
if err != nil {
return err
}
defer file.Close()
gzr, err := gzip.NewReader(file)
if err != nil {
return err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
path := filepath.Join(destDir, header.Name)
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(path, 0o755); err != nil {
return err
}
case tar.TypeReg:
outFile, err := os.Create(path)
if err != nil {
return err
}
if _, err := io.Copy(outFile, tr); err != nil {
outFile.Close()
return err
}
outFile.Close()
}
}
return nil
}
// @Title createJavaCliWrapper
// @Description Create the Java CLI wrapper
// @Param binPath string true "The binary path"
// @Success 200 {string} string "The created file path"
// @router /createJavaCliWrapper [post]
func createJavaCliWrapper(binPath string) error {
if runtime.GOOS == "windows" {
// Create a Windows CMD file
cmdPath := filepath.Join(binPath, "casbin-java-cli.cmd")
cmdContent := fmt.Sprintf(`@echo off
java -jar "%s\casbin-java-cli.jar" %%*`, binPath)
err := os.WriteFile(cmdPath, []byte(cmdContent), 0o755)
if err != nil {
return fmt.Errorf("failed to create Java CLI wrapper: %v", err)
}
} else {
// Create Unix shell script
shPath := filepath.Join(binPath, "casbin-java-cli")
shContent := fmt.Sprintf(`#!/bin/sh
java -jar "%s/casbin-java-cli.jar" "$@"`, binPath)
err := os.WriteFile(shPath, []byte(shContent), 0o755)
if err != nil {
return fmt.Errorf("failed to create Java CLI wrapper: %v", err)
}
}
return nil
}
// @Title downloadCLI
// @Description Download and setup CLI tools
// @Success 200 {error} error "Error if any"
func downloadCLI() error {
pathEnv := os.Getenv("PATH")
binPath, err := filepath.Abs(downloadFolder)
if err != nil {
return fmt.Errorf("failed to get absolute path to download directory: %v", err)
}
if !strings.Contains(pathEnv, binPath) {
newPath := fmt.Sprintf("%s%s%s", binPath, string(os.PathListSeparator), pathEnv)
if err := os.Setenv("PATH", newPath); err != nil {
return fmt.Errorf("failed to update PATH environment variable: %v", err)
}
}
if err := os.MkdirAll(downloadFolder, 0o755); err != nil {
return fmt.Errorf("failed to create download directory: %v", err)
}
repos := map[string]string{
"java": javaCliRepo,
"go": goCliRepo,
"rust": rustCliRepo,
}
for lang, repo := range repos {
cliURL, version, err := getLatestCLIURL(repo, lang)
if err != nil {
fmt.Printf("failed to get %s CLI URL: %v\n", lang, err)
continue
}
originalPath := filepath.Join(downloadFolder, getBinaryNames()[lang])
fmt.Printf("downloading %s CLI: %s\n", lang, cliURL)
client := proxy.GetHttpClient(cliURL)
resp, err := client.Get(cliURL)
if err != nil {
fmt.Printf("failed to download %s CLI: %v\n", lang, err)
continue
}
func() {
defer resp.Body.Close()
if err := os.MkdirAll(filepath.Dir(originalPath), 0o755); err != nil {
fmt.Printf("failed to create directory for %s CLI: %v\n", lang, err)
return
}
tmpFile := originalPath + ".tmp"
out, err := os.Create(tmpFile)
if err != nil {
fmt.Printf("failed to create or write %s CLI: %v\n", lang, err)
return
}
defer func() {
out.Close()
os.Remove(tmpFile)
}()
if _, err = io.Copy(out, resp.Body); err != nil ||
out.Close() != nil ||
os.Rename(tmpFile, originalPath) != nil {
fmt.Printf("failed to download %s CLI: %v\n", lang, err)
return
}
}()
if lang == "go" {
if err := extractGoCliFile(originalPath); err != nil {
fmt.Printf("failed to extract Go CLI: %v\n", err)
continue
}
} else {
finalPath := filepath.Join(downloadFolder, getFinalBinaryName(lang))
if err := os.Rename(originalPath, finalPath); err != nil {
fmt.Printf("failed to rename %s CLI: %v\n", lang, err)
continue
}
}
if runtime.GOOS != "windows" {
execPath := filepath.Join(downloadFolder, getFinalBinaryName(lang))
if err := os.Chmod(execPath, 0o755); err != nil {
fmt.Printf("failed to set %s CLI execution permission: %v\n", lang, err)
continue
}
}
fmt.Printf("downloaded %s CLI version: %s\n", lang, version)
if lang == "java" {
if err := createJavaCliWrapper(binPath); err != nil {
fmt.Printf("failed to create Java CLI wrapper: %v\n", err)
continue
}
}
}
return nil
}
// @Title RefreshEngines
// @Tag CLI API
// @Description Refresh all CLI engines
// @Param m query string true "Hash for request validation"
// @Param t query string true "Timestamp for request validation"
// @Success 200 {object} controllers.Response The Response object
// @router /refresh-engines [post]
func (c *ApiController) RefreshEngines() {
if !beego.AppConfig.DefaultBool("isDemoMode", false) {
c.ResponseError("refresh engines is only available in demo mode")
return
}
hash := c.Input().Get("m")
timestamp := c.Input().Get("t")
if hash == "" || timestamp == "" {
c.ResponseError("invalid identifier")
return
}
requestTime, err := time.Parse(time.RFC3339, timestamp)
if err != nil {
c.ResponseError("invalid identifier")
return
}
timeDiff := time.Since(requestTime)
if timeDiff > 5*time.Minute || timeDiff < -5*time.Minute {
c.ResponseError("invalid identifier")
return
}
version := "casbin-editor-v1"
rawString := fmt.Sprintf("%s|%s", version, timestamp)
hasher := sha256.New()
hasher.Write([]byte(rawString))
calculatedHash := strings.ToLower(hex.EncodeToString(hasher.Sum(nil)))
if calculatedHash != strings.ToLower(hash) {
c.ResponseError("invalid identifier")
return
}
err = downloadCLI()
if err != nil {
c.ResponseError(fmt.Sprintf("failed to refresh engines: %v", err))
return
}
c.ResponseOk(map[string]string{
"status": "success",
"message": "CLI engines updated successfully",
})
}
// @Title ScheduleCLIUpdater
// @Description Start periodic CLI update scheduler
func ScheduleCLIUpdater() {
if !beego.AppConfig.DefaultBool("isDemoMode", false) {
return
}
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
err := downloadCLI()
if err != nil {
fmt.Printf("failed to update CLI: %v\n", err)
} else {
fmt.Println("CLI updated successfully")
}
}
}
// @Title DownloadCLI
// @Description Download the CLI
// @Success 200 {string} string "The downloaded file path"
// @router /downloadCLI [post]
func DownloadCLI() error {
return downloadCLI()
}
// @Title InitCLIDownloader
// @Description Initialize CLI downloader and start update scheduler
func InitCLIDownloader() {
if !beego.AppConfig.DefaultBool("isDemoMode", false) {
return
}
util.SafeGoroutine(func() {
err := DownloadCLI()
if err != nil {
fmt.Printf("failed to initialize CLI downloader: %v\n", err)
}
ScheduleCLIUpdater()
})
}

View File

@@ -124,7 +124,9 @@ func (c *ApiController) UpdateOrganization() {
return
}
c.Data["json"] = wrapActionResponse(object.UpdateOrganization(id, &organization))
isGlobalAdmin, _ := c.isGlobalAdmin()
c.Data["json"] = wrapActionResponse(object.UpdateOrganization(id, &organization, isGlobalAdmin))
c.ServeJSON()
}

View File

@@ -510,20 +510,28 @@ func (c *ApiController) VerifyCode() {
}
}
result, err := object.CheckVerificationCode(checkDest, authForm.Code, c.GetAcceptLanguage())
passed, err := c.checkOrgMasterVerificationCode(user, authForm.Code)
if err != nil {
c.ResponseError(c.T(err.Error()))
return
}
if result.Code != object.VerificationSuccess {
c.ResponseError(result.Msg)
return
}
err = object.DisableVerificationCode(checkDest)
if err != nil {
c.ResponseError(err.Error())
return
if !passed {
result, err := object.CheckVerificationCode(checkDest, authForm.Code, c.GetAcceptLanguage())
if err != nil {
c.ResponseError(err.Error())
return
}
if result.Code != object.VerificationSuccess {
c.ResponseError(result.Msg)
return
}
err = object.DisableVerificationCode(checkDest)
if err != nil {
c.ResponseError(err.Error())
return
}
}
c.SetSession("verifiedCode", authForm.Code)

View File

@@ -0,0 +1,36 @@
// Copyright 2025 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"fmt"
"github.com/casdoor/casdoor/object"
)
func (c *ApiController) checkOrgMasterVerificationCode(user *object.User, code string) (bool, error) {
organization, err := object.GetOrganizationByUser(user)
if err != nil {
return false, err
}
if organization == nil {
return false, fmt.Errorf("The organization: %s does not exist", user.Owner)
}
if organization.MasterVerificationCode != "" && organization.MasterVerificationCode == code {
return true, nil
}
return false, nil
}

View File

@@ -22,6 +22,7 @@ import (
_ "github.com/beego/beego/session/redis"
"github.com/casdoor/casdoor/authz"
"github.com/casdoor/casdoor/conf"
"github.com/casdoor/casdoor/controllers"
"github.com/casdoor/casdoor/ldap"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/proxy"
@@ -45,6 +46,7 @@ func main() {
object.InitCasvisorConfig()
util.SafeGoroutine(func() { object.RunSyncUsersJob() })
util.SafeGoroutine(func() { controllers.InitCLIDownloader() })
// beego.DelStaticPath("/static")
// beego.SetStaticPath("/static", "web/build/static")

View File

@@ -79,6 +79,7 @@ type Organization struct {
UseEmailAsUsername bool `json:"useEmailAsUsername"`
EnableTour bool `json:"enableTour"`
IpRestriction string `json:"ipRestriction"`
NavItems []string `xorm:"varchar(500)" json:"navItems"`
MfaItems []*MfaItem `xorm:"varchar(300)" json:"mfaItems"`
AccountItems []*AccountItem `xorm:"varchar(5000)" json:"accountItems"`
@@ -195,9 +196,10 @@ func GetMaskedOrganizations(organizations []*Organization, errs ...error) ([]*Or
return organizations, nil
}
func UpdateOrganization(id string, organization *Organization) (bool, error) {
func UpdateOrganization(id string, organization *Organization, isGlobalAdmin bool) (bool, error) {
owner, name := util.GetOwnerAndNameFromId(id)
if org, err := getOrganization(owner, name); err != nil {
org, err := getOrganization(owner, name)
if err != nil {
return false, err
} else if org == nil {
return false, nil
@@ -222,6 +224,10 @@ func UpdateOrganization(id string, organization *Organization) (bool, error) {
}
}
if !isGlobalAdmin {
organization.NavItems = org.NavItems
}
session := ormer.Engine.ID(core.PK{owner, name}).AllCols()
if organization.MasterPassword == "***" {

View File

@@ -219,8 +219,11 @@ func BuyProduct(id string, user *User, providerName, pricingName, planName, host
ProductName: product.Name,
PayerName: payerName,
PayerId: user.Id,
PayerEmail: user.Email,
PaymentName: paymentName,
ProductDisplayName: product.DisplayName,
ProductDescription: product.Description,
ProductImage: product.Image,
Price: product.Price,
Currency: product.Currency,
ReturnUrl: returnUrl,

View File

@@ -325,6 +325,12 @@ func GetPaymentProvider(p *Provider) (pp.PaymentProvider, error) {
return nil, err
}
return pp, nil
} else if typ == "AirWallex" {
pp, err := pp.NewAirwallexPaymentProvider(p.ClientId, p.ClientSecret)
if err != nil {
return nil, err
}
return pp, nil
} else if typ == "Balance" {
pp, err := pp.NewBalancePaymentProvider()
if err != nil {

View File

@@ -144,13 +144,13 @@ func GetTokenByTokenValue(tokenValue, tokenTypeHint string) (*Token, error) {
return nil, nil
}
func updateUsedByCode(token *Token) bool {
func updateUsedByCode(token *Token) (bool, error) {
affected, err := ormer.Engine.Where("code=?", token.Code).Cols("code_is_used").Update(token)
if err != nil {
panic(err)
return false, err
}
return affected != 0
return affected != 0, nil
}
func GetToken(id string) (*Token, error) {

View File

@@ -248,7 +248,10 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
token.CodeIsUsed = true
go updateUsedByCode(token)
_, err = updateUsedByCode(token)
if err != nil {
return nil, err
}
tokenWrapper := &TokenWrapper{
AccessToken: token.AccessToken,

289
pp/airwallex.go Normal file
View File

@@ -0,0 +1,289 @@
package pp
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/casdoor/casdoor/conf"
)
type AirwallexPaymentProvider struct {
Client *AirwallexClient
}
func NewAirwallexPaymentProvider(clientId string, apiKey string) (*AirwallexPaymentProvider, error) {
isProd := conf.GetConfigString("runmode") == "prod"
apiEndpoint := "https://api-demo.airwallex.com/api/v1"
apiCheckout := "https://checkout-demo.airwallex.com/#/standalone/checkout?"
if isProd {
apiEndpoint = "https://api.airwallex.com/api/v1"
apiCheckout = "https://checkout.airwallex.com/#/standalone/checkout?"
}
client := &AirwallexClient{
ClientId: clientId,
APIKey: apiKey,
APIEndpoint: apiEndpoint,
APICheckout: apiCheckout,
client: &http.Client{Timeout: 15 * time.Second},
}
pp := &AirwallexPaymentProvider{
Client: client,
}
return pp, nil
}
func (pp *AirwallexPaymentProvider) Pay(r *PayReq) (*PayResp, error) {
// Create a payment intent
intent, err := pp.Client.CreateIntent(r)
if err != nil {
return nil, err
}
payUrl, err := pp.Client.GetCheckoutUrl(intent, r)
if err != nil {
return nil, err
}
return &PayResp{
PayUrl: payUrl,
OrderId: intent.MerchantOrderId,
}, nil
}
func (pp *AirwallexPaymentProvider) Notify(body []byte, orderId string) (*NotifyResult, error) {
notifyResult := &NotifyResult{}
intent, err := pp.Client.GetIntentByOrderId(orderId)
if err != nil {
return nil, err
}
// Check intent status
switch intent.Status {
case "PENDING", "REQUIRES_PAYMENT_METHOD", "REQUIRES_CUSTOMER_ACTION", "REQUIRES_CAPTURE":
notifyResult.PaymentStatus = PaymentStateCreated
return notifyResult, nil
case "CANCELLED":
notifyResult.PaymentStatus = PaymentStateCanceled
return notifyResult, nil
case "EXPIRED":
notifyResult.PaymentStatus = PaymentStateTimeout
return notifyResult, nil
case "SUCCEEDED":
// Skip
default:
notifyResult.PaymentStatus = PaymentStateError
notifyResult.NotifyMessage = fmt.Sprintf("unexpected airwallex checkout status: %v", intent.Status)
return notifyResult, nil
}
// Check attempt status
if intent.PaymentStatus != "" {
switch intent.PaymentStatus {
case "CANCELLED", "EXPIRED", "RECEIVED", "AUTHENTICATION_REDIRECTED", "AUTHORIZED", "CAPTURE_REQUESTED":
notifyResult.PaymentStatus = PaymentStateCreated
return notifyResult, nil
case "PAID", "SETTLED":
// Skip
default:
notifyResult.PaymentStatus = PaymentStateError
notifyResult.NotifyMessage = fmt.Sprintf("unexpected airwallex checkout payment status: %v", intent.PaymentStatus)
return notifyResult, nil
}
}
// The Payment has succeeded.
var productDisplayName, productName, providerName string
if description, ok := intent.Metadata["description"]; ok {
productName, productDisplayName, providerName, _ = parseAttachString(description.(string))
}
orderId = intent.MerchantOrderId
return &NotifyResult{
PaymentName: orderId,
PaymentStatus: PaymentStatePaid,
ProductName: productName,
ProductDisplayName: productDisplayName,
ProviderName: providerName,
Price: priceStringToFloat64(intent.Amount.String()),
Currency: intent.Currency,
OrderId: orderId,
}, nil
}
func (pp *AirwallexPaymentProvider) GetInvoice(paymentName, personName, personIdCard, personEmail, personPhone, invoiceType, invoiceTitle, invoiceTaxId string) (string, error) {
return "", nil
}
func (pp *AirwallexPaymentProvider) GetResponseError(err error) string {
if err == nil {
return "success"
}
return "fail"
}
/*
* Airwallex Client implementation (to be removed upon official SDK release)
*/
type AirwallexClient struct {
ClientId string
APIKey string
APIEndpoint string
APICheckout string
client *http.Client
tokenCache *AirWallexTokenInfo
tokenMutex sync.RWMutex
}
type AirWallexTokenInfo struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
parsedExpiresAt time.Time
}
type AirWallexIntentResp struct {
Id string `json:"id"`
ClientSecret string `json:"client_secret"`
MerchantOrderId string `json:"merchant_order_id"`
}
func (c *AirwallexClient) GetToken() (string, error) {
c.tokenMutex.Lock()
defer c.tokenMutex.Unlock()
if c.tokenCache != nil && time.Now().Before(c.tokenCache.parsedExpiresAt) {
return c.tokenCache.Token, nil
}
req, _ := http.NewRequest("POST", c.APIEndpoint+"/authentication/login", bytes.NewBuffer([]byte("{}")))
req.Header.Set("x-client-id", c.ClientId)
req.Header.Set("x-api-key", c.APIKey)
resp, err := c.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result AirWallexTokenInfo
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if result.Token == "" {
return "", fmt.Errorf("invalid token response")
}
expiresAt := strings.Replace(result.ExpiresAt, "+0000", "+00:00", 1)
result.parsedExpiresAt, _ = time.Parse(time.RFC3339, expiresAt)
c.tokenCache = &result
return result.Token, nil
}
func (c *AirwallexClient) authRequest(method, url string, body interface{}) (map[string]interface{}, error) {
token, err := c.GetToken()
if err != nil {
return nil, err
}
b, _ := json.Marshal(body)
req, _ := http.NewRequest(method, url, bytes.NewBuffer(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *AirwallexClient) CreateIntent(r *PayReq) (*AirWallexIntentResp, error) {
description := joinAttachString([]string{r.ProductName, r.ProductDisplayName, r.ProviderName})
orderId := r.PaymentName
intentReq := map[string]interface{}{
"currency": r.Currency,
"amount": r.Price,
"merchant_order_id": orderId,
"request_id": orderId,
"descriptor": strings.ReplaceAll(string([]rune(description)[:32]), "\x00", ""),
"metadata": map[string]interface{}{"description": description},
"order": map[string]interface{}{"products": []map[string]interface{}{{"name": r.ProductDisplayName, "quantity": 1, "desc": r.ProductDescription, "image_url": r.ProductImage}}},
"customer": map[string]interface{}{"merchant_customer_id": r.PayerId, "email": r.PayerEmail, "first_name": r.PayerName, "last_name": r.PayerName},
}
intentUrl := fmt.Sprintf("%s/pa/payment_intents/create", c.APIEndpoint)
intentRes, err := c.authRequest("POST", intentUrl, intentReq)
if err != nil {
return nil, fmt.Errorf("failed to create payment intent: %v", err)
}
return &AirWallexIntentResp{
Id: intentRes["id"].(string),
ClientSecret: intentRes["client_secret"].(string),
MerchantOrderId: intentRes["merchant_order_id"].(string),
}, nil
}
type AirwallexIntent struct {
Amount json.Number `json:"amount"`
Currency string `json:"currency"`
Id string `json:"id"`
Status string `json:"status"`
Descriptor string `json:"descriptor"`
MerchantOrderId string `json:"merchant_order_id"`
LatestPaymentAttempt struct {
Status string `json:"status"`
} `json:"latest_payment_attempt"`
Metadata map[string]interface{} `json:"metadata"`
}
type AirwallexIntents struct {
Items []AirwallexIntent `json:"items"`
}
type AirWallexIntentInfo struct {
Amount json.Number
Currency string
Id string
Status string
Descriptor string
MerchantOrderId string
PaymentStatus string
Metadata map[string]interface{}
}
func (c *AirwallexClient) GetIntentByOrderId(orderId string) (*AirWallexIntentInfo, error) {
intentUrl := fmt.Sprintf("%s/pa/payment_intents/?merchant_order_id=%s", c.APIEndpoint, orderId)
intentRes, err := c.authRequest("GET", intentUrl, nil)
if err != nil {
return nil, fmt.Errorf("failed to get payment intent: %v", err)
}
items := intentRes["items"].([]interface{})
if len(items) == 0 {
return nil, fmt.Errorf("no payment intent found for order id: %s", orderId)
}
var intent AirwallexIntent
if b, err := json.Marshal(items[0]); err == nil {
json.Unmarshal(b, &intent)
}
return &AirWallexIntentInfo{
Id: intent.Id,
Amount: intent.Amount,
Currency: intent.Currency,
Status: intent.Status,
Descriptor: intent.Descriptor,
MerchantOrderId: intent.MerchantOrderId,
PaymentStatus: intent.LatestPaymentAttempt.Status,
Metadata: intent.Metadata,
}, nil
}
func (c *AirwallexClient) GetCheckoutUrl(intent *AirWallexIntentResp, r *PayReq) (string, error) {
return fmt.Sprintf("%sintent_id=%s&client_secret=%s&mode=payment&currency=%s&amount=%v&requiredBillingContactFields=%s&successUrl=%s&failUrl=%s&logoUrl=%s",
c.APICheckout,
intent.Id,
intent.ClientSecret,
r.Currency,
r.Price,
url.QueryEscape(`["address"]`),
r.ReturnUrl,
r.ReturnUrl,
"data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=", // replace default logo
), nil
}

View File

@@ -33,8 +33,11 @@ type PayReq struct {
ProductName string
PayerName string
PayerId string
PayerEmail string
PaymentName string
ProductDisplayName string
ProductDescription string
ProductImage string
Price float64
Currency string

View File

@@ -175,6 +175,7 @@ func initAPI() {
beego.Router("/api/get-all-roles", &controllers.ApiController{}, "GET:GetAllRoles")
beego.Router("/api/run-casbin-command", &controllers.ApiController{}, "GET:RunCasbinCommand")
beego.Router("/api/refresh-engines", &controllers.ApiController{}, "POST:RefreshEngines")
beego.Router("/api/get-sessions", &controllers.ApiController{}, "GET:GetSessions")
beego.Router("/api/get-session", &controllers.ApiController{}, "GET:GetSingleSession")

View File

@@ -47,12 +47,12 @@ func getOrganizationThemeCookieFromUrlPath(ctx *context.Context, urlPath string)
var application *object.Application
var organization *object.Organization
var err error
if urlPath == "/login" {
if urlPath == "/login" || urlPath == "/signup" {
application, err = object.GetDefaultApplication(fmt.Sprintf("admin/built-in"))
if err != nil {
return nil, err
}
} else if strings.HasPrefix(urlPath, "/login/oauth/authorize") {
} else if strings.HasSuffix(urlPath, "/oauth/authorize") {
clientId := ctx.Input.Query("client_id")
if clientId == "" {
return nil, nil
@@ -76,6 +76,15 @@ func getOrganizationThemeCookieFromUrlPath(ctx *context.Context, urlPath string)
if err != nil {
return nil, err
}
} else if strings.HasPrefix(urlPath, "/signup/") {
owner, _ := strings.CutPrefix(urlPath, "/signup/")
if owner == "undefined" || strings.Count(owner, "/") > 0 {
return nil, nil
}
application, err = object.GetDefaultApplication(fmt.Sprintf("admin/%s", owner))
if err != nil {
return nil, err
}
} else if strings.HasPrefix(urlPath, "/cas/") && strings.HasSuffix(urlPath, "/login") {
owner, _ := strings.CutPrefix(urlPath, "/cas/")
owner, _ = strings.CutSuffix(owner, "/login")

View File

@@ -118,6 +118,6 @@ func IsValidOrigin(origin string) (bool, error) {
originHostOnly = fmt.Sprintf("%s://%s", urlObj.Scheme, urlObj.Hostname())
}
res := originHostOnly == "http://localhost" || originHostOnly == "https://localhost" || originHostOnly == "http://127.0.0.1" || originHostOnly == "http://casdoor-app" || strings.HasSuffix(originHostOnly, ".chromiumapp.org")
res := originHostOnly == "http://localhost" || originHostOnly == "https://localhost" || originHostOnly == "http://127.0.0.1" || originHostOnly == "http://casdoor-authenticator" || strings.HasSuffix(originHostOnly, ".chromiumapp.org")
return res, nil
}

View File

@@ -241,7 +241,7 @@ function ManagementPage(props) {
<Link to="/">
<img className="logo" src={logo ?? props.logo} alt="logo" />
</Link>,
disabled: true,
disabled: true, key: "logo",
style: {
padding: 0,
height: "auto",
@@ -323,7 +323,35 @@ function ManagementPage(props) {
}
}
return res;
const navItems = props.account.organization.navItems;
if (!Array.isArray(navItems)) {
return res;
}
if (navItems.includes("all")) {
return res;
}
const resFiltered = res.map(item => {
if (!Array.isArray(item.children)) {
return item;
}
const filteredChildren = [];
item.children.forEach(itemChild => {
if (navItems.includes(itemChild.key)) {
filteredChildren.push(itemChild);
}
});
item.children = filteredChildren;
return item;
});
return resFiltered.filter(item => {
if (item.key === "#" || item.key === "logo") {return true;}
return Array.isArray(item.children) && item.children.length > 0;
});
}
function renderLoginIfNotLoggedIn(component) {

View File

@@ -26,6 +26,7 @@ import LdapTable from "./table/LdapTable";
import AccountTable from "./table/AccountTable";
import ThemeEditor from "./common/theme/ThemeEditor";
import MfaTable from "./table/MfaTable";
import {NavItemTree} from "./common/NavItemTree";
const {Option} = Select;
@@ -522,6 +523,21 @@ class OrganizationEditPage extends React.Component {
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Navbar items"), i18next.t("general:Navbar items - Tooltip"))} :
</Col>
<Col span={22} >
<NavItemTree
disabled={!Setting.isAdminUser(this.props.account)}
checkedKeys={this.state.organization.navItems ?? ["all"]}
defaultExpandedKeys={["all"]}
onCheck={(checked, _) => {
this.updateOrganizationField("navItems", checked);
}}
/>
</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

@@ -122,7 +122,7 @@ class PaymentResultPage extends React.Component {
payment: payment,
});
if (payment.state === "Created") {
if (["PayPal", "Stripe", "Alipay", "WeChat Pay", "Balance"].includes(payment.type)) {
if (["PayPal", "Stripe", "AirWallex", "Alipay", "WeChat Pay", "Balance"].includes(payment.type)) {
this.setState({
timeout: setTimeout(async() => {
await PaymentBackend.notifyPayment(this.state.owner, this.state.paymentName);

View File

@@ -238,6 +238,8 @@ class ProductBuyPage extends React.Component {
text = i18next.t("product:PayPal");
} else if (provider.type === "Stripe") {
text = i18next.t("product:Stripe");
} else if (provider.type === "AirWallex") {
text = i18next.t("product:AirWallex");
}
return (

View File

@@ -467,6 +467,39 @@ class ProviderEditPage extends React.Component {
this.updateProviderField("issuerUrl", issuerUrl);
}
fetchSamlMetadata() {
this.setState({
metadataLoading: true,
});
fetch(this.state.requestUrl, {
method: "GET",
}).then(res => {
if (!res.ok) {
return Promise.reject("error");
}
return res.text();
}).then(text => {
this.updateProviderField("metadata", text);
this.parseSamlMetadata();
Setting.showMessage("success", i18next.t("general:Successfully added"));
}).catch(err => {
Setting.showMessage("error", err.message);
}).finally(() => {
this.setState({
metadataLoading: false,
});
});
}
parseSamlMetadata() {
try {
this.loadSamlConfiguration();
Setting.showMessage("success", i18next.t("provider:Parse metadata successfully"));
} catch (err) {
Setting.showMessage("error", i18next.t("provider:Can not parse metadata"));
}
}
renderProvider() {
return (
<Card size="small" title={
@@ -1242,6 +1275,21 @@ class ProviderEditPage extends React.Component {
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Metadata url"), i18next.t("provider:Metadata url - Tooltip"))} :
</Col>
<Col span={6} >
<Input value={this.state.requestUrl} onChange={e => {
this.setState({
requestUrl: e.target.value,
});
}} />
</Col>
<Col span={16} >
<Button type="primary" loading={this.state.metadataLoading} onClick={() => {this.fetchSamlMetadata();}}>{i18next.t("general:Request")}</Button>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Metadata"), i18next.t("provider:Metadata - Tooltip"))} :
@@ -1255,14 +1303,7 @@ class ProviderEditPage extends React.Component {
<Row style={{marginTop: "20px"}}>
<Col style={{marginTop: "5px"}} span={2} />
<Col span={2}>
<Button type="primary" onClick={() => {
try {
this.loadSamlConfiguration();
Setting.showMessage("success", i18next.t("provider:Parse metadata successfully"));
} catch (err) {
Setting.showMessage("error", i18next.t("provider:Can not parse metadata"));
}
}}>
<Button type="primary" onClick={() => {this.parseSamlMetadata();}}>
{i18next.t("provider:Parse")}
</Button>
</Col>

View File

@@ -279,6 +279,10 @@ export const OtherProviderInfo = {
logo: `${StaticBaseUrl}/img/social_stripe.png`,
url: "https://stripe.com/",
},
"AirWallex": {
logo: `${StaticBaseUrl}/img/payment_airwallex.svg`,
url: "https://airwallex.com/",
},
"GC": {
logo: `${StaticBaseUrl}/img/payment_gc.png`,
url: "https://gc.org",
@@ -1106,6 +1110,7 @@ export function getProviderTypeOptions(category) {
{id: "WeChat Pay", name: "WeChat Pay"},
{id: "PayPal", name: "PayPal"},
{id: "Stripe", name: "Stripe"},
{id: "AirWallex", name: "AirWallex"},
{id: "GC", name: "GC"},
]);
} else if (category === "Captcha") {
@@ -1398,7 +1403,13 @@ export function getTag(color, text, icon) {
}
export function getApplicationName(application) {
return `${application?.owner}/${application?.name}`;
let name = `${application?.owner}/${application?.name}`;
if (application?.isShared && application?.organization) {
name += `-org-${application.organization}`;
}
return name;
}
export function getApplicationDisplayName(application) {

View File

@@ -44,6 +44,7 @@ import KwaiLoginButton from "./KwaiLoginButton";
import LoginButton from "./LoginButton";
import * as AuthBackend from "./AuthBackend";
import {WechatOfficialAccountModal} from "./Util";
import * as Setting from "../Setting";
function getSigninButton(provider) {
const text = i18next.t("login:Sign in with {type}").replace("{type}", provider.displayName !== "" ? provider.displayName : provider.type);
@@ -114,10 +115,14 @@ function goToSamlUrl(provider, location) {
const relayState = `${clientId}&${state}&${providerName}&${realRedirectUri}&${redirectUri}`;
AuthBackend.getSamlLogin(`${provider.owner}/${providerName}`, btoa(relayState)).then((res) => {
if (res.data2 === "POST") {
document.write(res.data);
if (res.status === "ok") {
if (res.data2 === "POST") {
document.write(res.data);
} else {
window.location.href = res.data;
}
} else {
window.location.href = res.data;
Setting.showMessage("error", res.msg);
}
});
}

View File

@@ -27,7 +27,7 @@ export const generateCasdoorAppUrl = (accessToken, forQrCode = true) => {
return {qrUrl, error};
}
qrUrl = `casdoor-app://login?serverUrl=${window.location.origin}&accessToken=${accessToken}`;
qrUrl = `casdoor-authenticator://login?serverUrl=${window.location.origin}&accessToken=${accessToken}`;
if (forQrCode && qrUrl.length >= 2000) {
qrUrl = "";

View File

@@ -0,0 +1,97 @@
import i18next from "i18next";
import {Tree} from "antd";
import React from "react";
export const NavItemTree = ({disable, checkedKeys, defaultExpandedKeys, onCheck}) => {
const NavItemNodes = [
{
title: i18next.t("organization:All"),
key: "all",
children: [
{
title: i18next.t("general:Home"),
key: "/home-top",
children: [
{title: i18next.t("general:Dashboard"), key: "/"},
{title: i18next.t("general:Shortcuts"), key: "/shortcuts"},
{title: i18next.t("general:Apps"), key: "/apps"},
],
},
{
title: i18next.t("general:User Management"),
key: "/orgs-top",
children: [
{title: i18next.t("general:Organizations"), key: "/organizations"},
{title: i18next.t("general:Groups"), key: "/groups"},
{title: i18next.t("general:Users"), key: "/users"},
{title: i18next.t("general:Invitations"), key: "/invitations"},
],
},
{
title: i18next.t("general:Identity"),
key: "/applications-top",
children: [
{title: i18next.t("general:Applications"), key: "/applications"},
{title: i18next.t("general:Providers"), key: "/providers"},
{title: i18next.t("general:Resources"), key: "/resources"},
{title: i18next.t("general:Certs"), key: "/certs"},
],
},
{
title: i18next.t("general:Authorization"),
key: "/roles-top",
children: [
{title: i18next.t("general:Applications"), key: "/roles"},
{title: i18next.t("general:Permissions"), key: "/permissions"},
{title: i18next.t("general:Models"), key: "/models"},
{title: i18next.t("general:Adapters"), key: "/adapters"},
{title: i18next.t("general:Enforcers"), key: "/enforcers"},
],
},
{
title: i18next.t("general:Logging & Auditing"),
key: "/sessions-top",
children: [
{title: i18next.t("general:Sessions"), key: "/sessions"},
{title: i18next.t("general:Records"), key: "/records"},
{title: i18next.t("general:Tokens"), key: "/tokens"},
{title: i18next.t("general:Verifications"), key: "/verifications"},
],
},
{
title: i18next.t("general:Business & Payments"),
key: "/business-top",
children: [
{title: i18next.t("general:Products"), key: "/products"},
{title: i18next.t("general:Payments"), key: "/payments"},
{title: i18next.t("general:Plans"), key: "/plans"},
{title: i18next.t("general:Pricings"), key: "/pricings"},
{title: i18next.t("general:Subscriptions"), key: "/subscriptions"},
{title: i18next.t("general:Transactions"), key: "/transactions"},
],
},
{
title: i18next.t("general:Admin"),
key: "/admin-top",
children: [
{title: i18next.t("general:System Info"), key: "/sysinfo"},
{title: i18next.t("general:Syncers"), key: "/syncers"},
{title: i18next.t("general:Webhooks"), key: "/webhooks"},
{title: i18next.t("general:Swagger"), key: "/swagger"},
],
},
],
},
];
return (
<Tree
disabled={disable}
checkable
checkedKeys={checkedKeys}
defaultExpandedKeys={defaultExpandedKeys}
onCheck={onCheck}
treeData={NavItemNodes}
/>
);
};

View File

@@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "Prodáno",
"Sold - Tooltip": "Prodávané množství",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Štítek produktu",
"Test buy page..": "Testovací stránka nákupu..",
"There is no payment channel for this product.": "Pro tento produkt neexistuje žádný platební kanál.",

View File

@@ -757,6 +757,7 @@
"Sold": "Verkauft",
"Sold - Tooltip": "Menge verkauft",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag des Produkts",
"Test buy page..": "Testkaufseite.",
"There is no payment channel for this product.": "Es gibt keinen Zahlungskanal für dieses Produkt.",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "Vendido",
"Sold - Tooltip": "Cantidad vendida",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Etiqueta de producto",
"Test buy page..": "Página de compra de prueba.",
"There is no payment channel for this product.": "No hay canal de pago para este producto.",

View File

@@ -757,6 +757,7 @@
"Sold": "فروخته شده",
"Sold - Tooltip": "تعداد فروخته شده",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "برچسب محصول",
"Test buy page..": "صفحه تست خرید..",
"There is no payment channel for this product.": "برای این محصول کانال پرداختی وجود ندارد.",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "Vendu",
"Sold - Tooltip": "Quantité vendue",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Étiquette de produit",
"Test buy page..": "Page d'achat de test.",
"There is no payment channel for this product.": "Il n'y a aucun canal de paiement pour ce produit.",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "Terjual",
"Sold - Tooltip": "Jumlah terjual",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag produk",
"Test buy page..": "Halaman pembelian uji coba.",
"There is no payment channel for this product.": "Tidak ada saluran pembayaran untuk produk ini.",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "売れました",
"Sold - Tooltip": "販売数量",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "製品のタグ",
"Test buy page..": "テスト購入ページ。",
"There is no payment channel for this product.": "この製品には支払いチャネルがありません。",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "팔렸습니다",
"Sold - Tooltip": "판매량",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "제품 태그",
"Test buy page..": "시험 구매 페이지.",
"There is no payment channel for this product.": "이 제품에 대한 결제 채널이 없습니다.",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "Vendido",
"Sold - Tooltip": "Quantidade vendida",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag do produto",
"Test buy page..": "Página de teste de compra...",
"There is no payment channel for this product.": "Não há canal de pagamento disponível para este produto.",

View File

@@ -757,6 +757,7 @@
"Sold": "Продано",
"Sold - Tooltip": "Количество проданных",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Метка продукта",
"Test buy page..": "Страница для тестовой покупки.",
"There is no payment channel for this product.": "Для этого продукта нет канала оплаты.",

View File

@@ -757,6 +757,7 @@
"Sold": "Predané",
"Sold - Tooltip": "Množstvo predaných kusov",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Štítok produktu",
"Test buy page..": "Testovať stránku nákupu..",
"There is no payment channel for this product.": "Pre tento produkt neexistuje platobný kanál.",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@@ -757,6 +757,7 @@
"Sold": "Продано",
"Sold - Tooltip": "Продана кількість",
"Stripe": "смужка",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Тег товару",
"Test buy page..": "Сторінка тестової покупки..",
"There is no payment channel for this product.": "Для цього продукту немає платіжного каналу.",

View File

@@ -757,6 +757,7 @@
"Sold": "Đã bán",
"Sold - Tooltip": "Số lượng bán ra",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Nhãn sản phẩm",
"Test buy page..": "Trang mua thử.",
"There is no payment channel for this product.": "Không có kênh thanh toán cho sản phẩm này.",

View File

@@ -757,6 +757,7 @@
"Sold": "售出",
"Sold - Tooltip": "已售出的数量",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "商品类别",
"Test buy page..": "测试购买页面..",
"There is no payment channel for this product.": "该商品没有付款方式。",