Rename plan->tier, topics->reservations, more tests, more todos
parent
df512d0ba2
commit
1f54adad71
|
@ -502,7 +502,7 @@ func (c *messageCache) AttachmentsExpired() ([]string, error) {
|
|||
return ids, nil
|
||||
}
|
||||
|
||||
func (c *messageCache) MarkAttachmentsDeleted(ids []string) error {
|
||||
func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error {
|
||||
tx, err := c.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
@ -57,8 +57,9 @@ import (
|
|||
- visitor with/without user
|
||||
- plan-based message expiry
|
||||
- plan-based attachment expiry
|
||||
Docs:
|
||||
- "expires" field in message
|
||||
Refactor:
|
||||
- rename TopicsLimit -> ReservationsLimit
|
||||
- rename /access -> /reservation
|
||||
Later:
|
||||
- Password reset
|
||||
|
@ -544,8 +545,8 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
|
|||
if v.user != nil {
|
||||
m.User = v.user.Name
|
||||
}
|
||||
if v.user != nil && v.user.Plan != nil {
|
||||
m.Expires = time.Now().Unix() + v.user.Plan.MessagesExpiryDuration
|
||||
if v.user != nil && v.user.Tier != nil {
|
||||
m.Expires = time.Now().Unix() + v.user.Tier.MessagesExpiryDuration
|
||||
} else {
|
||||
m.Expires = time.Now().Add(s.config.CacheDuration).Unix()
|
||||
}
|
||||
|
@ -822,8 +823,8 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
|||
return errHTTPBadRequestAttachmentsDisallowed
|
||||
}
|
||||
var attachmentExpiryDuration time.Duration
|
||||
if v.user != nil && v.user.Plan != nil {
|
||||
attachmentExpiryDuration = time.Duration(v.user.Plan.AttachmentExpiryDuration) * time.Second
|
||||
if v.user != nil && v.user.Tier != nil {
|
||||
attachmentExpiryDuration = time.Duration(v.user.Tier.AttachmentExpiryDuration) * time.Second
|
||||
} else {
|
||||
attachmentExpiryDuration = s.config.AttachmentExpiryDuration
|
||||
}
|
||||
|
@ -1240,13 +1241,16 @@ func (s *Server) execManager() {
|
|||
if s.fileCache != nil {
|
||||
ids, err := s.messageCache.AttachmentsExpired()
|
||||
if err != nil {
|
||||
log.Warn("Error retrieving expired attachments: %s", err.Error())
|
||||
log.Warn("Manager: Error retrieving expired attachments: %s", err.Error())
|
||||
} else if len(ids) > 0 {
|
||||
if err := s.fileCache.Remove(ids...); err != nil {
|
||||
log.Warn("Error deleting attachments: %s", err.Error())
|
||||
if log.IsDebug() {
|
||||
log.Debug("Manager: Deleting attachments %s", strings.Join(ids, ", "))
|
||||
}
|
||||
if err := s.messageCache.MarkAttachmentsDeleted(ids); err != nil {
|
||||
log.Warn("Error marking attachments deleted: %s", err.Error())
|
||||
if err := s.fileCache.Remove(ids...); err != nil {
|
||||
log.Warn("Manager: Error deleting attachments: %s", err.Error())
|
||||
}
|
||||
if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil {
|
||||
log.Warn("Manager: Error marking attachments deleted: %s", err.Error())
|
||||
}
|
||||
} else {
|
||||
log.Debug("Manager: No expired attachments to delete")
|
||||
|
|
|
@ -50,8 +50,8 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
|
|||
MessagesRemaining: stats.MessagesRemaining,
|
||||
Emails: stats.Emails,
|
||||
EmailsRemaining: stats.EmailsRemaining,
|
||||
Topics: stats.Topics,
|
||||
TopicsRemaining: stats.TopicsRemaining,
|
||||
Reservations: stats.Reservations,
|
||||
ReservationsRemaining: stats.ReservationsRemaining,
|
||||
AttachmentTotalSize: stats.AttachmentTotalSize,
|
||||
AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining,
|
||||
},
|
||||
|
@ -60,7 +60,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
|
|||
Messages: stats.MessagesLimit,
|
||||
MessagesExpiryDuration: stats.MessagesExpiryDuration,
|
||||
Emails: stats.EmailsLimit,
|
||||
Topics: stats.TopicsLimit,
|
||||
Reservations: stats.ReservationsLimit,
|
||||
AttachmentTotalSize: stats.AttachmentTotalSizeLimit,
|
||||
AttachmentFileSize: stats.AttachmentFileSizeLimit,
|
||||
AttachmentExpiryDuration: stats.AttachmentExpiryDuration,
|
||||
|
@ -80,19 +80,19 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
|
|||
response.Subscriptions = v.user.Prefs.Subscriptions
|
||||
}
|
||||
}
|
||||
if v.user.Plan != nil {
|
||||
response.Plan = &apiAccountPlan{
|
||||
Code: v.user.Plan.Code,
|
||||
Upgradeable: v.user.Plan.Upgradeable,
|
||||
if v.user.Tier != nil {
|
||||
response.Tier = &apiAccountTier{
|
||||
Code: v.user.Tier.Code,
|
||||
Upgradeable: v.user.Tier.Upgradeable,
|
||||
}
|
||||
} else if v.user.Role == user.RoleAdmin {
|
||||
response.Plan = &apiAccountPlan{
|
||||
Code: string(user.PlanUnlimited),
|
||||
response.Tier = &apiAccountTier{
|
||||
Code: string(user.TierUnlimited),
|
||||
Upgradeable: false,
|
||||
}
|
||||
} else {
|
||||
response.Plan = &apiAccountPlan{
|
||||
Code: string(user.PlanDefault),
|
||||
response.Tier = &apiAccountTier{
|
||||
Code: string(user.TierDefault),
|
||||
Upgradeable: true,
|
||||
}
|
||||
}
|
||||
|
@ -112,8 +112,8 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
|
|||
} else {
|
||||
response.Username = user.Everyone
|
||||
response.Role = string(user.RoleAnonymous)
|
||||
response.Plan = &apiAccountPlan{
|
||||
Code: string(user.PlanNone),
|
||||
response.Tier = &apiAccountTier{
|
||||
Code: string(user.TierNone),
|
||||
Upgradeable: true,
|
||||
}
|
||||
}
|
||||
|
@ -340,7 +340,7 @@ func (s *Server) handleAccountAccessAdd(w http.ResponseWriter, r *http.Request,
|
|||
if err != nil {
|
||||
return errHTTPBadRequestPermissionInvalid
|
||||
}
|
||||
if v.user.Plan == nil {
|
||||
if v.user.Tier == nil {
|
||||
return errHTTPUnauthorized // FIXME there should always be a plan!
|
||||
}
|
||||
if err := s.userManager.CheckAllowAccess(v.user.Name, req.Topic); err != nil {
|
||||
|
@ -354,7 +354,7 @@ func (s *Server) handleAccountAccessAdd(w http.ResponseWriter, r *http.Request,
|
|||
reservations, err := s.userManager.ReservationsCount(v.user.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if reservations >= v.user.Plan.TopicsLimit {
|
||||
} else if reservations >= v.user.Tier.ReservationsLimit {
|
||||
return errHTTPTooManyRequestsLimitReservations
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/user"
|
||||
|
@ -343,7 +342,7 @@ func TestAccount_Delete_Not_Allowed(t *testing.T) {
|
|||
require.Equal(t, 401, rr.Code)
|
||||
}
|
||||
|
||||
func TestAccount_Reservation_Add_User_No_Plan_Failure(t *testing.T) {
|
||||
func TestAccount_Reservation_AddWithoutTierFails(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.EnableSignup = true
|
||||
s := newTestServer(t, conf)
|
||||
|
@ -357,7 +356,7 @@ func TestAccount_Reservation_Add_User_No_Plan_Failure(t *testing.T) {
|
|||
require.Equal(t, 401, rr.Code)
|
||||
}
|
||||
|
||||
func TestAccount_Reservation_Add_Admin_Success(t *testing.T) {
|
||||
func TestAccount_Reservation_AddAdminSuccess(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.EnableSignup = true
|
||||
s := newTestServer(t, conf)
|
||||
|
@ -370,7 +369,7 @@ func TestAccount_Reservation_Add_Admin_Success(t *testing.T) {
|
|||
require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestAccount_Reservation_Add_Remove_User_With_Plan_Success(t *testing.T) {
|
||||
func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.EnableSignup = true
|
||||
s := newTestServer(t, conf)
|
||||
|
@ -379,17 +378,19 @@ func TestAccount_Reservation_Add_Remove_User_With_Plan_Success(t *testing.T) {
|
|||
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Create a plan (hack!)
|
||||
db, err := sql.Open("sqlite3", conf.AuthFile)
|
||||
require.Nil(t, err)
|
||||
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO plan (id, code, messages_limit, messages_expiry_duration, emails_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, topics_limit)
|
||||
VALUES (1, 'testplan', 10, 86400, 10, 10, 10, 10800, 2);
|
||||
|
||||
UPDATE user SET plan_id = 1 WHERE user = 'phil';
|
||||
`)
|
||||
require.Nil(t, err)
|
||||
// Create a tier
|
||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||
Code: "pro",
|
||||
Upgradeable: false,
|
||||
MessagesLimit: 123,
|
||||
MessagesExpiryDuration: 86400,
|
||||
EmailsLimit: 32,
|
||||
ReservationsLimit: 2,
|
||||
AttachmentFileSizeLimit: 1231231,
|
||||
AttachmentTotalSizeLimit: 123123,
|
||||
AttachmentExpiryDuration: 10800,
|
||||
}))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
|
||||
// Reserve two topics
|
||||
rr = request(t, s, "POST", "/v1/account/access", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{
|
||||
|
@ -420,6 +421,14 @@ func TestAccount_Reservation_Add_Remove_User_With_Plan_Success(t *testing.T) {
|
|||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||
require.Equal(t, "pro", account.Tier.Code)
|
||||
require.Equal(t, int64(123), account.Limits.Messages)
|
||||
require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration)
|
||||
require.Equal(t, int64(32), account.Limits.Emails)
|
||||
require.Equal(t, int64(2), account.Limits.Reservations)
|
||||
require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize)
|
||||
require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize)
|
||||
require.Equal(t, int64(10800), account.Limits.AttachmentExpiryDuration)
|
||||
require.Equal(t, 2, len(account.Reservations))
|
||||
require.Equal(t, "another", account.Reservations[0].Topic)
|
||||
require.Equal(t, "write-only", account.Reservations[0].Everyone)
|
||||
|
@ -441,27 +450,21 @@ func TestAccount_Reservation_Add_Remove_User_With_Plan_Success(t *testing.T) {
|
|||
require.Equal(t, "mytopic", account.Reservations[0].Topic)
|
||||
}
|
||||
|
||||
func TestAccount_Reservation_Add_Access_By_Anonymous_Fails(t *testing.T) {
|
||||
func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.AuthDefault = user.PermissionReadWrite
|
||||
conf.EnableSignup = true
|
||||
s := newTestServer(t, conf)
|
||||
|
||||
// Create user
|
||||
// Create user with tier
|
||||
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Create a plan (hack!)
|
||||
db, err := sql.Open("sqlite3", conf.AuthFile)
|
||||
require.Nil(t, err)
|
||||
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO plan (id, code, messages_limit, messages_expiry_duration, emails_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, topics_limit)
|
||||
VALUES (1, 'testplan', 10, 86400, 10, 10, 10, 10800, 2);
|
||||
|
||||
UPDATE user SET plan_id = 1 WHERE user = 'phil';
|
||||
`)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||
Code: "pro",
|
||||
ReservationsLimit: 2,
|
||||
}))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
|
||||
// Reserve a topic
|
||||
rr = request(t, s, "POST", "/v1/account/access", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{
|
||||
|
|
|
@ -1090,6 +1090,34 @@ func TestServer_PublishAsJSON_Invalid(t *testing.T) {
|
|||
require.Equal(t, 400, response.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishWithTierBasedMessageLimitAndExpiry(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Create tier with certain limits
|
||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||
Code: "test",
|
||||
MessagesLimit: 5,
|
||||
MessagesExpiryDuration: 1, // Second
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
||||
|
||||
// Publish to reach message limit
|
||||
for i := 0; i < 5; i++ {
|
||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("this is message %d", i+1), map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.True(t, msg.Expires < time.Now().Unix()+5)
|
||||
}
|
||||
response := request(t, s, "PUT", "/mytopic", "this is too much", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 413, response.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachment(t *testing.T) {
|
||||
content := util.RandomString(5000) // > 4096
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
@ -1271,7 +1299,7 @@ func TestServer_PublishAttachmentAndPrune(t *testing.T) {
|
|||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, content, response.Body.String())
|
||||
|
||||
// DeleteMessages and makes sure it's gone
|
||||
// Prune and makes sure it's gone
|
||||
time.Sleep(time.Second) // Sigh ...
|
||||
s.execManager()
|
||||
require.NoFileExists(t, file)
|
||||
|
@ -1279,6 +1307,99 @@ func TestServer_PublishAttachmentAndPrune(t *testing.T) {
|
|||
require.Equal(t, 404, response.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) {
|
||||
content := util.RandomString(5000) // > 4096
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.AttachmentExpiryDuration = time.Millisecond // Hack
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Create tier with certain limits
|
||||
sevenDaysInSeconds := int64(604800)
|
||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||
Code: "test",
|
||||
MessagesExpiryDuration: sevenDaysInSeconds,
|
||||
AttachmentFileSizeLimit: 50_000,
|
||||
AttachmentTotalSizeLimit: 200_000,
|
||||
AttachmentExpiryDuration: sevenDaysInSeconds, // 7 days
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
||||
|
||||
// Publish and make sure we can retrieve it
|
||||
response := request(t, s, "PUT", "/mytopic", content, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||
require.True(t, msg.Attachment.Expires > time.Now().Unix()+sevenDaysInSeconds-30)
|
||||
require.True(t, msg.Expires > time.Now().Unix()+sevenDaysInSeconds-30)
|
||||
file := filepath.Join(s.config.AttachmentCacheDir, msg.ID)
|
||||
require.FileExists(t, file)
|
||||
|
||||
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
|
||||
response = request(t, s, "GET", path, "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, content, response.Body.String())
|
||||
|
||||
// Prune and makes sure it's still there
|
||||
time.Sleep(time.Second) // Sigh ...
|
||||
s.execManager()
|
||||
require.FileExists(t, file)
|
||||
response = request(t, s, "GET", path, "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) {
|
||||
smallFile := util.RandomString(20_000)
|
||||
largeFile := util.RandomString(50_000)
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.AttachmentFileSizeLimit = 20_000
|
||||
c.VisitorAttachmentTotalSizeLimit = 40_000
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Create tier with certain limits
|
||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||
Code: "test",
|
||||
AttachmentFileSizeLimit: 50_000,
|
||||
AttachmentTotalSizeLimit: 200_000,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
||||
|
||||
// Publish small file as anonymous
|
||||
response := request(t, s, "PUT", "/mytopic", smallFile, nil)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
||||
|
||||
// Publish large file as anonymous
|
||||
response = request(t, s, "PUT", "/mytopic", largeFile, nil)
|
||||
require.Equal(t, 413, response.Code)
|
||||
|
||||
// Publish too large file as phil
|
||||
response = request(t, s, "PUT", "/mytopic", largeFile+" a few more bytes", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 413, response.Code)
|
||||
|
||||
// Publish large file as phil (4x)
|
||||
for i := 0; i < 4; i++ {
|
||||
response = request(t, s, "PUT", "/mytopic", largeFile, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg = toMessage(t, response.Body.String())
|
||||
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
||||
}
|
||||
response = request(t, s, "PUT", "/mytopic", largeFile, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 413, response.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) {
|
||||
content := util.RandomString(5000) // > 4096
|
||||
|
||||
|
|
|
@ -235,17 +235,17 @@ type apiAccountTokenResponse struct {
|
|||
Expires int64 `json:"expires"`
|
||||
}
|
||||
|
||||
type apiAccountPlan struct {
|
||||
type apiAccountTier struct {
|
||||
Code string `json:"code"`
|
||||
Upgradeable bool `json:"upgradeable"`
|
||||
}
|
||||
|
||||
type apiAccountLimits struct {
|
||||
Basis string `json:"basis"` // "ip", "role" or "plan"
|
||||
Basis string `json:"basis"` // "ip", "role" or "tier"
|
||||
Messages int64 `json:"messages"`
|
||||
MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
|
||||
Emails int64 `json:"emails"`
|
||||
Topics int64 `json:"topics"`
|
||||
Reservations int64 `json:"reservations"`
|
||||
AttachmentTotalSize int64 `json:"attachment_total_size"`
|
||||
AttachmentFileSize int64 `json:"attachment_file_size"`
|
||||
AttachmentExpiryDuration int64 `json:"attachment_expiry_duration"`
|
||||
|
@ -256,8 +256,8 @@ type apiAccountStats struct {
|
|||
MessagesRemaining int64 `json:"messages_remaining"`
|
||||
Emails int64 `json:"emails"`
|
||||
EmailsRemaining int64 `json:"emails_remaining"`
|
||||
Topics int64 `json:"topics"`
|
||||
TopicsRemaining int64 `json:"topics_remaining"`
|
||||
Reservations int64 `json:"reservations"`
|
||||
ReservationsRemaining int64 `json:"reservations_remaining"`
|
||||
AttachmentTotalSize int64 `json:"attachment_total_size"`
|
||||
AttachmentTotalSizeRemaining int64 `json:"attachment_total_size_remaining"`
|
||||
}
|
||||
|
@ -274,7 +274,7 @@ type apiAccountResponse struct {
|
|||
Notification *user.NotificationPrefs `json:"notification,omitempty"`
|
||||
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
||||
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
||||
Plan *apiAccountPlan `json:"plan,omitempty"`
|
||||
Tier *apiAccountTier `json:"tier,omitempty"`
|
||||
Limits *apiAccountLimits `json:"limits,omitempty"`
|
||||
Stats *apiAccountStats `json:"stats,omitempty"`
|
||||
}
|
||||
|
|
|
@ -42,7 +42,7 @@ type visitor struct {
|
|||
}
|
||||
|
||||
type visitorInfo struct {
|
||||
Basis string // "ip", "role" or "plan"
|
||||
Basis string // "ip", "role" or "tier"
|
||||
Messages int64
|
||||
MessagesLimit int64
|
||||
MessagesRemaining int64
|
||||
|
@ -50,9 +50,9 @@ type visitorInfo struct {
|
|||
Emails int64
|
||||
EmailsLimit int64
|
||||
EmailsRemaining int64
|
||||
Topics int64
|
||||
TopicsLimit int64
|
||||
TopicsRemaining int64
|
||||
Reservations int64
|
||||
ReservationsLimit int64
|
||||
ReservationsRemaining int64
|
||||
AttachmentTotalSize int64
|
||||
AttachmentTotalSizeLimit int64
|
||||
AttachmentTotalSizeRemaining int64
|
||||
|
@ -69,9 +69,9 @@ func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Mana
|
|||
} else {
|
||||
accountLimiter = rate.NewLimiter(rate.Every(conf.VisitorAccountCreateLimitReplenish), conf.VisitorAccountCreateLimitBurst)
|
||||
}
|
||||
if user != nil && user.Plan != nil {
|
||||
requestLimiter = rate.NewLimiter(dailyLimitToRate(user.Plan.MessagesLimit), conf.VisitorRequestLimitBurst)
|
||||
emailsLimiter = rate.NewLimiter(dailyLimitToRate(user.Plan.EmailsLimit), conf.VisitorEmailLimitBurst)
|
||||
if user != nil && user.Tier != nil {
|
||||
requestLimiter = rate.NewLimiter(dailyLimitToRate(user.Tier.MessagesLimit), conf.VisitorRequestLimitBurst)
|
||||
emailsLimiter = rate.NewLimiter(dailyLimitToRate(user.Tier.EmailsLimit), conf.VisitorEmailLimitBurst)
|
||||
} else {
|
||||
requestLimiter = rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst)
|
||||
emailsLimiter = rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst)
|
||||
|
@ -183,21 +183,21 @@ func (v *visitor) Info() (*visitorInfo, error) {
|
|||
// All limits are zero!
|
||||
info.MessagesExpiryDuration = 24 * 3600 // FIXME this is awful. Should be from the Unlimited plan
|
||||
info.AttachmentExpiryDuration = 24 * 3600 // FIXME this is awful. Should be from the Unlimited plan
|
||||
} else if v.user != nil && v.user.Plan != nil {
|
||||
info.Basis = "plan"
|
||||
info.MessagesLimit = v.user.Plan.MessagesLimit
|
||||
info.MessagesExpiryDuration = v.user.Plan.MessagesExpiryDuration
|
||||
info.EmailsLimit = v.user.Plan.EmailsLimit
|
||||
info.TopicsLimit = v.user.Plan.TopicsLimit
|
||||
info.AttachmentTotalSizeLimit = v.user.Plan.AttachmentTotalSizeLimit
|
||||
info.AttachmentFileSizeLimit = v.user.Plan.AttachmentFileSizeLimit
|
||||
info.AttachmentExpiryDuration = v.user.Plan.AttachmentExpiryDuration
|
||||
} else if v.user != nil && v.user.Tier != nil {
|
||||
info.Basis = "tier"
|
||||
info.MessagesLimit = v.user.Tier.MessagesLimit
|
||||
info.MessagesExpiryDuration = v.user.Tier.MessagesExpiryDuration
|
||||
info.EmailsLimit = v.user.Tier.EmailsLimit
|
||||
info.ReservationsLimit = v.user.Tier.ReservationsLimit
|
||||
info.AttachmentTotalSizeLimit = v.user.Tier.AttachmentTotalSizeLimit
|
||||
info.AttachmentFileSizeLimit = v.user.Tier.AttachmentFileSizeLimit
|
||||
info.AttachmentExpiryDuration = v.user.Tier.AttachmentExpiryDuration
|
||||
} else {
|
||||
info.Basis = "ip"
|
||||
info.MessagesLimit = replenishDurationToDailyLimit(v.config.VisitorRequestLimitReplenish)
|
||||
info.MessagesExpiryDuration = int64(v.config.CacheDuration.Seconds())
|
||||
info.EmailsLimit = replenishDurationToDailyLimit(v.config.VisitorEmailLimitReplenish)
|
||||
info.TopicsLimit = 0 // FIXME
|
||||
info.ReservationsLimit = 0 // FIXME
|
||||
info.AttachmentTotalSizeLimit = v.config.VisitorAttachmentTotalSizeLimit
|
||||
info.AttachmentFileSizeLimit = v.config.AttachmentFileSizeLimit
|
||||
info.AttachmentExpiryDuration = int64(v.config.AttachmentExpiryDuration.Seconds())
|
||||
|
@ -212,20 +212,19 @@ func (v *visitor) Info() (*visitorInfo, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var topics int64
|
||||
var reservations int64
|
||||
if v.user != nil && v.userManager != nil {
|
||||
reservations, err := v.userManager.Reservations(v.user.Name) // FIXME dup call, move this to endpoint?
|
||||
reservations, err = v.userManager.ReservationsCount(v.user.Name) // FIXME dup call, move this to endpoint?
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
topics = int64(len(reservations))
|
||||
}
|
||||
info.Messages = messages
|
||||
info.MessagesRemaining = zeroIfNegative(info.MessagesLimit - info.Messages)
|
||||
info.Emails = emails
|
||||
info.EmailsRemaining = zeroIfNegative(info.EmailsLimit - info.Emails)
|
||||
info.Topics = topics
|
||||
info.TopicsRemaining = zeroIfNegative(info.TopicsLimit - info.Topics)
|
||||
info.Reservations = reservations
|
||||
info.ReservationsRemaining = zeroIfNegative(info.ReservationsLimit - info.Reservations)
|
||||
info.AttachmentTotalSize = attachmentsBytesUsed
|
||||
info.AttachmentTotalSizeRemaining = zeroIfNegative(info.AttachmentTotalSizeLimit - info.AttachmentTotalSize)
|
||||
return info, nil
|
||||
|
|
|
@ -32,28 +32,27 @@ var (
|
|||
// Manager-related queries
|
||||
const (
|
||||
createTablesQueriesNoTx = `
|
||||
CREATE TABLE IF NOT EXISTS plan (
|
||||
id INT NOT NULL,
|
||||
CREATE TABLE IF NOT EXISTS tier (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL,
|
||||
messages_limit INT NOT NULL,
|
||||
messages_expiry_duration INT NOT NULL,
|
||||
emails_limit INT NOT NULL,
|
||||
topics_limit INT NOT NULL,
|
||||
reservations_limit INT NOT NULL,
|
||||
attachment_file_size_limit INT NOT NULL,
|
||||
attachment_total_size_limit INT NOT NULL,
|
||||
attachment_expiry_duration INT NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
attachment_expiry_duration INT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plan_id INT,
|
||||
tier_id INT,
|
||||
user TEXT NOT NULL,
|
||||
pass TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
messages INT NOT NULL DEFAULT (0),
|
||||
emails INT NOT NULL DEFAULT (0),
|
||||
settings JSON,
|
||||
FOREIGN KEY (plan_id) REFERENCES plan (id)
|
||||
FOREIGN KEY (tier_id) REFERENCES tier (id)
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_user ON user (user);
|
||||
CREATE TABLE IF NOT EXISTS user_access (
|
||||
|
@ -85,16 +84,16 @@ const (
|
|||
`
|
||||
|
||||
selectUserByNameQuery = `
|
||||
SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.topics_limit, p.attachment_file_size_limit, p.attachment_total_size_limit, p.attachment_expiry_duration
|
||||
SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.reservations_limit, p.attachment_file_size_limit, p.attachment_total_size_limit, p.attachment_expiry_duration
|
||||
FROM user u
|
||||
LEFT JOIN plan p on p.id = u.plan_id
|
||||
LEFT JOIN tier p on p.id = u.tier_id
|
||||
WHERE user = ?
|
||||
`
|
||||
selectUserByTokenQuery = `
|
||||
SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.topics_limit, p.attachment_file_size_limit, p.attachment_total_size_limit, p.attachment_expiry_duration
|
||||
SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.messages_expiry_duration, p.emails_limit, p.reservations_limit, p.attachment_file_size_limit, p.attachment_total_size_limit, p.attachment_expiry_duration
|
||||
FROM user u
|
||||
JOIN user_token t on u.id = t.user_id
|
||||
LEFT JOIN plan p on p.id = u.plan_id
|
||||
LEFT JOIN tier p on p.id = u.tier_id
|
||||
WHERE t.token = ? AND t.expires >= ?
|
||||
`
|
||||
selectTopicPermsQuery = `
|
||||
|
@ -178,8 +177,14 @@ const (
|
|||
ORDER BY expires DESC
|
||||
LIMIT ?
|
||||
)
|
||||
;
|
||||
`
|
||||
|
||||
insertTierQuery = `
|
||||
INSERT INTO tier (code, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
selectTierIDQuery = `SELECT id FROM tier WHERE code = ?`
|
||||
updateUserTierQuery = `UPDATE user SET tier_id = ? WHERE user = ?`
|
||||
)
|
||||
|
||||
// Schema management queries
|
||||
|
@ -523,13 +528,13 @@ func (a *Manager) userByToken(token string) (*User, error) {
|
|||
func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
||||
defer rows.Close()
|
||||
var username, hash, role string
|
||||
var settings, planCode sql.NullString
|
||||
var settings, tierCode sql.NullString
|
||||
var messages, emails int64
|
||||
var messagesLimit, messagesExpiryDuration, emailsLimit, topicsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration sql.NullInt64
|
||||
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration sql.NullInt64
|
||||
if !rows.Next() {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err := rows.Scan(&username, &hash, &role, &messages, &emails, &settings, &planCode, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &topicsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration); err != nil {
|
||||
if err := rows.Scan(&username, &hash, &role, &messages, &emails, &settings, &tierCode, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration); err != nil {
|
||||
return nil, err
|
||||
} else if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
|
@ -549,14 +554,14 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
|||
return nil, err
|
||||
}
|
||||
}
|
||||
if planCode.Valid {
|
||||
user.Plan = &Plan{
|
||||
Code: planCode.String,
|
||||
if tierCode.Valid {
|
||||
user.Tier = &Tier{
|
||||
Code: tierCode.String,
|
||||
Upgradeable: false,
|
||||
MessagesLimit: messagesLimit.Int64,
|
||||
MessagesExpiryDuration: messagesExpiryDuration.Int64,
|
||||
EmailsLimit: emailsLimit.Int64,
|
||||
TopicsLimit: topicsLimit.Int64,
|
||||
ReservationsLimit: reservationsLimit.Int64,
|
||||
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
|
||||
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
|
||||
AttachmentExpiryDuration: attachmentExpiryDuration.Int64,
|
||||
|
@ -678,6 +683,30 @@ func (a *Manager) ChangeRole(username string, role Role) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// ChangeTier changes a user's tier using the tier code
|
||||
func (a *Manager) ChangeTier(username, tier string) error {
|
||||
if !AllowedUsername(username) {
|
||||
return ErrInvalidArgument
|
||||
}
|
||||
rows, err := a.db.Query(selectTierIDQuery, tier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return ErrInvalidArgument
|
||||
}
|
||||
var tierID int64
|
||||
if err := rows.Scan(&tierID); err != nil {
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
if _, err := a.db.Exec(updateUserTierQuery, tierID, username); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckAllowAccess tests if a user may create an access control entry for the given topic.
|
||||
// If there are any ACL entries that are not owned by the user, an error is returned.
|
||||
func (a *Manager) CheckAllowAccess(username string, topic string) error {
|
||||
|
@ -743,6 +772,14 @@ func (a *Manager) DefaultAccess() Permission {
|
|||
return a.defaultAccess
|
||||
}
|
||||
|
||||
// CreateTier creates a new tier in the database
|
||||
func (a *Manager) CreateTier(tier *Tier) error {
|
||||
if _, err := a.db.Exec(insertTierQuery, tier.Code, tier.MessagesLimit, tier.MessagesExpiryDuration, tier.EmailsLimit, tier.ReservationsLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, tier.AttachmentExpiryDuration); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toSQLWildcard(s string) string {
|
||||
return strings.ReplaceAll(s, "*", "%")
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ type User struct {
|
|||
Token string // Only set if token was used to log in
|
||||
Role Role
|
||||
Prefs *Prefs
|
||||
Plan *Plan
|
||||
Tier *Tier
|
||||
Stats *Stats
|
||||
}
|
||||
|
||||
|
@ -43,27 +43,27 @@ type Prefs struct {
|
|||
Subscriptions []*Subscription `json:"subscriptions,omitempty"`
|
||||
}
|
||||
|
||||
// PlanCode is code identifying a user's plan
|
||||
type PlanCode string
|
||||
// TierCode is code identifying a user's tier
|
||||
type TierCode string
|
||||
|
||||
// Default plan codes
|
||||
// Default tier codes
|
||||
const (
|
||||
PlanUnlimited = PlanCode("unlimited")
|
||||
PlanDefault = PlanCode("default")
|
||||
PlanNone = PlanCode("none")
|
||||
TierUnlimited = TierCode("unlimited")
|
||||
TierDefault = TierCode("default")
|
||||
TierNone = TierCode("none")
|
||||
)
|
||||
|
||||
// Plan represents a user's account type, including its account limits
|
||||
type Plan struct {
|
||||
// Tier represents a user's account type, including its account limits
|
||||
type Tier struct {
|
||||
Code string `json:"name"`
|
||||
Upgradeable bool `json:"upgradeable"`
|
||||
MessagesLimit int64 `json:"messages_limit"`
|
||||
MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
|
||||
EmailsLimit int64 `json:"emails_limit"`
|
||||
TopicsLimit int64 `json:"topics_limit"`
|
||||
ReservationsLimit int64 `json:"reservations_limit"`
|
||||
AttachmentFileSizeLimit int64 `json:"attachment_file_size_limit"`
|
||||
AttachmentTotalSizeLimit int64 `json:"attachment_total_size_limit"`
|
||||
AttachmentExpiryDuration int64 `json:"attachment_expiry_seconds"`
|
||||
AttachmentExpiryDuration int64 `json:"attachment_expiry_duration"`
|
||||
}
|
||||
|
||||
// Subscription represents a user's topic subscription
|
||||
|
|
|
@ -178,13 +178,13 @@
|
|||
"account_usage_of_limit": "of {{limit}}",
|
||||
"account_usage_unlimited": "Unlimited",
|
||||
"account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)",
|
||||
"account_usage_plan_title": "Account type",
|
||||
"account_usage_plan_code_default": "Default",
|
||||
"account_usage_plan_code_unlimited": "Unlimited",
|
||||
"account_usage_plan_code_none": "None",
|
||||
"account_usage_plan_code_pro": "Pro",
|
||||
"account_usage_plan_code_business": "Business",
|
||||
"account_usage_plan_code_business_plus": "Business Plus",
|
||||
"account_usage_tier_title": "Account type",
|
||||
"account_usage_tier_code_default": "Default",
|
||||
"account_usage_tier_code_unlimited": "Unlimited",
|
||||
"account_usage_tier_code_none": "None",
|
||||
"account_usage_tier_code_pro": "Pro",
|
||||
"account_usage_tier_code_business": "Business",
|
||||
"account_usage_tier_code_business_plus": "Business Plus",
|
||||
"account_usage_messages_title": "Published messages",
|
||||
"account_usage_emails_title": "Emails sent",
|
||||
"account_usage_topics_title": "Reserved topics",
|
||||
|
|
|
@ -169,7 +169,7 @@ const Stats = () => {
|
|||
if (!account) {
|
||||
return <></>;
|
||||
}
|
||||
const planCode = account.plan.code ?? "none";
|
||||
const tierCode = account.tier.code ?? "none";
|
||||
const normalize = (value, max) => Math.min(value / max * 100, 100);
|
||||
const barColor = (remaining, limit) => {
|
||||
if (account.role === "admin") {
|
||||
|
@ -186,12 +186,12 @@ const Stats = () => {
|
|||
{t("account_usage_title")}
|
||||
</Typography>
|
||||
<PrefGroup>
|
||||
<Pref title={t("account_usage_plan_title")}>
|
||||
<Pref title={t("account_usage_tier_title")}>
|
||||
<div>
|
||||
{account.role === "admin"
|
||||
? <>{t("account_usage_unlimited")} <Tooltip title={t("account_basics_username_admin_tooltip")}><span style={{cursor: "default"}}>👑</span></Tooltip></>
|
||||
: t(`account_usage_plan_code_${planCode}`)}
|
||||
{config.enable_payments && account.plan.upgradeable &&
|
||||
: t(`account_usage_tier_code_${tierCode}`)}
|
||||
{config.enable_payments && account.tier.upgradeable &&
|
||||
<em>{" "}
|
||||
<Link onClick={() => {}}>Upgrade</Link>
|
||||
</em>
|
||||
|
@ -199,20 +199,20 @@ const Stats = () => {
|
|||
</div>
|
||||
</Pref>
|
||||
<Pref title={t("account_usage_topics_title")}>
|
||||
{account.limits.topics > 0 &&
|
||||
{account.limits.reservations > 0 &&
|
||||
<>
|
||||
<div>
|
||||
<Typography variant="body2" sx={{float: "left"}}>{account.stats.topics}</Typography>
|
||||
<Typography variant="body2" sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", { limit: account.limits.topics }) : t("account_usage_unlimited")}</Typography>
|
||||
<Typography variant="body2" sx={{float: "left"}}>{account.stats.reservations}</Typography>
|
||||
<Typography variant="body2" sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", { limit: account.limits.reservations }) : t("account_usage_unlimited")}</Typography>
|
||||
</div>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={account.limits.topics > 0 ? normalize(account.stats.topics, account.limits.topics) : 100}
|
||||
color={barColor(account.stats.topics_remaining, account.limits.topics)}
|
||||
value={account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
|
||||
color={barColor(account.stats.reservations_remaining, account.limits.reservations)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
{account.limits.topics === 0 &&
|
||||
{account.limits.reservations === 0 &&
|
||||
<em>No reserved topics for this account</em>
|
||||
}
|
||||
</Pref>
|
||||
|
|
|
@ -99,7 +99,7 @@ const NavList = (props) => {
|
|||
navigate(routes.account);
|
||||
};
|
||||
|
||||
const showUpgradeBanner = config.enable_payments && (!props.account || props.account.plan.upgradeable);
|
||||
const showUpgradeBanner = config.enable_payments && (!props.account || props.account.tier.upgradeable);
|
||||
const showSubscriptionsList = props.subscriptions?.length > 0;
|
||||
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
|
||||
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
|
||||
|
|
|
@ -489,7 +489,7 @@ const Reservations = () => {
|
|||
return <></>;
|
||||
}
|
||||
const reservations = account.reservations || [];
|
||||
const limitReached = account.role === "user" && account.stats.topics_remaining === 0;
|
||||
const limitReached = account.role === "user" && account.stats.reservations_remaining === 0;
|
||||
|
||||
const handleAddClick = () => {
|
||||
setDialogKey(prev => prev+1);
|
||||
|
|
|
@ -87,7 +87,7 @@ const SubscribePage = (props) => {
|
|||
const existingBaseUrls = Array
|
||||
.from(new Set([publicBaseUrl, ...props.subscriptions.map(s => s.baseUrl)]))
|
||||
.filter(s => s !== config.base_url);
|
||||
//const reserveTopicEnabled = session.exists() && (account?.stats.topics_remaining || 0) > 0;
|
||||
//const reserveTopicEnabled = session.exists() && (account?.stats.reservations_remaining || 0) > 0;
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
const user = await userManager.get(baseUrl); // May be undefined
|
||||
|
@ -184,7 +184,7 @@ const SubscribePage = (props) => {
|
|||
control={
|
||||
<Checkbox
|
||||
fullWidth
|
||||
// disabled={account.stats.topics_remaining}
|
||||
// disabled={account.stats.reservations_remaining}
|
||||
checked={reserveTopicVisible}
|
||||
onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
|
||||
inputProps={{
|
||||
|
|
Loading…
Reference in New Issue