From 16c14bf709e3bdc61726f07df6a834d788f01e25 Mon Sep 17 00:00:00 2001
From: binwiederhier <pheckel@datto.com>
Date: Fri, 27 Jan 2023 23:10:59 -0500
Subject: [PATCH] Add Access Tokens UI

---
 server/server.go                         |  12 +-
 server/server_account.go                 | 126 +++++++---
 server/server_account_test.go            |   7 +-
 server/server_payments.go                |   4 +-
 server/types.go                          |  37 ++-
 server/util.go                           |   4 +-
 server/visitor.go                        |   7 +
 user/manager.go                          | 112 +++++++--
 user/manager_test.go                     |  28 ++-
 user/types.go                            |   2 +
 util/util.go                             |  12 +-
 util/util_test.go                        |  16 +-
 web/public/static/langs/en.json          |  27 +++
 web/src/app/AccountApi.js                |  61 ++++-
 web/src/components/Account.js            | 294 ++++++++++++++++++++++-
 web/src/components/Preferences.js        |   7 +-
 web/src/components/ReserveIcons.js       |   1 -
 web/src/components/ReserveTopicSelect.js |  17 +-
 web/src/components/UpgradeDialog.js      |   1 -
 19 files changed, 643 insertions(+), 132 deletions(-)

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 <Link>documentation</Link> 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. <strong>This action cannot be undone</strong>.",
+  "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 = () => {
             <Stack spacing={3}>
                 <Basics/>
                 <Stats/>
+                <Tokens/>
                 <Delete/>
             </Stack>
         </Container>
@@ -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 (
+        <Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}>
+            <CardContent sx={{ paddingBottom: 1 }}>
+                <Typography variant="h5" sx={{marginBottom: 2}}>
+                    {t("account_tokens_title")}
+                </Typography>
+                <Paragraph>
+                    <Trans
+                        i18nKey="account_tokens_description"
+                        components={{
+                            Link: <Link href="/docs"/>
+                        }}
+                    />
+                </Paragraph>
+                {tokens?.length > 0 && <TokensTable tokens={tokens}/>}
+            </CardContent>
+            <CardActions>
+                <Button onClick={handleCreateClick}>{t("account_tokens_table_create_token_button")}</Button>
+            </CardActions>
+            <TokenDialog
+                key={`tokenDialogCreate${dialogKey}`}
+                open={dialogOpen}
+                onClose={handleDialogClose}
+            />
+        </Card>
+    );
+};
+
+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 (
+        <Table size="small" aria-label={t("account_tokens_title")}>
+            <TableHead>
+                <TableRow>
+                    <TableCell sx={{paddingLeft: 0}}>{t("account_tokens_table_token_header")}</TableCell>
+                    <TableCell>{t("account_tokens_table_label_header")}</TableCell>
+                    <TableCell>{t("account_tokens_table_expires_header")}</TableCell>
+                    <TableCell/>
+                </TableRow>
+            </TableHead>
+            <TableBody>
+                {tokens.map(token => (
+                    <TableRow
+                        key={token.token}
+                        sx={{'&:last-child td, &:last-child th': {border: 0}}}
+                    >
+                        <TableCell component="th" scope="row" sx={{paddingLeft: 0}} aria-label={t("account_tokens_table_token_header")}>
+                            <span>
+                                <span style={{fontFamily: "Monospace", fontSize: "0.9rem"}}>{token.token.slice(0, 20)}</span>
+                                ...
+                                <Tooltip title={t("account_tokens_table_copy_to_clipboard")} placement="right">
+                                    <IconButton onClick={() => handleCopy(token.token)}><ContentCopy/></IconButton>
+                                </Tooltip>
+                            </span>
+                        </TableCell>
+                        <TableCell aria-label={t("account_tokens_table_label_header")}>
+                            {token.token === session.token() && <em>{t("account_tokens_table_current_session")}</em>}
+                            {token.token !== session.token() && (token.label || "-")}
+                        </TableCell>
+                        <TableCell aria-label={t("account_tokens_table_expires_header")}>
+                            {token.expires ? formatShortDateTime(token.expires) : <em>{t("account_tokens_table_never_expires")}</em>}
+                        </TableCell>
+                        <TableCell align="right">
+                            {token.token !== session.token() &&
+                                <>
+                                    <IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
+                                        <EditIcon/>
+                                    </IconButton>
+                                    <IconButton onClick={() => handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}>
+                                        <CloseIcon/>
+                                    </IconButton>
+                                </>
+                            }
+                            {token.token === session.token() &&
+                                <Tooltip title={t("account_tokens_table_cannot_delete_or_edit")}>
+                                    <span>
+                                        <IconButton disabled><EditIcon/></IconButton>
+                                        <IconButton disabled><CloseIcon/></IconButton>
+                                    </span>
+                                </Tooltip>
+                            }
+                        </TableCell>
+                    </TableRow>
+                ))}
+            </TableBody>
+            <Portal>
+                <Snackbar
+                    open={snackOpen}
+                    autoHideDuration={3000}
+                    onClose={() => setSnackOpen(false)}
+                    message={t("account_tokens_table_copied_to_clipboard")}
+                />
+            </Portal>
+            <TokenDialog
+                key={`tokenDialogEdit${upsertDialogKey}`}
+                open={upsertDialogOpen}
+                token={selectedToken}
+                onClose={handleDialogClose}
+            />
+            <TokenDeleteDialog
+                open={deleteDialogOpen}
+                token={selectedToken}
+                onClose={handleDialogClose}
+            />
+        </Table>
+    );
+};
+
+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 (
+        <Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
+            <DialogTitle>{editMode ? t("account_tokens_dialog_title_edit") : t("account_tokens_dialog_title_create")}</DialogTitle>
+            <DialogContent>
+                <TextField
+                    margin="dense"
+                    id="token-label"
+                    label={t("account_tokens_dialog_label")}
+                    aria-label={t("account_delete_dialog_label")}
+                    type="text"
+                    value={label}
+                    onChange={ev => setLabel(ev.target.value)}
+                    fullWidth
+                    variant="standard"
+                />
+                <FormControl fullWidth variant="standard" sx={{ mt: 1 }}>
+                    <Select value={expires} onChange={(ev) => setExpires(ev.target.value)} aria-label={t("account_tokens_dialog_expires_label")}>
+                        {editMode && <MenuItem value={-1}>{t("account_tokens_dialog_expires_unchanged")}</MenuItem>}
+                        <MenuItem value={0}>{t("account_tokens_dialog_expires_never")}</MenuItem>
+                        <MenuItem value={21600}>{t("account_tokens_dialog_expires_x_hours", { hours: 6 })}</MenuItem>
+                        <MenuItem value={43200}>{t("account_tokens_dialog_expires_x_hours", { hours: 12 })}</MenuItem>
+                        <MenuItem value={259200}>{t("account_tokens_dialog_expires_x_days", { days: 3 })}</MenuItem>
+                        <MenuItem value={604800}>{t("account_tokens_dialog_expires_x_days", { days: 7 })}</MenuItem>
+                        <MenuItem value={2592000}>{t("account_tokens_dialog_expires_x_days", { days: 30 })}</MenuItem>
+                        <MenuItem value={7776000}>{t("account_tokens_dialog_expires_x_days", { days: 90 })}</MenuItem>
+                        <MenuItem value={15552000}>{t("account_tokens_dialog_expires_x_days", { days: 180 })}</MenuItem>
+                    </Select>
+                </FormControl>
+            </DialogContent>
+            <DialogFooter status={errorText}>
+                <Button onClick={props.onClose}>{t("account_tokens_dialog_button_cancel")}</Button>
+                <Button onClick={handleSubmit}>{editMode ? t("account_tokens_dialog_button_update") : t("account_tokens_dialog_button_create")}</Button>
+            </DialogFooter>
+        </Dialog>
+    );
+};
+
+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 (
+        <Dialog open={props.open} onClose={props.onClose}>
+            <DialogTitle>{t("account_tokens_delete_dialog_title")}</DialogTitle>
+            <DialogContent>
+                <DialogContentText>
+                    <Trans i18nKey="account_tokens_delete_dialog_description"/>
+                </DialogContentText>
+            </DialogContent>
+            <DialogActions>
+                <Button onClick={props.onClose}>{t("common_cancel")}</Button>
+                <Button onClick={handleSubmit} color="error">{t("account_tokens_delete_dialog_submit_button")}</Button>
+            </DialogActions>
+        </Dialog>
+    );
+}
+
+
 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 <Public fontSize={size} ref={ref} {...props}/>;
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;