From eb0805a4706723aa0011918774de62e4422a040f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 7 May 2023 22:28:07 -0400 Subject: [PATCH] Update web app with SMS and calls stuff --- docs/releases.md | 4 ++ server/server.go | 2 + server/server_payments.go | 4 ++ server/server_twilio.go | 11 +-- server/server_twilio_test.go | 96 +++++++++++++++++++++++++ server/types.go | 2 + user/manager.go | 14 ++-- web/public/config.js | 4 +- web/public/static/langs/en.json | 18 +++++ web/src/app/utils.js | 6 +- web/src/components/Account.js | 87 ++++++++++++++++------ web/src/components/PublishDialog.js | 54 ++++++++++++++ web/src/components/SubscriptionPopup.js | 4 +- web/src/components/UpgradeDialog.js | 7 +- 14 files changed, 274 insertions(+), 39 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 4b24085..72cc39e 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1180,6 +1180,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## ntfy server v2.5.0 (UNRELEASED) +**Features:** + +* Support for SMS and voice calls using Twilio (no ticket) + **Bug fixes + maintenance:** * Removed old ntfy website from ntfy entirely (no ticket) diff --git a/server/server.go b/server/server.go index 8c2f83c..79aa808 100644 --- a/server/server.go +++ b/server/server.go @@ -529,6 +529,8 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi EnableLogin: s.config.EnableLogin, EnableSignup: s.config.EnableSignup, EnablePayments: s.config.StripeSecretKey != "", + EnableSMS: s.config.TwilioAccount != "", + EnableCalls: s.config.TwilioAccount != "", EnableReservations: s.config.EnableReservations, BillingContact: s.config.BillingContact, DisallowedTopics: s.config.DisallowedTopics, diff --git a/server/server_payments.go b/server/server_payments.go index cb58596..bd91338 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -68,6 +68,8 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ Messages: freeTier.MessageLimit, MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()), Emails: freeTier.EmailLimit, + SMS: freeTier.SMSLimit, + Calls: freeTier.CallLimit, Reservations: freeTier.ReservationsLimit, AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit, AttachmentFileSize: freeTier.AttachmentFileSizeLimit, @@ -96,6 +98,8 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ Messages: tier.MessageLimit, MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()), Emails: tier.EmailLimit, + SMS: tier.SMSLimit, + Calls: tier.CallLimit, Reservations: tier.ReservationLimit, AttachmentTotalSize: tier.AttachmentTotalSizeLimit, AttachmentFileSize: tier.AttachmentFileSizeLimit, diff --git a/server/server_twilio.go b/server/server_twilio.go index fc5fb65..1bd1111 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/prometheus/client_golang/prometheus" "heckel.io/ntfy/log" + "heckel.io/ntfy/user" "heckel.io/ntfy/util" "io" "net/http" @@ -32,7 +33,7 @@ const ( ) func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) { - body := fmt.Sprintf("%s\n\n--\n%s", m.Message, s.messageFooter(m)) + body := fmt.Sprintf("%s\n\n--\n%s", m.Message, s.messageFooter(v.User(), m)) data := url.Values{} data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) @@ -41,7 +42,7 @@ func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) { } func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { - body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(s.messageFooter(m))) + body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(s.messageFooter(v.User(), m))) data := url.Values{} data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) @@ -97,11 +98,11 @@ func (s *Server) performTwilioRequestInternal(endpoint string, data url.Values) return string(response), nil } -func (s *Server) messageFooter(m *message) string { +func (s *Server) messageFooter(u *user.User, m *message) string { // u may be nil! topicURL := s.config.BaseURL + "/" + m.Topic sender := m.Sender.String() - if m.User != "" { - sender = fmt.Sprintf("%s (%s)", m.User, m.Sender) + if u != nil { + sender = fmt.Sprintf("%s (%s)", u.Name, m.Sender) } return fmt.Sprintf(twilioMessageFooterFormat, sender, util.ShortTopicURL(topicURL)) } diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index d99f9b6..913a520 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -2,6 +2,8 @@ package server import ( "github.com/stretchr/testify/require" + "heckel.io/ntfy/user" + "heckel.io/ntfy/util" "io" "net/http" "net/http/httptest" @@ -27,6 +29,7 @@ func TestServer_Twilio_SMS(t *testing.T) { c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" + c.VisitorSMSDailyLimit = 1 s := newTestServer(t, c) response := request(t, s, "POST", "/mytopic", "test", map[string]string{ @@ -38,6 +41,56 @@ func TestServer_Twilio_SMS(t *testing.T) { }) } +func TestServer_Twilio_SMS_With_User(t *testing.T) { + var called atomic.Bool + twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if called.Load() { + t.Fatal("Should be only called once") + } + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Messages.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "Body=test%0A%0A--%0AThis+message+was+sent+by+phil+%289.9.9.9%29+via+ntfy.sh%2Fmytopic&From=%2B1234567890&To=%2B11122233344", string(body)) + called.Store(true) + })) + defer twilioServer.Close() + + c := newTestConfigWithAuthFile(t) + c.BaseURL = "https://ntfy.sh" + c.TwilioBaseURL = twilioServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioFromNumber = "+1234567890" + s := newTestServer(t, c) + + // Add tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 10, + SMSLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + + // Do request with user + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + "SMS": "+11122233344", + }) + require.Equal(t, "test", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) + + // Second one should fail due to rate limits + response = request(t, s, "POST", "/mytopic", "test", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + "SMS": "+11122233344", + }) + require.Equal(t, 42910, toHTTPError(t, response.Body.String()).Code) +} + func TestServer_Twilio_Call(t *testing.T) { var called atomic.Bool twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -55,6 +108,7 @@ func TestServer_Twilio_Call(t *testing.T) { c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" + c.VisitorCallDailyLimit = 1 s := newTestServer(t, c) body := `this message has @@ -69,6 +123,48 @@ and "quotes and other 'quotes` }) } +func TestServer_Twilio_Call_With_User(t *testing.T) { + var called atomic.Bool + twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if called.Load() { + t.Fatal("Should be only called once") + } + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EYou+have+a+message+from+notify+on+topic+mytopic.+Message%3A%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3Ehi+there%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EEnd+message.%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EThis+message+was+sent+by+phil+%289.9.9.9%29+via+127.0.0.1%3A12345%2Fmytopic%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%3C%2FResponse%3E", string(body)) + called.Store(true) + })) + defer twilioServer.Close() + + c := newTestConfigWithAuthFile(t) + c.TwilioBaseURL = twilioServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioFromNumber = "+1234567890" + s := newTestServer(t, c) + + // Add tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 10, + CallLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + + // Do the thing + response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + "x-call": "+11122233344", + }) + require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) +} + func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { c := newTestConfig(t) c.TwilioBaseURL = "https://127.0.0.1" diff --git a/server/types.go b/server/types.go index ae6724f..98ab4e2 100644 --- a/server/types.go +++ b/server/types.go @@ -351,6 +351,8 @@ type apiConfigResponse struct { EnableLogin bool `json:"enable_login"` EnableSignup bool `json:"enable_signup"` EnablePayments bool `json:"enable_payments"` + EnableSMS bool `json:"enable_sms"` + EnableCalls bool `json:"enable_calls"` EnableReservations bool `json:"enable_reservations"` BillingContact string `json:"billing_contact"` DisallowedTopics []string `json:"disallowed_topics"` diff --git a/user/manager.go b/user/manager.go index 3effd5c..017996c 100644 --- a/user/manager.go +++ b/user/manager.go @@ -127,26 +127,26 @@ const ( ` selectUserByIDQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE u.id = ? ` selectUserByNameQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE user = ? ` selectUserByTokenQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u JOIN user_token tk on u.id = tk.user_id LEFT JOIN tier t on t.id = u.tier_id WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?) ` selectUserByStripeCustomerIDQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE u.stripe_customer_id = ? @@ -927,11 +927,11 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { var id, username, hash, role, prefs, syncTopic string var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString var messages, emails, sms, calls int64 - var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 + var messagesLimit, messagesExpiryDuration, emailsLimit, smsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 if !rows.Next() { return nil, ErrUserNotFound } - if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &sms, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &sms, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &smsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err @@ -971,6 +971,8 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { MessageLimit: messagesLimit.Int64, MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, EmailLimit: emailsLimit.Int64, + SMSLimit: smsLimit.Int64, + CallLimit: callsLimit.Int64, ReservationLimit: reservationsLimit.Int64, AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, diff --git a/web/public/config.js b/web/public/config.js index 30da691..f5a5759 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -6,12 +6,14 @@ // During web development, you may change values here for rapid testing. var config = { - base_url: window.location.origin, // Change to test against a different server + base_url: "http://127.0.0.1:2586",// FIXME window.location.origin, // Change to test against a different server app_root: "/app", enable_login: true, enable_signup: true, enable_payments: true, enable_reservations: true, + enable_sms: true, + enable_calls: true, billing_contact: "", disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"] }; diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 8760eb3..600994b 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -127,6 +127,12 @@ "publish_dialog_email_label": "Email", "publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com", "publish_dialog_email_reset": "Remove email forward", + "publish_dialog_sms_label": "SMS", + "publish_dialog_sms_placeholder": "Phone number to send SMS to, e.g. +12223334444", + "publish_dialog_sms_reset": "Remove SMS message", + "publish_dialog_call_label": "Phone call", + "publish_dialog_call_placeholder": "Phone number to call with the message, e.g. +12223334444", + "publish_dialog_call_reset": "Remove phone call", "publish_dialog_attach_label": "Attachment URL", "publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk", "publish_dialog_attach_reset": "Remove attachment URL", @@ -138,6 +144,8 @@ "publish_dialog_other_features": "Other features:", "publish_dialog_chip_click_label": "Click URL", "publish_dialog_chip_email_label": "Forward to email", + "publish_dialog_chip_sms_label": "Send SMS", + "publish_dialog_chip_call_label": "Phone call", "publish_dialog_chip_attach_url_label": "Attach file by URL", "publish_dialog_chip_attach_file_label": "Attach local file", "publish_dialog_chip_delay_label": "Delay delivery", @@ -203,6 +211,10 @@ "account_basics_tier_manage_billing_button": "Manage billing", "account_usage_messages_title": "Published messages", "account_usage_emails_title": "Emails sent", + "account_usage_sms_title": "SMS sent", + "account_usage_sms_none": "No SMS can be sent with this account", + "account_usage_calls_title": "Phone calls made", + "account_usage_calls_none": "No phone calls can be made with this account", "account_usage_reservations_title": "Reserved topics", "account_usage_reservations_none": "No reserved topics for this account", "account_usage_attachment_storage_title": "Attachment storage", @@ -232,6 +244,12 @@ "account_upgrade_dialog_tier_features_messages_other": "{{messages}} daily messages", "account_upgrade_dialog_tier_features_emails_one": "{{emails}} daily email", "account_upgrade_dialog_tier_features_emails_other": "{{emails}} daily emails", + "account_upgrade_dialog_tier_features_sms_one": "{{sms}} daily SMS", + "account_upgrade_dialog_tier_features_sms_other": "{{sms}} daily SMS", + "account_upgrade_dialog_tier_features_no_sms": "No daily SMS", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} daily phone calls", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} daily phone calls", + "account_upgrade_dialog_tier_features_no_calls": "No daily phone calls", "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file", "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage", "account_upgrade_dialog_tier_price_per_month": "month", diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 6eb4ac5..25b4a45 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -206,10 +206,12 @@ export const formatBytes = (bytes, decimals = 2) => { } export const formatNumber = (n) => { - if (n % 1000 === 0) { + if (n === 0) { + return n; + } else if (n % 1000 === 0) { return `${n/1000}k`; } - return n; + return n.toLocaleString(); } export const formatPrice = (n) => { diff --git a/web/src/components/Account.js b/web/src/components/Account.js index e5b6007..dc80bab 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -51,6 +51,7 @@ import {ContentCopy, Public} from "@mui/icons-material"; import MenuItem from "@mui/material/MenuItem"; import DialogContentText from "@mui/material/DialogContentText"; import {IncorrectPasswordError, UnauthorizedError} from "../app/errors"; +import {ProChip} from "./SubscriptionPopup"; const Account = () => { if (!session.exists()) { @@ -337,23 +338,18 @@ const Stats = () => { {t("account_usage_title")} - - {(account.role === Role.ADMIN || account.limits.reservations > 0) && - <> -
- {account.stats.reservations} - {account.role === Role.USER ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")} -
- 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100} - /> - - } - {account.role === Role.USER && account.limits.reservations === 0 && - {t("account_usage_reservations_none")} - } -
+ {(account.role === Role.ADMIN || account.limits.reservations > 0) && + +
+ {account.stats.reservations.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.reservations.toLocaleString() }) : t("account_usage_unlimited")} +
+ 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100} + /> +
+ } {t("account_usage_messages_title")} @@ -361,8 +357,8 @@ const Stats = () => { }>
- {account.stats.messages} - {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")} + {account.stats.messages.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages.toLocaleString() }) : t("account_usage_unlimited")}
{ }>
- {account.stats.emails} - {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails }) : t("account_usage_unlimited")} + {account.stats.emails.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails.toLocaleString() }) : t("account_usage_unlimited")}
+ {(account.role === Role.ADMIN || account.limits.sms > 0) && + + {t("account_usage_sms_title")} + + + }> +
+ {account.stats.sms.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.sms.toLocaleString() }) : t("account_usage_unlimited")} +
+ 0 ? normalize(account.stats.sms, account.limits.sms) : 100} + /> +
+ } + {(account.role === Role.ADMIN || account.limits.calls > 0) && + + {t("account_usage_calls_title")} + + + }> +
+ {account.stats.calls.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.calls.toLocaleString() }) : t("account_usage_unlimited")} +
+ 0 ? normalize(account.stats.calls, account.limits.calls) : 100} + /> +
+ } { value={account.role === Role.USER ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100} /> + {config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 && + {t("account_usage_reservations_title")}{config.enable_payments && }}> + {t("account_usage_reservations_none")} + + } + {config.enable_sms && account.role === Role.USER && account.limits.sms === 0 && + {t("account_usage_sms_title")}{config.enable_payments && }}> + {t("account_usage_sms_none")} + + } + {config.enable_calls && account.role === Role.USER && account.limits.calls === 0 && + {t("account_usage_calls_title")}{config.enable_payments && }}> + {t("account_usage_calls_none")} + + }
{account.role === Role.USER && account.limits.basis === LimitBasis.IP && diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js index bdf6fb6..c410f19 100644 --- a/web/src/components/PublishDialog.js +++ b/web/src/components/PublishDialog.js @@ -45,6 +45,8 @@ const PublishDialog = (props) => { const [filename, setFilename] = useState(""); const [filenameEdited, setFilenameEdited] = useState(false); const [email, setEmail] = useState(""); + const [sms, setSms] = useState(""); + const [call, setCall] = useState(""); const [delay, setDelay] = useState(""); const [publishAnother, setPublishAnother] = useState(false); @@ -52,6 +54,8 @@ const PublishDialog = (props) => { const [showClickUrl, setShowClickUrl] = useState(false); const [showAttachUrl, setShowAttachUrl] = useState(false); const [showEmail, setShowEmail] = useState(false); + const [showSms, setShowSms] = useState(false); + const [showCall, setShowCall] = useState(false); const [showDelay, setShowDelay] = useState(false); const showAttachFile = !!attachFile && !showAttachUrl; @@ -124,6 +128,12 @@ const PublishDialog = (props) => { if (email.trim()) { url.searchParams.append("email", email.trim()); } + if (sms.trim()) { + url.searchParams.append("sms", sms.trim()); + } + if (call.trim()) { + url.searchParams.append("call", call.trim()); + } if (delay.trim()) { url.searchParams.append("delay", delay.trim()); } @@ -406,6 +416,48 @@ const PublishDialog = (props) => { /> } + {showSms && + { + setSms(""); + setShowSms(false); + }}> + setSms(ev.target.value)} + disabled={disabled} + type="tel" + variant="standard" + fullWidth + inputProps={{ + "aria-label": t("publish_dialog_sms_label") + }} + /> + + } + {showCall && + { + setCall(""); + setShowCall(false); + }}> + setCall(ev.target.value)} + disabled={disabled} + type="tel" + variant="standard" + fullWidth + inputProps={{ + "aria-label": t("publish_dialog_call_label") + }} + /> + + } {showAttachUrl && { setAttachUrl(""); @@ -510,6 +562,8 @@ const PublishDialog = (props) => {
{!showClickUrl && setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showEmail && setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>} + {!showSms && setShowSms(true)} sx={{marginRight: 1, marginBottom: 1}}/>} + {!showCall && setShowCall(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachUrl && !showAttachFile && setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachFile && !showAttachUrl && handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>} {!showDelay && setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>} diff --git a/web/src/components/SubscriptionPopup.js b/web/src/components/SubscriptionPopup.js index 7655605..024b6f2 100644 --- a/web/src/components/SubscriptionPopup.js +++ b/web/src/components/SubscriptionPopup.js @@ -277,14 +277,14 @@ const LimitReachedChip = () => { ); }; -const ProChip = () => { +export const ProChip = () => { const { t } = useTranslation(); return ( ); }; diff --git a/web/src/components/UpgradeDialog.js b/web/src/components/UpgradeDialog.js index c62560a..c4d665e 100644 --- a/web/src/components/UpgradeDialog.js +++ b/web/src/components/UpgradeDialog.js @@ -298,11 +298,14 @@ const TierCard = (props) => {
{tier.limits.reservations > 0 && {t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}} - {tier.limits.reservations === 0 && {t("account_upgrade_dialog_tier_features_no_reservations")}} {t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })} {t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })} + {tier.limits.sms > 0 && {t("account_upgrade_dialog_tier_features_sms", { sms: formatNumber(tier.limits.sms), count: tier.limits.sms })}} + {tier.limits.calls > 0 && {t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}} {t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })} - {t("account_upgrade_dialog_tier_features_attachment_total_size", { totalsize: formatBytes(tier.limits.attachment_total_size, 0) })} + {tier.limits.reservations === 0 && {t("account_upgrade_dialog_tier_features_no_reservations")}} + {tier.limits.sms === 0 && {t("account_upgrade_dialog_tier_features_no_sms")}} + {tier.limits.calls === 0 && {t("account_upgrade_dialog_tier_features_no_calls")}} {tier.prices && props.interval === SubscriptionInterval.MONTH &&