forked from mirrors/ntfy
Add password confirmation to account delete dialog, v1/tiers test
This commit is contained in:
parent
954d919361
commit
e82a2e518c
14 changed files with 242 additions and 93 deletions
|
@ -62,7 +62,7 @@ var (
|
||||||
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", ""}
|
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", ""}
|
||||||
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", ""}
|
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", ""}
|
||||||
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", ""}
|
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", ""}
|
||||||
errHTTPBadRequestCurrentPasswordWrong = &errHTTP{40030, http.StatusBadRequest, "invalid request: current password is not correct", ""}
|
errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40030, http.StatusBadRequest, "invalid request: password confirmation is not correct", ""}
|
||||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
|
||||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
|
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
|
||||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
|
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
|
||||||
|
|
|
@ -40,9 +40,9 @@ TODO
|
||||||
|
|
||||||
- Reservation: Kill existing subscribers when topic is reserved (deadcade)
|
- Reservation: Kill existing subscribers when topic is reserved (deadcade)
|
||||||
- Rate limiting: Sensitive endpoints (account/login/change-password/...)
|
- Rate limiting: Sensitive endpoints (account/login/change-password/...)
|
||||||
- Stripe: Add metadata to customer
|
|
||||||
- Reservation (UI): Show "This topic is reserved" error message when trying to reserve a reserved topic (Thorben)
|
- Reservation (UI): Show "This topic is reserved" error message when trying to reserve a reserved topic (Thorben)
|
||||||
- Reservation (UI): Ask for confirmation when removing reservation (deadcade)
|
- Reservation (UI): Ask for confirmation when removing reservation (deadcade)
|
||||||
|
- Reservation icons (UI)
|
||||||
|
|
||||||
races:
|
races:
|
||||||
- v.user --> see publishSyncEventAsync() test
|
- v.user --> see publishSyncEventAsync() test
|
||||||
|
@ -65,7 +65,6 @@ Make sure account endpoints make sense for admins
|
||||||
|
|
||||||
UI:
|
UI:
|
||||||
-
|
-
|
||||||
- reservation icons
|
|
||||||
- reservation table delete button: dialog "keep or delete messages?"
|
- reservation table delete button: dialog "keep or delete messages?"
|
||||||
- flicker of upgrade banner
|
- flicker of upgrade banner
|
||||||
- JS constants
|
- JS constants
|
||||||
|
@ -858,7 +857,6 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
||||||
if m.Time > attachmentExpiry {
|
if m.Time > attachmentExpiry {
|
||||||
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
|
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
|
||||||
}
|
}
|
||||||
fmt.Printf("v = %#v\nlimits = %#v\nstats = %#v\n", v, vinfo.Limits, vinfo.Stats)
|
|
||||||
contentLengthStr := r.Header.Get("Content-Length")
|
contentLengthStr := r.Header.Get("Content-Length")
|
||||||
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
|
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
|
||||||
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
|
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"heckel.io/ntfy/user"
|
"heckel.io/ntfy/user"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -118,17 +119,24 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
req, err := readJSONWithLimit[apiAccountDeleteRequest](r.Body, jsonBodyBytesLimit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if req.Password == "" {
|
||||||
|
return errHTTPBadRequest
|
||||||
|
}
|
||||||
|
if _, err := s.userManager.Authenticate(v.user.Name, req.Password); err != nil {
|
||||||
|
return errHTTPBadRequestIncorrectPasswordConfirmation
|
||||||
|
}
|
||||||
if v.user.Billing.StripeSubscriptionID != "" {
|
if v.user.Billing.StripeSubscriptionID != "" {
|
||||||
log.Info("%s Canceling billing subscription %s", logHTTPPrefix(v, r), v.user.Billing.StripeSubscriptionID)
|
log.Info("%s Canceling billing subscription %s", logHTTPPrefix(v, r), v.user.Billing.StripeSubscriptionID)
|
||||||
if v.user.Billing.StripeSubscriptionID != "" {
|
|
||||||
if _, err := s.stripe.CancelSubscription(v.user.Billing.StripeSubscriptionID); err != nil {
|
if _, err := s.stripe.CancelSubscription(v.user.Billing.StripeSubscriptionID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := s.maybeRemoveExcessReservations(logHTTPPrefix(v, r), v.user, 0); err != nil {
|
if err := s.maybeRemoveMessagesAndExcessReservations(logHTTPPrefix(v, r), v.user, 0); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
log.Info("%s Marking user %s as deleted", logHTTPPrefix(v, r), v.user.Name)
|
log.Info("%s Marking user %s as deleted", logHTTPPrefix(v, r), v.user.Name)
|
||||||
if err := s.userManager.MarkUserRemoved(v.user); err != nil {
|
if err := s.userManager.MarkUserRemoved(v.user); err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -144,7 +152,7 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
|
||||||
return errHTTPBadRequest
|
return errHTTPBadRequest
|
||||||
}
|
}
|
||||||
if _, err := s.userManager.Authenticate(v.user.Name, req.Password); err != nil {
|
if _, err := s.userManager.Authenticate(v.user.Name, req.Password); err != nil {
|
||||||
return errHTTPBadRequestCurrentPasswordWrong
|
return errHTTPBadRequestIncorrectPasswordConfirmation
|
||||||
}
|
}
|
||||||
if err := s.userManager.ChangePassword(v.user.Name, req.NewPassword); err != nil {
|
if err := s.userManager.ChangePassword(v.user.Name, req.NewPassword); err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -365,6 +373,30 @@ func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.R
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maybeRemoveMessagesAndExcessReservations deletes topic reservations for the given user (if too many for tier),
|
||||||
|
// and marks associated messages for the topics as deleted. This also eventually deletes attachments.
|
||||||
|
// The process relies on the manager to perform the actual deletions (see runManager).
|
||||||
|
func (s *Server) maybeRemoveMessagesAndExcessReservations(logPrefix string, u *user.User, reservationsLimit int64) error {
|
||||||
|
reservations, err := s.userManager.Reservations(u.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if int64(len(reservations)) <= reservationsLimit {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
topics := make([]string, 0)
|
||||||
|
for i := int64(len(reservations)) - 1; i >= reservationsLimit; i-- {
|
||||||
|
topics = append(topics, reservations[i].Topic)
|
||||||
|
}
|
||||||
|
log.Info("%s Removing excess reservations for topics %s", logPrefix, strings.Join(topics, ", "))
|
||||||
|
if err := s.userManager.RemoveReservations(u.Name, topics...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.messageCache.ExpireMessages(topics...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) publishSyncEvent(v *visitor) error {
|
func (s *Server) publishSyncEvent(v *visitor) error {
|
||||||
if v.user == nil || v.user.SyncTopic == "" {
|
if v.user == nil || v.user.SyncTopic == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -319,7 +319,7 @@ func TestAccount_Delete_Success(t *testing.T) {
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
rr = request(t, s, "DELETE", "/v1/account", "", map[string]string{
|
rr = request(t, s, "DELETE", "/v1/account", `{"password":"mypass"}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
@ -345,6 +345,15 @@ func TestAccount_Delete_Not_Allowed(t *testing.T) {
|
||||||
|
|
||||||
rr = request(t, s, "DELETE", "/v1/account", "", nil)
|
rr = request(t, s, "DELETE", "/v1/account", "", nil)
|
||||||
require.Equal(t, 401, rr.Code)
|
require.Equal(t, 401, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "DELETE", "/v1/account", `{"password":"mypass"}`, nil)
|
||||||
|
require.Equal(t, 401, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "DELETE", "/v1/account", `{"password":"INCORRECT"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 400, rr.Code)
|
||||||
|
require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAccount_Reservation_AddWithoutTierFails(t *testing.T) {
|
func TestAccount_Reservation_AddWithoutTierFails(t *testing.T) {
|
||||||
|
@ -386,7 +395,6 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
||||||
// Create a tier
|
// Create a tier
|
||||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
Code: "pro",
|
Code: "pro",
|
||||||
Paid: false,
|
|
||||||
MessagesLimit: 123,
|
MessagesLimit: 123,
|
||||||
MessagesExpiryDuration: 86400 * time.Second,
|
MessagesExpiryDuration: 86400 * time.Second,
|
||||||
EmailsLimit: 32,
|
EmailsLimit: 32,
|
||||||
|
|
|
@ -18,7 +18,6 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -66,6 +65,7 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
|
||||||
{
|
{
|
||||||
// This is a bit of a hack: This is the "Free" tier. It has no tier code, name or price.
|
// This is a bit of a hack: This is the "Free" tier. It has no tier code, name or price.
|
||||||
Limits: &apiAccountLimits{
|
Limits: &apiAccountLimits{
|
||||||
|
Basis: string(visitorLimitBasisIP),
|
||||||
Messages: freeTier.MessagesLimit,
|
Messages: freeTier.MessagesLimit,
|
||||||
MessagesExpiryDuration: int64(freeTier.MessagesExpiryDuration.Seconds()),
|
MessagesExpiryDuration: int64(freeTier.MessagesExpiryDuration.Seconds()),
|
||||||
Emails: freeTier.EmailsLimit,
|
Emails: freeTier.EmailsLimit,
|
||||||
|
@ -90,6 +90,7 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
|
||||||
Name: tier.Name,
|
Name: tier.Name,
|
||||||
Price: priceStr,
|
Price: priceStr,
|
||||||
Limits: &apiAccountLimits{
|
Limits: &apiAccountLimits{
|
||||||
|
Basis: string(visitorLimitBasisTier),
|
||||||
Messages: tier.MessagesLimit,
|
Messages: tier.MessagesLimit,
|
||||||
MessagesExpiryDuration: int64(tier.MessagesExpiryDuration.Seconds()),
|
MessagesExpiryDuration: int64(tier.MessagesExpiryDuration.Seconds()),
|
||||||
Emails: tier.EmailsLimit,
|
Emails: tier.EmailsLimit,
|
||||||
|
@ -143,6 +144,11 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
|
||||||
Quantity: stripe.Int64(1),
|
Quantity: stripe.Int64(1),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Params: stripe.Params{
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"user_id": v.user.ID,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
sess, err := s.stripe.NewCheckoutSession(params)
|
sess, err := s.stripe.NewCheckoutSession(params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -185,6 +191,17 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
v.SetUser(u)
|
v.SetUser(u)
|
||||||
|
customerParams := &stripe.CustomerParams{
|
||||||
|
Params: stripe.Params{
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"user_id": u.ID,
|
||||||
|
"user_name": u.Name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, err := s.stripe.UpdateCustomer(sess.Customer.ID, customerParams); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := s.updateSubscriptionAndTier(logHTTPPrefix(v, r), u, tier, sess.Customer.ID, sub.ID, string(sub.Status), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
|
if err := s.updateSubscriptionAndTier(logHTTPPrefix(v, r), u, tier, sess.Customer.ID, sub.ID, string(sub.Status), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -342,36 +359,12 @@ func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(event json.RawMe
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeRemoveExcessReservations deletes topic reservations for the given user (if too many for tier),
|
|
||||||
// and marks associated messages for the topics as deleted. This also eventually deletes attachments.
|
|
||||||
// The process relies on the manager to perform the actual deletions (see runManager).
|
|
||||||
func (s *Server) maybeRemoveExcessReservations(logPrefix string, u *user.User, reservationsLimit int64) error {
|
|
||||||
reservations, err := s.userManager.Reservations(u.Name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if int64(len(reservations)) <= reservationsLimit {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
topics := make([]string, 0)
|
|
||||||
for i := int64(len(reservations)) - 1; i >= reservationsLimit; i-- {
|
|
||||||
topics = append(topics, reservations[i].Topic)
|
|
||||||
}
|
|
||||||
log.Info("%s Removing excess reservations for topics %s", logPrefix, strings.Join(topics, ", "))
|
|
||||||
if err := s.userManager.RemoveReservations(u.Name, topics...); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.messageCache.ExpireMessages(topics...); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) updateSubscriptionAndTier(logPrefix string, u *user.User, tier *user.Tier, customerID, subscriptionID, status string, paidUntil, cancelAt int64) error {
|
func (s *Server) updateSubscriptionAndTier(logPrefix string, u *user.User, tier *user.Tier, customerID, subscriptionID, status string, paidUntil, cancelAt int64) error {
|
||||||
reservationsLimit := visitorDefaultReservationsLimit
|
reservationsLimit := visitorDefaultReservationsLimit
|
||||||
if tier != nil {
|
if tier != nil {
|
||||||
reservationsLimit = tier.ReservationsLimit
|
reservationsLimit = tier.ReservationsLimit
|
||||||
}
|
}
|
||||||
if err := s.maybeRemoveExcessReservations(logPrefix, u, reservationsLimit); err != nil {
|
if err := s.maybeRemoveMessagesAndExcessReservations(logPrefix, u, reservationsLimit); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if tier == nil {
|
if tier == nil {
|
||||||
|
@ -426,6 +419,7 @@ type stripeAPI interface {
|
||||||
GetCustomer(id string) (*stripe.Customer, error)
|
GetCustomer(id string) (*stripe.Customer, error)
|
||||||
GetSession(id string) (*stripe.CheckoutSession, error)
|
GetSession(id string) (*stripe.CheckoutSession, error)
|
||||||
GetSubscription(id string) (*stripe.Subscription, error)
|
GetSubscription(id string) (*stripe.Subscription, error)
|
||||||
|
UpdateCustomer(id string, params *stripe.CustomerParams) (*stripe.Customer, error)
|
||||||
UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error)
|
UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error)
|
||||||
CancelSubscription(id string) (*stripe.Subscription, error)
|
CancelSubscription(id string) (*stripe.Subscription, error)
|
||||||
ConstructWebhookEvent(payload []byte, header string, secret string) (stripe.Event, error)
|
ConstructWebhookEvent(payload []byte, header string, secret string) (stripe.Event, error)
|
||||||
|
@ -472,6 +466,10 @@ func (s *realStripeAPI) GetSubscription(id string) (*stripe.Subscription, error)
|
||||||
return subscription.Get(id, nil)
|
return subscription.Get(id, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *realStripeAPI) UpdateCustomer(id string, params *stripe.CustomerParams) (*stripe.Customer, error) {
|
||||||
|
return customer.Update(id, params)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *realStripeAPI) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) {
|
func (s *realStripeAPI) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) {
|
||||||
return subscription.Update(id, params)
|
return subscription.Update(id, params)
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,108 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestPayments_Tiers(t *testing.T) {
|
||||||
|
stripeMock := &testStripeAPI{}
|
||||||
|
defer stripeMock.AssertExpectations(t)
|
||||||
|
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
c.StripeSecretKey = "secret key"
|
||||||
|
c.StripeWebhookKey = "webhook key"
|
||||||
|
c.VisitorRequestLimitReplenish = 12 * time.Hour
|
||||||
|
c.CacheDuration = 13 * time.Hour
|
||||||
|
c.AttachmentFileSizeLimit = 111
|
||||||
|
c.VisitorAttachmentTotalSizeLimit = 222
|
||||||
|
c.AttachmentExpiryDuration = 123 * time.Second
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
s.stripe = stripeMock
|
||||||
|
|
||||||
|
// Define how the mock should react
|
||||||
|
stripeMock.
|
||||||
|
On("ListPrices", mock.Anything).
|
||||||
|
Return([]*stripe.Price{
|
||||||
|
{ID: "price_123", UnitAmount: 500},
|
||||||
|
{ID: "price_456", UnitAmount: 1000},
|
||||||
|
{ID: "price_999", UnitAmount: 9999},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
// Create tiers
|
||||||
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
|
ID: "ti_1",
|
||||||
|
Code: "admin",
|
||||||
|
Name: "Admin",
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
|
ID: "ti_123",
|
||||||
|
Code: "pro",
|
||||||
|
Name: "Pro",
|
||||||
|
MessagesLimit: 1000,
|
||||||
|
MessagesExpiryDuration: time.Hour,
|
||||||
|
EmailsLimit: 123,
|
||||||
|
ReservationsLimit: 777,
|
||||||
|
AttachmentFileSizeLimit: 999,
|
||||||
|
AttachmentTotalSizeLimit: 888,
|
||||||
|
AttachmentExpiryDuration: time.Minute,
|
||||||
|
StripePriceID: "price_123",
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
|
ID: "ti_444",
|
||||||
|
Code: "business",
|
||||||
|
Name: "Business",
|
||||||
|
MessagesLimit: 2000,
|
||||||
|
MessagesExpiryDuration: 10 * time.Hour,
|
||||||
|
EmailsLimit: 123123,
|
||||||
|
ReservationsLimit: 777333,
|
||||||
|
AttachmentFileSizeLimit: 999111,
|
||||||
|
AttachmentTotalSizeLimit: 888111,
|
||||||
|
AttachmentExpiryDuration: time.Hour,
|
||||||
|
StripePriceID: "price_456",
|
||||||
|
}))
|
||||||
|
response := request(t, s, "GET", "/v1/tiers", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
var tiers []apiAccountBillingTier
|
||||||
|
require.Nil(t, json.NewDecoder(response.Body).Decode(&tiers))
|
||||||
|
require.Equal(t, 3, len(tiers))
|
||||||
|
|
||||||
|
// Free tier
|
||||||
|
tier := tiers[0]
|
||||||
|
require.Equal(t, "", tier.Code)
|
||||||
|
require.Equal(t, "", tier.Name)
|
||||||
|
require.Equal(t, "ip", tier.Limits.Basis)
|
||||||
|
require.Equal(t, int64(0), tier.Limits.Reservations)
|
||||||
|
require.Equal(t, int64(2), tier.Limits.Messages) // :-(
|
||||||
|
require.Equal(t, int64(13*3600), tier.Limits.MessagesExpiryDuration)
|
||||||
|
require.Equal(t, int64(24), tier.Limits.Emails)
|
||||||
|
require.Equal(t, int64(111), tier.Limits.AttachmentFileSize)
|
||||||
|
require.Equal(t, int64(222), tier.Limits.AttachmentTotalSize)
|
||||||
|
require.Equal(t, int64(123), tier.Limits.AttachmentExpiryDuration)
|
||||||
|
|
||||||
|
// Admin tier is not included, because it is not paid!
|
||||||
|
|
||||||
|
tier = tiers[1]
|
||||||
|
require.Equal(t, "pro", tier.Code)
|
||||||
|
require.Equal(t, "Pro", tier.Name)
|
||||||
|
require.Equal(t, "tier", tier.Limits.Basis)
|
||||||
|
require.Equal(t, int64(777), tier.Limits.Reservations)
|
||||||
|
require.Equal(t, int64(1000), tier.Limits.Messages)
|
||||||
|
require.Equal(t, int64(3600), tier.Limits.MessagesExpiryDuration)
|
||||||
|
require.Equal(t, int64(123), tier.Limits.Emails)
|
||||||
|
require.Equal(t, int64(999), tier.Limits.AttachmentFileSize)
|
||||||
|
require.Equal(t, int64(888), tier.Limits.AttachmentTotalSize)
|
||||||
|
require.Equal(t, int64(60), tier.Limits.AttachmentExpiryDuration)
|
||||||
|
|
||||||
|
tier = tiers[2]
|
||||||
|
require.Equal(t, "business", tier.Code)
|
||||||
|
require.Equal(t, "Business", tier.Name)
|
||||||
|
require.Equal(t, "tier", tier.Limits.Basis)
|
||||||
|
require.Equal(t, int64(777333), tier.Limits.Reservations)
|
||||||
|
require.Equal(t, int64(2000), tier.Limits.Messages)
|
||||||
|
require.Equal(t, int64(36000), tier.Limits.MessagesExpiryDuration)
|
||||||
|
require.Equal(t, int64(123123), tier.Limits.Emails)
|
||||||
|
require.Equal(t, int64(999111), tier.Limits.AttachmentFileSize)
|
||||||
|
require.Equal(t, int64(888111), tier.Limits.AttachmentTotalSize)
|
||||||
|
require.Equal(t, int64(3600), tier.Limits.AttachmentExpiryDuration)
|
||||||
|
}
|
||||||
|
|
||||||
func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) {
|
func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) {
|
||||||
stripeMock := &testStripeAPI{}
|
stripeMock := &testStripeAPI{}
|
||||||
defer stripeMock.AssertExpectations(t)
|
defer stripeMock.AssertExpectations(t)
|
||||||
|
@ -122,7 +224,7 @@ func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) {
|
||||||
require.Nil(t, s.userManager.ChangeBilling(u.Name, billing))
|
require.Nil(t, s.userManager.ChangeBilling(u.Name, billing))
|
||||||
|
|
||||||
// Delete account
|
// Delete account
|
||||||
rr := request(t, s, "DELETE", "/v1/account", "", map[string]string{
|
rr := request(t, s, "DELETE", "/v1/account", `{"password": "phil"}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
@ -258,6 +360,8 @@ type testStripeAPI struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ stripeAPI = (*testStripeAPI)(nil)
|
||||||
|
|
||||||
func (s *testStripeAPI) NewCheckoutSession(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {
|
func (s *testStripeAPI) NewCheckoutSession(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {
|
||||||
args := s.Called(params)
|
args := s.Called(params)
|
||||||
return args.Get(0).(*stripe.CheckoutSession), args.Error(1)
|
return args.Get(0).(*stripe.CheckoutSession), args.Error(1)
|
||||||
|
@ -288,6 +392,11 @@ func (s *testStripeAPI) GetSubscription(id string) (*stripe.Subscription, error)
|
||||||
return args.Get(0).(*stripe.Subscription), args.Error(1)
|
return args.Get(0).(*stripe.Subscription), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *testStripeAPI) UpdateCustomer(id string, params *stripe.CustomerParams) (*stripe.Customer, error) {
|
||||||
|
args := s.Called(id)
|
||||||
|
return args.Get(0).(*stripe.Customer), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *testStripeAPI) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) {
|
func (s *testStripeAPI) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) {
|
||||||
args := s.Called(id)
|
args := s.Called(id)
|
||||||
return args.Get(0).(*stripe.Subscription), args.Error(1)
|
return args.Get(0).(*stripe.Subscription), args.Error(1)
|
||||||
|
@ -303,8 +412,6 @@ func (s *testStripeAPI) ConstructWebhookEvent(payload []byte, header string, sec
|
||||||
return args.Get(0).(stripe.Event), args.Error(1)
|
return args.Get(0).(stripe.Event), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ stripeAPI = (*testStripeAPI)(nil)
|
|
||||||
|
|
||||||
func jsonToStripeEvent(t *testing.T, v string) stripe.Event {
|
func jsonToStripeEvent(t *testing.T, v string) stripe.Event {
|
||||||
var e stripe.Event
|
var e stripe.Event
|
||||||
if err := json.Unmarshal([]byte(v), &e); err != nil {
|
if err := json.Unmarshal([]byte(v), &e); err != nil {
|
||||||
|
|
|
@ -231,6 +231,10 @@ type apiAccountPasswordChangeRequest struct {
|
||||||
NewPassword string `json:"new_password"`
|
NewPassword string `json:"new_password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type apiAccountDeleteRequest struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
type apiAccountTokenResponse struct {
|
type apiAccountTokenResponse struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Expires int64 `json:"expires"`
|
Expires int64 `json:"expires"`
|
||||||
|
|
|
@ -57,18 +57,18 @@ func logHTTPPrefix(v *visitor, r *http.Request) string {
|
||||||
if requestURI == "" {
|
if requestURI == "" {
|
||||||
requestURI = r.URL.Path
|
requestURI = r.URL.Path
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s HTTP %s %s", v.String(), r.Method, requestURI)
|
return fmt.Sprintf("HTTP %s %s %s", v.String(), r.Method, requestURI)
|
||||||
}
|
}
|
||||||
|
|
||||||
func logStripePrefix(customerID, subscriptionID string) string {
|
func logStripePrefix(customerID, subscriptionID string) string {
|
||||||
if subscriptionID != "" {
|
if subscriptionID != "" {
|
||||||
return fmt.Sprintf("%s/%s STRIPE", customerID, subscriptionID)
|
return fmt.Sprintf("STRIPE %s/%s", customerID, subscriptionID)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s STRIPE", customerID)
|
return fmt.Sprintf("STRIPE %s", customerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func logSMTPPrefix(state *smtp.ConnectionState) string {
|
func logSMTPPrefix(state *smtp.ConnectionState) string {
|
||||||
return fmt.Sprintf("%s/%s SMTP", state.Hostname, state.RemoteAddr.String())
|
return fmt.Sprintf("SMTP %s/%s", state.Hostname, state.RemoteAddr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderHTTPRequest(r *http.Request) string {
|
func renderHTTPRequest(r *http.Request) string {
|
||||||
|
|
|
@ -707,7 +707,6 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
||||||
user.Tier = &Tier{
|
user.Tier = &Tier{
|
||||||
Code: tierCode.String,
|
Code: tierCode.String,
|
||||||
Name: tierName.String,
|
Name: tierName.String,
|
||||||
Paid: stripePriceID.Valid, // If there is a price, it's a paid tier
|
|
||||||
MessagesLimit: messagesLimit.Int64,
|
MessagesLimit: messagesLimit.Int64,
|
||||||
MessagesExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
|
MessagesExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
|
||||||
EmailsLimit: emailsLimit.Int64,
|
EmailsLimit: emailsLimit.Int64,
|
||||||
|
@ -1066,7 +1065,6 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
|
||||||
ID: id,
|
ID: id,
|
||||||
Code: code,
|
Code: code,
|
||||||
Name: name,
|
Name: name,
|
||||||
Paid: stripePriceID.Valid, // If there is a price, it's a paid tier
|
|
||||||
MessagesLimit: messagesLimit.Int64,
|
MessagesLimit: messagesLimit.Int64,
|
||||||
MessagesExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
|
MessagesExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second,
|
||||||
EmailsLimit: emailsLimit.Int64,
|
EmailsLimit: emailsLimit.Int64,
|
||||||
|
|
|
@ -350,7 +350,6 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, RoleUser, ben.Role)
|
require.Equal(t, RoleUser, ben.Role)
|
||||||
require.Equal(t, "pro", ben.Tier.Code)
|
require.Equal(t, "pro", ben.Tier.Code)
|
||||||
require.Equal(t, true, ben.Tier.Paid)
|
|
||||||
require.Equal(t, int64(5000), ben.Tier.MessagesLimit)
|
require.Equal(t, int64(5000), ben.Tier.MessagesLimit)
|
||||||
require.Equal(t, 3*24*time.Hour, ben.Tier.MessagesExpiryDuration)
|
require.Equal(t, 3*24*time.Hour, ben.Tier.MessagesExpiryDuration)
|
||||||
require.Equal(t, int64(50), ben.Tier.EmailsLimit)
|
require.Equal(t, int64(50), ben.Tier.EmailsLimit)
|
||||||
|
|
|
@ -53,7 +53,6 @@ type Tier struct {
|
||||||
ID string
|
ID string
|
||||||
Code string
|
Code string
|
||||||
Name string
|
Name string
|
||||||
Paid bool
|
|
||||||
MessagesLimit int64
|
MessagesLimit int64
|
||||||
MessagesExpiryDuration time.Duration
|
MessagesExpiryDuration time.Duration
|
||||||
EmailsLimit int64
|
EmailsLimit int64
|
||||||
|
|
|
@ -175,7 +175,7 @@
|
||||||
"account_basics_password_dialog_confirm_password_label": "Confirm password",
|
"account_basics_password_dialog_confirm_password_label": "Confirm password",
|
||||||
"account_basics_password_dialog_button_cancel": "Cancel",
|
"account_basics_password_dialog_button_cancel": "Cancel",
|
||||||
"account_basics_password_dialog_button_submit": "Change password",
|
"account_basics_password_dialog_button_submit": "Change password",
|
||||||
"account_basics_password_dialog_current_password_incorrect": "Current password incorrect",
|
"account_basics_password_dialog_current_password_incorrect": "Password incorrect",
|
||||||
"account_usage_title": "Usage",
|
"account_usage_title": "Usage",
|
||||||
"account_usage_of_limit": "of {{limit}}",
|
"account_usage_of_limit": "of {{limit}}",
|
||||||
"account_usage_unlimited": "Unlimited",
|
"account_usage_unlimited": "Unlimited",
|
||||||
|
@ -199,8 +199,8 @@
|
||||||
"account_usage_basis_ip_description": "Usage stats and limits for this account are based on your IP address, so they may be shared with other users. Limits shown above are approximates based on the existing rate limits.",
|
"account_usage_basis_ip_description": "Usage stats and limits for this account are based on your IP address, so they may be shared with other users. Limits shown above are approximates based on the existing rate limits.",
|
||||||
"account_delete_title": "Delete account",
|
"account_delete_title": "Delete account",
|
||||||
"account_delete_description": "Permanently delete your account",
|
"account_delete_description": "Permanently delete your account",
|
||||||
"account_delete_dialog_description": "This will permanently delete your account, including all data that is stored on the server. If you really want to proceed, please type '{{username}}' in the text box below.",
|
"account_delete_dialog_description": "This will permanently delete your account, including all data that is stored on the server. If you really want to proceed, please confirm with your password in the box below.",
|
||||||
"account_delete_dialog_label": "Type '{{username}}' to delete account",
|
"account_delete_dialog_label": "Password",
|
||||||
"account_delete_dialog_button_cancel": "Cancel",
|
"account_delete_dialog_button_cancel": "Cancel",
|
||||||
"account_delete_dialog_button_submit": "Permanently delete account",
|
"account_delete_dialog_button_submit": "Permanently delete account",
|
||||||
"account_delete_dialog_billing_warning": "Deleting your account also cancels your billing subscription immediately. You will not have access to the billing dashboard anymore.",
|
"account_delete_dialog_billing_warning": "Deleting your account also cancels your billing subscription immediately. You will not have access to the billing dashboard anymore.",
|
||||||
|
|
|
@ -106,14 +106,19 @@ class AccountApi {
|
||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete() {
|
async delete(password) {
|
||||||
const url = accountUrl(config.base_url);
|
const url = accountUrl(config.base_url);
|
||||||
console.log(`[AccountApi] Deleting user account ${url}`);
|
console.log(`[AccountApi] Deleting user account ${url}`);
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: withBearerAuth({}, session.token())
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify({
|
||||||
|
password: password
|
||||||
|
})
|
||||||
});
|
});
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 400) {
|
||||||
|
throw new IncorrectPasswordError();
|
||||||
|
} else if (response.status === 401 || response.status === 403) {
|
||||||
throw new UnauthorizedError();
|
throw new UnauthorizedError();
|
||||||
} else if (response.status !== 200) {
|
} else if (response.status !== 200) {
|
||||||
throw new Error(`Unexpected server response ${response.status}`);
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
@ -132,7 +137,7 @@ class AccountApi {
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (response.status === 400) {
|
if (response.status === 400) {
|
||||||
throw new CurrentPasswordWrongError();
|
throw new IncorrectPasswordError();
|
||||||
} else if (response.status === 401 || response.status === 403) {
|
} else if (response.status === 401 || response.status === 403) {
|
||||||
throw new UnauthorizedError();
|
throw new UnauthorizedError();
|
||||||
} else if (response.status !== 200) {
|
} else if (response.status !== 200) {
|
||||||
|
@ -397,9 +402,9 @@ export class AccountCreateLimitReachedError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CurrentPasswordWrongError extends Error {
|
export class IncorrectPasswordError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
super("Current password incorrect");
|
super("Password incorrect");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,7 @@ import DialogActions from "@mui/material/DialogActions";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import {formatBytes, formatShortDate, formatShortDateTime} from "../app/utils";
|
import {formatBytes, formatShortDate, formatShortDateTime} from "../app/utils";
|
||||||
import accountApi, {CurrentPasswordWrongError, UnauthorizedError} from "../app/AccountApi";
|
import accountApi, {IncorrectPasswordError, UnauthorizedError} from "../app/AccountApi";
|
||||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||||
import {Pref, PrefGroup} from "./Pref";
|
import {Pref, PrefGroup} from "./Pref";
|
||||||
import db from "../app/db";
|
import db from "../app/db";
|
||||||
|
@ -128,7 +128,7 @@ const ChangePasswordDialog = (props) => {
|
||||||
props.onClose();
|
props.onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`[Account] Error changing password`, e);
|
console.log(`[Account] Error changing password`, e);
|
||||||
if ((e instanceof CurrentPasswordWrongError)) {
|
if ((e instanceof IncorrectPasswordError)) {
|
||||||
setErrorText(t("account_basics_password_dialog_current_password_incorrect"));
|
setErrorText(t("account_basics_password_dialog_current_password_incorrect"));
|
||||||
} else if ((e instanceof UnauthorizedError)) {
|
} else if ((e instanceof UnauthorizedError)) {
|
||||||
session.resetAndRedirect(routes.login);
|
session.resetAndRedirect(routes.login);
|
||||||
|
@ -414,26 +414,10 @@ const DeleteAccount = () => {
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDialogCancel = () => {
|
const handleDialogClose = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDialogSubmit = async () => {
|
|
||||||
try {
|
|
||||||
await accountApi.delete();
|
|
||||||
await db.delete();
|
|
||||||
setDialogOpen(false);
|
|
||||||
console.debug(`[Account] Account deleted`);
|
|
||||||
session.resetAndRedirect(routes.app);
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`[Account] Error deleting account`, e);
|
|
||||||
if ((e instanceof UnauthorizedError)) {
|
|
||||||
session.resetAndRedirect(routes.login);
|
|
||||||
}
|
|
||||||
// TODO show error
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pref title={t("account_delete_title")} description={t("account_delete_description")}>
|
<Pref title={t("account_delete_title")} description={t("account_delete_description")}>
|
||||||
<div>
|
<div>
|
||||||
|
@ -444,8 +428,7 @@ const DeleteAccount = () => {
|
||||||
<DeleteAccountDialog
|
<DeleteAccountDialog
|
||||||
key={`deleteAccountDialog${dialogKey}`}
|
key={`deleteAccountDialog${dialogKey}`}
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onCancel={handleDialogCancel}
|
onClose={handleDialogClose}
|
||||||
onSubmit={handleDialogSubmit}
|
|
||||||
/>
|
/>
|
||||||
</Pref>
|
</Pref>
|
||||||
)
|
)
|
||||||
|
@ -454,24 +437,42 @@ const DeleteAccount = () => {
|
||||||
const DeleteAccountDialog = (props) => {
|
const DeleteAccountDialog = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { account } = useContext(AccountContext);
|
const { account } = useContext(AccountContext);
|
||||||
const [username, setUsername] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [errorText, setErrorText] = useState("");
|
||||||
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
const buttonEnabled = username === session.username();
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await accountApi.delete(password);
|
||||||
|
await db.delete();
|
||||||
|
console.debug(`[Account] Account deleted`);
|
||||||
|
session.resetAndRedirect(routes.app);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Account] Error deleting account`, e);
|
||||||
|
if ((e instanceof IncorrectPasswordError)) {
|
||||||
|
setErrorText(t("account_basics_password_dialog_current_password_incorrect"));
|
||||||
|
} else if ((e instanceof UnauthorizedError)) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
}
|
||||||
|
// TODO show error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
|
||||||
<DialogTitle>{t("account_delete_title")}</DialogTitle>
|
<DialogTitle>{t("account_delete_title")}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Typography variant="body1">
|
<Typography variant="body1">
|
||||||
{t("account_delete_dialog_description", { username: session.username()})}
|
{t("account_delete_dialog_description")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
margin="dense"
|
margin="dense"
|
||||||
id="account-delete-confirm"
|
id="account-delete-confirm"
|
||||||
label={t("account_delete_dialog_label", { username: session.username()})}
|
label={t("account_delete_dialog_label")}
|
||||||
aria-label={t("account_delete_dialog_label", { username: session.username()})}
|
aria-label={t("account_delete_dialog_label")}
|
||||||
type="text"
|
type="password"
|
||||||
value={username}
|
value={password}
|
||||||
onChange={ev => setUsername(ev.target.value)}
|
onChange={ev => setPassword(ev.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="standard"
|
variant="standard"
|
||||||
/>
|
/>
|
||||||
|
@ -479,10 +480,10 @@ const DeleteAccountDialog = (props) => {
|
||||||
<Alert severity="warning" sx={{mt: 1}}>{t("account_delete_dialog_billing_warning")}</Alert>
|
<Alert severity="warning" sx={{mt: 1}}>{t("account_delete_dialog_billing_warning")}</Alert>
|
||||||
}
|
}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogFooter status={errorText}>
|
||||||
<Button onClick={props.onCancel}>{t("account_delete_dialog_button_cancel")}</Button>
|
<Button onClick={props.onClose}>{t("account_delete_dialog_button_cancel")}</Button>
|
||||||
<Button onClick={props.onSubmit} color="error" disabled={!buttonEnabled}>{t("account_delete_dialog_button_submit")}</Button>
|
<Button onClick={handleSubmit} color="error" disabled={password.length === 0}>{t("account_delete_dialog_button_submit")}</Button>
|
||||||
</DialogActions>
|
</DialogFooter>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue