mirror of
https://github.com/casdoor/casdoor.git
synced 2025-08-20 19:00:41 +08:00
Compare commits
4 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
7f9f7c6468 | ||
![]() |
b7a818e2d3 | ||
![]() |
1a8cfe4ee6 | ||
![]() |
b3526de675 |
@@ -99,6 +99,7 @@ p, *, *, GET, /api/get-all-objects, *, *
|
|||||||
p, *, *, GET, /api/get-all-actions, *, *
|
p, *, *, GET, /api/get-all-actions, *, *
|
||||||
p, *, *, GET, /api/get-all-roles, *, *
|
p, *, *, GET, /api/get-all-roles, *, *
|
||||||
p, *, *, GET, /api/run-casbin-command, *, *
|
p, *, *, GET, /api/run-casbin-command, *, *
|
||||||
|
p, *, *, POST, /api/refresh-engines, *, *
|
||||||
p, *, *, GET, /api/get-invitation-info, *, *
|
p, *, *, GET, /api/get-invitation-info, *, *
|
||||||
p, *, *, GET, /api/faceid-signin-begin, *, *
|
p, *, *, GET, /api/faceid-signin-begin, *, *
|
||||||
`
|
`
|
||||||
|
@@ -910,11 +910,20 @@ func (c *ApiController) Login() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = mfaUtil.Verify(authForm.Passcode)
|
passed, err := c.checkOrgMasterVerificationCode(user, authForm.Passcode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.ResponseError(err.Error())
|
c.ResponseError(err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !passed {
|
||||||
|
err = mfaUtil.Verify(authForm.Passcode)
|
||||||
|
if err != nil {
|
||||||
|
c.ResponseError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.SetSession("verificationCodeType", "")
|
c.SetSession("verificationCodeType", "")
|
||||||
} else if authForm.RecoveryCode != "" {
|
} else if authForm.RecoveryCode != "" {
|
||||||
err = object.MfaRecover(user, authForm.RecoveryCode)
|
err = object.MfaRecover(user, authForm.RecoveryCode)
|
||||||
|
502
controllers/cli_downloader.go
Normal file
502
controllers/cli_downloader.go
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"archive/zip"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/beego/beego"
|
||||||
|
)
|
||||||
|
|
||||||
|
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) {
|
||||||
|
resp, err := http.Get(repoURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", 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)
|
||||||
|
|
||||||
|
resp, err := http.Get(cliURL)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("failed to download %s CLI: %v\n", lang, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
func() {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
out, err := os.Create(originalPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("failed to create %s CLI file: %v\n", lang, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
if _, err = io.Copy(out, resp.Body); err != nil {
|
||||||
|
fmt.Printf("failed to save %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
|
||||||
|
}
|
||||||
|
|
||||||
|
err := DownloadCLI()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("failed to initialize CLI downloader: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go ScheduleCLIUpdater()
|
||||||
|
}
|
@@ -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 {
|
if err != nil {
|
||||||
c.ResponseError(c.T(err.Error()))
|
c.ResponseError(c.T(err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if result.Code != object.VerificationSuccess {
|
|
||||||
c.ResponseError(result.Msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = object.DisableVerificationCode(checkDest)
|
if !passed {
|
||||||
if err != nil {
|
result, err := object.CheckVerificationCode(checkDest, authForm.Code, c.GetAcceptLanguage())
|
||||||
c.ResponseError(err.Error())
|
if err != nil {
|
||||||
return
|
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)
|
c.SetSession("verifiedCode", authForm.Code)
|
||||||
|
36
controllers/verification_util.go
Normal file
36
controllers/verification_util.go
Normal 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
|
||||||
|
}
|
2
main.go
2
main.go
@@ -22,6 +22,7 @@ import (
|
|||||||
_ "github.com/beego/beego/session/redis"
|
_ "github.com/beego/beego/session/redis"
|
||||||
"github.com/casdoor/casdoor/authz"
|
"github.com/casdoor/casdoor/authz"
|
||||||
"github.com/casdoor/casdoor/conf"
|
"github.com/casdoor/casdoor/conf"
|
||||||
|
"github.com/casdoor/casdoor/controllers"
|
||||||
"github.com/casdoor/casdoor/ldap"
|
"github.com/casdoor/casdoor/ldap"
|
||||||
"github.com/casdoor/casdoor/object"
|
"github.com/casdoor/casdoor/object"
|
||||||
"github.com/casdoor/casdoor/proxy"
|
"github.com/casdoor/casdoor/proxy"
|
||||||
@@ -45,6 +46,7 @@ func main() {
|
|||||||
object.InitCasvisorConfig()
|
object.InitCasvisorConfig()
|
||||||
|
|
||||||
util.SafeGoroutine(func() { object.RunSyncUsersJob() })
|
util.SafeGoroutine(func() { object.RunSyncUsersJob() })
|
||||||
|
controllers.InitCLIDownloader()
|
||||||
|
|
||||||
// beego.DelStaticPath("/static")
|
// beego.DelStaticPath("/static")
|
||||||
// beego.SetStaticPath("/static", "web/build/static")
|
// beego.SetStaticPath("/static", "web/build/static")
|
||||||
|
@@ -219,8 +219,11 @@ func BuyProduct(id string, user *User, providerName, pricingName, planName, host
|
|||||||
ProductName: product.Name,
|
ProductName: product.Name,
|
||||||
PayerName: payerName,
|
PayerName: payerName,
|
||||||
PayerId: user.Id,
|
PayerId: user.Id,
|
||||||
|
PayerEmail: user.Email,
|
||||||
PaymentName: paymentName,
|
PaymentName: paymentName,
|
||||||
ProductDisplayName: product.DisplayName,
|
ProductDisplayName: product.DisplayName,
|
||||||
|
ProductDescription: product.Description,
|
||||||
|
ProductImage: product.Image,
|
||||||
Price: product.Price,
|
Price: product.Price,
|
||||||
Currency: product.Currency,
|
Currency: product.Currency,
|
||||||
ReturnUrl: returnUrl,
|
ReturnUrl: returnUrl,
|
||||||
|
@@ -325,6 +325,12 @@ func GetPaymentProvider(p *Provider) (pp.PaymentProvider, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return pp, nil
|
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" {
|
} else if typ == "Balance" {
|
||||||
pp, err := pp.NewBalancePaymentProvider()
|
pp, err := pp.NewBalancePaymentProvider()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
289
pp/airwallex.go
Normal file
289
pp/airwallex.go
Normal 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": string([]rune(description)[:32]), // display to the customer.
|
||||||
|
"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¤cy=%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
|
||||||
|
}
|
@@ -33,8 +33,11 @@ type PayReq struct {
|
|||||||
ProductName string
|
ProductName string
|
||||||
PayerName string
|
PayerName string
|
||||||
PayerId string
|
PayerId string
|
||||||
|
PayerEmail string
|
||||||
PaymentName string
|
PaymentName string
|
||||||
ProductDisplayName string
|
ProductDisplayName string
|
||||||
|
ProductDescription string
|
||||||
|
ProductImage string
|
||||||
Price float64
|
Price float64
|
||||||
Currency string
|
Currency string
|
||||||
|
|
||||||
|
@@ -175,6 +175,7 @@ func initAPI() {
|
|||||||
beego.Router("/api/get-all-roles", &controllers.ApiController{}, "GET:GetAllRoles")
|
beego.Router("/api/get-all-roles", &controllers.ApiController{}, "GET:GetAllRoles")
|
||||||
|
|
||||||
beego.Router("/api/run-casbin-command", &controllers.ApiController{}, "GET:RunCasbinCommand")
|
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-sessions", &controllers.ApiController{}, "GET:GetSessions")
|
||||||
beego.Router("/api/get-session", &controllers.ApiController{}, "GET:GetSingleSession")
|
beego.Router("/api/get-session", &controllers.ApiController{}, "GET:GetSingleSession")
|
||||||
|
@@ -122,7 +122,7 @@ class PaymentResultPage extends React.Component {
|
|||||||
payment: payment,
|
payment: payment,
|
||||||
});
|
});
|
||||||
if (payment.state === "Created") {
|
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({
|
this.setState({
|
||||||
timeout: setTimeout(async() => {
|
timeout: setTimeout(async() => {
|
||||||
await PaymentBackend.notifyPayment(this.state.owner, this.state.paymentName);
|
await PaymentBackend.notifyPayment(this.state.owner, this.state.paymentName);
|
||||||
|
@@ -238,6 +238,8 @@ class ProductBuyPage extends React.Component {
|
|||||||
text = i18next.t("product:PayPal");
|
text = i18next.t("product:PayPal");
|
||||||
} else if (provider.type === "Stripe") {
|
} else if (provider.type === "Stripe") {
|
||||||
text = i18next.t("product:Stripe");
|
text = i18next.t("product:Stripe");
|
||||||
|
} else if (provider.type === "AirWallex") {
|
||||||
|
text = i18next.t("product:AirWallex");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -467,6 +467,39 @@ class ProviderEditPage extends React.Component {
|
|||||||
this.updateProviderField("issuerUrl", issuerUrl);
|
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() {
|
renderProvider() {
|
||||||
return (
|
return (
|
||||||
<Card size="small" title={
|
<Card size="small" title={
|
||||||
@@ -1242,6 +1275,21 @@ class ProviderEditPage extends React.Component {
|
|||||||
}} />
|
}} />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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"}} >
|
<Row style={{marginTop: "20px"}} >
|
||||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||||
{Setting.getLabel(i18next.t("provider:Metadata"), i18next.t("provider:Metadata - Tooltip"))} :
|
{Setting.getLabel(i18next.t("provider:Metadata"), i18next.t("provider:Metadata - Tooltip"))} :
|
||||||
@@ -1255,14 +1303,7 @@ class ProviderEditPage extends React.Component {
|
|||||||
<Row style={{marginTop: "20px"}}>
|
<Row style={{marginTop: "20px"}}>
|
||||||
<Col style={{marginTop: "5px"}} span={2} />
|
<Col style={{marginTop: "5px"}} span={2} />
|
||||||
<Col span={2}>
|
<Col span={2}>
|
||||||
<Button type="primary" onClick={() => {
|
<Button type="primary" onClick={() => {this.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"));
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
{i18next.t("provider:Parse")}
|
{i18next.t("provider:Parse")}
|
||||||
</Button>
|
</Button>
|
||||||
</Col>
|
</Col>
|
||||||
|
@@ -279,6 +279,10 @@ export const OtherProviderInfo = {
|
|||||||
logo: `${StaticBaseUrl}/img/social_stripe.png`,
|
logo: `${StaticBaseUrl}/img/social_stripe.png`,
|
||||||
url: "https://stripe.com/",
|
url: "https://stripe.com/",
|
||||||
},
|
},
|
||||||
|
"AirWallex": {
|
||||||
|
logo: `${StaticBaseUrl}/img/payment_airwallex.svg`,
|
||||||
|
url: "https://airwallex.com/",
|
||||||
|
},
|
||||||
"GC": {
|
"GC": {
|
||||||
logo: `${StaticBaseUrl}/img/payment_gc.png`,
|
logo: `${StaticBaseUrl}/img/payment_gc.png`,
|
||||||
url: "https://gc.org",
|
url: "https://gc.org",
|
||||||
@@ -1106,6 +1110,7 @@ export function getProviderTypeOptions(category) {
|
|||||||
{id: "WeChat Pay", name: "WeChat Pay"},
|
{id: "WeChat Pay", name: "WeChat Pay"},
|
||||||
{id: "PayPal", name: "PayPal"},
|
{id: "PayPal", name: "PayPal"},
|
||||||
{id: "Stripe", name: "Stripe"},
|
{id: "Stripe", name: "Stripe"},
|
||||||
|
{id: "AirWallex", name: "AirWallex"},
|
||||||
{id: "GC", name: "GC"},
|
{id: "GC", name: "GC"},
|
||||||
]);
|
]);
|
||||||
} else if (category === "Captcha") {
|
} else if (category === "Captcha") {
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Sold",
|
"Sold": "Sold",
|
||||||
"Sold - Tooltip": "Quantity sold",
|
"Sold - Tooltip": "Quantity sold",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Tag of product",
|
"Tag - Tooltip": "Tag of product",
|
||||||
"Test buy page..": "Test buy page..",
|
"Test buy page..": "Test buy page..",
|
||||||
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Prodáno",
|
"Sold": "Prodáno",
|
||||||
"Sold - Tooltip": "Prodávané množství",
|
"Sold - Tooltip": "Prodávané množství",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Štítek produktu",
|
"Tag - Tooltip": "Štítek produktu",
|
||||||
"Test buy page..": "Testovací stránka nákupu..",
|
"Test buy page..": "Testovací stránka nákupu..",
|
||||||
"There is no payment channel for this product.": "Pro tento produkt neexistuje žádný platební kanál.",
|
"There is no payment channel for this product.": "Pro tento produkt neexistuje žádný platební kanál.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Verkauft",
|
"Sold": "Verkauft",
|
||||||
"Sold - Tooltip": "Menge verkauft",
|
"Sold - Tooltip": "Menge verkauft",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Tag des Produkts",
|
"Tag - Tooltip": "Tag des Produkts",
|
||||||
"Test buy page..": "Testkaufseite.",
|
"Test buy page..": "Testkaufseite.",
|
||||||
"There is no payment channel for this product.": "Es gibt keinen Zahlungskanal für dieses Produkt.",
|
"There is no payment channel for this product.": "Es gibt keinen Zahlungskanal für dieses Produkt.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Sold",
|
"Sold": "Sold",
|
||||||
"Sold - Tooltip": "Quantity sold",
|
"Sold - Tooltip": "Quantity sold",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Tag of product",
|
"Tag - Tooltip": "Tag of product",
|
||||||
"Test buy page..": "Test buy page..",
|
"Test buy page..": "Test buy page..",
|
||||||
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Vendido",
|
"Sold": "Vendido",
|
||||||
"Sold - Tooltip": "Cantidad vendida",
|
"Sold - Tooltip": "Cantidad vendida",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Etiqueta de producto",
|
"Tag - Tooltip": "Etiqueta de producto",
|
||||||
"Test buy page..": "Página de compra de prueba.",
|
"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.",
|
"There is no payment channel for this product.": "No hay canal de pago para este producto.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "فروخته شده",
|
"Sold": "فروخته شده",
|
||||||
"Sold - Tooltip": "تعداد فروخته شده",
|
"Sold - Tooltip": "تعداد فروخته شده",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "برچسب محصول",
|
"Tag - Tooltip": "برچسب محصول",
|
||||||
"Test buy page..": "صفحه تست خرید..",
|
"Test buy page..": "صفحه تست خرید..",
|
||||||
"There is no payment channel for this product.": "برای این محصول کانال پرداختی وجود ندارد.",
|
"There is no payment channel for this product.": "برای این محصول کانال پرداختی وجود ندارد.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Sold",
|
"Sold": "Sold",
|
||||||
"Sold - Tooltip": "Quantity sold",
|
"Sold - Tooltip": "Quantity sold",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Tag of product",
|
"Tag - Tooltip": "Tag of product",
|
||||||
"Test buy page..": "Test buy page..",
|
"Test buy page..": "Test buy page..",
|
||||||
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Vendu",
|
"Sold": "Vendu",
|
||||||
"Sold - Tooltip": "Quantité vendue",
|
"Sold - Tooltip": "Quantité vendue",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Étiquette de produit",
|
"Tag - Tooltip": "Étiquette de produit",
|
||||||
"Test buy page..": "Page d'achat de test.",
|
"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.",
|
"There is no payment channel for this product.": "Il n'y a aucun canal de paiement pour ce produit.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Sold",
|
"Sold": "Sold",
|
||||||
"Sold - Tooltip": "Quantity sold",
|
"Sold - Tooltip": "Quantity sold",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Tag of product",
|
"Tag - Tooltip": "Tag of product",
|
||||||
"Test buy page..": "Test buy page..",
|
"Test buy page..": "Test buy page..",
|
||||||
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Terjual",
|
"Sold": "Terjual",
|
||||||
"Sold - Tooltip": "Jumlah terjual",
|
"Sold - Tooltip": "Jumlah terjual",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Tag produk",
|
"Tag - Tooltip": "Tag produk",
|
||||||
"Test buy page..": "Halaman pembelian uji coba.",
|
"Test buy page..": "Halaman pembelian uji coba.",
|
||||||
"There is no payment channel for this product.": "Tidak ada saluran pembayaran untuk produk ini.",
|
"There is no payment channel for this product.": "Tidak ada saluran pembayaran untuk produk ini.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Sold",
|
"Sold": "Sold",
|
||||||
"Sold - Tooltip": "Quantity sold",
|
"Sold - Tooltip": "Quantity sold",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Tag of product",
|
"Tag - Tooltip": "Tag of product",
|
||||||
"Test buy page..": "Test buy page..",
|
"Test buy page..": "Test buy page..",
|
||||||
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "売れました",
|
"Sold": "売れました",
|
||||||
"Sold - Tooltip": "販売数量",
|
"Sold - Tooltip": "販売数量",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "製品のタグ",
|
"Tag - Tooltip": "製品のタグ",
|
||||||
"Test buy page..": "テスト購入ページ。",
|
"Test buy page..": "テスト購入ページ。",
|
||||||
"There is no payment channel for this product.": "この製品には支払いチャネルがありません。",
|
"There is no payment channel for this product.": "この製品には支払いチャネルがありません。",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Sold",
|
"Sold": "Sold",
|
||||||
"Sold - Tooltip": "Quantity sold",
|
"Sold - Tooltip": "Quantity sold",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Tag of product",
|
"Tag - Tooltip": "Tag of product",
|
||||||
"Test buy page..": "Test buy page..",
|
"Test buy page..": "Test buy page..",
|
||||||
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "팔렸습니다",
|
"Sold": "팔렸습니다",
|
||||||
"Sold - Tooltip": "판매량",
|
"Sold - Tooltip": "판매량",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "제품 태그",
|
"Tag - Tooltip": "제품 태그",
|
||||||
"Test buy page..": "시험 구매 페이지.",
|
"Test buy page..": "시험 구매 페이지.",
|
||||||
"There is no payment channel for this product.": "이 제품에 대한 결제 채널이 없습니다.",
|
"There is no payment channel for this product.": "이 제품에 대한 결제 채널이 없습니다.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Sold",
|
"Sold": "Sold",
|
||||||
"Sold - Tooltip": "Quantity sold",
|
"Sold - Tooltip": "Quantity sold",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Tag of product",
|
"Tag - Tooltip": "Tag of product",
|
||||||
"Test buy page..": "Test buy page..",
|
"Test buy page..": "Test buy page..",
|
||||||
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Sold",
|
"Sold": "Sold",
|
||||||
"Sold - Tooltip": "Quantity sold",
|
"Sold - Tooltip": "Quantity sold",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Tag of product",
|
"Tag - Tooltip": "Tag of product",
|
||||||
"Test buy page..": "Test buy page..",
|
"Test buy page..": "Test buy page..",
|
||||||
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Sold",
|
"Sold": "Sold",
|
||||||
"Sold - Tooltip": "Quantity sold",
|
"Sold - Tooltip": "Quantity sold",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Tag of product",
|
"Tag - Tooltip": "Tag of product",
|
||||||
"Test buy page..": "Test buy page..",
|
"Test buy page..": "Test buy page..",
|
||||||
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Vendido",
|
"Sold": "Vendido",
|
||||||
"Sold - Tooltip": "Quantidade vendida",
|
"Sold - Tooltip": "Quantidade vendida",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Tag do produto",
|
"Tag - Tooltip": "Tag do produto",
|
||||||
"Test buy page..": "Página de teste de compra...",
|
"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.",
|
"There is no payment channel for this product.": "Não há canal de pagamento disponível para este produto.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Продано",
|
"Sold": "Продано",
|
||||||
"Sold - Tooltip": "Количество проданных",
|
"Sold - Tooltip": "Количество проданных",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Метка продукта",
|
"Tag - Tooltip": "Метка продукта",
|
||||||
"Test buy page..": "Страница для тестовой покупки.",
|
"Test buy page..": "Страница для тестовой покупки.",
|
||||||
"There is no payment channel for this product.": "Для этого продукта нет канала оплаты.",
|
"There is no payment channel for this product.": "Для этого продукта нет канала оплаты.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Predané",
|
"Sold": "Predané",
|
||||||
"Sold - Tooltip": "Množstvo predaných kusov",
|
"Sold - Tooltip": "Množstvo predaných kusov",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Štítok produktu",
|
"Tag - Tooltip": "Štítok produktu",
|
||||||
"Test buy page..": "Testovať stránku nákupu..",
|
"Test buy page..": "Testovať stránku nákupu..",
|
||||||
"There is no payment channel for this product.": "Pre tento produkt neexistuje platobný kanál.",
|
"There is no payment channel for this product.": "Pre tento produkt neexistuje platobný kanál.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Sold",
|
"Sold": "Sold",
|
||||||
"Sold - Tooltip": "Quantity sold",
|
"Sold - Tooltip": "Quantity sold",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Tag of product",
|
"Tag - Tooltip": "Tag of product",
|
||||||
"Test buy page..": "Test buy page..",
|
"Test buy page..": "Test buy page..",
|
||||||
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Sold",
|
"Sold": "Sold",
|
||||||
"Sold - Tooltip": "Quantity sold",
|
"Sold - Tooltip": "Quantity sold",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Tag of product",
|
"Tag - Tooltip": "Tag of product",
|
||||||
"Test buy page..": "Test buy page..",
|
"Test buy page..": "Test buy page..",
|
||||||
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
"There is no payment channel for this product.": "There is no payment channel for this product.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Продано",
|
"Sold": "Продано",
|
||||||
"Sold - Tooltip": "Продана кількість",
|
"Sold - Tooltip": "Продана кількість",
|
||||||
"Stripe": "смужка",
|
"Stripe": "смужка",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Тег товару",
|
"Tag - Tooltip": "Тег товару",
|
||||||
"Test buy page..": "Сторінка тестової покупки..",
|
"Test buy page..": "Сторінка тестової покупки..",
|
||||||
"There is no payment channel for this product.": "Для цього продукту немає платіжного каналу.",
|
"There is no payment channel for this product.": "Для цього продукту немає платіжного каналу.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "Đã bán",
|
"Sold": "Đã bán",
|
||||||
"Sold - Tooltip": "Số lượng bán ra",
|
"Sold - Tooltip": "Số lượng bán ra",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "Nhãn sản phẩm",
|
"Tag - Tooltip": "Nhãn sản phẩm",
|
||||||
"Test buy page..": "Trang mua thử.",
|
"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.",
|
"There is no payment channel for this product.": "Không có kênh thanh toán cho sản phẩm này.",
|
||||||
|
@@ -757,6 +757,7 @@
|
|||||||
"Sold": "售出",
|
"Sold": "售出",
|
||||||
"Sold - Tooltip": "已售出的数量",
|
"Sold - Tooltip": "已售出的数量",
|
||||||
"Stripe": "Stripe",
|
"Stripe": "Stripe",
|
||||||
|
"AirWallex": "AirWallex",
|
||||||
"Tag - Tooltip": "商品类别",
|
"Tag - Tooltip": "商品类别",
|
||||||
"Test buy page..": "测试购买页面..",
|
"Test buy page..": "测试购买页面..",
|
||||||
"There is no payment channel for this product.": "该商品没有付款方式。",
|
"There is no payment channel for this product.": "该商品没有付款方式。",
|
||||||
|
Reference in New Issue
Block a user