Compare commits

..

6 Commits

Author SHA1 Message Date
hsluoyz
fa01aa99fa Revert "feat: fix UI in IE11 (#1878)"
This reverts commit 44cd55e55f.
2023-05-22 22:20:35 +08:00
Yaodong Yu
44cd55e55f feat: fix UI in IE11 (#1878) 2023-05-22 16:59:37 +08:00
Yang Luo
6b42d35223 Fix state encoding for Moodle 2023-05-21 15:47:18 +08:00
Yang Luo
c84150cede Fix getObject() bug for some API 2023-05-21 11:07:01 +08:00
hsluoyz
de2689ac39 fix: revert "feat: fix UI in IE11" (#1873)
* Revert "feat: fix UI in IE11 (#1871)"

This reverts commit 319031da28.

* Update MfaVerifyForm.js
2023-05-21 00:43:42 +08:00
Ilya Sulimanov
88c0856d17 feat: add subscription managment (#1858)
* feat: subscription managment

* fix: remove console log

* fix: webhooks

* fix linter

* fix: fix via gofumpt

* fix: review changes

* fix: Copyright 2023

* Update account.go

---------

Co-authored-by: hsluoyz <hsluoyz@qq.com>
2023-05-20 15:56:21 +08:00
43 changed files with 5457 additions and 471 deletions

View File

@@ -124,6 +124,9 @@ p, *, *, GET, /api/get-release, *, *
p, *, *, GET, /api/get-default-application, *, * p, *, *, GET, /api/get-default-application, *, *
p, *, *, GET, /api/get-prometheus-info, *, * p, *, *, GET, /api/get-prometheus-info, *, *
p, *, *, *, /api/metrics, *, * p, *, *, *, /api/metrics, *, *
p, *, *, GET, /api/get-subscriptions, *, *
p, *, *, GET, /api/get-pricing, *, *
p, *, *, GET, /api/get-plan, *, *
` `
sa := stringadapter.NewAdapter(ruleText) sa := stringadapter.NewAdapter(ruleText)

View File

@@ -189,6 +189,11 @@ func (c *ApiController) Signup() {
object.DisableVerificationCode(authForm.Email) object.DisableVerificationCode(authForm.Email)
object.DisableVerificationCode(checkPhone) object.DisableVerificationCode(checkPhone)
isSignupFromPricing := authForm.Plan != "" && authForm.Pricing != ""
if isSignupFromPricing {
object.Subscribe(organization.Name, user.Name, authForm.Plan, authForm.Pricing)
}
record := object.NewRecord(c.Ctx) record := object.NewRecord(c.Ctx)
record.Organization = application.Organization record.Organization = application.Organization
record.User = user.Name record.User = user.Name

137
controllers/plan.go Normal file
View File

@@ -0,0 +1,137 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"encoding/json"
"github.com/beego/beego/utils/pagination"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
// GetPlans
// @Title GetPlans
// @Tag Plan API
// @Description get plans
// @Param owner query string true "The owner of plans"
// @Success 200 {array} object.Plan The Response object
// @router /get-plans [get]
func (c *ApiController) GetPlans() {
owner := c.Input().Get("owner")
limit := c.Input().Get("pageSize")
page := c.Input().Get("p")
field := c.Input().Get("field")
value := c.Input().Get("value")
sortField := c.Input().Get("sortField")
sortOrder := c.Input().Get("sortOrder")
if limit == "" || page == "" {
c.Data["json"] = object.GetPlans(owner)
c.ServeJSON()
} else {
limit := util.ParseInt(limit)
paginator := pagination.SetPaginator(c.Ctx, limit, int64(object.GetPlanCount(owner, field, value)))
plan := object.GetPaginatedPlans(owner, paginator.Offset(), limit, field, value, sortField, sortOrder)
c.ResponseOk(plan, paginator.Nums())
}
}
// GetPlan
// @Title GetPlan
// @Tag Plan API
// @Description get plan
// @Param id query string true "The id ( owner/name ) of the plan"
// @Param includeOption query bool false "Should include plan's option"
// @Success 200 {object} object.Plan The Response object
// @router /get-plan [get]
func (c *ApiController) GetPlan() {
id := c.Input().Get("id")
includeOption := c.Input().Get("includeOption") == "true"
plan := object.GetPlan(id)
if includeOption {
options := object.GetPermissionsByRole(plan.Role)
for _, option := range options {
plan.Options = append(plan.Options, option.DisplayName)
}
c.Data["json"] = plan
} else {
c.Data["json"] = plan
}
c.ServeJSON()
}
// UpdatePlan
// @Title UpdatePlan
// @Tag Plan API
// @Description update plan
// @Param id query string true "The id ( owner/name ) of the plan"
// @Param body body object.Plan true "The details of the plan"
// @Success 200 {object} controllers.Response The Response object
// @router /update-plan [post]
func (c *ApiController) UpdatePlan() {
id := c.Input().Get("id")
var plan object.Plan
err := json.Unmarshal(c.Ctx.Input.RequestBody, &plan)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.UpdatePlan(id, &plan))
c.ServeJSON()
}
// AddPlan
// @Title AddPlan
// @Tag Plan API
// @Description add plan
// @Param body body object.Plan true "The details of the plan"
// @Success 200 {object} controllers.Response The Response object
// @router /add-plan [post]
func (c *ApiController) AddPlan() {
var plan object.Plan
err := json.Unmarshal(c.Ctx.Input.RequestBody, &plan)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.AddPlan(&plan))
c.ServeJSON()
}
// DeletePlan
// @Title DeletePlan
// @Tag Plan API
// @Description delete plan
// @Param body body object.Plan true "The details of the plan"
// @Success 200 {object} controllers.Response The Response object
// @router /delete-plan [post]
func (c *ApiController) DeletePlan() {
var plan object.Plan
err := json.Unmarshal(c.Ctx.Input.RequestBody, &plan)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.DeletePlan(&plan))
c.ServeJSON()
}

125
controllers/pricing.go Normal file
View File

@@ -0,0 +1,125 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"encoding/json"
"github.com/beego/beego/utils/pagination"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
// GetPricings
// @Title GetPricings
// @Tag Pricing API
// @Description get pricings
// @Param owner query string true "The owner of pricings"
// @Success 200 {array} object.Pricing The Response object
// @router /get-pricings [get]
func (c *ApiController) GetPricings() {
owner := c.Input().Get("owner")
limit := c.Input().Get("pageSize")
page := c.Input().Get("p")
field := c.Input().Get("field")
value := c.Input().Get("value")
sortField := c.Input().Get("sortField")
sortOrder := c.Input().Get("sortOrder")
if limit == "" || page == "" {
c.Data["json"] = object.GetPricings(owner)
c.ServeJSON()
} else {
limit := util.ParseInt(limit)
paginator := pagination.SetPaginator(c.Ctx, limit, int64(object.GetPricingCount(owner, field, value)))
pricing := object.GetPaginatedPricings(owner, paginator.Offset(), limit, field, value, sortField, sortOrder)
c.ResponseOk(pricing, paginator.Nums())
}
}
// GetPricing
// @Title GetPricing
// @Tag Pricing API
// @Description get pricing
// @Param id query string true "The id ( owner/name ) of the pricing"
// @Success 200 {object} object.pricing The Response object
// @router /get-pricing [get]
func (c *ApiController) GetPricing() {
id := c.Input().Get("id")
pricing := object.GetPricing(id)
c.Data["json"] = pricing
c.ServeJSON()
}
// UpdatePricing
// @Title UpdatePricing
// @Tag Pricing API
// @Description update pricing
// @Param id query string true "The id ( owner/name ) of the pricing"
// @Param body body object.Pricing true "The details of the pricing"
// @Success 200 {object} controllers.Response The Response object
// @router /update-pricing [post]
func (c *ApiController) UpdatePricing() {
id := c.Input().Get("id")
var pricing object.Pricing
err := json.Unmarshal(c.Ctx.Input.RequestBody, &pricing)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.UpdatePricing(id, &pricing))
c.ServeJSON()
}
// AddPricing
// @Title AddPricing
// @Tag Pricing API
// @Description add pricing
// @Param body body object.Pricing true "The details of the pricing"
// @Success 200 {object} controllers.Response The Response object
// @router /add-pricing [post]
func (c *ApiController) AddPricing() {
var pricing object.Pricing
err := json.Unmarshal(c.Ctx.Input.RequestBody, &pricing)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.AddPricing(&pricing))
c.ServeJSON()
}
// DeletePricing
// @Title DeletePricing
// @Tag Pricing API
// @Description delete pricing
// @Param body body object.Pricing true "The details of the pricing"
// @Success 200 {object} controllers.Response The Response object
// @router /delete-pricing [post]
func (c *ApiController) DeletePricing() {
var pricing object.Pricing
err := json.Unmarshal(c.Ctx.Input.RequestBody, &pricing)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.DeletePricing(&pricing))
c.ServeJSON()
}

125
controllers/subscription.go Normal file
View File

@@ -0,0 +1,125 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"encoding/json"
"github.com/beego/beego/utils/pagination"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
// GetSubscriptions
// @Title GetSubscriptions
// @Tag Subscription API
// @Description get subscriptions
// @Param owner query string true "The owner of subscriptions"
// @Success 200 {array} object.Subscription The Response object
// @router /get-subscriptions [get]
func (c *ApiController) GetSubscriptions() {
owner := c.Input().Get("owner")
limit := c.Input().Get("pageSize")
page := c.Input().Get("p")
field := c.Input().Get("field")
value := c.Input().Get("value")
sortField := c.Input().Get("sortField")
sortOrder := c.Input().Get("sortOrder")
if limit == "" || page == "" {
c.Data["json"] = object.GetSubscriptions(owner)
c.ServeJSON()
} else {
limit := util.ParseInt(limit)
paginator := pagination.SetPaginator(c.Ctx, limit, int64(object.GetSubscriptionCount(owner, field, value)))
subscription := object.GetPaginationSubscriptions(owner, paginator.Offset(), limit, field, value, sortField, sortOrder)
c.ResponseOk(subscription, paginator.Nums())
}
}
// GetSubscription
// @Title GetSubscription
// @Tag Subscription API
// @Description get subscription
// @Param id query string true "The id ( owner/name ) of the subscription"
// @Success 200 {object} object.subscription The Response object
// @router /get-subscription [get]
func (c *ApiController) GetSubscription() {
id := c.Input().Get("id")
subscription := object.GetSubscription(id)
c.Data["json"] = subscription
c.ServeJSON()
}
// UpdateSubscription
// @Title UpdateSubscription
// @Tag Subscription API
// @Description update subscription
// @Param id query string true "The id ( owner/name ) of the subscription"
// @Param body body object.Subscription true "The details of the subscription"
// @Success 200 {object} controllers.Response The Response object
// @router /update-subscription [post]
func (c *ApiController) UpdateSubscription() {
id := c.Input().Get("id")
var subscription object.Subscription
err := json.Unmarshal(c.Ctx.Input.RequestBody, &subscription)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.UpdateSubscription(id, &subscription))
c.ServeJSON()
}
// AddSubscription
// @Title AddSubscription
// @Tag Subscription API
// @Description add subscription
// @Param body body object.Subscription true "The details of the subscription"
// @Success 200 {object} controllers.Response The Response object
// @router /add-subscription [post]
func (c *ApiController) AddSubscription() {
var subscription object.Subscription
err := json.Unmarshal(c.Ctx.Input.RequestBody, &subscription)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.AddSubscription(&subscription))
c.ServeJSON()
}
// DeleteSubscription
// @Title DeleteSubscription
// @Tag Subscription API
// @Description delete subscription
// @Param body body object.Subscription true "The details of the subscription"
// @Success 200 {object} controllers.Response The Response object
// @router /delete-subscription [post]
func (c *ApiController) DeleteSubscription() {
var subscription object.Subscription
err := json.Unmarshal(c.Ctx.Input.RequestBody, &subscription)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.DeleteSubscription(&subscription))
c.ServeJSON()
}

View File

@@ -54,4 +54,7 @@ type AuthForm struct {
MfaType string `json:"mfaType"` MfaType string `json:"mfaType"`
Passcode string `json:"passcode"` Passcode string `json:"passcode"`
RecoveryCode string `json:"recoveryCode"` RecoveryCode string `json:"recoveryCode"`
Plan string `json:"plan"`
Pricing string `json:"pricing"`
} }

View File

@@ -240,6 +240,21 @@ func (a *Adapter) createTable() {
if err != nil { if err != nil {
panic(err) panic(err)
} }
err = a.Engine.Sync2(new(Subscription))
if err != nil {
panic(err)
}
err = a.Engine.Sync2(new(Plan))
if err != nil {
panic(err)
}
err = a.Engine.Sync2(new(Pricing))
if err != nil {
panic(err)
}
} }
func GetSession(owner string, offset, limit int, field, value, sortField, sortOrder string) *xorm.Session { func GetSession(owner string, offset, limit int, field, value, sortField, sortOrder string) *xorm.Session {

145
object/plan.go Normal file
View File

@@ -0,0 +1,145 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
)
type Plan struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
Description string `xorm:"varchar(100)" json:"description"`
PricePerMonth float64 `json:"pricePerMonth"`
PricePerYear float64 `json:"pricePerYear"`
Currency string `xorm:"varchar(100)" json:"currency"`
IsEnabled bool `json:"isEnabled"`
Role string `xorm:"varchar(100)" json:"role"`
Options []string `xorm:"-" json:"options"`
}
func GetPlanCount(owner, field, value string) int {
session := GetSession(owner, -1, -1, field, value, "", "")
count, err := session.Count(&Plan{})
if err != nil {
panic(err)
}
return int(count)
}
func GetPlans(owner string) []*Plan {
plans := []*Plan{}
err := adapter.Engine.Desc("created_time").Find(&plans, &Plan{Owner: owner})
if err != nil {
panic(err)
}
return plans
}
func GetPaginatedPlans(owner string, offset, limit int, field, value, sortField, sortOrder string) []*Plan {
plans := []*Plan{}
session := GetSession(owner, offset, limit, field, value, sortField, sortOrder)
err := session.Find(&plans)
if err != nil {
panic(err)
}
return plans
}
func getPlan(owner, name string) *Plan {
if owner == "" || name == "" {
return nil
}
plan := Plan{Owner: owner, Name: name}
existed, err := adapter.Engine.Get(&plan)
if err != nil {
panic(err)
}
if existed {
return &plan
} else {
return nil
}
}
func GetPlan(id string) *Plan {
owner, name := util.GetOwnerAndNameFromId(id)
return getPlan(owner, name)
}
func UpdatePlan(id string, plan *Plan) bool {
owner, name := util.GetOwnerAndNameFromId(id)
if getPlan(owner, name) == nil {
return false
}
affected, err := adapter.Engine.ID(core.PK{owner, name}).AllCols().Update(plan)
if err != nil {
panic(err)
}
return affected != 0
}
func AddPlan(plan *Plan) bool {
affected, err := adapter.Engine.Insert(plan)
if err != nil {
panic(err)
}
return affected != 0
}
func DeletePlan(plan *Plan) bool {
affected, err := adapter.Engine.ID(core.PK{plan.Owner, plan.Name}).Delete(plan)
if err != nil {
panic(err)
}
return affected != 0
}
func (plan *Plan) GetId() string {
return fmt.Sprintf("%s/%s", plan.Owner, plan.Name)
}
func Subscribe(owner string, user string, plan string, pricing string) *Subscription {
selectedPricing := GetPricing(fmt.Sprintf("%s/%s", owner, pricing))
valid := selectedPricing != nil && selectedPricing.IsEnabled
if !valid {
return nil
}
planBelongToPricing := selectedPricing.HasPlan(owner, plan)
if planBelongToPricing {
newSubscription := NewSubscription(owner, user, plan, selectedPricing.TrialDuration)
affected := AddSubscription(newSubscription)
if affected {
return newSubscription
}
}
return nil
}

147
object/pricing.go Normal file
View File

@@ -0,0 +1,147 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"strings"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
)
type Pricing struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
Description string `xorm:"varchar(100)" json:"description"`
Plans []string `xorm:"mediumtext" json:"plans"`
IsEnabled bool `json:"isEnabled"`
HasTrial bool `json:"hasTrial"`
TrialDuration int `json:"trialDuration"`
Application string `xorm:"varchar(100)" json:"application"`
Submitter string `xorm:"varchar(100)" json:"submitter"`
Approver string `xorm:"varchar(100)" json:"approver"`
ApproveTime string `xorm:"varchar(100)" json:"approveTime"`
State string `xorm:"varchar(100)" json:"state"`
}
func GetPricingCount(owner, field, value string) int {
session := GetSession(owner, -1, -1, field, value, "", "")
count, err := session.Count(&Pricing{})
if err != nil {
panic(err)
}
return int(count)
}
func GetPricings(owner string) []*Pricing {
pricings := []*Pricing{}
err := adapter.Engine.Desc("created_time").Find(&pricings, &Pricing{Owner: owner})
if err != nil {
panic(err)
}
return pricings
}
func GetPaginatedPricings(owner string, offset, limit int, field, value, sortField, sortOrder string) []*Pricing {
pricings := []*Pricing{}
session := GetSession(owner, offset, limit, field, value, sortField, sortOrder)
err := session.Find(&pricings)
if err != nil {
panic(err)
}
return pricings
}
func getPricing(owner, name string) *Pricing {
if owner == "" || name == "" {
return nil
}
pricing := Pricing{Owner: owner, Name: name}
existed, err := adapter.Engine.Get(&pricing)
if err != nil {
panic(err)
}
if existed {
return &pricing
} else {
return nil
}
}
func GetPricing(id string) *Pricing {
owner, name := util.GetOwnerAndNameFromId(id)
return getPricing(owner, name)
}
func UpdatePricing(id string, pricing *Pricing) bool {
owner, name := util.GetOwnerAndNameFromId(id)
if getPricing(owner, name) == nil {
return false
}
affected, err := adapter.Engine.ID(core.PK{owner, name}).AllCols().Update(pricing)
if err != nil {
panic(err)
}
return affected != 0
}
func AddPricing(pricing *Pricing) bool {
affected, err := adapter.Engine.Insert(pricing)
if err != nil {
panic(err)
}
return affected != 0
}
func DeletePricing(pricing *Pricing) bool {
affected, err := adapter.Engine.ID(core.PK{pricing.Owner, pricing.Name}).Delete(pricing)
if err != nil {
panic(err)
}
return affected != 0
}
func (pricing *Pricing) GetId() string {
return fmt.Sprintf("%s/%s", pricing.Owner, pricing.Name)
}
func (pricing *Pricing) HasPlan(owner string, plan string) bool {
selectedPlan := GetPlan(fmt.Sprintf("%s/%s", owner, plan))
if selectedPlan == nil {
return false
}
result := false
for _, pricingPlan := range pricing.Plans {
if strings.Contains(pricingPlan, selectedPlan.Name) {
result = true
break
}
}
return result
}

154
object/subscription.go Normal file
View File

@@ -0,0 +1,154 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"time"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
)
const defaultStatus = "Pending"
type Subscription struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
Duration int `json:"duration"`
Description string `xorm:"varchar(100)" json:"description"`
Plan string `xorm:"varchar(100)" json:"plan"`
StartDate time.Time `json:"startDate"`
EndDate time.Time `json:"endDate"`
User string `xorm:"mediumtext" json:"user"`
IsEnabled bool `json:"isEnabled"`
Submitter string `xorm:"varchar(100)" json:"submitter"`
Approver string `xorm:"varchar(100)" json:"approver"`
ApproveTime string `xorm:"varchar(100)" json:"approveTime"`
State string `xorm:"varchar(100)" json:"state"`
}
func NewSubscription(owner string, user string, plan string, duration int) *Subscription {
id := util.GenerateId()[:6]
return &Subscription{
Name: "Subscription_" + id,
DisplayName: "New Subscription - " + id,
Owner: owner,
User: owner + "/" + user,
Plan: owner + "/" + plan,
CreatedTime: util.GetCurrentTime(),
State: defaultStatus,
Duration: duration,
StartDate: time.Now(),
EndDate: time.Now().AddDate(0, 0, duration),
}
}
func GetSubscriptionCount(owner, field, value string) int {
session := GetSession(owner, -1, -1, field, value, "", "")
count, err := session.Count(&Subscription{})
if err != nil {
panic(err)
}
return int(count)
}
func GetSubscriptions(owner string) []*Subscription {
subscriptions := []*Subscription{}
err := adapter.Engine.Desc("created_time").Find(&subscriptions, &Subscription{Owner: owner})
if err != nil {
panic(err)
}
return subscriptions
}
func GetPaginationSubscriptions(owner string, offset, limit int, field, value, sortField, sortOrder string) []*Subscription {
subscriptions := []*Subscription{}
session := GetSession(owner, offset, limit, field, value, sortField, sortOrder)
err := session.Find(&subscriptions)
if err != nil {
panic(err)
}
return subscriptions
}
func getSubscription(owner string, name string) *Subscription {
if owner == "" || name == "" {
return nil
}
subscription := Subscription{Owner: owner, Name: name}
existed, err := adapter.Engine.Get(&subscription)
if err != nil {
panic(err)
}
if existed {
return &subscription
} else {
return nil
}
}
func GetSubscription(id string) *Subscription {
owner, name := util.GetOwnerAndNameFromId(id)
return getSubscription(owner, name)
}
func UpdateSubscription(id string, subscription *Subscription) bool {
owner, name := util.GetOwnerAndNameFromId(id)
if getSubscription(owner, name) == nil {
return false
}
affected, err := adapter.Engine.ID(core.PK{owner, name}).AllCols().Update(subscription)
if err != nil {
panic(err)
}
return affected != 0
}
func AddSubscription(subscription *Subscription) bool {
affected, err := adapter.Engine.Insert(subscription)
if err != nil {
panic(err)
}
return affected != 0
}
func DeleteSubscription(subscription *Subscription) bool {
affected, err := adapter.Engine.ID(core.PK{subscription.Owner, subscription.Name}).Delete(&Subscription{})
if err != nil {
panic(err)
}
return affected != 0
}
func (subscription *Subscription) GetId() string {
return fmt.Sprintf("%s/%s", subscription.Owner, subscription.Name)
}

View File

@@ -125,7 +125,11 @@ func AuthzFilter(ctx *context.Context) {
subOwner, subName := getSubject(ctx) subOwner, subName := getSubject(ctx)
method := ctx.Request.Method method := ctx.Request.Method
urlPath := getUrlPath(ctx.Request.URL.Path) urlPath := getUrlPath(ctx.Request.URL.Path)
objOwner, objName := getObject(ctx)
objOwner, objName := "", ""
if urlPath != "/api/get-app-login" {
objOwner, objName = getObject(ctx)
}
if strings.HasPrefix(urlPath, "/api/notify-payment") { if strings.HasPrefix(urlPath, "/api/notify-payment") {
urlPath = "/api/notify-payment" urlPath = "/api/notify-payment"

View File

@@ -203,6 +203,24 @@ func initAPI() {
beego.Router("/api/add-message", &controllers.ApiController{}, "POST:AddMessage") beego.Router("/api/add-message", &controllers.ApiController{}, "POST:AddMessage")
beego.Router("/api/delete-message", &controllers.ApiController{}, "POST:DeleteMessage") beego.Router("/api/delete-message", &controllers.ApiController{}, "POST:DeleteMessage")
beego.Router("/api/get-subscriptions", &controllers.ApiController{}, "GET:GetSubscriptions")
beego.Router("/api/get-subscription", &controllers.ApiController{}, "GET:GetSubscription")
beego.Router("/api/update-subscription", &controllers.ApiController{}, "POST:UpdateSubscription")
beego.Router("/api/add-subscription", &controllers.ApiController{}, "POST:AddSubscription")
beego.Router("/api/delete-subscription", &controllers.ApiController{}, "POST:DeleteSubscription")
beego.Router("/api/get-plans", &controllers.ApiController{}, "GET:GetPlans")
beego.Router("/api/get-plan", &controllers.ApiController{}, "GET:GetPlan")
beego.Router("/api/update-plan", &controllers.ApiController{}, "POST:UpdatePlan")
beego.Router("/api/add-plan", &controllers.ApiController{}, "POST:AddPlan")
beego.Router("/api/delete-plan", &controllers.ApiController{}, "POST:DeletePlan")
beego.Router("/api/get-pricings", &controllers.ApiController{}, "GET:GetPricings")
beego.Router("/api/get-pricing", &controllers.ApiController{}, "GET:GetPricing")
beego.Router("/api/update-pricing", &controllers.ApiController{}, "POST:UpdatePricing")
beego.Router("/api/add-pricing", &controllers.ApiController{}, "POST:AddPricing")
beego.Router("/api/delete-pricing", &controllers.ApiController{}, "POST:DeletePricing")
beego.Router("/api/get-products", &controllers.ApiController{}, "GET:GetProducts") beego.Router("/api/get-products", &controllers.ApiController{}, "GET:GetProducts")
beego.Router("/api/get-product", &controllers.ApiController{}, "GET:GetProduct") beego.Router("/api/get-product", &controllers.ApiController{}, "GET:GetProduct")
beego.Router("/api/update-product", &controllers.ApiController{}, "POST:UpdateProduct") beego.Router("/api/update-product", &controllers.ApiController{}, "POST:UpdateProduct")

File diff suppressed because it is too large Load Diff

View File

@@ -177,6 +177,42 @@ paths:
description: The Response object description: The Response object
schema: schema:
$ref: '#/definitions/controllers.Response' $ref: '#/definitions/controllers.Response'
/api/add-plan:
post:
tags:
- Plan API
description: add plan
operationId: ApiController.AddPlan
parameters:
- in: body
name: body
description: The details of the plan
required: true
schema:
$ref: '#/definitions/object.Plan'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/add-pricing:
post:
tags:
- Pricing API
description: add pricing
operationId: ApiController.AddPricing
parameters:
- in: body
name: body
description: The details of the pricing
required: true
schema:
$ref: '#/definitions/object.Pricing'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/add-product: /api/add-product:
post: post:
tags: tags:
@@ -278,6 +314,24 @@ paths:
type: array type: array
items: items:
type: string type: string
/api/add-subscription:
post:
tags:
- Subscription API
description: add subscription
operationId: ApiController.AddSubscription
parameters:
- in: body
name: body
description: The details of the subscription
required: true
schema:
$ref: '#/definitions/object.Subscription'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/add-syncer: /api/add-syncer:
post: post:
tags: tags:
@@ -552,6 +606,17 @@ paths:
description: The Response object description: The Response object
schema: schema:
$ref: '#/definitions/controllers.Response' $ref: '#/definitions/controllers.Response'
/api/delete-mfa/:
post:
tags:
- MFA API
description: ': Delete MFA'
operationId: ApiController.DeleteMfa
responses:
"200":
description: object
schema:
$ref: '#/definitions/Response'
/api/delete-model: /api/delete-model:
post: post:
tags: tags:
@@ -624,6 +689,42 @@ paths:
description: The Response object description: The Response object
schema: schema:
$ref: '#/definitions/controllers.Response' $ref: '#/definitions/controllers.Response'
/api/delete-plan:
post:
tags:
- Plan API
description: delete plan
operationId: ApiController.DeletePlan
parameters:
- in: body
name: body
description: The details of the plan
required: true
schema:
$ref: '#/definitions/object.Plan'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/delete-pricing:
post:
tags:
- Pricing API
description: delete pricing
operationId: ApiController.DeletePricing
parameters:
- in: body
name: body
description: The details of the pricing
required: true
schema:
$ref: '#/definitions/object.Pricing'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/delete-product: /api/delete-product:
post: post:
tags: tags:
@@ -702,6 +803,24 @@ paths:
type: array type: array
items: items:
type: string type: string
/api/delete-subscription:
post:
tags:
- Subscription API
description: delete subscription
operationId: ApiController.DeleteSubscription
parameters:
- in: body
name: body
description: The details of the subscription
required: true
schema:
$ref: '#/definitions/object.Subscription'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/delete-syncer: /api/delete-syncer:
post: post:
tags: tags:
@@ -995,6 +1114,19 @@ paths:
type: array type: array
items: items:
$ref: '#/definitions/object.User' $ref: '#/definitions/object.User'
/api/get-globle-certs:
get:
tags:
- Cert API
description: get globle certs
operationId: ApiController.GetGlobleCerts
responses:
"200":
description: The Response object
schema:
type: array
items:
$ref: '#/definitions/object.Cert'
/api/get-ldap: /api/get-ldap:
get: get:
tags: tags:
@@ -1027,6 +1159,23 @@ paths:
description: The Response object description: The Response object
schema: schema:
$ref: '#/definitions/object.Message' $ref: '#/definitions/object.Message'
/api/get-message-answer:
get:
tags:
- Message API
description: get message answer
operationId: ApiController.GetMessageAnswer
parameters:
- in: query
name: id
description: The id ( owner/name ) of the message
required: true
type: string
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/object.Message'
/api/get-messages: /api/get-messages:
get: get:
tags: tags:
@@ -1241,6 +1390,82 @@ paths:
type: array type: array
items: items:
$ref: '#/definitions/object.Permission' $ref: '#/definitions/object.Permission'
/api/get-plan:
get:
tags:
- Plan API
description: get plan
operationId: ApiController.GetPlan
parameters:
- in: query
name: id
description: The id ( owner/name ) of the plan
required: true
type: string
- in: query
name: includeOption
description: Should include plan's option
type: boolean
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/object.Plan'
/api/get-plans:
get:
tags:
- Plan API
description: get plans
operationId: ApiController.GetPlans
parameters:
- in: query
name: owner
description: The owner of plans
required: true
type: string
responses:
"200":
description: The Response object
schema:
type: array
items:
$ref: '#/definitions/object.Plan'
/api/get-pricing:
get:
tags:
- Pricing API
description: get pricing
operationId: ApiController.GetPricing
parameters:
- in: query
name: id
description: The id ( owner/name ) of the pricing
required: true
type: string
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/object.pricing'
/api/get-pricings:
get:
tags:
- Pricing API
description: get pricings
operationId: ApiController.GetPricings
parameters:
- in: query
name: owner
description: The owner of pricings
required: true
type: string
responses:
"200":
description: The Response object
schema:
type: array
items:
$ref: '#/definitions/object.Pricing'
/api/get-product: /api/get-product:
get: get:
tags: tags:
@@ -1277,6 +1502,17 @@ paths:
type: array type: array
items: items:
$ref: '#/definitions/object.Product' $ref: '#/definitions/object.Product'
/api/get-prometheus-info:
get:
tags:
- Prometheus API
description: get Prometheus Info
operationId: ApiController.GetPrometheusInfo
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/object.PrometheusInfo'
/api/get-provider: /api/get-provider:
get: get:
tags: tags:
@@ -1466,6 +1702,42 @@ paths:
type: array type: array
items: items:
$ref: '#/definitions/object.User' $ref: '#/definitions/object.User'
/api/get-subscription:
get:
tags:
- Subscription API
description: get subscription
operationId: ApiController.GetSubscription
parameters:
- in: query
name: id
description: The id ( owner/name ) of the subscription
required: true
type: string
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/object.subscription'
/api/get-subscriptions:
get:
tags:
- Subscription API
description: get subscriptions
operationId: ApiController.GetSubscriptions
parameters:
- in: query
name: owner
description: The owner of subscriptions
required: true
type: string
responses:
"200":
description: The Response object
schema:
type: array
items:
$ref: '#/definitions/object.Subscription'
/api/get-syncer: /api/get-syncer:
get: get:
tags: tags:
@@ -1975,6 +2247,39 @@ paths:
description: The Response object description: The Response object
schema: schema:
$ref: '#/definitions/controllers.Response' $ref: '#/definitions/controllers.Response'
/api/mfa/setup/enable:
post:
tags:
- MFA API
description: enable totp
operationId: ApiController.MfaSetupEnable
responses:
"200":
description: object
schema:
$ref: '#/definitions/Response'
/api/mfa/setup/initiate:
post:
tags:
- MFA API
description: setup MFA
operationId: ApiController.MfaSetupInitiate
responses:
"200":
description: Response object
schema:
$ref: '#/definitions/The'
/api/mfa/setup/verify:
post:
tags:
- MFA API
description: setup verify totp
operationId: ApiController.MfaSetupVerify
responses:
"200":
description: object
schema:
$ref: '#/definitions/Response'
/api/notify-payment: /api/notify-payment:
post: post:
tags: tags:
@@ -2048,6 +2353,17 @@ paths:
description: The Response object description: The Response object
schema: schema:
$ref: '#/definitions/controllers.Response' $ref: '#/definitions/controllers.Response'
/api/set-preferred-mfa:
post:
tags:
- MFA API
description: ': Set specific Mfa Preferred'
operationId: ApiController.SetPreferredMfa
responses:
"200":
description: object
schema:
$ref: '#/definitions/Response'
/api/signup: /api/signup:
post: post:
tags: tags:
@@ -2268,6 +2584,52 @@ paths:
description: The Response object description: The Response object
schema: schema:
$ref: '#/definitions/controllers.Response' $ref: '#/definitions/controllers.Response'
/api/update-plan:
post:
tags:
- Plan API
description: update plan
operationId: ApiController.UpdatePlan
parameters:
- in: query
name: id
description: The id ( owner/name ) of the plan
required: true
type: string
- in: body
name: body
description: The details of the plan
required: true
schema:
$ref: '#/definitions/object.Plan'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/update-pricing:
post:
tags:
- Pricing API
description: update pricing
operationId: ApiController.UpdatePricing
parameters:
- in: query
name: id
description: The id ( owner/name ) of the pricing
required: true
type: string
- in: body
name: body
description: The details of the pricing
required: true
schema:
$ref: '#/definitions/object.Pricing'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/update-product: /api/update-product:
post: post:
tags: tags:
@@ -2361,6 +2723,29 @@ paths:
type: array type: array
items: items:
type: string type: string
/api/update-subscription:
post:
tags:
- Subscription API
description: update subscription
operationId: ApiController.UpdateSubscription
parameters:
- in: query
name: id
description: The id ( owner/name ) of the subscription
required: true
type: string
- in: body
name: body
description: The details of the subscription
required: true
schema:
$ref: '#/definitions/object.Subscription'
responses:
"200":
description: The Response object
schema:
$ref: '#/definitions/controllers.Response'
/api/update-syncer: /api/update-syncer:
post: post:
tags: tags:
@@ -2555,10 +2940,10 @@ paths:
schema: schema:
$ref: '#/definitions/Response' $ref: '#/definitions/Response'
definitions: definitions:
1183.0xc000455050.false: 1183.0x1400042eb70.false:
title: "false" title: "false"
type: object type: object
1217.0xc000455080.false: 1217.0x1400042eba0.false:
title: "false" title: "false"
type: object type: object
LaravelResponse: LaravelResponse:
@@ -2567,6 +2952,9 @@ definitions:
Response: Response:
title: Response title: Response
type: object type: object
The:
title: The
type: object
controllers.AuthForm: controllers.AuthForm:
title: AuthForm title: AuthForm
type: object type: object
@@ -2591,9 +2979,9 @@ definitions:
type: object type: object
properties: properties:
data: data:
$ref: '#/definitions/1183.0xc000455050.false' $ref: '#/definitions/1183.0x1400042eb70.false'
data2: data2:
$ref: '#/definitions/1217.0xc000455080.false' $ref: '#/definitions/1217.0x1400042eba0.false'
msg: msg:
type: string type: string
name: name:
@@ -2799,6 +3187,17 @@ definitions:
type: array type: array
items: items:
type: string type: string
object.GaugeVecInfo:
title: GaugeVecInfo
type: object
properties:
method:
type: string
name:
type: string
throughput:
type: number
format: double
object.Header: object.Header:
title: Header title: Header
type: object type: object
@@ -2807,6 +3206,19 @@ definitions:
type: string type: string
value: value:
type: string type: string
object.HistogramVecInfo:
title: HistogramVecInfo
type: object
properties:
count:
type: integer
format: int64
latency:
type: string
method:
type: string
name:
type: string
object.IntrospectionResponse: object.IntrospectionResponse:
title: IntrospectionResponse title: IntrospectionResponse
type: object type: object
@@ -2868,8 +3280,30 @@ definitions:
type: string type: string
owner: owner:
type: string type: string
replyTo:
type: string
text: text:
type: string type: string
object.MfaProps:
title: MfaProps
type: object
properties:
countryCode:
type: string
id:
type: string
isPreferred:
type: boolean
recoveryCodes:
type: array
items:
type: string
secret:
type: string
type:
type: string
url:
type: string
object.Model: object.Model:
title: Model title: Model
type: object type: object
@@ -3098,6 +3532,67 @@ definitions:
type: array type: array
items: items:
type: string type: string
object.Plan:
title: Plan
type: object
properties:
createdTime:
type: string
currency:
type: string
description:
type: string
displayName:
type: string
name:
type: string
owner:
type: string
pricePerMonth:
type: number
format: double
pricePerYear:
type: number
format: double
role:
type: string
options:
type: array
object.Pricing:
title: Pricing
type: object
properties:
application:
type: string
approveTime:
type: string
approver:
type: string
createdTime:
type: string
description:
type: string
displayName:
type: string
hasTrial:
type: boolean
isEnabled:
type: boolean
name:
type: string
owner:
type: string
plans:
type: array
items:
type: string
state:
type: string
submitter:
type: string
trialDuration:
type: integer
format: int64
object.Product: object.Product:
title: Product title: Product
type: object type: object
@@ -3141,6 +3636,21 @@ definitions:
type: string type: string
tag: tag:
type: string type: string
object.PrometheusInfo:
title: PrometheusInfo
type: object
properties:
apiLatency:
type: array
items:
$ref: '#/definitions/object.HistogramVecInfo'
apiThroughput:
type: array
items:
$ref: '#/definitions/object.GaugeVecInfo'
totalThroughput:
type: number
format: double
object.Provider: object.Provider:
title: Provider title: Provider
type: object type: object
@@ -3313,6 +3823,43 @@ definitions:
type: string type: string
visible: visible:
type: boolean type: boolean
object.Subscription:
title: Subscription
type: object
properties:
approveTime:
type: string
approver:
type: string
createdTime:
type: string
description:
type: string
displayName:
type: string
duration:
type: integer
format: int64
endDate:
type: string
format: datetime
isEnabled:
type: boolean
name:
type: string
owner:
type: string
plan:
type: string
startDate:
type: string
format: datetime
state:
type: string
submitter:
type: string
user:
type: string
object.Syncer: object.Syncer:
title: Syncer title: Syncer
type: object type: object
@@ -3612,6 +4159,10 @@ definitions:
type: string type: string
microsoftonline: microsoftonline:
type: string type: string
multiFactorAuths:
type: array
items:
$ref: '#/definitions/object.MfaProps'
name: name:
type: string type: string
naver: naver:
@@ -3778,6 +4329,12 @@ definitions:
type: string type: string
url: url:
type: string type: string
object.pricing:
title: pricing
type: object
object.subscription:
title: subscription
type: object
protocol.CredentialAssertion: protocol.CredentialAssertion:
title: CredentialAssertion title: CredentialAssertion
type: object type: object

View File

@@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@ant-design/cssinjs": "^1.9.0", "@ant-design/cssinjs": "^1.8.1",
"@ant-design/icons": "^4.7.0", "@ant-design/icons": "^4.7.0",
"@craco/craco": "^6.4.5", "@craco/craco": "^6.4.5",
"@crowdin/cli": "^3.7.10", "@crowdin/cli": "^3.7.10",
@@ -12,7 +12,7 @@
"@testing-library/jest-dom": "^4.2.4", "@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2", "@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2", "@testing-library/user-event": "^7.1.2",
"antd": "5.5.0", "antd": "5.2.3",
"antd-token-previewer": "^1.1.0-22", "antd-token-previewer": "^1.1.0-22",
"codemirror": "^5.61.1", "codemirror": "^5.61.1",
"copy-to-clipboard": "^3.3.1", "copy-to-clipboard": "^3.3.1",

View File

@@ -44,6 +44,12 @@ import SyncerListPage from "./SyncerListPage";
import SyncerEditPage from "./SyncerEditPage"; import SyncerEditPage from "./SyncerEditPage";
import CertListPage from "./CertListPage"; import CertListPage from "./CertListPage";
import CertEditPage from "./CertEditPage"; import CertEditPage from "./CertEditPage";
import SubscriptionListPage from "./SubscriptionListPage";
import SubscriptionEditPage from "./SubscriptionEditPage";
import PricingListPage from "./PricingListPage";
import PricingEditPage from "./PricingEditPage";
import PlanListPage from "./PlanListPage";
import PlanEditPage from "./PlanEditPage";
import ChatListPage from "./ChatListPage"; import ChatListPage from "./ChatListPage";
import ChatEditPage from "./ChatEditPage"; import ChatEditPage from "./ChatEditPage";
import ChatPage from "./ChatPage"; import ChatPage from "./ChatPage";
@@ -168,6 +174,12 @@ class App extends Component {
this.setState({selectedMenuKey: "/result"}); this.setState({selectedMenuKey: "/result"});
} else if (uri.includes("/sysinfo")) { } else if (uri.includes("/sysinfo")) {
this.setState({selectedMenuKey: "/sysinfo"}); this.setState({selectedMenuKey: "/sysinfo"});
} else if (uri.includes("/subscriptions")) {
this.setState({selectedMenuKey: "/subscriptions"});
} else if (uri.includes("/plans")) {
this.setState({selectedMenuKey: "/plans"});
} else if (uri.includes("/pricings")) {
this.setState({selectedMenuKey: "/pricings"});
} else { } else {
this.setState({selectedMenuKey: -1}); this.setState({selectedMenuKey: -1});
} }
@@ -335,6 +347,8 @@ class App extends Component {
const onClick = (e) => { const onClick = (e) => {
if (e.key === "/account") { if (e.key === "/account") {
this.props.history.push("/account"); this.props.history.push("/account");
} else if (e.key === "/subscription") {
this.props.history.push("/subscription");
} else if (e.key === "/chat") { } else if (e.key === "/chat") {
this.props.history.push("/chat"); this.props.history.push("/chat");
} else if (e.key === "/logout") { } else if (e.key === "/logout") {
@@ -444,6 +458,19 @@ class App extends Component {
res.push(Setting.getItem(<Link to="/records">{i18next.t("general:Records")}</Link>, res.push(Setting.getItem(<Link to="/records">{i18next.t("general:Records")}</Link>,
"/records" "/records"
)); ));
res.push(Setting.getItem(<Link to="/plans">{i18next.t("general:Plans")}</Link>,
"/plans"
));
res.push(Setting.getItem(<Link to="/pricings">{i18next.t("general:Pricings")}</Link>,
"/pricings"
));
res.push(Setting.getItem(<Link to="/subscriptions">{i18next.t("general:Subscriptions")}</Link>,
"/subscriptions"
));
} }
if (Setting.isLocalAdminUser(this.state.account)) { if (Setting.isLocalAdminUser(this.state.account)) {
@@ -468,6 +495,7 @@ class App extends Component {
)); ));
if (Conf.EnableExtraPages) { if (Conf.EnableExtraPages) {
res.push(Setting.getItem(<Link to="/products">{i18next.t("general:Products")}</Link>, res.push(Setting.getItem(<Link to="/products">{i18next.t("general:Products")}</Link>,
"/products" "/products"
)); ));
@@ -556,6 +584,12 @@ class App extends Component {
<Route exact path="/chat" render={(props) => this.renderLoginIfNotLoggedIn(<ChatPage account={this.state.account} {...props} />)} /> <Route exact path="/chat" render={(props) => this.renderLoginIfNotLoggedIn(<ChatPage account={this.state.account} {...props} />)} />
<Route exact path="/messages" render={(props) => this.renderLoginIfNotLoggedIn(<MessageListPage account={this.state.account} {...props} />)} /> <Route exact path="/messages" render={(props) => this.renderLoginIfNotLoggedIn(<MessageListPage account={this.state.account} {...props} />)} />
<Route exact path="/messages/:messageName" render={(props) => this.renderLoginIfNotLoggedIn(<MessageEditPage account={this.state.account} {...props} />)} /> <Route exact path="/messages/:messageName" render={(props) => this.renderLoginIfNotLoggedIn(<MessageEditPage account={this.state.account} {...props} />)} />
<Route exact path="/plans" render={(props) => this.renderLoginIfNotLoggedIn(<PlanListPage account={this.state.account} {...props} />)} />
<Route exact path="/plan/:organizationName/:planName" render={(props) => this.renderLoginIfNotLoggedIn(<PlanEditPage account={this.state.account} {...props} />)} />
<Route exact path="/pricings" render={(props) => this.renderLoginIfNotLoggedIn(<PricingListPage account={this.state.account} {...props} />)} />
<Route exact path="/pricing/:organizationName/:pricingName" render={(props) => this.renderLoginIfNotLoggedIn(<PricingEditPage account={this.state.account} {...props} />)} />
<Route exact path="/subscriptions" render={(props) => this.renderLoginIfNotLoggedIn(<SubscriptionListPage account={this.state.account} {...props} />)} />
<Route exact path="/subscription/:organizationName/:subscriptionName" render={(props) => this.renderLoginIfNotLoggedIn(<SubscriptionEditPage account={this.state.account} {...props} />)} />
<Route exact path="/products" render={(props) => this.renderLoginIfNotLoggedIn(<ProductListPage account={this.state.account} {...props} />)} /> <Route exact path="/products" render={(props) => this.renderLoginIfNotLoggedIn(<ProductListPage account={this.state.account} {...props} />)} />
<Route exact path="/products/:productName" render={(props) => this.renderLoginIfNotLoggedIn(<ProductEditPage account={this.state.account} {...props} />)} /> <Route exact path="/products/:productName" render={(props) => this.renderLoginIfNotLoggedIn(<ProductEditPage account={this.state.account} {...props} />)} />
<Route exact path="/products/:productName/buy" render={(props) => this.renderLoginIfNotLoggedIn(<ProductBuyPage account={this.state.account} {...props} />)} /> <Route exact path="/products/:productName/buy" render={(props) => this.renderLoginIfNotLoggedIn(<ProductBuyPage account={this.state.account} {...props} />)} />
@@ -674,7 +708,8 @@ class App extends Component {
window.location.pathname.startsWith("/prompt") || window.location.pathname.startsWith("/prompt") ||
window.location.pathname.startsWith("/result") || window.location.pathname.startsWith("/result") ||
window.location.pathname.startsWith("/cas") || window.location.pathname.startsWith("/cas") ||
window.location.pathname.startsWith("/auto-signup"); window.location.pathname.startsWith("/auto-signup") ||
window.location.pathname.startsWith("/select-plan");
} }
renderPage() { renderPage() {

View File

@@ -16,6 +16,8 @@ import React from "react";
import {Redirect, Route, Switch} from "react-router-dom"; import {Redirect, Route, Switch} from "react-router-dom";
import {Spin} from "antd"; import {Spin} from "antd";
import i18next from "i18next"; import i18next from "i18next";
import * as ApplicationBackend from "./backend/ApplicationBackend";
import PricingPage from "./pricing/PricingPage";
import * as Setting from "./Setting"; import * as Setting from "./Setting";
import * as Conf from "./Conf"; import * as Conf from "./Conf";
import SignupPage from "./auth/SignupPage"; import SignupPage from "./auth/SignupPage";
@@ -33,6 +35,7 @@ class EntryPage extends React.Component {
super(props); super(props);
this.state = { this.state = {
application: undefined, application: undefined,
pricing: undefined,
}; };
} }
@@ -65,9 +68,23 @@ class EntryPage extends React.Component {
this.props.updataThemeData(themeData); this.props.updataThemeData(themeData);
}; };
const onUpdatePricing = (pricing) => {
this.setState({
pricing: pricing,
});
ApplicationBackend.getApplication("admin", pricing.application)
.then((application) => {
const themeData = application !== null ? Setting.getThemeData(application.organizationObj, application) : Conf.ThemeDefault;
this.props.updataThemeData(themeData);
});
};
return ( return (
<div className="loginBackground" style={{backgroundImage: Setting.inIframe() || Setting.isMobile() ? null : `url(${this.state.application?.formBackgroundUrl})`}}> <div className="loginBackground"
<Spin size="large" spinning={this.state.application === undefined} tip={i18next.t("login:Loading")} style={{margin: "0 auto"}} /> style={{backgroundImage: Setting.inIframe() || Setting.isMobile() ? null : `url(${this.state.application?.formBackgroundUrl})`}}>
<Spin size="large" spinning={this.state.application === undefined && this.state.pricing === undefined} tip={i18next.t("login:Loading")}
style={{margin: "0 auto"}} />
<Switch> <Switch>
<Route exact path="/signup" render={(props) => this.renderHomeIfLoggedIn(<SignupPage {...this.props} application={this.state.application} applicationName={authConfig.appName} onUpdateApplication={onUpdateApplication} {...props} />)} /> <Route exact path="/signup" render={(props) => this.renderHomeIfLoggedIn(<SignupPage {...this.props} application={this.state.application} applicationName={authConfig.appName} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/signup/:applicationName" render={(props) => this.renderHomeIfLoggedIn(<SignupPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} /> <Route exact path="/signup/:applicationName" render={(props) => this.renderHomeIfLoggedIn(<SignupPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
@@ -85,6 +102,7 @@ class EntryPage extends React.Component {
<Route exact path="/result/:applicationName" render={(props) => this.renderHomeIfLoggedIn(<ResultPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} /> <Route exact path="/result/:applicationName" render={(props) => this.renderHomeIfLoggedIn(<ResultPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/cas/:owner/:casApplicationName/logout" render={(props) => this.renderHomeIfLoggedIn(<CasLogout {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} /> <Route exact path="/cas/:owner/:casApplicationName/logout" render={(props) => this.renderHomeIfLoggedIn(<CasLogout {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/cas/:owner/:casApplicationName/login" render={(props) => {return (<LoginPage {...this.props} application={this.state.application} type={"cas"} mode={"signin"} onUpdateApplication={onUpdateApplication} {...props} />);}} /> <Route exact path="/cas/:owner/:casApplicationName/login" render={(props) => {return (<LoginPage {...this.props} application={this.state.application} type={"cas"} mode={"signin"} onUpdateApplication={onUpdateApplication} {...props} />);}} />
<Route exact path="/select-plan/:pricingName" render={(props) => this.renderHomeIfLoggedIn(<PricingPage {...this.props} pricing={this.state.pricing} onUpdatePricing={onUpdatePricing} {...props} />)} />
</Switch> </Switch>
</div> </div>
); );

273
web/src/PlanEditPage.js Normal file
View File

@@ -0,0 +1,273 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Button, Card, Col, Input, InputNumber, Row, Select, Switch} from "antd";
import * as OrganizationBackend from "./backend/OrganizationBackend";
import * as RoleBackend from "./backend/RoleBackend";
import * as PlanBackend from "./backend/PlanBackend";
import * as UserBackend from "./backend/UserBackend";
import * as Setting from "./Setting";
import i18next from "i18next";
const {Option} = Select;
class PlanEditPage extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
organizationName: props.organizationName !== undefined ? props.organizationName : props.match.params.organizationName,
planName: props.match.params.planName,
plan: null,
organizations: [],
users: [],
roles: [],
providers: [],
mode: props.location.mode !== undefined ? props.location.mode : "edit",
};
}
UNSAFE_componentWillMount() {
this.getPlan();
this.getOrganizations();
}
getPlan() {
PlanBackend.getPlan(this.state.organizationName, this.state.planName)
.then((plan) => {
this.setState({
plan: plan,
});
this.getUsers(plan.owner);
this.getRoles(plan.owner);
});
}
getRoles(organizationName) {
RoleBackend.getRoles(organizationName)
.then((res) => {
this.setState({
roles: res,
});
});
}
getUsers(organizationName) {
UserBackend.getUsers(organizationName)
.then((res) => {
this.setState({
users: res,
});
});
}
getOrganizations() {
OrganizationBackend.getOrganizations("admin")
.then((res) => {
this.setState({
organizations: (res.msg === undefined) ? res : [],
});
});
}
parsePlanField(key, value) {
if ([""].includes(key)) {
value = Setting.myParseInt(value);
}
return value;
}
updatePlanField(key, value) {
value = this.parsePlanField(key, value);
const plan = this.state.plan;
plan[key] = value;
this.setState({
plan: plan,
});
}
renderPlan() {
return (
<Card size="small" title={
<div>
{this.state.mode === "add" ? i18next.t("plan:New Plan") : i18next.t("plan:Edit Plan")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button onClick={() => this.submitPlanEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" onClick={() => this.submitPlanEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} onClick={() => this.deletePlan()}>{i18next.t("general:Cancel")}</Button> : null}
</div>
} style={(Setting.isMobile()) ? {margin: "5px"} : {}} type="inner">
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Organization"), i18next.t("general:Organization - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.plan.owner} onChange={(owner => {
this.updatePlanField("owner", owner);
this.getUsers(owner);
this.getRoles(owner);
})}
options={this.state.organizations.map((organization) => Setting.getOption(organization.name, organization.name))
} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Name"), i18next.t("general:Name - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.plan.name} onChange={e => {
this.updatePlanField("name", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Display name"), i18next.t("general:Display name - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.plan.displayName} onChange={e => {
this.updatePlanField("displayName", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("role:Sub roles"), i18next.t("plan:Sub roles - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.plan.role} onChange={(value => {this.updatePlanField("role", value);})}
options={this.state.roles.map((role) => Setting.getOption(`${role.owner}/${role.name}`, `${role.owner}/${role.name}`))
} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Description"), i18next.t("general:Description - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.plan.description} onChange={e => {
this.updatePlanField("description", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("plan:PricePerMonth"), i18next.t("plan:PricePerMonth - Tooltip"))} :
</Col>
<Col span={22} >
<InputNumber value={this.state.plan.pricePerMonth} onChange={value => {
this.updatePlanField("pricePerMonth", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("plan:PricePerYear"), i18next.t("plan:PricePerYear - Tooltip"))} :
</Col>
<Col span={22} >
<InputNumber value={this.state.plan.pricePerYear} onChange={value => {
this.updatePlanField("pricePerYear", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("payment:Currency"), i18next.t("payment:Currency - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.plan.currency} onChange={(value => {
this.updatePlanField("currency", value);
})}>
{
[
{id: "USD", name: "USD"},
{id: "CNY", name: "CNY"},
].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
{Setting.getLabel(i18next.t("general:Is enabled"), i18next.t("general:Is enabled - Tooltip"))} :
</Col>
<Col span={1} >
<Switch checked={this.state.plan.isEnabled} onChange={checked => {
this.updatePlanField("isEnabled", checked);
}} />
</Col>
</Row>
</Card>
);
}
submitPlanEdit(willExist) {
const plan = Setting.deepCopy(this.state.plan);
PlanBackend.updatePlan(this.state.organizationName, this.state.planName, plan)
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully saved"));
this.setState({
planName: this.state.plan.name,
});
if (willExist) {
this.props.history.push("/plans");
} else {
this.props.history.push(`/plan/${this.state.plan.owner}/${this.state.plan.name}`);
}
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to save")}: ${res.msg}`);
this.updatePlanField("name", this.state.planName);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
deletePlan() {
PlanBackend.deletePlan(this.state.plan)
.then((res) => {
if (res.status === "ok") {
this.props.history.push("/plans");
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
render() {
return (
<div>
{
this.state.plan !== null ? this.renderPlan() : null
}
<div style={{marginTop: "20px", marginLeft: "40px"}}>
<Button size="large" onClick={() => this.submitPlanEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" size="large" onClick={() => this.submitPlanEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} size="large" onClick={() => this.deletePlan()}>{i18next.t("general:Cancel")}</Button> : null}
</div>
</div>
);
}
}
export default PlanEditPage;

236
web/src/PlanListPage.js Normal file
View File

@@ -0,0 +1,236 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Link} from "react-router-dom";
import {Button, Switch, Table} from "antd";
import moment from "moment";
import * as Setting from "./Setting";
import * as PlanBackend from "./backend/PlanBackend";
import i18next from "i18next";
import BaseListPage from "./BaseListPage";
import PopconfirmModal from "./common/modal/PopconfirmModal";
class PlanListPage extends BaseListPage {
newPlan() {
const randomName = Setting.getRandomName();
const owner = (this.state.organizationName !== undefined) ? this.state.organizationName : this.props.account.owner;
return {
owner: owner,
name: `plan_${randomName}`,
createdTime: moment().format(),
pricePerMonth: 10,
pricePerYear: 100,
currency: "USD",
displayName: `New Plan - ${randomName}`,
};
}
addPlan() {
const newPlan = this.newPlan();
PlanBackend.addPlan(newPlan)
.then((res) => {
if (res.status === "ok") {
this.props.history.push({pathname: `/plan/${newPlan.owner}/${newPlan.name}`, mode: "add"});
Setting.showMessage("success", i18next.t("general:Successfully added"));
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to add")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
deletePlan(i) {
PlanBackend.deletePlan(this.state.data[i])
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully deleted"));
this.setState({
data: Setting.deleteRow(this.state.data, i),
pagination: {total: this.state.pagination.total - 1},
});
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
renderTable(plans) {
const columns = [
{
title: i18next.t("general:Name"),
dataIndex: "name",
key: "name",
width: "140px",
fixed: "left",
sorter: true,
...this.getColumnSearchProps("name"),
render: (text, record, index) => {
return (
<Link to={`/plans/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Organization"),
dataIndex: "owner",
key: "owner",
width: "120px",
sorter: true,
...this.getColumnSearchProps("owner"),
render: (text, record, index) => {
return (
<Link to={`/organizations/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Created time"),
dataIndex: "createdTime",
key: "createdTime",
width: "160px",
sorter: true,
render: (text, record, index) => {
return Setting.getFormattedDate(text);
},
},
{
title: i18next.t("general:Display name"),
dataIndex: "displayName",
key: "displayName",
width: "170px",
sorter: true,
...this.getColumnSearchProps("displayName"),
},
{
title: i18next.t("plan:Price per month"),
dataIndex: "pricePerMonth",
key: "pricePerMonth",
width: "130px",
...this.getColumnSearchProps("pricePerMonth"),
},
{
title: i18next.t("plan:Price per year"),
dataIndex: "pricePerYear",
key: "pricePerYear",
width: "130px",
...this.getColumnSearchProps("pricePerYear"),
},
{
title: i18next.t("plan:Sub role"),
dataIndex: "role",
key: "role",
width: "140px",
...this.getColumnSearchProps("role"),
},
{
title: i18next.t("general:Is enabled"),
dataIndex: "isEnabled",
key: "isEnabled",
width: "120px",
sorter: true,
render: (text, record, index) => {
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
);
},
},
{
title: i18next.t("general:Action"),
dataIndex: "",
key: "op",
width: "200px",
fixed: (Setting.isMobile()) ? "false" : "right",
render: (text, record, index) => {
return (
<div>
<Button style={{marginTop: "10px", marginBottom: "10px", marginRight: "10px"}} type="primary" onClick={() => this.props.history.push(`/plan/${record.owner}/${record.name}`)}>{i18next.t("general:Edit")}</Button>
<PopconfirmModal
title={i18next.t("general:Sure to delete") + `: ${record.name} ?`}
onConfirm={() => this.deletePlan(index)}
>
</PopconfirmModal>
</div>
);
},
},
];
const paginationProps = {
total: this.state.pagination.total,
showQuickJumper: true,
showSizeChanger: true,
showTotal: () => i18next.t("general:{total} in total").replace("{total}", this.state.pagination.total),
};
return (
<div>
<Table scroll={{x: "max-content"}} columns={columns} dataSource={plans} rowKey="name" size="middle" bordered pagination={paginationProps}
title={() => (
<div>
{i18next.t("general:Plans")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button type="primary" size="small" onClick={this.addPlan.bind(this)}>{i18next.t("general:Add")}</Button>
</div>
)}
loading={this.state.loading}
onChange={this.handleTableChange}
/>
</div>
);
}
fetch = (params = {}) => {
let field = params.searchedColumn, value = params.searchText;
const sortField = params.sortField, sortOrder = params.sortOrder;
if (params.type !== undefined && params.type !== null) {
field = "type";
value = params.type;
}
this.setState({loading: true});
PlanBackend.getPlans("", params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
.then((res) => {
if (res.status === "ok") {
this.setState({
loading: false,
data: res.data,
pagination: {
...params.pagination,
total: res.data2,
},
searchText: params.searchText,
searchedColumn: params.searchedColumn,
});
} else {
if (Setting.isResponseDenied(res)) {
this.setState({
loading: false,
isAuthorized: false,
});
}
}
});
};
}
export default PlanListPage;

309
web/src/PricingEditPage.js Normal file
View File

@@ -0,0 +1,309 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {CopyOutlined} from "@ant-design/icons";
import copy from "copy-to-clipboard";
import React from "react";
import {Button, Card, Col, Input, InputNumber, Row, Select, Switch} from "antd";
import * as ApplicationBackend from "./backend/ApplicationBackend";
import * as OrganizationBackend from "./backend/OrganizationBackend";
import * as PricingBackend from "./backend/PricingBackend";
import * as PlanBackend from "./backend/PlanBackend";
import PricingPage from "./pricing/PricingPage";
import * as Setting from "./Setting";
import i18next from "i18next";
class PricingEditPage extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
organizationName: props.organizationName !== undefined ? props.organizationName : props.match.params.organizationName,
pricingName: props.match.params.pricingName,
organizations: [],
application: null,
applications: [],
pricing: null,
plans: [],
mode: props.location.mode !== undefined ? props.location.mode : "edit",
};
}
UNSAFE_componentWillMount() {
this.getPricing();
this.getOrganizations();
this.getApplicationsByOrganization(this.state.organizationName);
this.getUserApplication();
}
getPricing() {
PricingBackend.getPricing(this.state.organizationName, this.state.pricingName)
.then((pricing) => {
this.setState({
pricing: pricing,
});
this.getPlans(pricing.owner);
});
}
getPlans(organizationName) {
PlanBackend.getPlans(organizationName)
.then((res) => {
this.setState({
plans: res,
});
});
}
getOrganizations() {
OrganizationBackend.getOrganizations("admin")
.then((res) => {
this.setState({
organizations: (res.msg === undefined) ? res : [],
});
});
}
parsePricingField(key, value) {
if ([""].includes(key)) {
value = Setting.myParseInt(value);
}
return value;
}
updatePricingField(key, value) {
value = this.parsePricingField(key, value);
const pricing = this.state.pricing;
pricing[key] = value;
this.setState({
pricing: pricing,
});
}
getApplicationsByOrganization(organizationName) {
ApplicationBackend.getApplicationsByOrganization("admin", organizationName)
.then((res) => {
this.setState({
applications: (res.msg === undefined) ? res : [],
});
});
}
getUserApplication() {
ApplicationBackend.getUserApplication(this.state.organizationName, this.state.userName)
.then((application) => {
this.setState({
application: application,
});
});
}
renderPricing() {
return (
<Card size="small" title={
<div>
{this.state.mode === "add" ? i18next.t("pricing:New Pricing") : i18next.t("pricing:Edit Pricing")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button onClick={() => this.submitPricingEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" onClick={() => this.submitPricingEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} onClick={() => this.deletePricing()}>{i18next.t("general:Cancel")}</Button> : null}
</div>
} style={(Setting.isMobile()) ? {margin: "5px"} : {}} type="inner">
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Organization"), i18next.t("general:Organization - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.pricing.owner} onChange={(owner => {
this.updatePricingField("owner", owner);
this.getApplicationsByOrganization(owner);
this.getPlans(owner);
})}
options={this.state.organizations.map((organization) => Setting.getOption(organization.name, organization.name))
} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Name"), i18next.t("general:Name - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.pricing.name} onChange={e => {
this.updatePricingField("name", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Display name"), i18next.t("general:Display name - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.pricing.displayName} onChange={e => {
this.updatePricingField("displayName", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Description"), i18next.t("general:Description - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.pricing.description} onChange={e => {
this.updatePricingField("description", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Application"), i18next.t("general:Application - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.pricing.application}
onChange={(value => {this.updatePricingField("application", value);})}
options={this.state.applications.map((application) => Setting.getOption(application.name, application.name))
} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("pricing:Sub plans"), i18next.t("Pricing:Sub plans - Tooltip"))} :
</Col>
<Col span={22} >
<Select mode="tags" style={{width: "100%"}} value={this.state.pricing.plans}
onChange={(value => {
this.updatePricingField("plans", value);
})}
options={this.state.plans.map((plan) => Setting.getOption(`${plan.owner}/${plan.name}`, `${plan.owner}/${plan.name}`))}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
{Setting.getLabel(i18next.t("pricing:Has trial"), i18next.t("pricing:Has trial - Tooltip"))} :
</Col>
<Col span={1} >
<Switch disabled={true} checked={this.state.pricing.hasTrial} onChange={checked => {
this.updatePricingField("hasTrial", checked);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("pricing:Trial duration"), i18next.t("pricing:Trial duration - Tooltip"))} :
</Col>
<Col span={22} >
<InputNumber min={1} value={this.state.pricing.trialDuration} onChange={value => {
this.updatePricingField("trialDuration", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
{Setting.getLabel(i18next.t("general:Is enabled"), i18next.t("general:Is enabled - Tooltip"))} :
</Col>
<Col span={1} >
<Switch checked={this.state.pricing.isEnabled} onChange={checked => {
this.updatePricingField("isEnabled", checked);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Preview"), i18next.t("general:Preview - Tooltip"))} :
</Col>
{
this.renderPreview()
}
</Row>
</Card>
);
}
submitPricingEdit(willExist) {
const pricing = Setting.deepCopy(this.state.pricing);
PricingBackend.updatePricing(this.state.organizationName, this.state.pricingName, pricing)
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully saved"));
this.setState({
pricingName: this.state.pricing.name,
});
if (willExist) {
this.props.history.push("/pricings");
} else {
this.props.history.push(`/pricing/${this.state.pricing.owner}/${this.state.pricing.name}`);
}
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to save")}: ${res.msg}`);
this.updatePricingField("name", this.state.pricingName);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
deletePricing() {
PricingBackend.deletePricing(this.state.pricing)
.then((res) => {
if (res.status === "ok") {
this.props.history.push("/pricings");
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
render() {
return (
<div>
{
this.state.pricing !== null ? this.renderPricing() : null
}
<div style={{marginTop: "20px", marginLeft: "40px"}}>
<Button size="large" onClick={() => this.submitPricingEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" size="large" onClick={() => this.submitPricingEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} size="large" onClick={() => this.deletePricing()}>{i18next.t("general:Cancel")}</Button> : null}
</div>
</div>
);
}
renderPreview() {
const pricingUrl = `/select-plan/${this.state.pricing.name}`;
return (
<React.Fragment>
<Col>
<Button style={{marginBottom: "10px", marginTop: Setting.isMobile() ? "15px" : "0"}} type="primary" shape="round" icon={<CopyOutlined />} onClick={() => {
copy(`${window.location.origin}${pricingUrl}`);
Setting.showMessage("success", i18next.t("pricing:pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser"));
}}
>
{i18next.t("pricing:Copy pricing page URL")}
</Button>
</Col>
<Col>
<PricingPage pricing={this.state.pricing}></PricingPage>
</Col>
</React.Fragment>
);
}
}
export default PricingEditPage;

217
web/src/PricingListPage.js Normal file
View File

@@ -0,0 +1,217 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Link} from "react-router-dom";
import {Button, Switch, Table} from "antd";
import moment from "moment";
import * as Setting from "./Setting";
import * as PricingBackend from "./backend/PricingBackend";
import i18next from "i18next";
import BaseListPage from "./BaseListPage";
import PopconfirmModal from "./common/modal/PopconfirmModal";
class PricingListPage extends BaseListPage {
newPricing() {
const randomName = Setting.getRandomName();
const owner = (this.state.organizationName !== undefined) ? this.state.organizationName : this.props.account.owner;
return {
owner: owner,
name: `pricing_${randomName}`,
createdTime: moment().format(),
plans: [],
displayName: `New Pricing - ${randomName}`,
hasTrial: true,
isEnabled: true,
trialDuration: 14,
};
}
addPricing() {
const newPricing = this.newPricing();
PricingBackend.addPricing(newPricing)
.then((res) => {
if (res.status === "ok") {
this.props.history.push({pathname: `/pricing/${newPricing.owner}/${newPricing.name}`, mode: "add"});
Setting.showMessage("success", i18next.t("general:Successfully added"));
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to add")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
deletePricing(i) {
PricingBackend.deletePricing(this.state.data[i])
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully deleted"));
this.setState({
data: Setting.deleteRow(this.state.data, i),
pagination: {total: this.state.pagination.total - 1},
});
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
renderTable(pricings) {
const columns = [
{
title: i18next.t("general:Name"),
dataIndex: "name",
key: "name",
width: "140px",
fixed: "left",
sorter: true,
...this.getColumnSearchProps("name"),
render: (text, record, index) => {
return (
<Link to={`/pricing/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Organization"),
dataIndex: "owner",
key: "owner",
width: "120px",
sorter: true,
...this.getColumnSearchProps("owner"),
render: (text, record, index) => {
return (
<Link to={`/organizations/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Created time"),
dataIndex: "createdTime",
key: "createdTime",
width: "160px",
sorter: true,
render: (text, record, index) => {
return Setting.getFormattedDate(text);
},
},
{
title: i18next.t("general:Display name"),
dataIndex: "displayName",
key: "displayName",
width: "170px",
sorter: true,
...this.getColumnSearchProps("displayName"),
},
{
title: i18next.t("general:Is enabled"),
dataIndex: "isEnabled",
key: "isEnabled",
width: "120px",
sorter: true,
render: (text, record, index) => {
return (
<Switch disabled checkedChildren="ON" unCheckedChildren="OFF" checked={text} />
);
},
},
{
title: i18next.t("general:Action"),
dataIndex: "",
key: "op",
width: "230px",
fixed: (Setting.isMobile()) ? "false" : "right",
render: (text, record, index) => {
return (
<div>
<Button style={{marginTop: "10px", marginBottom: "10px", marginRight: "10px"}} type="primary" onClick={() => this.props.history.push(`/pricing/${record.owner}/${record.name}`)}>{i18next.t("general:Edit")}</Button>
<PopconfirmModal
title={i18next.t("general:Sure to delete") + `: ${record.name} ?`}
onConfirm={() => this.deletePricing(index)}
>
</PopconfirmModal>
</div>
);
},
},
];
const paginationProps = {
total: this.state.pagination.total,
showQuickJumper: true,
showSizeChanger: true,
showTotal: () => i18next.t("general:{total} in total").replace("{total}", this.state.pagination.total),
};
return (
<div>
<Table scroll={{x: "max-content"}} columns={columns} dataSource={pricings} rowKey="name" size="middle" bordered pagination={paginationProps}
title={() => (
<div>
{i18next.t("general:Pricings")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button type="primary" size="small" onClick={this.addPricing.bind(this)}>{i18next.t("general:Add")}</Button>
</div>
)}
loading={this.state.loading}
onChange={this.handleTableChange}
/>
</div>
);
}
fetch = (params = {}) => {
let field = params.searchedColumn, value = params.searchText;
const sortField = params.sortField, sortOrder = params.sortOrder;
if (params.type !== undefined && params.type !== null) {
field = "type";
value = params.type;
}
this.setState({loading: true});
PricingBackend.getPricings("", params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
.then((res) => {
if (res.status === "ok") {
this.setState({
loading: false,
data: res.data,
pagination: {
...params.pagination,
total: res.data2,
},
searchText: params.searchText,
searchedColumn: params.searchedColumn,
});
} else {
if (Setting.isResponseDenied(res)) {
this.setState({
loading: false,
isAuthorized: false,
});
}
}
});
};
}
export default PricingListPage;

View File

@@ -0,0 +1,333 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import moment from "moment";
import React from "react";
import {Button, Card, Col, DatePicker, Input, InputNumber, Row, Select, Switch} from "antd";
import * as OrganizationBackend from "./backend/OrganizationBackend";
import * as PlanBackend from "./backend/PlanBackend";
import * as SubscriptionBackend from "./backend/SubscriptionBackend";
import * as UserBackend from "./backend/UserBackend";
import * as Setting from "./Setting";
import i18next from "i18next";
import dayjs from "dayjs";
class SubscriptionEditPage extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
organizationName: props.organizationName !== undefined ? props.organizationName : props.match.params.organizationName,
subscriptionName: props.match.params.subscriptionName,
subscription: null,
organizations: [],
users: [],
planes: [],
providers: [],
mode: props.location.mode !== undefined ? props.location.mode : "edit",
};
}
UNSAFE_componentWillMount() {
this.getSubscription();
this.getOrganizations();
}
getSubscription() {
SubscriptionBackend.getSubscription(this.state.organizationName, this.state.subscriptionName)
.then((subscription) => {
this.setState({
subscription: subscription,
});
this.getUsers(subscription.owner);
this.getPlanes(subscription.owner);
});
}
getPlanes(organizationName) {
PlanBackend.getPlans(organizationName)
.then((res) => {
this.setState({
planes: res,
});
});
}
getUsers(organizationName) {
UserBackend.getUsers(organizationName)
.then((res) => {
this.setState({
users: res,
});
});
}
getOrganizations() {
OrganizationBackend.getOrganizations("admin")
.then((res) => {
this.setState({
organizations: (res.msg === undefined) ? res : [],
});
});
}
parseSubscriptionField(key, value) {
if ([""].includes(key)) {
value = Setting.myParseInt(value);
}
return value;
}
updateSubscriptionField(key, value) {
value = this.parseSubscriptionField(key, value);
const subscription = this.state.subscription;
subscription[key] = value;
this.setState({
subscription: subscription,
});
}
renderSubscription() {
return (
<Card size="small" title={
<div>
{this.state.mode === "add" ? i18next.t("subscription:New Subscription") : i18next.t("subscription:Edit Subscription")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button onClick={() => this.submitSubscriptionEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" onClick={() => this.submitSubscriptionEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} onClick={() => this.deleteSubscription()}>{i18next.t("general:Cancel")}</Button> : null}
</div>
} style={(Setting.isMobile()) ? {margin: "5px"} : {}} type="inner">
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Organization"), i18next.t("general:Organization - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.subscription.owner} onChange={(owner => {
this.updateSubscriptionField("owner", owner);
this.getUsers(owner);
this.getPlanes(owner);
})}
options={this.state.organizations.map((organization) => Setting.getOption(organization.name, organization.name))
} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Name"), i18next.t("general:Name - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.subscription.name} onChange={e => {
this.updateSubscriptionField("name", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Display name"), i18next.t("general:Display name - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.subscription.displayName} onChange={e => {
this.updateSubscriptionField("displayName", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("subscription:Duration"), i18next.t("subscription:Duration - Tooltip"))}
</Col>
<Col span={22} >
<InputNumber value={this.state.subscription.duration} onChange={value => {
this.updateSubscriptionField("duration", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("subscription:Start Date"), i18next.t("subscription:Start Date - Tooltip"))}
</Col>
<Col span={22} >
<DatePicker value={dayjs(this.state.subscription.startDate)} onChange={value => {
this.updateSubscriptionField("startDate", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("subscription:End Date"), i18next.t("subscription:End Date - Tooltip"))}
</Col>
<Col span={22} >
<DatePicker value={dayjs(this.state.subscription.endDate)} onChange={value => {
this.updateSubscriptionField("endDate", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("subscription:Sub users"), i18next.t("subscription:Sub users - Tooltip"))} :
</Col>
<Col span={22} >
<Select style={{width: "100%"}} value={this.state.subscription.user}
onChange={(value => {this.updateSubscriptionField("user", value);})}
options={this.state.users.map((user) => Setting.getOption(`${user.owner}/${user.name}`, `${user.owner}/${user.name}`))}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("subscription:Sub plan"), i18next.t("subscription:Sub plan - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.subscription.plan} onChange={(value => {this.updateSubscriptionField("plan", value);})}
options={this.state.planes.map((plan) => Setting.getOption(`${plan.owner}/${plan.name}`, `${plan.owner}/${plan.name}`))
} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Description"), i18next.t("general:Description - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.subscription.description} onChange={e => {
this.updateSubscriptionField("description", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
{Setting.getLabel(i18next.t("general:Is enabled"), i18next.t("general:Is enabled - Tooltip"))} :
</Col>
<Col span={1} >
<Switch checked={this.state.subscription.isEnabled} onChange={checked => {
this.updateSubscriptionField("isEnabled", checked);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Submitter"), i18next.t("general:Submitter - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.subscription.submitter} onChange={e => {
this.updateSubscriptionField("submitter", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Approver"), i18next.t("general:Approver - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.subscription.approver} onChange={e => {
this.updateSubscriptionField("approver", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Approve time"), i18next.t("general:Approve time - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={Setting.getFormattedDate(this.state.subscription.approveTime)} onChange={e => {
this.updatePermissionField("approveTime", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:State"), i18next.t("general:State - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} disabled={!Setting.isLocalAdminUser(this.props.account)} style={{width: "100%"}} value={this.state.subscription.state} onChange={(value => {
if (this.state.subscription.state !== value) {
if (value === "Approved") {
this.updateSubscriptionField("approver", this.props.account.name);
this.updateSubscriptionField("approveTime", moment().format());
} else {
this.updateSubscriptionField("approver", "");
this.updateSubscriptionField("approveTime", "");
}
}
this.updateSubscriptionField("state", value);
})}
options={[
{value: "Approved", name: i18next.t("subscription:Approved")},
{value: "Pending", name: i18next.t("subscription:Pending")},
].map((item) => Setting.getOption(item.name, item.value))}
/>
</Col>
</Row>
</Card>
);
}
submitSubscriptionEdit(willExist) {
const subscription = Setting.deepCopy(this.state.subscription);
SubscriptionBackend.updateSubscription(this.state.organizationName, this.state.subscriptionName, subscription)
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully saved"));
this.setState({
subscriptionName: this.state.subscription.name,
});
if (willExist) {
this.props.history.push("/subscriptions");
} else {
this.props.history.push(`/subscription/${this.state.subscription.owner}/${this.state.subscription.name}`);
}
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to save")}: ${res.msg}`);
this.updateSubscriptionField("name", this.state.subscriptionName);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
deleteSubscription() {
SubscriptionBackend.deleteSubscription(this.state.subscription)
.then((res) => {
if (res.status === "ok") {
this.props.history.push("/subscriptions");
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
render() {
return (
<div>
{
this.state.subscription !== null ? this.renderSubscription() : null
}
<div style={{marginTop: "20px", marginLeft: "40px"}}>
<Button size="large" onClick={() => this.submitSubscriptionEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" size="large" onClick={() => this.submitSubscriptionEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} size="large" onClick={() => this.deleteSubscription()}>{i18next.t("general:Cancel")}</Button> : null}
</div>
</div>
);
}
}
export default SubscriptionEditPage;

View File

@@ -0,0 +1,239 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Link} from "react-router-dom";
import {Button, Table} from "antd";
import moment from "moment";
import * as Setting from "./Setting";
import * as SubscriptionBackend from "./backend/SubscriptionBackend";
import i18next from "i18next";
import BaseListPage from "./BaseListPage";
import PopconfirmModal from "./common/modal/PopconfirmModal";
class SubscriptionListPage extends BaseListPage {
newSubscription() {
const randomName = Setting.getRandomName();
const owner = (this.state.organizationName !== undefined) ? this.state.organizationName : this.props.account.owner;
const defaultDuration = 365;
return {
owner: owner,
name: `subscription_${randomName}`,
createdTime: moment().format(),
startDate: moment().format(),
endDate: moment().add(defaultDuration, "d").format(),
displayName: `New Subscription - ${randomName}`,
tag: "",
users: [],
expireInDays: defaultDuration,
submitter: this.props.account.name,
approver: "",
approveTime: "",
state: "Pending",
};
}
addSubscription() {
const newSubscription = this.newSubscription();
SubscriptionBackend.addSubscription(newSubscription)
.then((res) => {
if (res.status === "ok") {
this.props.history.push({pathname: `/subscription/${newSubscription.owner}/${newSubscription.name}`, mode: "add"});
Setting.showMessage("success", i18next.t("general:Successfully added"));
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to add")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
deleteSubscription(i) {
SubscriptionBackend.deleteSubscription(this.state.data[i])
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully deleted"));
this.setState({
data: Setting.deleteRow(this.state.data, i),
pagination: {total: this.state.pagination.total - 1},
});
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
renderTable(subscriptions) {
const columns = [
{
title: i18next.t("general:Name"),
dataIndex: "name",
key: "name",
width: "140px",
fixed: "left",
sorter: true,
...this.getColumnSearchProps("name"),
render: (text, record, index) => {
return (
<Link to={`/subscriptions/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Organization"),
dataIndex: "owner",
key: "owner",
width: "120px",
sorter: true,
...this.getColumnSearchProps("owner"),
render: (text, record, index) => {
return (
<Link to={`/organizations/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Created time"),
dataIndex: "createdTime",
key: "createdTime",
width: "160px",
sorter: true,
render: (text, record, index) => {
return Setting.getFormattedDate(text);
},
},
{
title: i18next.t("general:Display name"),
dataIndex: "displayName",
key: "displayName",
width: "170px",
sorter: true,
...this.getColumnSearchProps("displayName"),
},
{
title: i18next.t("subscription:Duration"),
dataIndex: "duration",
key: "duration",
width: "140px",
...this.getColumnSearchProps("duration"),
},
{
title: i18next.t("subscription:Sub plane"),
dataIndex: "plan",
key: "plan",
width: "140px",
...this.getColumnSearchProps("plan"),
},
{
title: i18next.t("subscription:Sub user"),
dataIndex: "user",
key: "user",
width: "140px",
...this.getColumnSearchProps("user"),
},
{
title: i18next.t("general:State"),
dataIndex: "state",
key: "state",
width: "120px",
sorter: true,
...this.getColumnSearchProps("state"),
},
{
title: i18next.t("general:Action"),
dataIndex: "",
key: "op",
width: "230px",
fixed: (Setting.isMobile()) ? "false" : "right",
render: (text, record, index) => {
return (
<div>
<Button style={{marginTop: "10px", marginBottom: "10px", marginRight: "10px"}} type="primary" onClick={() => this.props.history.push(`/subscription/${record.owner}/${record.name}`)}>{i18next.t("general:Edit")}</Button>
<PopconfirmModal
title={i18next.t("general:Sure to delete") + `: ${record.name} ?`}
onConfirm={() => this.deleteSubscription(index)}
>
</PopconfirmModal>
</div>
);
},
},
];
const paginationProps = {
total: this.state.pagination.total,
showQuickJumper: true,
showSizeChanger: true,
showTotal: () => i18next.t("general:{total} in total").replace("{total}", this.state.pagination.total),
};
return (
<div>
<Table scroll={{x: "max-content"}} columns={columns} dataSource={subscriptions} rowKey="name" size="middle" bordered pagination={paginationProps}
title={() => (
<div>
{i18next.t("general:Subscriptions")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button type="primary" size="small" onClick={this.addSubscription.bind(this)}>{i18next.t("general:Add")}</Button>
</div>
)}
loading={this.state.loading}
onChange={this.handleTableChange}
/>
</div>
);
}
fetch = (params = {}) => {
let field = params.searchedColumn, value = params.searchText;
const sortField = params.sortField, sortOrder = params.sortOrder;
if (params.type !== undefined && params.type !== null) {
field = "type";
value = params.type;
}
this.setState({loading: true});
SubscriptionBackend.getSubscriptions("", params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
.then((res) => {
if (res.status === "ok") {
this.setState({
loading: false,
data: res.data,
pagination: {
...params.pagination,
total: res.data2,
},
searchText: params.searchText,
searchedColumn: params.searchedColumn,
});
} else {
if (Setting.isResponseDenied(res)) {
this.setState({
loading: false,
isAuthorized: false,
});
}
}
});
};
}
export default SubscriptionListPage;

View File

@@ -259,7 +259,7 @@ class WebhookEditPage extends React.Component {
}} > }} >
{ {
( (
["signup", "login", "logout", "add-user", "update-user", "delete-user", "add-organization", "update-organization", "delete-organization", "add-application", "update-application", "delete-application", "add-provider", "update-provider", "delete-provider"].map((option, index) => { ["signup", "login", "logout", "add-user", "update-user", "delete-user", "add-organization", "update-organization", "delete-organization", "add-application", "update-application", "delete-application", "add-provider", "update-provider", "delete-provider", "update-subscription"].map((option, index) => {
return ( return (
<Option key={option} value={option}>{option}</Option> <Option key={option} value={option}>{option}</Option>
); );

View File

@@ -438,6 +438,7 @@ class LoginPage extends React.Component {
<Form <Form
name="normal_login" name="normal_login"
initialValues={{ initialValues={{
organization: application.organization, organization: application.organization,
application: application.name, application: application.name,
autoSignin: true, autoSignin: true,

View File

@@ -165,6 +165,11 @@ class SignupPage extends React.Component {
onFinish(values) { onFinish(values) {
const application = this.getApplicationObj(); const application = this.getApplicationObj();
const params = new URLSearchParams(window.location.search);
values["plan"] = params.get("plan");
values["pricing"] = params.get("pricing");
AuthBackend.signup(values) AuthBackend.signup(values)
.then((res) => { .then((res) => {
if (res.status === "ok") { if (res.status === "ok") {

View File

@@ -86,7 +86,13 @@ export function getOAuthGetParameters(params) {
const responseType = getRefinedValue(queries.get("response_type")); const responseType = getRefinedValue(queries.get("response_type"));
const redirectUri = getRefinedValue(queries.get("redirect_uri")); const redirectUri = getRefinedValue(queries.get("redirect_uri"));
const scope = getRefinedValue(queries.get("scope")); const scope = getRefinedValue(queries.get("scope"));
const state = getRefinedValue(queries.get("state"));
let state = getRefinedValue(queries.get("state"));
if (state.startsWith("/auth/oauth2/login.php?wantsurl=")) {
// state contains URL param encoding for Moodle, URLSearchParams automatically decoded it, so here encode it again
state = encodeURIComponent(state);
}
const nonce = getRefinedValue(queries.get("nonce")); const nonce = getRefinedValue(queries.get("nonce"));
const challengeMethod = getRefinedValue(queries.get("code_challenge_method")); const challengeMethod = getRefinedValue(queries.get("code_challenge_method"));
const codeChallenge = getRefinedValue(queries.get("code_challenge")); const codeChallenge = getRefinedValue(queries.get("code_challenge"));

View File

@@ -0,0 +1,81 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as Setting from "../Setting";
export function getPlans(owner, page = "", pageSize = "", field = "", value = "", sortField = "", sortOrder = "") {
return fetch(`${Setting.ServerUrl}/api/get-plans?owner=${owner}&p=${page}&pageSize=${pageSize}&field=${field}&value=${value}&sortField=${sortField}&sortOrder=${sortOrder}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function getPlanById(id, includeOption = false) {
return fetch(`${Setting.ServerUrl}/api/get-plan?id=${id}&includeOption=${includeOption}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function getPlan(owner, name) {
return fetch(`${Setting.ServerUrl}/api/get-plan?id=${owner}/${encodeURIComponent(name)}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function updatePlan(owner, name, plan) {
const newPlan = Setting.deepCopy(plan);
return fetch(`${Setting.ServerUrl}/api/update-plan?id=${owner}/${encodeURIComponent(name)}`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newPlan),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function addPlan(plan) {
const newPlan = Setting.deepCopy(plan);
return fetch(`${Setting.ServerUrl}/api/add-plan`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newPlan),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function deletePlan(plan) {
const newPlan = Setting.deepCopy(plan);
return fetch(`${Setting.ServerUrl}/api/delete-plan`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newPlan),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}

View File

@@ -0,0 +1,71 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as Setting from "../Setting";
export function getPricings(owner, page = "", pageSize = "", field = "", value = "", sortField = "", sortOrder = "") {
return fetch(`${Setting.ServerUrl}/api/get-pricings?owner=${owner}&p=${page}&pageSize=${pageSize}&field=${field}&value=${value}&sortField=${sortField}&sortOrder=${sortOrder}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function getPricing(owner, name) {
return fetch(`${Setting.ServerUrl}/api/get-pricing?id=${owner}/${encodeURIComponent(name)}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function updatePricing(owner, name, pricing) {
const newPricing = Setting.deepCopy(pricing);
return fetch(`${Setting.ServerUrl}/api/update-pricing?id=${owner}/${encodeURIComponent(name)}`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newPricing),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function addPricing(pricing) {
const newPricing = Setting.deepCopy(pricing);
return fetch(`${Setting.ServerUrl}/api/add-pricing`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newPricing),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function deletePricing(pricing) {
const newPricing = Setting.deepCopy(pricing);
return fetch(`${Setting.ServerUrl}/api/delete-pricing`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newPricing),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}

View File

@@ -0,0 +1,71 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as Setting from "../Setting";
export function getSubscriptions(owner, page = "", pageSize = "", field = "", value = "", sortField = "", sortOrder = "") {
return fetch(`${Setting.ServerUrl}/api/get-subscriptions?owner=${owner}&p=${page}&pageSize=${pageSize}&field=${field}&value=${value}&sortField=${sortField}&sortOrder=${sortOrder}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function getSubscription(owner, name) {
return fetch(`${Setting.ServerUrl}/api/get-subscription?id=${owner}/${encodeURIComponent(name)}`, {
method: "GET",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function updateSubscription(owner, name, subscription) {
const newSubscription = Setting.deepCopy(subscription);
return fetch(`${Setting.ServerUrl}/api/update-subscription?id=${owner}/${encodeURIComponent(name)}`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newSubscription),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function addSubscription(subscription) {
const newSubscription = Setting.deepCopy(subscription);
return fetch(`${Setting.ServerUrl}/api/add-subscription`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newSubscription),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function deleteSubscription(subscription) {
const newSubscription = Setting.deepCopy(subscription);
return fetch(`${Setting.ServerUrl}/api/delete-subscription`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newSubscription),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}

View File

@@ -253,6 +253,8 @@
"Phone": "Telefon", "Phone": "Telefon",
"Phone - Tooltip": "Telefonnummer", "Phone - Tooltip": "Telefonnummer",
"Phone or email": "Phone or email", "Phone or email": "Phone or email",
"Plans": "Pläne",
"Pricings": "Preise",
"Preview": "Vorschau", "Preview": "Vorschau",
"Preview - Tooltip": "Vorschau der konfigurierten Effekte", "Preview - Tooltip": "Vorschau der konfigurierten Effekte",
"Products": "Produkte", "Products": "Produkte",
@@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "Es tut uns leid, aber Sie haben keine Berechtigung, auf diese Seite zuzugreifen, oder Sie sind nicht angemeldet.", "Sorry, you do not have permission to access this page or logged in status invalid.": "Es tut uns leid, aber Sie haben keine Berechtigung, auf diese Seite zuzugreifen, oder Sie sind nicht angemeldet.",
"State": "Bundesland / Staat", "State": "Bundesland / Staat",
"State - Tooltip": "Bundesland", "State - Tooltip": "Bundesland",
"Subscriptions": "Abonnements",
"Successfully added": "Erfolgreich hinzugefügt", "Successfully added": "Erfolgreich hinzugefügt",
"Successfully deleted": "Erfolgreich gelöscht", "Successfully deleted": "Erfolgreich gelöscht",
"Successfully saved": "Erfolgreich gespeichert", "Successfully saved": "Erfolgreich gespeichert",
@@ -884,5 +887,36 @@
"Method - Tooltip": "HTTP Methode", "Method - Tooltip": "HTTP Methode",
"New Webhook": "Neue Webhook", "New Webhook": "Neue Webhook",
"Value": "Wert" "Value": "Wert"
},
"plan": {
"Sub roles - Tooltip": "Rolle im aktuellen Plan enthalten",
"PricePerMonth": "Preis pro Monat",
"PricePerYear": "Preis pro Jahr",
"PerMonth": "pro Monat"
},
"pricing": {
"Sub plans": "Zusatzpläne",
"Sub plans - Tooltip": "Pläne im aktuellen Preismodell enthalten",
"Has trial": "Testphase verfügbar",
"Has trial - Tooltip": "Verfügbarkeit der Testphase nach Auswahl eines Plans",
"Trial duration": "Testphase Dauer",
"Trial duration - Tooltip": "Dauer der Testphase",
"Getting started": "Loslegen",
"Copy pricing page URL": "Preisseite URL kopieren",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser": "Preisseite URL erfolgreich in die Zwischenablage kopiert. Bitte fügen Sie sie in ein Inkognito-Fenster oder einen anderen Browser ein.",
"days trial available!": "Tage Testphase verfügbar!",
"Free": "Kostenlos"
},
"subscription": {
"Duration": "Laufzeit",
"Duration - Tooltip": "Laufzeit des Abonnements",
"Start Date": "Startdatum",
"Start Date - Tooltip": "Startdatum",
"End Date": "Enddatum",
"End Date - Tooltip": "Enddatum",
"Sub users": "Abonnenten",
"Sub users - Tooltip": "Abonnenten",
"Sub plan": "Abonnementplan",
"Sub plan - Tooltip": "Abonnementplan"
} }
} }

View File

@@ -253,6 +253,8 @@
"Phone": "Phone", "Phone": "Phone",
"Phone - Tooltip": "Phone number", "Phone - Tooltip": "Phone number",
"Phone or email": "Phone or email", "Phone or email": "Phone or email",
"Plans": "Plans",
"Pricings": "Pricings",
"Preview": "Preview", "Preview": "Preview",
"Preview - Tooltip": "Preview the configured effects", "Preview - Tooltip": "Preview the configured effects",
"Products": "Products", "Products": "Products",
@@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "Sorry, you do not have permission to access this page or logged in status invalid.", "Sorry, you do not have permission to access this page or logged in status invalid.": "Sorry, you do not have permission to access this page or logged in status invalid.",
"State": "State", "State": "State",
"State - Tooltip": "State", "State - Tooltip": "State",
"Subscriptions": "Subscriptions",
"Successfully added": "Successfully added", "Successfully added": "Successfully added",
"Successfully deleted": "Successfully deleted", "Successfully deleted": "Successfully deleted",
"Successfully saved": "Successfully saved", "Successfully saved": "Successfully saved",
@@ -884,5 +887,37 @@
"Method - Tooltip": "HTTP method", "Method - Tooltip": "HTTP method",
"New Webhook": "New Webhook", "New Webhook": "New Webhook",
"Value": "Value" "Value": "Value"
},
"plan": {
"Sub roles - Tooltip": "Role included in the current plane",
"PricePerMonth": "Price per month",
"PricePerYear": "Price per year",
"PerMonth": "per month"
},
"pricing": {
"Sub plans": "Sub plans",
"Sub plans - Tooltip": "Plans included in the current pricing",
"Has trial": "Has trial",
"Has trial - Tooltip": "Availability of the trial period after choosing a plan",
"Trial duration": "Trial duration",
"Trial duration - Tooltip": "Trial duration period",
"Getting started" : "Getting started",
"Copy pricing page URL": "Copy pricing page URL",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser" : "pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser",
"days trial available!": "days trial available!",
"Free": "Free"
},
"subscription": {
"Duration": "Duration",
"Duration - Tooltip": "Subscription duration",
"Start Date": "Start Date",
"Start Date - Tooltip": "Start Date",
"End Date": "End Date",
"End Date - Tooltip": "End Date",
"Sub users": "Sub users",
"Sub users - Tooltip": "Sub users",
"Sub plan": "Sub plan",
"Sub plan - Tooltip": "Sub plan"
} }
} }

View File

@@ -253,6 +253,8 @@
"Phone": "Teléfono", "Phone": "Teléfono",
"Phone - Tooltip": "Número de teléfono", "Phone - Tooltip": "Número de teléfono",
"Phone or email": "Phone or email", "Phone or email": "Phone or email",
"Plans": "Planes",
"Pricings": "Precios",
"Preview": "Avance", "Preview": "Avance",
"Preview - Tooltip": "Vista previa de los efectos configurados", "Preview - Tooltip": "Vista previa de los efectos configurados",
"Products": "Productos", "Products": "Productos",
@@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "Lo siento, no tiene permiso para acceder a esta página o su estado de inicio de sesión es inválido.", "Sorry, you do not have permission to access this page or logged in status invalid.": "Lo siento, no tiene permiso para acceder a esta página o su estado de inicio de sesión es inválido.",
"State": "Estado", "State": "Estado",
"State - Tooltip": "Estado", "State - Tooltip": "Estado",
"Subscriptions": "Suscripciones",
"Successfully added": "Éxito al agregar", "Successfully added": "Éxito al agregar",
"Successfully deleted": "Éxito en la eliminación", "Successfully deleted": "Éxito en la eliminación",
"Successfully saved": "Guardado exitosamente", "Successfully saved": "Guardado exitosamente",
@@ -884,5 +887,36 @@
"Method - Tooltip": "Método HTTP", "Method - Tooltip": "Método HTTP",
"New Webhook": "Nuevo Webhook", "New Webhook": "Nuevo Webhook",
"Value": "Valor" "Value": "Valor"
},
"plan": {
"Sub roles - Tooltip": "Rol incluido en el plan actual",
"PricePerMonth": "Precio por mes",
"PricePerYear": "Precio por año",
"PerMonth": "por mes"
},
"pricing": {
"Sub plans": "Planes adicionales",
"Sub plans - Tooltip": "Planes incluidos en la tarifa actual",
"Has trial": "Tiene período de prueba",
"Has trial - Tooltip": "Disponibilidad del período de prueba después de elegir un plan",
"Trial duration": "Duración del período de prueba",
"Trial duration - Tooltip": "Duración del período de prueba",
"Getting started": "Empezar",
"Copy pricing page URL": "Copiar URL de la página de precios",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser": "URL de la página de precios copiada correctamente al portapapeles, péguela en una ventana de incógnito u otro navegador",
"days trial available!": "días de prueba disponibles",
"Free": "Gratis"
},
"subscription": {
"Duration": "Duración",
"Duration - Tooltip": "Duración de la suscripción",
"Start Date": "Fecha de inicio",
"Start Date - Tooltip": "Fecha de inicio",
"End Date": "Fecha de finalización",
"End Date - Tooltip": "Fecha de finalización",
"Sub users": "Usuarios de la suscripción",
"Sub users - Tooltip": "Usuarios de la suscripción",
"Sub plan": "Plan de suscripción",
"Sub plan - Tooltip": "Plan de suscripción"
} }
} }

View File

@@ -253,6 +253,8 @@
"Phone": "Téléphone", "Phone": "Téléphone",
"Phone - Tooltip": "Numéro de téléphone", "Phone - Tooltip": "Numéro de téléphone",
"Phone or email": "Phone or email", "Phone or email": "Phone or email",
"Plans": "Plans",
"Pricings": "Tarifs",
"Preview": "Aperçu", "Preview": "Aperçu",
"Preview - Tooltip": "Prévisualisez les effets configurés", "Preview - Tooltip": "Prévisualisez les effets configurés",
"Products": "Produits", "Products": "Produits",
@@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "Désolé, vous n'avez pas la permission d'accéder à cette page ou votre statut de connexion est invalide.", "Sorry, you do not have permission to access this page or logged in status invalid.": "Désolé, vous n'avez pas la permission d'accéder à cette page ou votre statut de connexion est invalide.",
"State": "État", "State": "État",
"State - Tooltip": "État", "State - Tooltip": "État",
"Subscriptions": "Abonnements",
"Successfully added": "Ajouté avec succès", "Successfully added": "Ajouté avec succès",
"Successfully deleted": "Supprimé avec succès", "Successfully deleted": "Supprimé avec succès",
"Successfully saved": "Succès enregistré", "Successfully saved": "Succès enregistré",
@@ -884,5 +887,36 @@
"Method - Tooltip": "Méthode HTTP", "Method - Tooltip": "Méthode HTTP",
"New Webhook": "Nouveau webhook", "New Webhook": "Nouveau webhook",
"Value": "Valeur" "Value": "Valeur"
},
"plan": {
"Sub roles - Tooltip": "Rôle inclus dans le plan actuel",
"PricePerMonth": "Prix par mois",
"PricePerYear": "Prix par an",
"PerMonth": "par mois"
},
"pricing": {
"Sub plans": "Forfaits supplémentaires",
"Sub plans - Tooltip": "Forfaits inclus dans la tarification actuelle",
"Has trial": "Essai gratuit disponible",
"Has trial - Tooltip": "Disponibilité de la période d'essai après avoir choisi un forfait",
"Trial duration": "Durée de l'essai",
"Trial duration - Tooltip": "Durée de la période d'essai",
"Getting started": "Commencer",
"Copy pricing page URL": "Copier l'URL de la page tarifs",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser": "URL de la page tarifs copiée avec succès dans le presse-papiers, veuillez le coller dans une fenêtre de navigation privée ou un autre navigateur",
"days trial available!": "jours d'essai disponibles !",
"Free": "Gratuit"
},
"subscription": {
"Duration": "Durée",
"Duration - Tooltip": "Durée de l'abonnement",
"Start Date": "Date de début",
"Start Date - Tooltip": "Date de début",
"End Date": "Date de fin",
"End Date - Tooltip": "Date de fin",
"Sub users": "Utilisateurs de l'abonnement",
"Sub users - Tooltip": "Utilisateurs de l'abonnement",
"Sub plan": "Plan de l'abonnement",
"Sub plan - Tooltip": "Plan de l'abonnement"
} }
} }

View File

@@ -253,6 +253,8 @@
"Phone": "Telepon", "Phone": "Telepon",
"Phone - Tooltip": "Nomor telepon", "Phone - Tooltip": "Nomor telepon",
"Phone or email": "Phone or email", "Phone or email": "Phone or email",
"Plans": "Rencana",
"Pricings": "Harga",
"Preview": "Tinjauan", "Preview": "Tinjauan",
"Preview - Tooltip": "Mengawali pratinjau efek yang sudah dikonfigurasi", "Preview - Tooltip": "Mengawali pratinjau efek yang sudah dikonfigurasi",
"Products": "Produk", "Products": "Produk",
@@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "Maaf, Anda tidak memiliki izin untuk mengakses halaman ini atau status masuk tidak valid.", "Sorry, you do not have permission to access this page or logged in status invalid.": "Maaf, Anda tidak memiliki izin untuk mengakses halaman ini atau status masuk tidak valid.",
"State": "Negara", "State": "Negara",
"State - Tooltip": "Negara", "State - Tooltip": "Negara",
"Subscriptions": "Langganan",
"Successfully added": "Berhasil ditambahkan", "Successfully added": "Berhasil ditambahkan",
"Successfully deleted": "Berhasil dihapus", "Successfully deleted": "Berhasil dihapus",
"Successfully saved": "Berhasil disimpan", "Successfully saved": "Berhasil disimpan",
@@ -884,5 +887,36 @@
"Method - Tooltip": "Metode HTTP", "Method - Tooltip": "Metode HTTP",
"New Webhook": "Webhook Baru", "New Webhook": "Webhook Baru",
"Value": "Nilai" "Value": "Nilai"
},
"plan": {
"Sub roles - Tooltip": "Peran yang termasuk dalam rencana saat ini",
"PricePerMonth": "Harga per bulan",
"PricePerYear": "Harga per tahun",
"PerMonth": "per bulan"
},
"pricing": {
"Sub plans": "Rencana Tambahan",
"Sub plans - Tooltip": "Rencana yang termasuk dalam harga saat ini",
"Has trial": "Mempunyai periode percobaan",
"Has trial - Tooltip": "Ketersediaan periode percobaan setelah memilih rencana",
"Trial duration": "Durasi percobaan",
"Trial duration - Tooltip": "Durasi periode percobaan",
"Getting started": "Mulai",
"Copy pricing page URL": "Salin URL halaman harga",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser": "URL halaman harga berhasil disalin ke clipboard, silakan tempelkan ke dalam jendela mode penyamaran atau browser lainnya",
"days trial available!": "hari percobaan tersedia!",
"Free": "Gratis"
},
"subscription": {
"Duration": "Durasi",
"Duration - Tooltip": "Durasi langganan",
"Start Date": "Tanggal Mulai",
"Start Date - Tooltip": "Tanggal Mulai",
"End Date": "Tanggal Berakhir",
"End Date - Tooltip": "Tanggal Berakhir",
"Sub users": "Pengguna Langganan",
"Sub users - Tooltip": "Pengguna Langganan",
"Sub plan": "Rencana Langganan",
"Sub plan - Tooltip": "Rencana Langganan"
} }
} }

View File

@@ -253,6 +253,8 @@
"Phone": "電話", "Phone": "電話",
"Phone - Tooltip": "電話番号", "Phone - Tooltip": "電話番号",
"Phone or email": "Phone or email", "Phone or email": "Phone or email",
"Plans": "プラン",
"Pricings": "価格設定",
"Preview": "プレビュー", "Preview": "プレビュー",
"Preview - Tooltip": "構成されたエフェクトをプレビューする", "Preview - Tooltip": "構成されたエフェクトをプレビューする",
"Products": "製品", "Products": "製品",
@@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "申し訳ありませんが、このページにアクセスする権限がありません、またはログイン状態が無効です。", "Sorry, you do not have permission to access this page or logged in status invalid.": "申し訳ありませんが、このページにアクセスする権限がありません、またはログイン状態が無効です。",
"State": "州", "State": "州",
"State - Tooltip": "状態", "State - Tooltip": "状態",
"Subscriptions": "サブスクリプション",
"Successfully added": "正常に追加されました", "Successfully added": "正常に追加されました",
"Successfully deleted": "正常に削除されました", "Successfully deleted": "正常に削除されました",
"Successfully saved": "成功的に保存されました", "Successfully saved": "成功的に保存されました",
@@ -884,5 +887,36 @@
"Method - Tooltip": "HTTPメソッド", "Method - Tooltip": "HTTPメソッド",
"New Webhook": "新しいWebhook", "New Webhook": "新しいWebhook",
"Value": "値" "Value": "値"
},
"plan": {
"Sub roles - Tooltip": "現在のプランに含まれるロール",
"PricePerMonth": "月額料金",
"PricePerYear": "年間料金",
"PerMonth": "月毎"
},
"pricing": {
"Sub plans": "追加プラン",
"Sub plans - Tooltip": "現在の価格設定に含まれるプラン",
"Has trial": "トライアル期間あり",
"Has trial - Tooltip": "プラン選択後のトライアル期間の有無",
"Trial duration": "トライアル期間の長さ",
"Trial duration - Tooltip": "トライアル期間の長さ",
"Getting started": "はじめる",
"Copy pricing page URL": "価格ページのURLをコピー",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser": "価格ページのURLが正常にクリップボードにコピーされました。シークレットウィンドウや別のブラウザに貼り付けてください。",
"days trial available!": "日間のトライアルが利用可能です!",
"Free": "無料"
},
"subscription": {
"Duration": "期間",
"Duration - Tooltip": "購読の期間",
"Start Date": "開始日",
"Start Date - Tooltip": "開始日",
"End Date": "終了日",
"End Date - Tooltip": "終了日",
"Sub users": "購読ユーザー",
"Sub users - Tooltip": "購読ユーザー",
"Sub plan": "購読プラン",
"Sub plan - Tooltip": "購読プラン"
} }
} }

View File

@@ -253,6 +253,8 @@
"Phone": "전화기", "Phone": "전화기",
"Phone - Tooltip": "전화 번호", "Phone - Tooltip": "전화 번호",
"Phone or email": "Phone or email", "Phone or email": "Phone or email",
"Plans": "플랜",
"Pricings": "가격",
"Preview": "미리보기", "Preview": "미리보기",
"Preview - Tooltip": "구성된 효과를 미리보기합니다", "Preview - Tooltip": "구성된 효과를 미리보기합니다",
"Products": "제품들", "Products": "제품들",
@@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "죄송합니다. 이 페이지에 접근할 권한이 없거나 로그인 상태가 유효하지 않습니다.", "Sorry, you do not have permission to access this page or logged in status invalid.": "죄송합니다. 이 페이지에 접근할 권한이 없거나 로그인 상태가 유효하지 않습니다.",
"State": "주", "State": "주",
"State - Tooltip": "국가", "State - Tooltip": "국가",
"Subscriptions": "구독",
"Successfully added": "성공적으로 추가되었습니다", "Successfully added": "성공적으로 추가되었습니다",
"Successfully deleted": "성공적으로 삭제되었습니다", "Successfully deleted": "성공적으로 삭제되었습니다",
"Successfully saved": "성공적으로 저장되었습니다", "Successfully saved": "성공적으로 저장되었습니다",
@@ -884,5 +887,36 @@
"Method - Tooltip": "HTTP 방법", "Method - Tooltip": "HTTP 방법",
"New Webhook": "새로운 웹훅", "New Webhook": "새로운 웹훅",
"Value": "가치" "Value": "가치"
},
"plan": {
"Sub roles - Tooltip": "현재 플랜에 포함된 역할",
"PricePerMonth": "월별 가격",
"PricePerYear": "연간 가격",
"PerMonth": "월"
},
"pricing": {
"Sub plans": "추가 플랜",
"Sub plans - Tooltip": "현재 가격 책정에 포함된 플랜",
"Has trial": "무료 체험 가능",
"Has trial - Tooltip": "플랜 선택 후 체험 기간의 가용 여부",
"Trial duration": "체험 기간",
"Trial duration - Tooltip": "체험 기간의 기간",
"Getting started": "시작하기",
"Copy pricing page URL": "가격 페이지 URL 복사",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser": "가격 페이지 URL이 클립보드에 성공적으로 복사되었습니다. 시크릿 창이나 다른 브라우저에 붙여넣기해주세요.",
"days trial available!": "일 무료 체험 가능!",
"Free": "무료"
},
"subscription": {
"Duration": "기간",
"Duration - Tooltip": "구독 기간",
"Start Date": "시작일",
"Start Date - Tooltip": "시작일",
"End Date": "종료일",
"End Date - Tooltip": "종료일",
"Sub users": "구독 사용자",
"Sub users - Tooltip": "구독 사용자",
"Sub plan": "구독 플랜",
"Sub plan - Tooltip": "구독 플랜"
} }
} }

View File

@@ -253,6 +253,8 @@
"Phone": "Телефон", "Phone": "Телефон",
"Phone - Tooltip": "Номер телефона", "Phone - Tooltip": "Номер телефона",
"Phone or email": "Phone or email", "Phone or email": "Phone or email",
"Plans": "Планы",
"Pricings": "Тарифы",
"Preview": "Предварительный просмотр", "Preview": "Предварительный просмотр",
"Preview - Tooltip": "Предварительный просмотр настроенных эффектов", "Preview - Tooltip": "Предварительный просмотр настроенных эффектов",
"Products": "Продукты", "Products": "Продукты",
@@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "К сожалению, у вас нет разрешения на доступ к этой странице или ваш статус входа недействителен.", "Sorry, you do not have permission to access this page or logged in status invalid.": "К сожалению, у вас нет разрешения на доступ к этой странице или ваш статус входа недействителен.",
"State": "Государство", "State": "Государство",
"State - Tooltip": "Государство", "State - Tooltip": "Государство",
"Subscriptions": "Подписки",
"Successfully added": "Успешно добавлено", "Successfully added": "Успешно добавлено",
"Successfully deleted": "Успешно удалено", "Successfully deleted": "Успешно удалено",
"Successfully saved": "Успешно сохранено", "Successfully saved": "Успешно сохранено",
@@ -884,5 +887,36 @@
"Method - Tooltip": "Метод HTTP", "Method - Tooltip": "Метод HTTP",
"New Webhook": "Новый вебхук", "New Webhook": "Новый вебхук",
"Value": "Значение" "Value": "Значение"
},
"plan": {
"Sub roles - Tooltip": "Роль, включенная в текущий план",
"PricePerMonth": "Цена за месяц",
"PricePerYear": "Цена за год",
"PerMonth": "в месяц"
},
"pricing": {
"Sub plans": "Тарифные планы",
"Sub plans - Tooltip": "Планы, включенные в прайслист",
"Has trial": "Есть пробный период",
"Has trial - Tooltip": "Наличие пробного периода после выбора плана",
"Trial duration": "Продолжительность пробного периода",
"Trial duration - Tooltip": "Продолжительность пробного периода",
"Getting started": "Выьрать план",
"Copy pricing page URL": "Скопировать URL прайс-листа",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser": "URL страницы прайс-листа успешно скопирован в буфер обмена, пожалуйста, вставьте его в режиме инкогнито или другом браузере",
"days trial available!": "дней пробного периода доступно!",
"Free": "Бесплатно"
},
"subscription": {
"Duration": "Продолжительность",
"Duration - Tooltip": "Продолжительность подписки",
"Start Date": "Дата начала",
"Start Date - Tooltip": "Дата начала",
"End Date": "Дата окончания",
"End Date - Tooltip": "Дата окончания",
"Sub users": "Пользователь подписки",
"Sub users - Tooltip": "Пользователь которому офомлена подписка",
"Sub plan": "План подписки",
"Sub plan - Tooltip": "План подписки"
} }
} }

View File

@@ -253,6 +253,8 @@
"Phone": "Điện thoại", "Phone": "Điện thoại",
"Phone - Tooltip": "Số điện thoại", "Phone - Tooltip": "Số điện thoại",
"Phone or email": "Phone or email", "Phone or email": "Phone or email",
"Plans": "Kế hoạch",
"Pricings": "Bảng giá",
"Preview": "Xem trước", "Preview": "Xem trước",
"Preview - Tooltip": "Xem trước các hiệu ứng đã cấu hình", "Preview - Tooltip": "Xem trước các hiệu ứng đã cấu hình",
"Products": "Sản phẩm", "Products": "Sản phẩm",
@@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "Xin lỗi, bạn không có quyền truy cập trang này hoặc trạng thái đăng nhập không hợp lệ.", "Sorry, you do not have permission to access this page or logged in status invalid.": "Xin lỗi, bạn không có quyền truy cập trang này hoặc trạng thái đăng nhập không hợp lệ.",
"State": "Nhà nước", "State": "Nhà nước",
"State - Tooltip": "Trạng thái", "State - Tooltip": "Trạng thái",
"Subscriptions": "Đăng ký",
"Successfully added": "Đã thêm thành công", "Successfully added": "Đã thêm thành công",
"Successfully deleted": "Đã xóa thành công", "Successfully deleted": "Đã xóa thành công",
"Successfully saved": "Thành công đã được lưu lại", "Successfully saved": "Thành công đã được lưu lại",
@@ -884,5 +887,36 @@
"Method - Tooltip": "Phương thức HTTP", "Method - Tooltip": "Phương thức HTTP",
"New Webhook": "Webhook mới", "New Webhook": "Webhook mới",
"Value": "Giá trị" "Value": "Giá trị"
},
"plan": {
"Sub roles - Tooltip": "Vai trò bao gồm trong kế hoạch hiện tại",
"PricePerMonth": "Giá mỗi tháng",
"PricePerYear": "Giá mỗi năm",
"PerMonth": "mỗi tháng"
},
"pricing": {
"Sub plans": "Kế hoạch phụ",
"Sub plans - Tooltip": "Các kế hoạch bao gồm trong bảng giá hiện tại",
"Has trial": "Có thời gian thử nghiệm",
"Has trial - Tooltip": "Khả dụng thời gian thử nghiệm sau khi chọn kế hoạch",
"Trial duration": "Thời gian thử nghiệm",
"Trial duration - Tooltip": "Thời gian thử nghiệm",
"Getting started": "Bắt đầu",
"Copy pricing page URL": "Sao chép URL trang bảng giá",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser": "URL trang bảng giá đã được sao chép vào clipboard thành công, vui lòng dán vào cửa sổ ẩn danh hoặc trình duyệt khác",
"days trial available!": "ngày dùng thử có sẵn!",
"Free": "Miễn phí"
},
"subscription": {
"Duration": "Thời lượng",
"Duration - Tooltip": "Thời lượng đăng ký",
"Start Date": "Ngày bắt đầu",
"Start Date - Tooltip": "Ngày bắt đầu",
"End Date": "Ngày kết thúc",
"End Date - Tooltip": "Ngày kết thúc",
"Sub users": "Người dùng đăng ký",
"Sub users - Tooltip": "Người dùng đăng ký",
"Sub plan": "Kế hoạch đăng ký",
"Sub plan - Tooltip": "Kế hoạch đăng ký"
} }
} }

View File

@@ -253,6 +253,8 @@
"Phone": "手机号", "Phone": "手机号",
"Phone - Tooltip": "手机号", "Phone - Tooltip": "手机号",
"Phone or email": "手机或邮箱", "Phone or email": "手机或邮箱",
"Plans": "计划",
"Pricings": "定价",
"Preview": "预览", "Preview": "预览",
"Preview - Tooltip": "可预览所配置的效果", "Preview - Tooltip": "可预览所配置的效果",
"Products": "商品", "Products": "商品",
@@ -281,6 +283,7 @@
"Sorry, you do not have permission to access this page or logged in status invalid.": "抱歉,您无权访问该页面或登录状态失效", "Sorry, you do not have permission to access this page or logged in status invalid.": "抱歉,您无权访问该页面或登录状态失效",
"State": "状态", "State": "状态",
"State - Tooltip": "状态", "State - Tooltip": "状态",
"Subscriptions": "订阅",
"Successfully added": "添加成功", "Successfully added": "添加成功",
"Successfully deleted": "删除成功", "Successfully deleted": "删除成功",
"Successfully saved": "保存成功", "Successfully saved": "保存成功",
@@ -884,5 +887,36 @@
"Method - Tooltip": "HTTP方法", "Method - Tooltip": "HTTP方法",
"New Webhook": "添加Webhook", "New Webhook": "添加Webhook",
"Value": "值" "Value": "值"
},
"plan": {
"Sub roles - Tooltip": "当前计划中包含的角色",
"PricePerMonth": "每月价格",
"PricePerYear": "每年价格",
"PerMonth": "每月"
},
"pricing": {
"Sub plans": "附加计划",
"Sub plans - Tooltip": "包含在当前定价中的计划",
"Has trial": "有试用期",
"Has trial - Tooltip": "选择计划后是否有试用期",
"Trial duration": "试用期时长",
"Trial duration - Tooltip": "试用期时长",
"Getting started": "开始使用",
"Copy pricing page URL": "复制定价页面链接",
"pricing page URL copied to clipboard successfully, please paste it into the incognito window or another browser": "定价页面链接已成功复制到剪贴板,请粘贴到隐身窗口或其他浏览器中",
"days trial available!": "天试用期可用!",
"Free": "免费"
},
"subscription": {
"Duration": "订阅时长",
"Duration - Tooltip": "订阅时长",
"Start Date": "开始日期",
"Start Date - Tooltip": "开始日期",
"End Date": "结束日期",
"End Date - Tooltip": "结束日期",
"Sub users": "订阅用户",
"Sub users - Tooltip": "订阅用户",
"Sub plan": "订阅计划",
"Sub plan - Tooltip": "订阅计划"
} }
} }

View File

@@ -0,0 +1,167 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Card, Col, Row} from "antd";
import * as PricingBackend from "../backend/PricingBackend";
import * as PlanBackend from "../backend/PlanBackend";
import CustomGithubCorner from "../common/CustomGithubCorner";
import * as Setting from "../Setting";
import SingleCard from "./SingleCard";
import i18next from "i18next";
class PricingPage extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
applications: null,
pricingName: (props.pricingName ?? props.match?.params?.pricingName) ?? null,
pricing: props.pricing,
plans: null,
loading: false,
};
}
componentDidMount() {
this.setState({
applications: [],
});
if (this.state.pricing) {
this.loadPlans();
} else {
this.loadPricing(this.state.pricingName);
}
this.setState({
loading: true,
});
}
componentDidUpdate() {
if (this.state.pricing &&
this.state.pricing.plans?.length !== this.state.plans?.length && !this.state.loading) {
this.setState({loading: true});
this.loadPlans();
}
}
loadPlans() {
const plans = this.state.pricing.plans.map((plan) =>
PlanBackend.getPlanById(plan, true));
Promise.all(plans)
.then(results => {
this.setState({
plans: results,
loading: false,
});
})
.catch(error => {
Setting.showMessage("error", `Failed to get plans: ${error}`);
});
}
loadPricing(pricingName) {
if (pricingName === undefined) {
return;
}
PricingBackend.getPricing("built-in", pricingName)
.then((result) => {
this.setState({
loading: false,
pricing: result,
});
this.onUpdatePricing(result);
});
}
onUpdatePricing(pricing) {
this.props.onUpdatePricing(pricing);
}
renderCards() {
const getUrlByPlan = (plan) => {
const pricing = this.state.pricing;
const signUpUrl = `/signup/${pricing.application}?plan=${plan}&pricing=${pricing.name}`;
return `${window.location.origin}${signUpUrl}`;
};
if (Setting.isMobile()) {
return (
<Card style={{border: "none"}} bodyStyle={{padding: 0}}>
{
this.state.plans.map(item => {
return (
<SingleCard link={getUrlByPlan(item.name)} key={item.name} plan={item} isSingle={this.state.plans.length === 1} />
);
})
}
</Card>
);
} else {
return (
<div style={{marginRight: "15px", marginLeft: "15px"}}>
<Row style={{justifyContent: "center"}} gutter={24}>
{
this.state.plans.map(item => {
return (
<SingleCard style={{marginRight: "5px", marginLeft: "5px"}} link={getUrlByPlan(item.name)} key={item.name} plan={item} isSingle={this.state.plans.length === 1} />
);
})
}
</Row>
</div>
);
}
}
render() {
if (this.state.loading || this.state.plans === null || this.state.plans === undefined) {
return null;
}
const pricing = this.state.pricing;
return (
<React.Fragment>
<CustomGithubCorner />
<div className="login-content">
<div className="login-panel">
<div className="login-form">
<h1 style={{fontSize: "48px", marginTop: "0px", marginBottom: "15px"}}>{pricing.displayName}</h1>
<span style={{fontSize: "20px"}}>{pricing.description}</span>
<Row style={{width: "100%", marginTop: "40px"}}>
<Col span={24} style={{display: "flex", justifyContent: "center"}} >
{
this.renderCards()
}
</Col>
</Row>
<Row style={{justifyContent: "center"}}>
{pricing && pricing.trialDuration > 0
? <i>{i18next.t("pricing:Free")} {pricing.trialDuration}-{i18next.t("pricing:days trial available!")}</i>
: null}
</Row>
</div>
</div>
</div>
</React.Fragment>
);
}
}
export default PricingPage;

View File

@@ -0,0 +1,85 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import i18next from "i18next";
import React from "react";
import {Button, Card, Col} from "antd";
import * as Setting from "../Setting";
import {withRouter} from "react-router-dom";
const {Meta} = Card;
class SingleCard extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
};
}
renderCard(plan, isSingle, link) {
return (
<Col style={{minWidth: "320px", paddingLeft: "20px", paddingRight: "20px", paddingBottom: "20px", marginBottom: "20px", paddingTop: "0px"}} span={6}>
<Card
hoverable
onClick={() => Setting.isMobile() ? window.location.href = link : null}
style={isSingle ? {width: "320px", height: "100%"} : {width: "100%", height: "100%", paddingTop: "0px"}}
>
<div style={{textAlign: "right"}}>
<h2
style={{marginTop: "0px"}}>{plan.displayName}</h2>
</div>
<div style={{textAlign: "left"}} className="px-10 mt-5">
<span style={{fontWeight: 700, fontSize: "48px"}}>$ {plan.pricePerMonth}</span>
<span style={{fontSize: "18px", fontWeight: 600, color: "gray"}}> {i18next.t("plan:PerMonth")}</span>
</div>
<br />
<div style={{textAlign: "left", fontSize: "18px"}}>
<Meta description={plan.description} />
</div>
<br />
<ul style={{listStyleType: "none", paddingLeft: "0px", textAlign: "left"}}>
{(plan.options ?? []).map((option) => {
// eslint-disable-next-line react/jsx-key
return <li>
<svg style={{height: "1rem", width: "1rem", fill: "green", marginRight: "10px"}} xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20">
<path d="M0 11l2-2 5 5L18 3l2 2L7 18z"></path>
</svg>
<span style={{fontSize: "16px"}}>{option}</span>
</li>;
})}
</ul>
<div style={{minHeight: "60px"}}>
</div>
<Button style={{width: "100%", position: "absolute", height: "50px", borderRadius: "0px", bottom: "0", left: "0"}} type="primary" key="subscribe" onClick={() => window.location.href = link}>
{
i18next.t("pricing:Getting started")
}
</Button>
</Card>
</Col>
);
}
render() {
return this.renderCard(this.props.plan, this.props.isSingle, this.props.link);
}
}
export default withRouter(SingleCard);

File diff suppressed because it is too large Load Diff