Add Access Tokens UI
parent
62140ec001
commit
16c14bf709
|
@ -37,6 +37,8 @@ import (
|
||||||
/*
|
/*
|
||||||
|
|
||||||
- HIGH Rate limiting: Sensitive endpoints (account/login/change-password/...)
|
- 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: Races with v.user (see publishSyncEventAsync test)
|
||||||
- MEDIUM: Test that anonymous user and user without tier are the same visitor
|
- MEDIUM: Test that anonymous user and user without tier are the same visitor
|
||||||
- MEDIUM: Make sure account endpoints make sense for admins
|
- 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)
|
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
|
||||||
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPath {
|
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPath {
|
||||||
return s.ensureUserManager(s.handleAccountCreate)(w, r, v)
|
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 {
|
} else if r.Method == http.MethodGet && r.URL.Path == apiAccountPath {
|
||||||
return s.handleAccountGet(w, r, v) // Allowed by anonymous
|
return s.handleAccountGet(w, r, v) // Allowed by anonymous
|
||||||
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPath {
|
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPath {
|
||||||
return s.ensureUser(s.withAccountSync(s.handleAccountDelete))(w, r, v)
|
return s.ensureUser(s.withAccountSync(s.handleAccountDelete))(w, r, v)
|
||||||
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordPath {
|
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordPath {
|
||||||
return s.ensureUser(s.handleAccountPasswordChange)(w, r, v)
|
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 {
|
} 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 {
|
} 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 {
|
} else if r.Method == http.MethodPatch && r.URL.Path == apiAccountSettingsPath {
|
||||||
return s.ensureUser(s.withAccountSync(s.handleAccountSettingsChange))(w, r, v)
|
return s.ensureUser(s.withAccountSync(s.handleAccountSettingsChange))(w, r, v)
|
||||||
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountSubscriptionPath {
|
} 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.
|
// 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 {
|
func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,12 +7,14 @@ import (
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
subscriptionIDLength = 16
|
subscriptionIDLength = 16
|
||||||
subscriptionIDPrefix = "su_"
|
subscriptionIDPrefix = "su_"
|
||||||
syncTopicAccountSyncEvent = "sync"
|
syncTopicAccountSyncEvent = "sync"
|
||||||
|
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
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
|
return errHTTPTooManyRequestsLimitAccountCreation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit)
|
newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -69,37 +71,38 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
|
||||||
AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining,
|
AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if v.user != nil {
|
u := v.User()
|
||||||
response.Username = v.user.Name
|
if u != nil {
|
||||||
response.Role = string(v.user.Role)
|
response.Username = u.Name
|
||||||
response.SyncTopic = v.user.SyncTopic
|
response.Role = string(u.Role)
|
||||||
if v.user.Prefs != nil {
|
response.SyncTopic = u.SyncTopic
|
||||||
if v.user.Prefs.Language != nil {
|
if u.Prefs != nil {
|
||||||
response.Language = *v.user.Prefs.Language
|
if u.Prefs.Language != nil {
|
||||||
|
response.Language = *u.Prefs.Language
|
||||||
}
|
}
|
||||||
if v.user.Prefs.Notification != nil {
|
if u.Prefs.Notification != nil {
|
||||||
response.Notification = v.user.Prefs.Notification
|
response.Notification = u.Prefs.Notification
|
||||||
}
|
}
|
||||||
if v.user.Prefs.Subscriptions != nil {
|
if u.Prefs.Subscriptions != nil {
|
||||||
response.Subscriptions = v.user.Prefs.Subscriptions
|
response.Subscriptions = u.Prefs.Subscriptions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v.user.Tier != nil {
|
if u.Tier != nil {
|
||||||
response.Tier = &apiAccountTier{
|
response.Tier = &apiAccountTier{
|
||||||
Code: v.user.Tier.Code,
|
Code: u.Tier.Code,
|
||||||
Name: v.user.Tier.Name,
|
Name: u.Tier.Name,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v.user.Billing.StripeCustomerID != "" {
|
if u.Billing.StripeCustomerID != "" {
|
||||||
response.Billing = &apiAccountBilling{
|
response.Billing = &apiAccountBilling{
|
||||||
Customer: true,
|
Customer: true,
|
||||||
Subscription: v.user.Billing.StripeSubscriptionID != "",
|
Subscription: u.Billing.StripeSubscriptionID != "",
|
||||||
Status: string(v.user.Billing.StripeSubscriptionStatus),
|
Status: string(u.Billing.StripeSubscriptionStatus),
|
||||||
PaidUntil: v.user.Billing.StripeSubscriptionPaidUntil.Unix(),
|
PaidUntil: u.Billing.StripeSubscriptionPaidUntil.Unix(),
|
||||||
CancelAt: v.user.Billing.StripeSubscriptionCancelAt.Unix(),
|
CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reservations, err := s.userManager.Reservations(v.user.Name)
|
reservations, err := s.userManager.Reservations(u.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
} else {
|
||||||
response.Username = user.Everyone
|
response.Username = user.Everyone
|
||||||
response.Role = string(user.RoleAnonymous)
|
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 {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if req.Password == "" {
|
} 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 {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if req.Password == "" || req.NewPassword == "" {
|
} 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())
|
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
|
// 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
response := &apiAccountTokenResponse{
|
response := &apiAccountTokenResponse{
|
||||||
Token: token.Value,
|
Token: token.Value,
|
||||||
|
Label: token.Label,
|
||||||
Expires: token.Expires.Unix(),
|
Expires: token.Expires.Unix(),
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, response)
|
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
|
// TODO rate limit
|
||||||
if v.user == nil {
|
u := v.User()
|
||||||
return errHTTPUnauthorized
|
req, err := readJSONWithLimit[apiAccountTokenUpdateRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
|
||||||
} else if v.user.Token == "" {
|
if err != nil {
|
||||||
return errHTTPBadRequestNoTokenProvided
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
response := &apiAccountTokenResponse{
|
response := &apiAccountTokenResponse{
|
||||||
Token: token.Value,
|
Token: token.Value,
|
||||||
|
Label: token.Label,
|
||||||
Expires: token.Expires.Unix(),
|
Expires: token.Expires.Unix(),
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, response)
|
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
|
// TODO rate limit
|
||||||
if v.user.Token == "" {
|
u := v.User()
|
||||||
return errHTTPBadRequestNoTokenProvided
|
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 err
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
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 {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -266,7 +314,7 @@ func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.
|
||||||
return errHTTPInternalErrorInvalidPath
|
return errHTTPInternalErrorInvalidPath
|
||||||
}
|
}
|
||||||
subscriptionID := matches[1]
|
subscriptionID := matches[1]
|
||||||
updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit)
|
updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if v.user != nil && v.user.Role == user.RoleAdmin {
|
||||||
return errHTTPBadRequestMakesNoSenseForAdmin
|
return errHTTPBadRequestMakesNoSenseForAdmin
|
||||||
}
|
}
|
||||||
req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit)
|
req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ package server
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
"heckel.io/ntfy/user"
|
"heckel.io/ntfy/user"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"io"
|
"io"
|
||||||
|
@ -149,8 +150,8 @@ func TestAccount_Get_Anonymous(t *testing.T) {
|
||||||
func TestAccount_ChangeSettings(t *testing.T) {
|
func TestAccount_ChangeSettings(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||||
user, _ := s.userManager.User("phil")
|
u, _ := s.userManager.User("phil")
|
||||||
token, _ := s.userManager.CreateToken(user)
|
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{
|
rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
@ -294,6 +295,8 @@ func TestAccount_DeleteToken(t *testing.T) {
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
token, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
|
token, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
|
||||||
require.Nil(t, err)
|
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)
|
// Delete token failure (using basic auth)
|
||||||
rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{
|
rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{
|
||||||
|
|
|
@ -110,7 +110,7 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
|
||||||
if v.user.Billing.StripeSubscriptionID != "" {
|
if v.user.Billing.StripeSubscriptionID != "" {
|
||||||
return errHTTPBadRequestBillingSubscriptionExists
|
return errHTTPBadRequestBillingSubscriptionExists
|
||||||
}
|
}
|
||||||
req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit)
|
req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -215,7 +215,7 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r
|
||||||
if v.user.Billing.StripeSubscriptionID == "" {
|
if v.user.Billing.StripeSubscriptionID == "" {
|
||||||
return errNoBillingSubscription
|
return errNoBillingSubscription
|
||||||
}
|
}
|
||||||
req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit)
|
req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -235,9 +235,21 @@ type apiAccountDeleteRequest struct {
|
||||||
Password string `json:"password"`
|
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 {
|
type apiAccountTokenResponse struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Expires int64 `json:"expires"`
|
Label string `json:"label,omitempty"`
|
||||||
|
Expires int64 `json:"expires,omitempty"` // Unix timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountTier struct {
|
type apiAccountTier struct {
|
||||||
|
@ -282,17 +294,18 @@ type apiAccountBilling struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountResponse struct {
|
type apiAccountResponse struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Role string `json:"role,omitempty"`
|
Role string `json:"role,omitempty"`
|
||||||
SyncTopic string `json:"sync_topic,omitempty"`
|
SyncTopic string `json:"sync_topic,omitempty"`
|
||||||
Language string `json:"language,omitempty"`
|
Language string `json:"language,omitempty"`
|
||||||
Notification *user.NotificationPrefs `json:"notification,omitempty"`
|
Notification *user.NotificationPrefs `json:"notification,omitempty"`
|
||||||
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
||||||
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
||||||
Tier *apiAccountTier `json:"tier,omitempty"`
|
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
|
||||||
Limits *apiAccountLimits `json:"limits,omitempty"`
|
Tier *apiAccountTier `json:"tier,omitempty"`
|
||||||
Stats *apiAccountStats `json:"stats,omitempty"`
|
Limits *apiAccountLimits `json:"limits,omitempty"`
|
||||||
Billing *apiAccountBilling `json:"billing,omitempty"`
|
Stats *apiAccountStats `json:"stats,omitempty"`
|
||||||
|
Billing *apiAccountBilling `json:"billing,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountReservationRequest struct {
|
type apiAccountReservationRequest struct {
|
||||||
|
|
|
@ -130,8 +130,8 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
|
||||||
func readJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
|
func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
|
||||||
obj, err := util.UnmarshalJSONWithLimit[T](r, limit)
|
obj, err := util.UnmarshalJSONWithLimit[T](r, limit, allowEmpty)
|
||||||
if err == util.ErrUnmarshalJSON {
|
if err == util.ErrUnmarshalJSON {
|
||||||
return nil, errHTTPBadRequestJSONInvalid
|
return nil, errHTTPBadRequestJSONInvalid
|
||||||
} else if err == util.ErrTooLargeJSON {
|
} else if err == util.ErrTooLargeJSON {
|
||||||
|
|
|
@ -254,6 +254,13 @@ func (v *visitor) User() *user.User {
|
||||||
return v.user // May be nil
|
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
|
// SetUser sets the visitors user to the given value
|
||||||
func (v *visitor) SetUser(u *user.User) {
|
func (v *visitor) SetUser(u *user.User) {
|
||||||
v.mu.Lock()
|
v.mu.Lock()
|
||||||
|
|
112
user/manager.go
112
user/manager.go
|
@ -28,8 +28,7 @@ const (
|
||||||
userHardDeleteAfterDuration = 7 * 24 * time.Hour
|
userHardDeleteAfterDuration = 7 * 24 * time.Hour
|
||||||
tokenPrefix = "tk_"
|
tokenPrefix = "tk_"
|
||||||
tokenLength = 32
|
tokenLength = 32
|
||||||
tokenMaxCount = 10 // Only keep this many tokens in the table per user
|
tokenMaxCount = 10 // Only keep this many tokens in the table per user
|
||||||
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -92,6 +91,7 @@ const (
|
||||||
CREATE TABLE IF NOT EXISTS user_token (
|
CREATE TABLE IF NOT EXISTS user_token (
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
token TEXT NOT NULL,
|
token TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
expires INT NOT NULL,
|
expires INT NOT NULL,
|
||||||
PRIMARY KEY (user_id, token),
|
PRIMARY KEY (user_id, token),
|
||||||
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||||
|
@ -126,7 +126,7 @@ const (
|
||||||
FROM user u
|
FROM user u
|
||||||
JOIN user_token t on u.id = t.user_id
|
JOIN user_token t on u.id = t.user_id
|
||||||
LEFT JOIN tier t on t.id = u.tier_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 = `
|
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
|
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 = ?`
|
selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE user_id = ?`
|
||||||
insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES (?, ?, ?)`
|
selectTokensQuery = `SELECT token, label, expires FROM user_token WHERE user_id = ?`
|
||||||
updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?`
|
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 = ?`
|
deleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?`
|
||||||
deleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?`
|
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 = `
|
deleteExcessTokensQuery = `
|
||||||
DELETE FROM user_token
|
DELETE FROM user_token
|
||||||
WHERE (user_id, token) NOT IN (
|
WHERE (user_id, token) NOT IN (
|
||||||
|
@ -285,7 +288,6 @@ const (
|
||||||
DROP TABLE access;
|
DROP TABLE access;
|
||||||
DROP TABLE user_old;
|
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
|
// 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
|
// 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.
|
// given user, if there are too many of them.
|
||||||
func (a *Manager) CreateToken(user *User) (*Token, error) {
|
func (a *Manager) CreateToken(userID, label string, expires time.Time) (*Token, error) {
|
||||||
token, expires := util.RandomStringPrefix(tokenPrefix, tokenLength), time.Now().Add(tokenExpiryDuration)
|
token := util.RandomStringPrefix(tokenPrefix, tokenLength)
|
||||||
tx, err := a.db.Begin()
|
tx, err := a.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
rows, err := tx.Query(selectTokenCountQuery, user.ID)
|
rows, err := tx.Query(selectTokenCountQuery, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -390,7 +392,7 @@ func (a *Manager) CreateToken(user *User) (*Token, error) {
|
||||||
if tokenCount >= tokenMaxCount {
|
if tokenCount >= tokenMaxCount {
|
||||||
// This pruning logic is done in two queries for efficiency. The SELECT above is a lookup
|
// 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.
|
// 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
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -399,31 +401,89 @@ func (a *Manager) CreateToken(user *User) (*Token, error) {
|
||||||
}
|
}
|
||||||
return &Token{
|
return &Token{
|
||||||
Value: token,
|
Value: token,
|
||||||
|
Label: label,
|
||||||
Expires: expires,
|
Expires: expires,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtendToken sets the new expiry date for a token, thereby extending its use further into the future.
|
func (a *Manager) Tokens(userID string) ([]*Token, error) {
|
||||||
func (a *Manager) ExtendToken(user *User) (*Token, error) {
|
rows, err := a.db.Query(selectTokensQuery, userID)
|
||||||
if user.Token == "" {
|
if err != nil {
|
||||||
return nil, errNoTokenProvided
|
return nil, err
|
||||||
}
|
}
|
||||||
newExpires := time.Now().Add(tokenExpiryDuration)
|
defer rows.Close()
|
||||||
if _, err := a.db.Exec(updateTokenExpiryQuery, newExpires.Unix(), user.Name, user.Token); err != nil {
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &Token{
|
return &Token{
|
||||||
Value: user.Token,
|
Value: token,
|
||||||
Expires: newExpires,
|
Label: label,
|
||||||
|
Expires: time.Unix(expires, 0),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveToken deletes the token defined in User.Token
|
// ChangeToken updates a token's label and/or expiry date
|
||||||
func (a *Manager) RemoveToken(user *User) error {
|
func (a *Manager) ChangeToken(userID, token string, label *string, expires *time.Time) (*Token, error) {
|
||||||
if user.Token == "" {
|
if token == "" {
|
||||||
return ErrUnauthorized
|
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 err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -138,7 +138,7 @@ func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.False(t, u.Deleted)
|
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)
|
require.Nil(t, err)
|
||||||
|
|
||||||
u, err = a.Authenticate("user", "pass")
|
u, err = a.Authenticate("user", "pass")
|
||||||
|
@ -396,9 +396,10 @@ func TestManager_Token_Valid(t *testing.T) {
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
// Create token for user
|
// 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.Nil(t, err)
|
||||||
require.NotEmpty(t, token.Value)
|
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())
|
require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires.Unix())
|
||||||
|
|
||||||
u2, err := a.AuthenticateToken(token.Value)
|
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, u.Name, u2.Name)
|
||||||
require.Equal(t, token.Value, u2.Token)
|
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
|
// 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)
|
u3, err := a.AuthenticateToken(token.Value)
|
||||||
require.Equal(t, ErrUnauthenticated, err)
|
require.Equal(t, ErrUnauthenticated, err)
|
||||||
require.Nil(t, u3)
|
require.Nil(t, u3)
|
||||||
|
@ -434,12 +440,12 @@ func TestManager_Token_Expire(t *testing.T) {
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
// Create tokens for user
|
// 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.Nil(t, err)
|
||||||
require.NotEmpty(t, token1.Value)
|
require.NotEmpty(t, token1.Value)
|
||||||
require.True(t, time.Now().Add(71*time.Hour).Unix() < token1.Expires.Unix())
|
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.Nil(t, err)
|
||||||
require.NotEmpty(t, token2.Value)
|
require.NotEmpty(t, token2.Value)
|
||||||
require.NotEqual(t, token1.Value, 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")
|
u, err := a.User("ben")
|
||||||
require.Nil(t, err)
|
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)
|
require.Equal(t, errNoTokenProvided, err)
|
||||||
|
|
||||||
// Create token for user
|
// 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.Nil(t, err)
|
||||||
require.NotEmpty(t, token.Value)
|
require.NotEmpty(t, token.Value)
|
||||||
|
|
||||||
userWithToken, err := a.AuthenticateToken(token.Value)
|
userWithToken, err := a.AuthenticateToken(token.Value)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
time.Sleep(1100 * time.Millisecond)
|
extendedToken, err := a.ChangeToken(userWithToken.ID, userWithToken.Token, util.String("changed label"), util.Time(time.Now().Add(100*time.Hour)))
|
||||||
|
|
||||||
extendedToken, err := a.ExtendToken(userWithToken)
|
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, token.Value, extendedToken.Value)
|
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, 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) {
|
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)
|
baseTime := time.Now().Add(24 * time.Hour)
|
||||||
tokens := make([]string, 0)
|
tokens := make([]string, 0)
|
||||||
for i := 0; i < 12; i++ {
|
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.Nil(t, err)
|
||||||
require.NotEmpty(t, token.Value)
|
require.NotEmpty(t, token.Value)
|
||||||
tokens = append(tokens, token.Value)
|
tokens = append(tokens, token.Value)
|
||||||
|
|
|
@ -47,6 +47,7 @@ type Auther interface {
|
||||||
// Token represents a user token, including expiry date
|
// Token represents a user token, including expiry date
|
||||||
type Token struct {
|
type Token struct {
|
||||||
Value string
|
Value string
|
||||||
|
Label string
|
||||||
Expires time.Time
|
Expires time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -237,5 +238,6 @@ var (
|
||||||
ErrInvalidArgument = errors.New("invalid argument")
|
ErrInvalidArgument = errors.New("invalid argument")
|
||||||
ErrUserNotFound = errors.New("user not found")
|
ErrUserNotFound = errors.New("user not found")
|
||||||
ErrTierNotFound = errors.New("tier not found")
|
ErrTierNotFound = errors.New("tier not found")
|
||||||
|
ErrTokenNotFound = errors.New("token not found")
|
||||||
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
||||||
)
|
)
|
||||||
|
|
12
util/util.go
12
util/util.go
|
@ -1,6 +1,7 @@
|
||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"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
|
// 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()
|
defer r.Close()
|
||||||
p, err := Peek(r, limit)
|
p, err := Peek(r, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -319,7 +320,9 @@ func UnmarshalJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
|
||||||
return nil, ErrTooLargeJSON
|
return nil, ErrTooLargeJSON
|
||||||
}
|
}
|
||||||
var obj T
|
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 nil, ErrUnmarshalJSON
|
||||||
}
|
}
|
||||||
return &obj, nil
|
return &obj, nil
|
||||||
|
@ -357,3 +360,8 @@ func String(v string) *string {
|
||||||
func Int(v int) *int {
|
func Int(v int) *int {
|
||||||
return &v
|
return &v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Time turns a time.Time into a pointer
|
||||||
|
func Time(v time.Time) *time.Time {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
|
@ -190,13 +190,25 @@ func TestReadJSON_Failure(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReadJSONWithLimit_Success(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.Nil(t, err)
|
||||||
require.Equal(t, "some name", v.Name)
|
require.Equal(t, "some name", v.Name)
|
||||||
require.Equal(t, 99, v.Something)
|
require.Equal(t, 99, v.Something)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReadJSONWithLimit_FailureTooLong(t *testing.T) {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
{
|
{
|
||||||
|
"common_cancel": "Cancel",
|
||||||
"signup_title": "Create a ntfy account",
|
"signup_title": "Create a ntfy account",
|
||||||
"signup_form_username": "Username",
|
"signup_form_username": "Username",
|
||||||
"signup_form_password": "Password",
|
"signup_form_password": "Password",
|
||||||
|
@ -221,6 +222,32 @@
|
||||||
"account_upgrade_dialog_button_pay_now": "Pay now and subscribe",
|
"account_upgrade_dialog_button_pay_now": "Pay now and subscribe",
|
||||||
"account_upgrade_dialog_button_cancel_subscription": "Cancel subscription",
|
"account_upgrade_dialog_button_cancel_subscription": "Cancel subscription",
|
||||||
"account_upgrade_dialog_button_update_subscription": "Update 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_title": "Notifications",
|
||||||
"prefs_notifications_sound_title": "Notification sound",
|
"prefs_notifications_sound_title": "Notification sound",
|
||||||
"prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive",
|
"prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive",
|
||||||
|
|
|
@ -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() {
|
async extendToken() {
|
||||||
const url = accountTokenUrl(config.base_url);
|
const url = accountTokenUrl(config.base_url);
|
||||||
console.log(`[AccountApi] Extending user access token ${url}`);
|
console.log(`[AccountApi] Extending user access token ${url}`);
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "PATCH",
|
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) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
throw new UnauthorizedError();
|
throw new UnauthorizedError();
|
||||||
|
|
|
@ -1,13 +1,23 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {useContext, useState} from 'react';
|
import {useContext, useEffect, useState} from 'react';
|
||||||
import {Alert, LinearProgress, Stack, useMediaQuery} from "@mui/material";
|
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 Tooltip from '@mui/material/Tooltip';
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import Container from "@mui/material/Container";
|
import Container from "@mui/material/Container";
|
||||||
import Card from "@mui/material/Card";
|
import Card from "@mui/material/Card";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import {useTranslation} from "react-i18next";
|
import {Trans, useTranslation} from "react-i18next";
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
|
@ -15,10 +25,9 @@ import Dialog from "@mui/material/Dialog";
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import DialogActions from "@mui/material/DialogActions";
|
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import {formatBytes, formatShortDate, formatShortDateTime} from "../app/utils";
|
import {formatBytes, formatShortDate, formatShortDateTime, truncateString, validUrl} from "../app/utils";
|
||||||
import accountApi, {IncorrectPasswordError, UnauthorizedError} from "../app/AccountApi";
|
import accountApi, {IncorrectPasswordError, UnauthorizedError} from "../app/AccountApi";
|
||||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||||
import {Pref, PrefGroup} from "./Pref";
|
import {Pref, PrefGroup} from "./Pref";
|
||||||
|
@ -28,8 +37,18 @@ import humanizeDuration from "humanize-duration";
|
||||||
import UpgradeDialog from "./UpgradeDialog";
|
import UpgradeDialog from "./UpgradeDialog";
|
||||||
import CelebrationIcon from "@mui/icons-material/Celebration";
|
import CelebrationIcon from "@mui/icons-material/Celebration";
|
||||||
import {AccountContext} from "./App";
|
import {AccountContext} from "./App";
|
||||||
import {Warning, WarningAmber} from "@mui/icons-material";
|
|
||||||
import DialogFooter from "./DialogFooter";
|
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 = () => {
|
const Account = () => {
|
||||||
if (!session.exists()) {
|
if (!session.exists()) {
|
||||||
|
@ -41,6 +60,7 @@ const Account = () => {
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<Basics/>
|
<Basics/>
|
||||||
<Stats/>
|
<Stats/>
|
||||||
|
<Tokens/>
|
||||||
<Delete/>
|
<Delete/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</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 Delete = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -3,7 +3,8 @@ import {useContext, useEffect, useState} from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
CardActions,
|
CardActions,
|
||||||
CardContent, Chip,
|
CardContent,
|
||||||
|
Chip,
|
||||||
FormControl,
|
FormControl,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
|
@ -20,7 +21,6 @@ import prefs from "../app/Prefs";
|
||||||
import {Paragraph} from "./styles";
|
import {Paragraph} from "./styles";
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||||
import Container from "@mui/material/Container";
|
import Container from "@mui/material/Container";
|
||||||
|
@ -42,12 +42,11 @@ import routes from "./routes";
|
||||||
import accountApi, {UnauthorizedError} from "../app/AccountApi";
|
import accountApi, {UnauthorizedError} from "../app/AccountApi";
|
||||||
import {Pref, PrefGroup} from "./Pref";
|
import {Pref, PrefGroup} from "./Pref";
|
||||||
import LockIcon from "@mui/icons-material/Lock";
|
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 DialogContentText from "@mui/material/DialogContentText";
|
||||||
import ReserveTopicSelect from "./ReserveTopicSelect";
|
import ReserveTopicSelect from "./ReserveTopicSelect";
|
||||||
import {AccountContext} from "./App";
|
import {AccountContext} from "./App";
|
||||||
import {useOutletContext} from "react-router-dom";
|
import {useOutletContext} from "react-router-dom";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
|
||||||
|
|
||||||
const Preferences = () => {
|
const Preferences = () => {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -2,7 +2,6 @@ import * as React from 'react';
|
||||||
import {Lock, Public} from "@mui/icons-material";
|
import {Lock, Public} from "@mui/icons-material";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
|
|
||||||
|
|
||||||
export const PermissionReadWrite = React.forwardRef((props, ref) => {
|
export const PermissionReadWrite = React.forwardRef((props, ref) => {
|
||||||
const size = props.size ?? "medium";
|
const size = props.size ?? "medium";
|
||||||
return <Public fontSize={size} ref={ref} {...props}/>;
|
return <Public fontSize={size} ref={ref} {...props}/>;
|
||||||
|
|
|
@ -1,24 +1,9 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {useState} from 'react';
|
import {FormControl, Select} from "@mui/material";
|
||||||
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 {useTranslation} from "react-i18next";
|
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 MenuItem from "@mui/material/MenuItem";
|
||||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||||
import LockIcon from "@mui/icons-material/Lock";
|
|
||||||
import ListItemText from "@mui/material/ListItemText";
|
import ListItemText from "@mui/material/ListItemText";
|
||||||
import {Public, PublicOff} from "@mui/icons-material";
|
|
||||||
import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons";
|
import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons";
|
||||||
|
|
||||||
const ReserveTopicSelect = (props) => {
|
const ReserveTopicSelect = (props) => {
|
||||||
|
|
|
@ -261,5 +261,4 @@ const Banner = {
|
||||||
RESERVATIONS_WARNING: 3
|
RESERVATIONS_WARNING: 3
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default UpgradeDialog;
|
export default UpgradeDialog;
|
||||||
|
|
Loading…
Reference in New Issue