diff --git a/server/server.go b/server/server.go index f5dbcd97..5a129fc6 100644 --- a/server/server.go +++ b/server/server.go @@ -37,6 +37,8 @@ import ( /* - HIGH Rate limiting: Sensitive endpoints (account/login/change-password/...) +- HIGH Stripe payment methods +- MEDIUM: Test new token endpoints & never-expiring token - MEDIUM: Races with v.user (see publishSyncEventAsync test) - MEDIUM: Test that anonymous user and user without tier are the same visitor - MEDIUM: Make sure account endpoints make sense for admins @@ -348,18 +350,18 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPath { return s.ensureUserManager(s.handleAccountCreate)(w, r, v) - } else if r.Method == http.MethodPost && r.URL.Path == apiAccountTokenPath { - return s.ensureUser(s.handleAccountTokenIssue)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiAccountPath { return s.handleAccountGet(w, r, v) // Allowed by anonymous } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPath { return s.ensureUser(s.withAccountSync(s.handleAccountDelete))(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordPath { return s.ensureUser(s.handleAccountPasswordChange)(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountTokenPath { + return s.ensureUser(s.withAccountSync(s.handleAccountTokenCreate))(w, r, v) } else if r.Method == http.MethodPatch && r.URL.Path == apiAccountTokenPath { - return s.ensureUser(s.handleAccountTokenExtend)(w, r, v) + return s.ensureUser(s.withAccountSync(s.handleAccountTokenUpdate))(w, r, v) } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountTokenPath { - return s.ensureUser(s.handleAccountTokenDelete)(w, r, v) + return s.ensureUser(s.withAccountSync(s.handleAccountTokenDelete))(w, r, v) } else if r.Method == http.MethodPatch && r.URL.Path == apiAccountSettingsPath { return s.ensureUser(s.withAccountSync(s.handleAccountSettingsChange))(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == apiAccountSubscriptionPath { @@ -1485,7 +1487,7 @@ func (s *Server) limitRequests(next handleFunc) handleFunc { // before passing it on to the next handler. This is meant to be used in combination with handlePublish. func (s *Server) transformBodyJSON(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageLimit*2) // 2x to account for JSON format overhead + m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageLimit*2, false) // 2x to account for JSON format overhead if err != nil { return err } diff --git a/server/server_account.go b/server/server_account.go index 5f1c82aa..5de8df98 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -7,12 +7,14 @@ import ( "heckel.io/ntfy/util" "net/http" "strings" + "time" ) const ( subscriptionIDLength = 16 subscriptionIDPrefix = "su_" syncTopicAccountSyncEvent = "sync" + tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much ) func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { @@ -27,7 +29,7 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * return errHTTPTooManyRequestsLimitAccountCreation } } - newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit) + newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } @@ -69,37 +71,38 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining, }, } - if v.user != nil { - response.Username = v.user.Name - response.Role = string(v.user.Role) - response.SyncTopic = v.user.SyncTopic - if v.user.Prefs != nil { - if v.user.Prefs.Language != nil { - response.Language = *v.user.Prefs.Language + u := v.User() + if u != nil { + response.Username = u.Name + response.Role = string(u.Role) + response.SyncTopic = u.SyncTopic + if u.Prefs != nil { + if u.Prefs.Language != nil { + response.Language = *u.Prefs.Language } - if v.user.Prefs.Notification != nil { - response.Notification = v.user.Prefs.Notification + if u.Prefs.Notification != nil { + response.Notification = u.Prefs.Notification } - if v.user.Prefs.Subscriptions != nil { - response.Subscriptions = v.user.Prefs.Subscriptions + if u.Prefs.Subscriptions != nil { + response.Subscriptions = u.Prefs.Subscriptions } } - if v.user.Tier != nil { + if u.Tier != nil { response.Tier = &apiAccountTier{ - Code: v.user.Tier.Code, - Name: v.user.Tier.Name, + Code: u.Tier.Code, + Name: u.Tier.Name, } } - if v.user.Billing.StripeCustomerID != "" { + if u.Billing.StripeCustomerID != "" { response.Billing = &apiAccountBilling{ Customer: true, - Subscription: v.user.Billing.StripeSubscriptionID != "", - Status: string(v.user.Billing.StripeSubscriptionStatus), - PaidUntil: v.user.Billing.StripeSubscriptionPaidUntil.Unix(), - CancelAt: v.user.Billing.StripeSubscriptionCancelAt.Unix(), + Subscription: u.Billing.StripeSubscriptionID != "", + Status: string(u.Billing.StripeSubscriptionStatus), + PaidUntil: u.Billing.StripeSubscriptionPaidUntil.Unix(), + CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(), } } - reservations, err := s.userManager.Reservations(v.user.Name) + reservations, err := s.userManager.Reservations(u.Name) if err != nil { return err } @@ -112,6 +115,20 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis }) } } + tokens, err := s.userManager.Tokens(u.ID) + if err != nil { + return err + } + if len(tokens) > 0 { + response.Tokens = make([]*apiAccountTokenResponse, 0) + for _, t := range tokens { + response.Tokens = append(response.Tokens, &apiAccountTokenResponse{ + Token: t.Value, + Label: t.Label, + Expires: t.Expires.Unix(), + }) + } + } } else { response.Username = user.Everyone response.Role = string(user.RoleAnonymous) @@ -120,7 +137,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis } func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiAccountDeleteRequest](r.Body, jsonBodyBytesLimit) + req, err := readJSONWithLimit[apiAccountDeleteRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } else if req.Password == "" { @@ -146,7 +163,7 @@ func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v * } func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error { - req, err := readJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, jsonBodyBytesLimit) + req, err := readJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } else if req.Password == "" || req.NewPassword == "" { @@ -161,50 +178,81 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ return s.writeJSON(w, newSuccessResponse()) } -func (s *Server) handleAccountTokenIssue(w http.ResponseWriter, _ *http.Request, v *visitor) error { +func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { // TODO rate limit - token, err := s.userManager.CreateToken(v.user) + req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body! + if err != nil { + return err + } + var label string + if req.Label != nil { + label = *req.Label + } + expires := time.Now().Add(tokenExpiryDuration) + if req.Expires != nil { + expires = time.Unix(*req.Expires, 0) + } + token, err := s.userManager.CreateToken(v.User().ID, label, expires) if err != nil { return err } response := &apiAccountTokenResponse{ Token: token.Value, + Label: token.Label, Expires: token.Expires.Unix(), } return s.writeJSON(w, response) } -func (s *Server) handleAccountTokenExtend(w http.ResponseWriter, _ *http.Request, v *visitor) error { +func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { // TODO rate limit - if v.user == nil { - return errHTTPUnauthorized - } else if v.user.Token == "" { - return errHTTPBadRequestNoTokenProvided + u := v.User() + req, err := readJSONWithLimit[apiAccountTokenUpdateRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body! + if err != nil { + return err + } else if req.Token == "" { + req.Token = u.Token + if req.Token == "" { + return errHTTPBadRequestNoTokenProvided + } } - token, err := s.userManager.ExtendToken(v.user) + var expires *time.Time + if req.Expires != nil { + expires = util.Time(time.Unix(*req.Expires, 0)) + } else if req.Label == nil { + // If label and expires are not set, simply extend the token by 72 hours + expires = util.Time(time.Now().Add(tokenExpiryDuration)) + } + token, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires) if err != nil { return err } response := &apiAccountTokenResponse{ Token: token.Value, + Label: token.Label, Expires: token.Expires.Unix(), } return s.writeJSON(w, response) } -func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, _ *http.Request, v *visitor) error { +func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { // TODO rate limit - if v.user.Token == "" { - return errHTTPBadRequestNoTokenProvided + u := v.User() + token := readParam(r, "X-Token", "Token") // DELETEs cannot have a body, and we don't want it in the path + if token == "" { + token = u.Token + if token == "" { + return errHTTPBadRequestNoTokenProvided + } } - if err := s.userManager.RemoveToken(v.user); err != nil { + if err := s.userManager.RemoveToken(u.ID, token); err != nil { return err } return s.writeJSON(w, newSuccessResponse()) } func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Request, v *visitor) error { - newPrefs, err := readJSONWithLimit[user.Prefs](r.Body, jsonBodyBytesLimit) + newPrefs, err := readJSONWithLimit[user.Prefs](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } @@ -236,7 +284,7 @@ func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Requ } func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { - newSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit) + newSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } @@ -266,7 +314,7 @@ func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http. return errHTTPInternalErrorInvalidPath } subscriptionID := matches[1] - updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit) + updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } @@ -318,7 +366,7 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ if v.user != nil && v.user.Role == user.RoleAdmin { return errHTTPBadRequestMakesNoSenseForAdmin } - req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit) + req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } diff --git a/server/server_account_test.go b/server/server_account_test.go index e3bbf118..77519e51 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -3,6 +3,7 @@ package server import ( "fmt" "github.com/stretchr/testify/require" + "heckel.io/ntfy/log" "heckel.io/ntfy/user" "heckel.io/ntfy/util" "io" @@ -149,8 +150,8 @@ func TestAccount_Get_Anonymous(t *testing.T) { func TestAccount_ChangeSettings(t *testing.T) { s := newTestServer(t, newTestConfigWithAuthFile(t)) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) - user, _ := s.userManager.User("phil") - token, _ := s.userManager.CreateToken(user) + u, _ := s.userManager.User("phil") + token, _ := s.userManager.CreateToken(u.ID, "", time.Unix(0, 0)) rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{ "Authorization": util.BasicAuth("phil", "phil"), @@ -294,6 +295,8 @@ func TestAccount_DeleteToken(t *testing.T) { require.Equal(t, 200, rr.Code) token, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) require.Nil(t, err) + log.Info("token = %#v", token) + require.True(t, token.Expires > time.Now().Add(71*time.Hour).Unix()) // Delete token failure (using basic auth) rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{ diff --git a/server/server_payments.go b/server/server_payments.go index 4e927577..76628973 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -110,7 +110,7 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r if v.user.Billing.StripeSubscriptionID != "" { return errHTTPBadRequestBillingSubscriptionExists } - req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit) + req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } @@ -215,7 +215,7 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r if v.user.Billing.StripeSubscriptionID == "" { return errNoBillingSubscription } - req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit) + req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } diff --git a/server/types.go b/server/types.go index 15c1b846..981d99fd 100644 --- a/server/types.go +++ b/server/types.go @@ -235,9 +235,21 @@ type apiAccountDeleteRequest struct { Password string `json:"password"` } +type apiAccountTokenIssueRequest struct { + Label *string `json:"label"` + Expires *int64 `json:"expires"` // Unix timestamp +} + +type apiAccountTokenUpdateRequest struct { + Token string `json:"token"` + Label *string `json:"label"` + Expires *int64 `json:"expires"` // Unix timestamp +} + type apiAccountTokenResponse struct { Token string `json:"token"` - Expires int64 `json:"expires"` + Label string `json:"label,omitempty"` + Expires int64 `json:"expires,omitempty"` // Unix timestamp } type apiAccountTier struct { @@ -282,17 +294,18 @@ type apiAccountBilling struct { } type apiAccountResponse struct { - Username string `json:"username"` - Role string `json:"role,omitempty"` - SyncTopic string `json:"sync_topic,omitempty"` - Language string `json:"language,omitempty"` - Notification *user.NotificationPrefs `json:"notification,omitempty"` - Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` - Reservations []*apiAccountReservation `json:"reservations,omitempty"` - Tier *apiAccountTier `json:"tier,omitempty"` - Limits *apiAccountLimits `json:"limits,omitempty"` - Stats *apiAccountStats `json:"stats,omitempty"` - Billing *apiAccountBilling `json:"billing,omitempty"` + Username string `json:"username"` + Role string `json:"role,omitempty"` + SyncTopic string `json:"sync_topic,omitempty"` + Language string `json:"language,omitempty"` + Notification *user.NotificationPrefs `json:"notification,omitempty"` + Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` + Reservations []*apiAccountReservation `json:"reservations,omitempty"` + Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` + Tier *apiAccountTier `json:"tier,omitempty"` + Limits *apiAccountLimits `json:"limits,omitempty"` + Stats *apiAccountStats `json:"stats,omitempty"` + Billing *apiAccountBilling `json:"billing,omitempty"` } type apiAccountReservationRequest struct { diff --git a/server/util.go b/server/util.go index 2a7bfe89..3e24dacf 100644 --- a/server/util.go +++ b/server/util.go @@ -130,8 +130,8 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr { return ip } -func readJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) { - obj, err := util.UnmarshalJSONWithLimit[T](r, limit) +func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) { + obj, err := util.UnmarshalJSONWithLimit[T](r, limit, allowEmpty) if err == util.ErrUnmarshalJSON { return nil, errHTTPBadRequestJSONInvalid } else if err == util.ErrTooLargeJSON { diff --git a/server/visitor.go b/server/visitor.go index 0fdd98d6..d4d2ea10 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -254,6 +254,13 @@ func (v *visitor) User() *user.User { return v.user // May be nil } +// Authenticated returns true if a user successfully authenticated +func (v *visitor) Authenticated() bool { + v.mu.Lock() + defer v.mu.Unlock() + return v.user != nil +} + // SetUser sets the visitors user to the given value func (v *visitor) SetUser(u *user.User) { v.mu.Lock() diff --git a/user/manager.go b/user/manager.go index 5f147a78..57f107a9 100644 --- a/user/manager.go +++ b/user/manager.go @@ -28,8 +28,7 @@ const ( userHardDeleteAfterDuration = 7 * 24 * time.Hour tokenPrefix = "tk_" tokenLength = 32 - tokenMaxCount = 10 // Only keep this many tokens in the table per user - tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much + tokenMaxCount = 10 // Only keep this many tokens in the table per user ) var ( @@ -92,6 +91,7 @@ const ( CREATE TABLE IF NOT EXISTS user_token ( user_id TEXT NOT NULL, token TEXT NOT NULL, + label TEXT NOT NULL, expires INT NOT NULL, PRIMARY KEY (user_id, token), FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE @@ -126,7 +126,7 @@ const ( FROM user u JOIN user_token t on u.id = t.user_id LEFT JOIN tier t on t.id = u.tier_id - WHERE t.token = ? AND t.expires >= ? + WHERE t.token = ? AND (t.expires = 0 OR t.expires >= ?) ` selectUserByStripeCustomerIDQuery = ` SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, 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_price_id @@ -216,11 +216,14 @@ const ( ` selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE user_id = ?` - insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES (?, ?, ?)` - updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?` + selectTokensQuery = `SELECT token, label, expires FROM user_token WHERE user_id = ?` + selectTokenQuery = `SELECT token, label, expires FROM user_token WHERE user_id = ? AND token = ?` + insertTokenQuery = `INSERT INTO user_token (user_id, token, label, expires) VALUES (?, ?, ?, ?)` + updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?` + updateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?` deleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?` deleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?` - deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires < ?` + deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?` deleteExcessTokensQuery = ` DELETE FROM user_token WHERE (user_id, token) NOT IN ( @@ -285,7 +288,6 @@ const ( DROP TABLE access; DROP TABLE user_old; ` - migrate1To2UpdateSyncTopicNoTx = `UPDATE user SET sync_topic = ? WHERE id = ?` ) // Manager is an implementation of Manager. It stores users and access control list @@ -363,19 +365,19 @@ func (a *Manager) AuthenticateToken(token string) (*User, error) { } // CreateToken generates a random token for the given user and returns it. The token expires -// after a fixed duration unless ExtendToken is called. This function also prunes tokens for the +// after a fixed duration unless ChangeToken is called. This function also prunes tokens for the // given user, if there are too many of them. -func (a *Manager) CreateToken(user *User) (*Token, error) { - token, expires := util.RandomStringPrefix(tokenPrefix, tokenLength), time.Now().Add(tokenExpiryDuration) +func (a *Manager) CreateToken(userID, label string, expires time.Time) (*Token, error) { + token := util.RandomStringPrefix(tokenPrefix, tokenLength) tx, err := a.db.Begin() if err != nil { return nil, err } defer tx.Rollback() - if _, err := tx.Exec(insertTokenQuery, user.ID, token, expires.Unix()); err != nil { + if _, err := tx.Exec(insertTokenQuery, userID, token, label, expires.Unix()); err != nil { return nil, err } - rows, err := tx.Query(selectTokenCountQuery, user.ID) + rows, err := tx.Query(selectTokenCountQuery, userID) if err != nil { return nil, err } @@ -390,7 +392,7 @@ func (a *Manager) CreateToken(user *User) (*Token, error) { if tokenCount >= tokenMaxCount { // This pruning logic is done in two queries for efficiency. The SELECT above is a lookup // on two indices, whereas the query below is a full table scan. - if _, err := tx.Exec(deleteExcessTokensQuery, user.ID, tokenMaxCount); err != nil { + if _, err := tx.Exec(deleteExcessTokensQuery, userID, tokenMaxCount); err != nil { return nil, err } } @@ -399,31 +401,89 @@ func (a *Manager) CreateToken(user *User) (*Token, error) { } return &Token{ Value: token, + Label: label, Expires: expires, }, nil } -// ExtendToken sets the new expiry date for a token, thereby extending its use further into the future. -func (a *Manager) ExtendToken(user *User) (*Token, error) { - if user.Token == "" { - return nil, errNoTokenProvided +func (a *Manager) Tokens(userID string) ([]*Token, error) { + rows, err := a.db.Query(selectTokensQuery, userID) + if err != nil { + return nil, err } - newExpires := time.Now().Add(tokenExpiryDuration) - if _, err := a.db.Exec(updateTokenExpiryQuery, newExpires.Unix(), user.Name, user.Token); err != nil { + defer rows.Close() + tokens := make([]*Token, 0) + for { + token, err := a.readToken(rows) + if err == ErrTokenNotFound { + break + } else if err != nil { + return nil, err + } + tokens = append(tokens, token) + } + return tokens, nil +} + +func (a *Manager) Token(userID, token string) (*Token, error) { + rows, err := a.db.Query(selectTokenQuery, userID, token) + if err != nil { + return nil, err + } + defer rows.Close() + return a.readToken(rows) +} + +func (a *Manager) readToken(rows *sql.Rows) (*Token, error) { + var token, label string + var expires int64 + if !rows.Next() { + return nil, ErrTokenNotFound + } + if err := rows.Scan(&token, &label, &expires); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { return nil, err } return &Token{ - Value: user.Token, - Expires: newExpires, + Value: token, + Label: label, + Expires: time.Unix(expires, 0), }, nil } -// RemoveToken deletes the token defined in User.Token -func (a *Manager) RemoveToken(user *User) error { - if user.Token == "" { - return ErrUnauthorized +// ChangeToken updates a token's label and/or expiry date +func (a *Manager) ChangeToken(userID, token string, label *string, expires *time.Time) (*Token, error) { + if token == "" { + return nil, errNoTokenProvided } - if _, err := a.db.Exec(deleteTokenQuery, user.ID, user.Token); err != nil { + tx, err := a.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + if label != nil { + if _, err := tx.Exec(updateTokenLabelQuery, *label, userID, token); err != nil { + return nil, err + } + } + if expires != nil { + if _, err := tx.Exec(updateTokenExpiryQuery, expires.Unix(), userID, token); err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + return a.Token(userID, token) +} + +// RemoveToken deletes the token defined in User.Token +func (a *Manager) RemoveToken(userID, token string) error { + if token == "" { + return errNoTokenProvided + } + if _, err := a.db.Exec(deleteTokenQuery, userID, token); err != nil { return err } return nil diff --git a/user/manager_test.go b/user/manager_test.go index 860799ea..e4b742f9 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -138,7 +138,7 @@ func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) { require.Nil(t, err) require.False(t, u.Deleted) - token, err := a.CreateToken(u) + token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour)) require.Nil(t, err) u, err = a.Authenticate("user", "pass") @@ -396,9 +396,10 @@ func TestManager_Token_Valid(t *testing.T) { require.Nil(t, err) // Create token for user - token, err := a.CreateToken(u) + token, err := a.CreateToken(u.ID, "some label", time.Now().Add(72*time.Hour)) require.Nil(t, err) require.NotEmpty(t, token.Value) + require.Equal(t, "some label", token.Label) require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires.Unix()) u2, err := a.AuthenticateToken(token.Value) @@ -406,8 +407,13 @@ func TestManager_Token_Valid(t *testing.T) { require.Equal(t, u.Name, u2.Name) require.Equal(t, token.Value, u2.Token) + token2, err := a.Token(u.ID, token.Value) + require.Nil(t, err) + require.Equal(t, token.Value, token2.Value) + require.Equal(t, "some label", token2.Label) + // Remove token and auth again - require.Nil(t, a.RemoveToken(u2)) + require.Nil(t, a.RemoveToken(u2.ID, u2.Token)) u3, err := a.AuthenticateToken(token.Value) require.Equal(t, ErrUnauthenticated, err) require.Nil(t, u3) @@ -434,12 +440,12 @@ func TestManager_Token_Expire(t *testing.T) { require.Nil(t, err) // Create tokens for user - token1, err := a.CreateToken(u) + token1, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour)) require.Nil(t, err) require.NotEmpty(t, token1.Value) require.True(t, time.Now().Add(71*time.Hour).Unix() < token1.Expires.Unix()) - token2, err := a.CreateToken(u) + token2, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour)) require.Nil(t, err) require.NotEmpty(t, token2.Value) require.NotEqual(t, token1.Value, token2.Value) @@ -482,23 +488,23 @@ func TestManager_Token_Extend(t *testing.T) { u, err := a.User("ben") require.Nil(t, err) - _, err = a.ExtendToken(u) + _, err = a.ChangeToken(u.ID, u.Token, util.String("some label"), util.Time(time.Now().Add(time.Hour))) require.Equal(t, errNoTokenProvided, err) // Create token for user - token, err := a.CreateToken(u) + token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour)) require.Nil(t, err) require.NotEmpty(t, token.Value) userWithToken, err := a.AuthenticateToken(token.Value) require.Nil(t, err) - time.Sleep(1100 * time.Millisecond) - - extendedToken, err := a.ExtendToken(userWithToken) + extendedToken, err := a.ChangeToken(userWithToken.ID, userWithToken.Token, util.String("changed label"), util.Time(time.Now().Add(100*time.Hour))) require.Nil(t, err) require.Equal(t, token.Value, extendedToken.Value) + require.Equal(t, "changed label", extendedToken.Label) require.True(t, token.Expires.Unix() < extendedToken.Expires.Unix()) + require.True(t, time.Now().Add(99*time.Hour).Unix() < extendedToken.Expires.Unix()) } func TestManager_Token_MaxCount_AutoDelete(t *testing.T) { @@ -513,7 +519,7 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) { baseTime := time.Now().Add(24 * time.Hour) tokens := make([]string, 0) for i := 0; i < 12; i++ { - token, err := a.CreateToken(u) + token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour)) require.Nil(t, err) require.NotEmpty(t, token.Value) tokens = append(tokens, token.Value) diff --git a/user/types.go b/user/types.go index e14e7579..d6c291bc 100644 --- a/user/types.go +++ b/user/types.go @@ -47,6 +47,7 @@ type Auther interface { // Token represents a user token, including expiry date type Token struct { Value string + Label string Expires time.Time } @@ -237,5 +238,6 @@ var ( ErrInvalidArgument = errors.New("invalid argument") ErrUserNotFound = errors.New("user not found") ErrTierNotFound = errors.New("tier not found") + ErrTokenNotFound = errors.New("token not found") ErrTooManyReservations = errors.New("new tier has lower reservation limit") ) diff --git a/util/util.go b/util/util.go index 2d021dc9..20baed56 100644 --- a/util/util.go +++ b/util/util.go @@ -1,6 +1,7 @@ package util import ( + "bytes" "encoding/base64" "encoding/json" "errors" @@ -310,7 +311,7 @@ func UnmarshalJSON[T any](body io.ReadCloser) (*T, error) { } // UnmarshalJSONWithLimit reads the given io.ReadCloser into a struct, but only until limit is reached -func UnmarshalJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) { +func UnmarshalJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) { defer r.Close() p, err := Peek(r, limit) if err != nil { @@ -319,7 +320,9 @@ func UnmarshalJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) { return nil, ErrTooLargeJSON } var obj T - if err := json.NewDecoder(p).Decode(&obj); err != nil { + if len(bytes.TrimSpace(p.PeekedBytes)) == 0 && allowEmpty { + return &obj, nil + } else if err := json.NewDecoder(p).Decode(&obj); err != nil { return nil, ErrUnmarshalJSON } return &obj, nil @@ -357,3 +360,8 @@ func String(v string) *string { func Int(v int) *int { return &v } + +// Time turns a time.Time into a pointer +func Time(v time.Time) *time.Time { + return &v +} diff --git a/util/util_test.go b/util/util_test.go index 04a988ae..10381f38 100644 --- a/util/util_test.go +++ b/util/util_test.go @@ -190,13 +190,25 @@ func TestReadJSON_Failure(t *testing.T) { } func TestReadJSONWithLimit_Success(t *testing.T) { - v, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 100) + v, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 100, false) require.Nil(t, err) require.Equal(t, "some name", v.Name) require.Equal(t, 99, v.Something) } func TestReadJSONWithLimit_FailureTooLong(t *testing.T) { - _, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 10) + _, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 10, false) require.Equal(t, ErrTooLargeJSON, err) } + +func TestReadJSONWithLimit_AllowEmpty(t *testing.T) { + v, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(` `)), 10, true) + require.Nil(t, err) + require.Equal(t, "", v.Name) + require.Equal(t, 0, v.Something) +} + +func TestReadJSONWithLimit_NoAllowEmpty(t *testing.T) { + _, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(` `)), 10, false) + require.Equal(t, ErrUnmarshalJSON, err) +} diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index cd8832d7..722652aa 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -1,4 +1,5 @@ { + "common_cancel": "Cancel", "signup_title": "Create a ntfy account", "signup_form_username": "Username", "signup_form_password": "Password", @@ -221,6 +222,32 @@ "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", + "account_tokens_title": "Access tokens", + "account_tokens_description": "Use access tokens when publishing and subscribing via the ntfy API, so you don't have to send your account credentials. Check out the documentation to learn more.", + "account_tokens_table_token_header": "Token", + "account_tokens_table_label_header": "Label", + "account_tokens_table_expires_header": "Expires", + "account_tokens_table_never_expires": "Never expires", + "account_tokens_table_current_session": "Current browser session", + "account_tokens_table_copy_to_clipboard": "Copy to clipboard", + "account_tokens_table_copied_to_clipboard": "Access token copied", + "account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token", + "account_tokens_table_create_token_button": "Create access token", + "account_tokens_dialog_title_create": "Create access token", + "account_tokens_dialog_title_edit": "Edit access token", + "account_tokens_dialog_title_delete": "Delete access token", + "account_tokens_dialog_label": "Label, e.g. Radarr notifications", + "account_tokens_dialog_button_create": "Create token", + "account_tokens_dialog_button_update": "Update token", + "account_tokens_dialog_button_cancel": "Cancel", + "account_tokens_dialog_expires_label": "Access token expires in", + "account_tokens_dialog_expires_unchanged": "Leave expiry date unchanged", + "account_tokens_dialog_expires_x_hours": "Token expires in {{hours}} hours", + "account_tokens_dialog_expires_x_days": "Token expires in {{days}} days", + "account_tokens_dialog_expires_never": "Token never expires", + "account_tokens_delete_dialog_title": "Delete access token", + "account_tokens_delete_dialog_description": "Before deleting an access token, be sure that no applications or scripts are actively using it. This action cannot be undone.", + "account_tokens_delete_dialog_submit_button": "Permanently delete token", "prefs_notifications_title": "Notifications", "prefs_notifications_sound_title": "Notification sound", "prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 581e4a32..b879a1a4 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -145,12 +145,71 @@ class AccountApi { } } + async createToken(label, expires) { + const url = accountTokenUrl(config.base_url); + const body = { + label: label, + expires: (expires > 0) ? Math.floor(Date.now() / 1000) + expires : 0 + }; + console.log(`[AccountApi] Creating user access token ${url}`); + const response = await fetch(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify(body) + }); + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedError(); + } else if (response.status !== 200) { + throw new Error(`Unexpected server response ${response.status}`); + } + } + + async updateToken(token, label, expires) { + const url = accountTokenUrl(config.base_url); + const body = { + token: token, + label: label + }; + if (expires > 0) { + body.expires = Math.floor(Date.now() / 1000) + expires; + } + console.log(`[AccountApi] Creating user access token ${url}`); + const response = await fetch(url, { + method: "PATCH", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify(body) + }); + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedError(); + } else if (response.status !== 200) { + throw new Error(`Unexpected server response ${response.status}`); + } + } + async extendToken() { const url = accountTokenUrl(config.base_url); console.log(`[AccountApi] Extending user access token ${url}`); const response = await fetch(url, { method: "PATCH", - headers: withBearerAuth({}, session.token()) + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + token: session.token(), + expires: Math.floor(Date.now() / 1000) + 6220800 // FIXME + }) + }); + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedError(); + } else if (response.status !== 200) { + throw new Error(`Unexpected server response ${response.status}`); + } + } + + async deleteToken(token) { + const url = accountTokenUrl(config.base_url); + console.log(`[AccountApi] Deleting user access token ${url}`); + const response = await fetch(url, { + method: "DELETE", + headers: withBearerAuth({"X-Token": token}, session.token()) }); if (response.status === 401 || response.status === 403) { throw new UnauthorizedError(); diff --git a/web/src/components/Account.js b/web/src/components/Account.js index 4d4aebfa..7552b5b3 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -1,13 +1,23 @@ import * as React from 'react'; -import {useContext, useState} from 'react'; -import {Alert, LinearProgress, Stack, useMediaQuery} from "@mui/material"; +import {useContext, useEffect, useState} from 'react'; +import { + Alert, + CardActions, + CardContent, FormControl, + LinearProgress, Link, Portal, Select, Snackbar, + Stack, + Table, TableBody, TableCell, + TableHead, + TableRow, + useMediaQuery +} from "@mui/material"; import Tooltip from '@mui/material/Tooltip'; import Typography from "@mui/material/Typography"; import EditIcon from '@mui/icons-material/Edit'; import Container from "@mui/material/Container"; import Card from "@mui/material/Card"; import Button from "@mui/material/Button"; -import {useTranslation} from "react-i18next"; +import {Trans, useTranslation} from "react-i18next"; import session from "../app/Session"; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import theme from "./theme"; @@ -15,10 +25,9 @@ import Dialog from "@mui/material/Dialog"; import DialogTitle from "@mui/material/DialogTitle"; import DialogContent from "@mui/material/DialogContent"; import TextField from "@mui/material/TextField"; -import DialogActions from "@mui/material/DialogActions"; import routes from "./routes"; import IconButton from "@mui/material/IconButton"; -import {formatBytes, formatShortDate, formatShortDateTime} from "../app/utils"; +import {formatBytes, formatShortDate, formatShortDateTime, truncateString, validUrl} from "../app/utils"; import accountApi, {IncorrectPasswordError, UnauthorizedError} from "../app/AccountApi"; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import {Pref, PrefGroup} from "./Pref"; @@ -28,8 +37,18 @@ import humanizeDuration from "humanize-duration"; import UpgradeDialog from "./UpgradeDialog"; import CelebrationIcon from "@mui/icons-material/Celebration"; import {AccountContext} from "./App"; -import {Warning, WarningAmber} from "@mui/icons-material"; import DialogFooter from "./DialogFooter"; +import {useLiveQuery} from "dexie-react-hooks"; +import userManager from "../app/UserManager"; +import {Paragraph} from "./styles"; +import CloseIcon from "@mui/icons-material/Close"; +import DialogActions from "@mui/material/DialogActions"; +import {ContentCopy} from "@mui/icons-material"; +import MenuItem from "@mui/material/MenuItem"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons"; +import ListItemText from "@mui/material/ListItemText"; +import DialogContentText from "@mui/material/DialogContentText"; const Account = () => { if (!session.exists()) { @@ -41,6 +60,7 @@ const Account = () => { + @@ -390,6 +410,268 @@ const InfoIcon = () => { ); } + +const Tokens = () => { + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + const tokens = account?.tokens || []; + + const handleCreateClick = () => { + setDialogKey(prev => prev+1); + setDialogOpen(true); + }; + + const handleDialogClose = () => { + setDialogOpen(false); + }; + + const handleDialogSubmit = async (user) => { + setDialogOpen(false); + // + }; + return ( + + + + {t("account_tokens_title")} + + + + }} + /> + + {tokens?.length > 0 && } + + + + + + + ); +}; + +const TokensTable = (props) => { + const { t } = useTranslation(); + const [snackOpen, setSnackOpen] = useState(false); + const [upsertDialogKey, setUpsertDialogKey] = useState(0); + const [upsertDialogOpen, setUpsertDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedToken, setSelectedToken] = useState(null); + + const tokens = (props.tokens || []) + .sort( (a, b) => { + if (a.token === session.token()) { + return -1; + } else if (b.token === session.token()) { + return 1; + } + return a.token.localeCompare(b.token); + }); + + const handleEditClick = (token) => { + setUpsertDialogKey(prev => prev+1); + setSelectedToken(token); + setUpsertDialogOpen(true); + }; + + const handleDialogClose = () => { + setUpsertDialogOpen(false); + setDeleteDialogOpen(false); + setSelectedToken(null); + }; + + const handleDeleteClick = async (token) => { + setSelectedToken(token); + setDeleteDialogOpen(true); + }; + + const handleCopy = async (token) => { + await navigator.clipboard.writeText(token); + setSnackOpen(true); + }; + + return ( + + + + {t("account_tokens_table_token_header")} + {t("account_tokens_table_label_header")} + {t("account_tokens_table_expires_header")} + + + + + {tokens.map(token => ( + + + + {token.token.slice(0, 20)} + ... + + handleCopy(token.token)}> + + + + + {token.token === session.token() && {t("account_tokens_table_current_session")}} + {token.token !== session.token() && (token.label || "-")} + + + {token.expires ? formatShortDateTime(token.expires) : {t("account_tokens_table_never_expires")}} + + + {token.token !== session.token() && + <> + handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}> + + + handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}> + + + + } + {token.token === session.token() && + + + + + + + } + + + ))} + + + setSnackOpen(false)} + message={t("account_tokens_table_copied_to_clipboard")} + /> + + + +
+ ); +}; + +const TokenDialog = (props) => { + const { t } = useTranslation(); + const [label, setLabel] = useState(props.token?.label || ""); + const [expires, setExpires] = useState(props.token ? -1 : 0); + const [errorText, setErrorText] = useState(""); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const editMode = !!props.token; + + const handleSubmit = async () => { + try { + if (editMode) { + await accountApi.updateToken(props.token.token, label, expires); + } else { + await accountApi.createToken(label, expires); + } + props.onClose(); + } catch (e) { + console.log(`[Account] Error creating token`, e); + if ((e instanceof UnauthorizedError)) { + session.resetAndRedirect(routes.login); + } + // TODO show error + } + }; + + return ( + + {editMode ? t("account_tokens_dialog_title_edit") : t("account_tokens_dialog_title_create")} + + setLabel(ev.target.value)} + fullWidth + variant="standard" + /> + + + + + + + + + + ); +}; + +const TokenDeleteDialog = (props) => { + const { t } = useTranslation(); + + const handleSubmit = async () => { + try { + await accountApi.deleteToken(props.token.token); + props.onClose(); + } catch (e) { + console.log(`[Account] Error deleting token`, e); + if ((e instanceof UnauthorizedError)) { + session.resetAndRedirect(routes.login); + } + // TODO show error + } + }; + + return ( + + {t("account_tokens_delete_dialog_title")} + + + + + + + + + + + ); +} + + const Delete = () => { const { t } = useTranslation(); return ( diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index 2ec59738..97e001f3 100644 --- a/web/src/components/Preferences.js +++ b/web/src/components/Preferences.js @@ -3,7 +3,8 @@ import {useContext, useEffect, useState} from 'react'; import { Alert, CardActions, - CardContent, Chip, + CardContent, + Chip, FormControl, Select, Stack, @@ -20,7 +21,6 @@ import prefs from "../app/Prefs"; import {Paragraph} from "./styles"; import EditIcon from '@mui/icons-material/Edit'; import CloseIcon from "@mui/icons-material/Close"; -import WarningIcon from '@mui/icons-material/Warning'; import IconButton from "@mui/material/IconButton"; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import Container from "@mui/material/Container"; @@ -42,12 +42,11 @@ import routes from "./routes"; import accountApi, {UnauthorizedError} from "../app/AccountApi"; import {Pref, PrefGroup} from "./Pref"; import LockIcon from "@mui/icons-material/Lock"; -import {Check, Info, Public, PublicOff} from "@mui/icons-material"; +import {Info, Public, PublicOff} from "@mui/icons-material"; import DialogContentText from "@mui/material/DialogContentText"; import ReserveTopicSelect from "./ReserveTopicSelect"; import {AccountContext} from "./App"; import {useOutletContext} from "react-router-dom"; -import subscriptionManager from "../app/SubscriptionManager"; const Preferences = () => { return ( diff --git a/web/src/components/ReserveIcons.js b/web/src/components/ReserveIcons.js index b5821f58..9969f20f 100644 --- a/web/src/components/ReserveIcons.js +++ b/web/src/components/ReserveIcons.js @@ -2,7 +2,6 @@ import * as React from 'react'; import {Lock, Public} from "@mui/icons-material"; import Box from "@mui/material/Box"; - export const PermissionReadWrite = React.forwardRef((props, ref) => { const size = props.size ?? "medium"; return ; diff --git a/web/src/components/ReserveTopicSelect.js b/web/src/components/ReserveTopicSelect.js index 59fa4c8f..45c9e6ce 100644 --- a/web/src/components/ReserveTopicSelect.js +++ b/web/src/components/ReserveTopicSelect.js @@ -1,24 +1,9 @@ import * as React from 'react'; -import {useState} from 'react'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import Dialog from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; -import DialogContentText from '@mui/material/DialogContentText'; -import DialogTitle from '@mui/material/DialogTitle'; -import {Checkbox, FormControl, FormControlLabel, Select, useMediaQuery} from "@mui/material"; -import theme from "./theme"; -import subscriptionManager from "../app/SubscriptionManager"; -import DialogFooter from "./DialogFooter"; +import {FormControl, Select} from "@mui/material"; import {useTranslation} from "react-i18next"; -import accountApi, {UnauthorizedError} from "../app/AccountApi"; -import session from "../app/Session"; -import routes from "./routes"; import MenuItem from "@mui/material/MenuItem"; import ListItemIcon from "@mui/material/ListItemIcon"; -import LockIcon from "@mui/icons-material/Lock"; import ListItemText from "@mui/material/ListItemText"; -import {Public, PublicOff} from "@mui/icons-material"; import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons"; const ReserveTopicSelect = (props) => { diff --git a/web/src/components/UpgradeDialog.js b/web/src/components/UpgradeDialog.js index f907542d..c6da9fd8 100644 --- a/web/src/components/UpgradeDialog.js +++ b/web/src/components/UpgradeDialog.js @@ -261,5 +261,4 @@ const Banner = { RESERVATIONS_WARNING: 3 }; - export default UpgradeDialog;