Просмотр исходного кода

A little polishing, make upgrade banner work when not logged in

binwiederhier 3 лет назад
Родитель
Сommit
f945fb4cdd

+ 0 - 2
cmd/serve.go

@@ -201,7 +201,6 @@ func execServe(c *cli.Context) error {
 
 	webRootIsApp := webRoot == "app"
 	enableWeb := webRoot != "disable"
-	enablePayments := stripeSecretKey != ""
 
 	// Default auth permissions
 	authDefault, err := user.ParsePermission(authDefaultAccess)
@@ -298,7 +297,6 @@ func execServe(c *cli.Context) error {
 	conf.EnableWeb = enableWeb
 	conf.EnableSignup = enableSignup
 	conf.EnableLogin = enableLogin
-	conf.EnablePayments = enablePayments
 	conf.EnableReservations = enableReservations
 	conf.Version = c.App.Version
 

+ 2 - 2
docs/config.md

@@ -1037,8 +1037,8 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
 | `enable-signup`                            | `NTFY_SIGNUP`                                   | *boolean* (`true` or `false`)                       | `false`           | Allows users to sign up via the web app, or API                                                                                                                                                                                 |
 | `enable-login`                             | `NTFY_LOGIN`                                    | *boolean* (`true` or `false`)                       | `false`           | Allows users to log in via the web app, or API                                                                                                                                                                                  |
 | `enable-reservations`                      | `NTFY_RESERVATIONS`                             | *boolean* (`true` or `false`)                       | `false`           | Allows users to reserve topics (if their tier allows it)                                                                                                                                                                        |
-| `enable-payments`                          | `NTFY_PAYMENTS`                                 | *boolean* (`true` or `false`)                       | `false`           | Enables payments integration (_preliminary option, may change_)                                                                                                                                                                 |
-
+| `stripe-secret-key`                        | `NTFY_STRIPE_SECRET_KEY`                        | *string*                                            | -                 | Payments: Key used for the Stripe API communication, this enables payments                                                                                                                                                      |
+| `stripe-webhook-key`                       | `NTFY_STRIPE_WEBHOOK_KEY`                       | *string*                                            | -                 | Payments: Key required to validate the authenticity of incoming webhooks from Stripe                                                                                                                                            |
 
 The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.   
 The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.

+ 0 - 1
server/config.go

@@ -115,7 +115,6 @@ type Config struct {
 	EnableWeb                            bool
 	EnableSignup                         bool // Enable creation of accounts via API and UI
 	EnableLogin                          bool
-	EnablePayments                       bool
 	EnableReservations                   bool   // Allow users with role "user" to own/reserve topics
 	Version                              string // injected by App
 }

+ 2 - 1
server/errors.go

@@ -59,7 +59,8 @@ var (
 	errHTTPBadRequestPermissionInvalid               = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", ""}
 	errHTTPBadRequestMakesNoSenseForAdmin            = &errHTTP{40026, http.StatusBadRequest, "invalid request: this makes no sense for admins", ""}
 	errHTTPBadRequestNotAPaidUser                    = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", ""}
-	errHTTPBadRequestInvalidStripeRequest            = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid Stripe request", ""}
+	errHTTPBadRequestBillingRequestInvalid           = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", ""}
+	errHTTPBadRequestBillingSubscriptionExists       = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", ""}
 	errHTTPNotFound                                  = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
 	errHTTPUnauthorized                              = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
 	errHTTPForbidden                                 = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}

+ 16 - 8
server/message_cache.go

@@ -89,12 +89,7 @@ const (
 		WHERE time <= ? AND published = 0
 		ORDER BY time, id
 	`
-	selectMessagesExpiredQuery = `
-		SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, encoding
-		FROM messages 
-		WHERE expires <= ? AND published = 1
-		ORDER BY time, id
-	`
+	selectMessagesExpiredQuery      = `SELECT mid FROM messages WHERE expires <= ? AND published = 1`
 	updateMessagePublishedQuery     = `UPDATE messages SET published = 1 WHERE mid = ?`
 	selectMessagesCountQuery        = `SELECT COUNT(*) FROM messages`
 	selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic`
@@ -431,12 +426,25 @@ func (c *messageCache) MessagesDue() ([]*message, error) {
 	return readMessages(rows)
 }
 
-func (c *messageCache) MessagesExpired() ([]*message, error) {
+// MessagesExpired returns a list of IDs for messages that have expires (should be deleted)
+func (c *messageCache) MessagesExpired() ([]string, error) {
 	rows, err := c.db.Query(selectMessagesExpiredQuery, time.Now().Unix())
 	if err != nil {
 		return nil, err
 	}
-	return readMessages(rows)
+	defer rows.Close()
+	ids := make([]string, 0)
+	for rows.Next() {
+		var id string
+		if err := rows.Scan(&id); err != nil {
+			return nil, err
+		}
+		ids = append(ids, id)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return ids, nil
 }
 
 func (c *messageCache) MarkPublished(m *message) error {

+ 2 - 6
server/message_cache_test.go

@@ -270,13 +270,9 @@ func testCachePrune(t *testing.T, c *messageCache) {
 	require.Equal(t, 2, counts["mytopic"])
 	require.Equal(t, 1, counts["another_topic"])
 
-	expiredMessages, err := c.MessagesExpired()
+	expiredMessageIDs, err := c.MessagesExpired()
 	require.Nil(t, err)
-	ids := make([]string, 0)
-	for _, m := range expiredMessages {
-		ids = append(ids, m.ID)
-	}
-	require.Nil(t, c.DeleteMessages(ids...))
+	require.Nil(t, c.DeleteMessages(expiredMessageIDs...))
 
 	counts, err = c.MessageCounts()
 	require.Nil(t, err)

+ 16 - 16
server/server.go

@@ -43,10 +43,13 @@ import (
 		- delete subscription when account deleted
 		- delete messages + reserved topics on ResetTier
 
+		- move v1/account/tiers to v1/tiers
+
 		Limits & rate limiting:
 			users without tier: should the stats be persisted? are they meaningful?
 				-> test that the visitor is based on the IP address!
 			login/account endpoints
+			when ResetStats() is run, reset messagesLimiter (and others)?
 		update last_seen when API is accessed
 		Make sure account endpoints make sense for admins
 
@@ -54,11 +57,10 @@ import (
 		- flicker of upgrade banner
 		- JS constants
 		Sync:
-			- "mute" setting
-			- figure out what settings are "web" or "phone"
 			- sync problems with "deleteAfter=0" and "displayName="
 		Delete visitor when tier is changed to refresh rate limiters
 		Tests:
+		- Payment endpoints (make mocks)
 		- Change tier from higher to lower tier (delete reservations)
 		- Message rate limiting and reset tests
 		- test that the visitor is based on the IP address when a user has no tier
@@ -104,13 +106,13 @@ var (
 	accountPath                                          = "/account"
 	matrixPushPath                                       = "/_matrix/push/v1/notify"
 	apiHealthPath                                        = "/v1/health"
+	apiTiers                                             = "/v1/tiers"
 	apiAccountPath                                       = "/v1/account"
 	apiAccountTokenPath                                  = "/v1/account/token"
 	apiAccountPasswordPath                               = "/v1/account/password"
 	apiAccountSettingsPath                               = "/v1/account/settings"
 	apiAccountSubscriptionPath                           = "/v1/account/subscription"
 	apiAccountReservationPath                            = "/v1/account/reservation"
-	apiAccountBillingTiersPath                           = "/v1/account/billing/tiers"
 	apiAccountBillingPortalPath                          = "/v1/account/billing/portal"
 	apiAccountBillingWebhookPath                         = "/v1/account/billing/webhook"
 	apiAccountBillingSubscriptionPath                    = "/v1/account/billing/subscription"
@@ -378,20 +380,20 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
 		return s.ensureUser(s.withAccountSync(s.handleAccountReservationAdd))(w, r, v)
 	} else if r.Method == http.MethodDelete && apiAccountReservationSingleRegex.MatchString(r.URL.Path) {
 		return s.ensureUser(s.withAccountSync(s.handleAccountReservationDelete))(w, r, v)
-	} else if r.Method == http.MethodGet && r.URL.Path == apiAccountBillingTiersPath {
-		return s.ensurePaymentsEnabled(s.handleAccountBillingTiersGet)(w, r, v)
 	} else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingSubscriptionPath {
 		return s.ensurePaymentsEnabled(s.ensureUser(s.handleAccountBillingSubscriptionCreate))(w, r, v) // Account sync via incoming Stripe webhook
 	} else if r.Method == http.MethodGet && apiAccountBillingSubscriptionCheckoutSuccessRegex.MatchString(r.URL.Path) {
 		return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingSubscriptionCreateSuccess))(w, r, v) // No user context!
 	} else if r.Method == http.MethodPut && r.URL.Path == apiAccountBillingSubscriptionPath {
-		return s.ensurePaymentsEnabled(s.ensureUser(s.handleAccountBillingSubscriptionUpdate))(w, r, v) // Account sync via incoming Stripe webhook
+		return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingSubscriptionUpdate))(w, r, v) // Account sync via incoming Stripe webhook
 	} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountBillingSubscriptionPath {
 		return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingSubscriptionDelete))(w, r, v) // Account sync via incoming Stripe webhook
 	} else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingPortalPath {
 		return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingPortalSessionCreate))(w, r, v)
 	} else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath {
-		return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v)
+		return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe!
+	} else if r.Method == http.MethodGet && r.URL.Path == apiTiers {
+		return s.ensurePaymentsEnabled(s.handleBillingTiersGet)(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
 		return s.handleMatrixDiscovery(w)
 	} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
@@ -480,7 +482,7 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
 		AppRoot:            appRoot,
 		EnableLogin:        s.config.EnableLogin,
 		EnableSignup:       s.config.EnableSignup,
-		EnablePayments:     s.config.EnablePayments,
+		EnablePayments:     s.config.StripeSecretKey != "",
 		EnableReservations: s.config.EnableReservations,
 		DisallowedTopics:   disallowedTopics,
 	}
@@ -1271,18 +1273,14 @@ func (s *Server) execManager() {
 
 	// DeleteMessages message cache
 	log.Debug("Manager: Pruning messages")
-	expiredMessages, err := s.messageCache.MessagesExpired()
+	expiredMessageIDs, err := s.messageCache.MessagesExpired()
 	if err != nil {
 		log.Warn("Manager: Error retrieving expired messages: %s", err.Error())
-	} else if len(expiredMessages) > 0 {
-		ids := make([]string, 0)
-		for _, m := range expiredMessages {
-			ids = append(ids, m.ID)
-		}
-		if err := s.fileCache.Remove(ids...); err != nil {
+	} else if len(expiredMessageIDs) > 0 {
+		if err := s.fileCache.Remove(expiredMessageIDs...); err != nil {
 			log.Warn("Manager: Error deleting attachments for expired messages: %s", err.Error())
 		}
-		if err := s.messageCache.DeleteMessages(ids...); err != nil {
+		if err := s.messageCache.DeleteMessages(expiredMessageIDs...); err != nil {
 			log.Warn("Manager: Error marking attachments deleted: %s", err.Error())
 		}
 	} else {
@@ -1359,6 +1357,8 @@ func (s *Server) runManager() {
 	}
 }
 
+// runStatsResetter runs once a day (usually midnight UTC) to reset all the visitor's message and
+// email counters. The stats are used to display the counters in the web app, as well as for rate limiting.
 func (s *Server) runStatsResetter() {
 	for {
 		runAt := util.NextOccurrenceUTC(s.config.VisitorStatsResetTime, time.Now())

+ 1 - 1
server/server_middleware.go

@@ -33,7 +33,7 @@ func (s *Server) ensureUser(next handleFunc) handleFunc {
 
 func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {
 	return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
-		if !s.config.EnablePayments {
+		if s.config.StripeSecretKey == "" {
 			return errHTTPNotFound
 		}
 		return next(w, r, v)

+ 21 - 18
server/server_payments.go

@@ -25,11 +25,15 @@ const (
 )
 
 var (
-	errNotAPaidTier = errors.New("tier does not have Stripe price identifier")
+	errNotAPaidTier                 = errors.New("tier does not have billing price identifier")
+	errMultipleBillingSubscriptions = errors.New("cannot have multiple billing subscriptions")
+	errNoBillingSubscription        = errors.New("user does not have an active billing subscription")
 )
 
-func (s *Server) handleAccountBillingTiersGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
-	tiers, err := v.userManager.Tiers()
+// handleBillingTiersGet returns all available paid tiers, and the free tier. This is to populate the upgrade dialog
+// in the UI. Note that this endpoint does NOT have a user context (no v.user!).
+func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
+	tiers, err := s.userManager.Tiers()
 	if err != nil {
 		return err
 	}
@@ -92,7 +96,7 @@ func (s *Server) handleAccountBillingTiersGet(w http.ResponseWriter, r *http.Req
 // will be updated by a subsequent webhook from Stripe, once the subscription becomes active.
 func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
 	if v.user.Billing.StripeSubscriptionID != "" {
-		return errors.New("subscription already exists") //FIXME
+		return errHTTPBadRequestBillingSubscriptionExists
 	}
 	req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit)
 	if err != nil {
@@ -112,7 +116,7 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
 		if err != nil {
 			return err
 		} else if stripeCustomer.Subscriptions != nil && len(stripeCustomer.Subscriptions.Data) > 0 {
-			return errors.New("customer cannot have more than one subscription") //FIXME
+			return errMultipleBillingSubscriptions
 		}
 	}
 	successURL := s.config.BaseURL + apiAccountBillingSubscriptionCheckoutSuccessTemplate
@@ -157,15 +161,15 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
 	sess, err := session.Get(sessionID, nil) // FIXME how do I rate limit this?
 	if err != nil {
 		log.Warn("Stripe: %s", err)
-		return errHTTPBadRequestInvalidStripeRequest
+		return errHTTPBadRequestBillingRequestInvalid
 	} else if sess.Customer == nil || sess.Subscription == nil || sess.ClientReferenceID == "" {
-		return wrapErrHTTP(errHTTPBadRequestInvalidStripeRequest, "customer or subscription not found")
+		return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "customer or subscription not found")
 	}
 	sub, err := subscription.Get(sess.Subscription.ID, nil)
 	if err != nil {
 		return err
 	} else if sub.Items == nil || len(sub.Items.Data) != 1 || sub.Items.Data[0].Price == nil {
-		return wrapErrHTTP(errHTTPBadRequestInvalidStripeRequest, "more than one line item in existing subscription")
+		return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "more than one line item in existing subscription")
 	}
 	tier, err := s.userManager.TierByStripePrice(sub.Items.Data[0].Price.ID)
 	if err != nil {
@@ -186,7 +190,7 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
 // a user's tier accordingly. This endpoint only works if there is an existing subscription.
 func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
 	if v.user.Billing.StripeSubscriptionID == "" {
-		return errors.New("no existing subscription for user")
+		return errNoBillingSubscription
 	}
 	req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit)
 	if err != nil {
@@ -226,9 +230,6 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r
 // handleAccountBillingSubscriptionDelete facilitates downgrading a paid user to a tier-less user,
 // and cancelling the Stripe subscription entirely
 func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
-	if v.user.Billing.StripeCustomerID == "" {
-		return errHTTPBadRequestNotAPaidUser
-	}
 	if v.user.Billing.StripeSubscriptionID != "" {
 		params := &stripe.SubscriptionParams{
 			CancelAtPeriodEnd: stripe.Bool(true),
@@ -269,11 +270,13 @@ func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter,
 	return nil
 }
 
+// handleAccountBillingWebhook handles incoming Stripe webhooks. It mainly keeps the local user database in sync
+// with the Stripe view of the world. This endpoint is authorized via the Stripe webhook secret. Note that the
+// visitor (v) in this endpoint is the Stripe API, so we don't have v.user available.
 func (s *Server) handleAccountBillingWebhook(w http.ResponseWriter, r *http.Request, _ *visitor) error {
-	// Note that the visitor (v) in this endpoint is the Stripe API, so we don't have v.user available
 	stripeSignature := r.Header.Get("Stripe-Signature")
 	if stripeSignature == "" {
-		return errHTTPBadRequestInvalidStripeRequest
+		return errHTTPBadRequestBillingRequestInvalid
 	}
 	body, err := util.Peek(r.Body, stripeBodyBytesLimit)
 	if err != nil {
@@ -283,9 +286,9 @@ func (s *Server) handleAccountBillingWebhook(w http.ResponseWriter, r *http.Requ
 	}
 	event, err := webhook.ConstructEvent(body.PeekedBytes, stripeSignature, s.config.StripeWebhookKey)
 	if err != nil {
-		return errHTTPBadRequestInvalidStripeRequest
+		return errHTTPBadRequestBillingRequestInvalid
 	} else if event.Data == nil || event.Data.Raw == nil {
-		return errHTTPBadRequestInvalidStripeRequest
+		return errHTTPBadRequestBillingRequestInvalid
 	}
 	log.Info("Stripe: webhook event %s received", event.Type)
 	switch event.Type {
@@ -306,7 +309,7 @@ func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(event json.RawMe
 	cancelAt := gjson.GetBytes(event, "cancel_at")
 	priceID := gjson.GetBytes(event, "items.data.0.price.id")
 	if !subscriptionID.Exists() || !status.Exists() || !currentPeriodEnd.Exists() || !cancelAt.Exists() || !priceID.Exists() {
-		return errHTTPBadRequestInvalidStripeRequest
+		return errHTTPBadRequestBillingRequestInvalid
 	}
 	log.Info("Stripe: customer %s: Updating subscription to status %s, with price %s", customerID.String(), status, priceID)
 	u, err := s.userManager.UserByStripeCustomer(customerID.String())
@@ -327,7 +330,7 @@ func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(event json.RawMe
 func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(event json.RawMessage) error {
 	customerID := gjson.GetBytes(event, "customer")
 	if !customerID.Exists() {
-		return errHTTPBadRequestInvalidStripeRequest
+		return errHTTPBadRequestBillingRequestInvalid
 	}
 	log.Info("Stripe: customer %s: subscription deleted, downgrading to unpaid tier", customerID.String())
 	u, err := s.userManager.UserByStripeCustomer(customerID.String())

+ 4 - 4
server/server_test.go

@@ -745,7 +745,7 @@ func TestServer_Auth_ViaQuery(t *testing.T) {
 func TestServer_StatsResetter(t *testing.T) {
 	c := newTestConfigWithAuthFile(t)
 	c.AuthDefault = user.PermissionDenyAll
-	c.VisitorStatsResetTime = time.Now().Add(time.Second)
+	c.VisitorStatsResetTime = time.Now().Add(2 * time.Second)
 	s := newTestServer(t, c)
 	go s.runStatsResetter()
 
@@ -773,8 +773,8 @@ func TestServer_StatsResetter(t *testing.T) {
 	require.Nil(t, err)
 	require.Equal(t, int64(5), account.Stats.Messages)
 
-	// Start stats resetter
-	time.Sleep(1200 * time.Millisecond)
+	// Wait for stats resetter to run
+	time.Sleep(2200 * time.Millisecond)
 
 	// User stats show 0 messages now!
 	response = request(t, s, "GET", "/v1/account", "", nil)
@@ -1325,7 +1325,7 @@ func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *
 	require.Equal(t, 41301, err.Code)
 }
 
-func TestServer_PublishAttachmentAndPrune(t *testing.T) {
+func TestServer_PublishAttachmentAndExpire(t *testing.T) {
 	content := util.RandomString(5000) // > 4096
 
 	c := newTestConfig(t)

+ 1 - 0
server/visitor.go

@@ -208,6 +208,7 @@ func (v *visitor) ResetStats() {
 	if v.user != nil {
 		v.user.Stats.Messages = 0
 		v.user.Stats.Emails = 0
+		// v.messagesLimiter = ... // FIXME
 	}
 }
 

+ 1 - 0
web/public/static/langs/en.json

@@ -211,6 +211,7 @@
   "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage",
   "account_upgrade_dialog_tier_selected_label": "Selected",
   "account_upgrade_dialog_button_cancel": "Cancel",
+  "account_upgrade_dialog_button_redirect_signup": "Sign up now",
   "account_upgrade_dialog_button_pay_now": "Pay now and subscribe",
   "account_upgrade_dialog_button_cancel_subscription": "Cancel subscription",
   "account_upgrade_dialog_button_update_subscription": "Update subscription",

+ 4 - 43
web/src/app/AccountApi.js

@@ -8,7 +8,7 @@ import {
     accountTokenUrl,
     accountUrl, maybeWithAuth, topicUrl,
     withBasicAuth,
-    withBearerAuth, accountBillingSubscriptionUrl, accountBillingPortalUrl, accountBillingTiersUrl
+    withBearerAuth, accountBillingSubscriptionUrl, accountBillingPortalUrl, tiersUrl
 } from "./utils";
 import session from "./Session";
 import subscriptionManager from "./SubscriptionManager";
@@ -170,7 +170,6 @@ class AccountApi {
         } else if (response.status !== 200) {
             throw new Error(`Unexpected server response ${response.status}`);
         }
-        this.triggerChange(); // Dangle!
     }
 
     async addSubscription(payload) {
@@ -189,7 +188,6 @@ class AccountApi {
         }
         const subscription = await response.json();
         console.log(`[AccountApi] Subscription`, subscription);
-        this.triggerChange(); // Dangle!
         return subscription;
     }
 
@@ -209,7 +207,6 @@ class AccountApi {
         }
         const subscription = await response.json();
         console.log(`[AccountApi] Subscription`, subscription);
-        this.triggerChange(); // Dangle!
         return subscription;
     }
 
@@ -225,7 +222,6 @@ class AccountApi {
         } else if (response.status !== 200) {
             throw new Error(`Unexpected server response ${response.status}`);
         }
-        this.triggerChange(); // Dangle!
     }
 
     async upsertReservation(topic, everyone) {
@@ -246,7 +242,6 @@ class AccountApi {
         } else if (response.status !== 200) {
             throw new Error(`Unexpected server response ${response.status}`);
         }
-        this.triggerChange(); // Dangle!
     }
 
     async deleteReservation(topic) {
@@ -261,18 +256,13 @@ class AccountApi {
         } else if (response.status !== 200) {
             throw new Error(`Unexpected server response ${response.status}`);
         }
-        this.triggerChange(); // Dangle!
     }
 
     async billingTiers() {
-        const url = accountBillingTiersUrl(config.base_url);
+        const url = tiersUrl(config.base_url);
         console.log(`[AccountApi] Fetching billing tiers`);
-        const response = await fetch(url, {
-            headers: withBearerAuth({}, session.token())
-        });
-        if (response.status === 401 || response.status === 403) {
-            throw new UnauthorizedError();
-        } else if (response.status !== 200) {
+        const response = await fetch(url); // No auth needed!
+        if (response.status !== 200) {
             throw new Error(`Unexpected server response ${response.status}`);
         }
         return await response.json();
@@ -367,35 +357,6 @@ class AccountApi {
         }
     }
 
-    async triggerChange() {
-        return null;
-        const account = await this.get();
-        if (!account.sync_topic) {
-            return;
-        }
-        const url = topicUrl(config.base_url, account.sync_topic);
-        console.log(`[AccountApi] Triggering account change to ${url}`);
-        const user = await userManager.get(config.base_url);
-        const headers = {
-            Cache: "no" // We really don't need to store this!
-        };
-        try {
-            const response = await fetch(url, {
-                method: 'PUT',
-                body: JSON.stringify({
-                    event: "sync",
-                    source: this.identity
-                }),
-                headers: maybeWithAuth(headers, user)
-            });
-            if (response.status < 200 || response.status > 299) {
-                throw new Error(`Unexpected response: ${response.status}`);
-            }
-        } catch (e) {
-            console.log(`[AccountApi] Publishing to sync topic failed`, e);
-        }
-    }
-
     startWorker() {
         if (this.timer !== null) {
             return;

+ 1 - 1
web/src/app/utils.js

@@ -28,7 +28,7 @@ export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reserva
 export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`;
 export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`;
 export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
-export const accountBillingTiersUrl = (baseUrl) => `${baseUrl}/v1/account/billing/tiers`;
+export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
 export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
 export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
 export const expandSecureUrl = (url) => `https://${url}`;

+ 27 - 18
web/src/components/UpgradeDialog.js

@@ -24,7 +24,7 @@ import Box from "@mui/material/Box";
 
 const UpgradeDialog = (props) => {
     const { t } = useTranslation();
-    const { account } = useContext(AccountContext);
+    const { account } = useContext(AccountContext); // May be undefined!
     const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
     const [tiers, setTiers] = useState(null);
     const [newTier, setNewTier] = useState(account?.tier?.code); // May be undefined
@@ -37,28 +37,32 @@ const UpgradeDialog = (props) => {
         })();
     }, []);
 
-    if (!account || !tiers) {
+    if (!tiers) {
         return <></>;
     }
 
-    const currentTier = account.tier?.code; // May be undefined
+    const currentTier = account?.tier?.code; // May be undefined
     let action, submitButtonLabel, submitButtonEnabled;
-    if (currentTier === newTier) {
+    if (!account) {
+        submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup");
+        submitButtonEnabled = true;
+        action = Action.REDIRECT_SIGNUP;
+    } else if (currentTier === newTier) {
         submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
         submitButtonEnabled = false;
         action = null;
     } else if (!currentTier) {
         submitButtonLabel = t("account_upgrade_dialog_button_pay_now");
         submitButtonEnabled = true;
-        action = Action.CREATE;
+        action = Action.CREATE_SUBSCRIPTION;
     } else if (!newTier) {
         submitButtonLabel = t("account_upgrade_dialog_button_cancel_subscription");
         submitButtonEnabled = true;
-        action = Action.CANCEL;
+        action = Action.CANCEL_SUBSCRIPTION;
     } else {
         submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
         submitButtonEnabled = true;
-        action = Action.UPDATE;
+        action = Action.UPDATE_SUBSCRIPTION;
     }
 
     if (loading) {
@@ -66,14 +70,18 @@ const UpgradeDialog = (props) => {
     }
 
     const handleSubmit = async () => {
+        if (action === Action.REDIRECT_SIGNUP) {
+            window.location.href = routes.signup;
+            return;
+        }
         try {
             setLoading(true);
-            if (action === Action.CREATE) {
+            if (action === Action.CREATE_SUBSCRIPTION) {
                 const response = await accountApi.createBillingSubscription(newTier);
                 window.location.href = response.redirect_url;
-            } else if (action === Action.UPDATE) {
+            } else if (action === Action.UPDATE_SUBSCRIPTION) {
                 await accountApi.updateBillingSubscription(newTier);
-            } else if (action === Action.CANCEL) {
+            } else if (action === Action.CANCEL_SUBSCRIPTION) {
                 await accountApi.deleteBillingSubscription();
             }
             props.onCancel();
@@ -113,14 +121,14 @@ const UpgradeDialog = (props) => {
                         />
                     )}
                 </div>
-                {action === Action.CANCEL &&
+                {action === Action.CANCEL_SUBSCRIPTION &&
                     <Alert severity="warning">
                         <Trans
                             i18nKey="account_upgrade_dialog_cancel_warning"
-                            values={{ date: formatShortDate(account.billing.paid_until) }} />
+                            values={{ date: formatShortDate(account?.billing?.paid_until || 0) }} />
                     </Alert>
                 }
-                {currentTier && (!action || action === Action.UPDATE) &&
+                {currentTier && (!action || action === Action.UPDATE_SUBSCRIPTION) &&
                     <Alert severity="info">
                         <Trans i18nKey="account_upgrade_dialog_proration_info" />
                     </Alert>
@@ -148,8 +156,8 @@ const TierCard = (props) => {
             flexShrink: 1,
             flexBasis: 0,
             borderRadius: "3px",
-            "&:first-child": { ml: 0 },
-            "&:last-child": { mr: 0 },
+            "&:first-of-type": { ml: 0 },
+            "&:last-of-type": { mr: 0 },
             ...cardStyle
         }}>
             <Card sx={{ height: "100%" }}>
@@ -209,9 +217,10 @@ const FeatureItem = (props) => {
 };
 
 const Action = {
-    CREATE: 1,
-    UPDATE: 2,
-    CANCEL: 3
+    REDIRECT_SIGNUP: 0,
+    CREATE_SUBSCRIPTION: 1,
+    UPDATE_SUBSCRIPTION: 2,
+    CANCEL_SUBSCRIPTION: 3
 };
 
 export default UpgradeDialog;