Change password, delete account, etc.
This commit is contained in:
parent
8ff168283c
commit
81a8efcca3
14 changed files with 628 additions and 214 deletions
|
@ -81,11 +81,12 @@ const (
|
||||||
|
|
||||||
// Manager-related queries
|
// Manager-related queries
|
||||||
const (
|
const (
|
||||||
insertUserQuery = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)`
|
insertUserQuery = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)`
|
||||||
selectUsernamesQuery = `SELECT user FROM user ORDER BY role, user`
|
selectUsernamesQuery = `SELECT user FROM user ORDER BY role, user`
|
||||||
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
||||||
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
||||||
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
updateUserSettingsQuery = `UPDATE user SET settings = ? WHERE user = ?`
|
||||||
|
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
||||||
|
|
||||||
upsertUserAccessQuery = `INSERT INTO user_access (user_id, topic, read, write) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?)`
|
upsertUserAccessQuery = `INSERT INTO user_access (user_id, topic, read, write) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?)`
|
||||||
selectUserAccessQuery = `SELECT topic, read, write FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)`
|
selectUserAccessQuery = `SELECT topic, read, write FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)`
|
||||||
|
@ -93,9 +94,9 @@ const (
|
||||||
deleteUserAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)`
|
deleteUserAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)`
|
||||||
deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?`
|
deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?`
|
||||||
|
|
||||||
insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?)`
|
insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?)`
|
||||||
deleteTokenQuery = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?`
|
deleteTokenQuery = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?`
|
||||||
updateUserSettingsQuery = `UPDATE user SET settings = ? WHERE user = ?`
|
deleteUserTokenQuery = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?)`
|
||||||
)
|
)
|
||||||
|
|
||||||
// Schema management queries
|
// Schema management queries
|
||||||
|
@ -250,10 +251,13 @@ func (a *SQLiteAuthManager) RemoveUser(username string) error {
|
||||||
if !AllowedUsername(username) {
|
if !AllowedUsername(username) {
|
||||||
return ErrInvalidArgument
|
return ErrInvalidArgument
|
||||||
}
|
}
|
||||||
if _, err := a.db.Exec(deleteUserQuery, username); err != nil {
|
if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil {
|
if _, err := a.db.Exec(deleteUserTokenQuery, username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := a.db.Exec(deleteUserQuery, username); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
207
server/server.go
207
server/server.go
|
@ -39,13 +39,13 @@ import (
|
||||||
expire tokens
|
expire tokens
|
||||||
auto-refresh tokens from UI
|
auto-refresh tokens from UI
|
||||||
reserve topics
|
reserve topics
|
||||||
|
rate limit for signup (2 per 24h)
|
||||||
|
handle invalid session token
|
||||||
|
update disallowed topics
|
||||||
Pages:
|
Pages:
|
||||||
- Home
|
- Home
|
||||||
- Signup
|
|
||||||
- Sign-in
|
|
||||||
- Password reset
|
- Password reset
|
||||||
- Pricing
|
- Pricing
|
||||||
- change password
|
|
||||||
- change email
|
- change email
|
||||||
-
|
-
|
||||||
|
|
||||||
|
@ -92,6 +92,7 @@ var (
|
||||||
userStatsPath = "/user/stats" // FIXME get rid of this in favor of /user/account
|
userStatsPath = "/user/stats" // FIXME get rid of this in favor of /user/account
|
||||||
accountPath = "/v1/account"
|
accountPath = "/v1/account"
|
||||||
accountTokenPath = "/v1/account/token"
|
accountTokenPath = "/v1/account/token"
|
||||||
|
accountPasswordPath = "/v1/account/password"
|
||||||
accountSettingsPath = "/v1/account/settings"
|
accountSettingsPath = "/v1/account/settings"
|
||||||
accountSubscriptionPath = "/v1/account/subscription"
|
accountSubscriptionPath = "/v1/account/subscription"
|
||||||
accountSubscriptionSingleRegex = regexp.MustCompile(`^/v1/account/subscription/([-_A-Za-z0-9]{16})$`)
|
accountSubscriptionSingleRegex = regexp.MustCompile(`^/v1/account/subscription/([-_A-Za-z0-9]{16})$`)
|
||||||
|
@ -329,7 +330,11 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
|
||||||
return s.handleUserStats(w, r, v)
|
return s.handleUserStats(w, r, v)
|
||||||
} else if r.Method == http.MethodPost && r.URL.Path == accountPath {
|
} else if r.Method == http.MethodPost && r.URL.Path == accountPath {
|
||||||
return s.handleUserAccountCreate(w, r, v)
|
return s.handleAccountCreate(w, r, v)
|
||||||
|
} else if r.Method == http.MethodDelete && r.URL.Path == accountPath {
|
||||||
|
return s.handleAccountDelete(w, r, v)
|
||||||
|
} else if r.Method == http.MethodPost && r.URL.Path == accountPasswordPath {
|
||||||
|
return s.handleAccountPasswordChange(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == accountTokenPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == accountTokenPath {
|
||||||
return s.handleAccountTokenGet(w, r, v)
|
return s.handleAccountTokenGet(w, r, v)
|
||||||
} else if r.Method == http.MethodDelete && r.URL.Path == accountTokenPath {
|
} else if r.Method == http.MethodDelete && r.URL.Path == accountTokenPath {
|
||||||
|
@ -337,7 +342,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == accountSettingsPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == accountSettingsPath {
|
||||||
return s.handleAccountSettingsGet(w, r, v)
|
return s.handleAccountSettingsGet(w, r, v)
|
||||||
} else if r.Method == http.MethodPost && r.URL.Path == accountSettingsPath {
|
} else if r.Method == http.MethodPost && r.URL.Path == accountSettingsPath {
|
||||||
return s.handleAccountSettingsPost(w, r, v)
|
return s.handleAccountSettingsChange(w, r, v)
|
||||||
} else if r.Method == http.MethodPost && r.URL.Path == accountSubscriptionPath {
|
} else if r.Method == http.MethodPost && r.URL.Path == accountSubscriptionPath {
|
||||||
return s.handleAccountSubscriptionAdd(w, r, v)
|
return s.handleAccountSubscriptionAdd(w, r, v)
|
||||||
} else if r.Method == http.MethodDelete && accountSubscriptionSingleRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodDelete && accountSubscriptionSingleRegex.MatchString(r.URL.Path) {
|
||||||
|
@ -436,198 +441,6 @@ func (s *Server) handleUserStats(w http.ResponseWriter, r *http.Request, v *visi
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountTokenGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
// TODO rate limit
|
|
||||||
if v.user == nil {
|
|
||||||
return errHTTPUnauthorized
|
|
||||||
}
|
|
||||||
token, err := s.auth.CreateToken(v.user)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
response := &apiAccountTokenResponse{
|
|
||||||
Token: token,
|
|
||||||
}
|
|
||||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
// TODO rate limit
|
|
||||||
if v.user == nil || v.user.Token == "" {
|
|
||||||
return errHTTPUnauthorized
|
|
||||||
}
|
|
||||||
if err := s.auth.RemoveToken(v.user); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAccountSettingsGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
response := &apiAccountSettingsResponse{}
|
|
||||||
if v.user != nil {
|
|
||||||
response.Username = v.user.Name
|
|
||||||
response.Role = string(v.user.Role)
|
|
||||||
if v.user.Prefs != nil {
|
|
||||||
if v.user.Prefs.Language != "" {
|
|
||||||
response.Language = v.user.Prefs.Language
|
|
||||||
}
|
|
||||||
if v.user.Prefs.Notification != nil {
|
|
||||||
response.Notification = v.user.Prefs.Notification
|
|
||||||
}
|
|
||||||
if v.user.Prefs.Subscriptions != nil {
|
|
||||||
response.Subscriptions = v.user.Prefs.Subscriptions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
response = &apiAccountSettingsResponse{
|
|
||||||
Username: auth.Everyone,
|
|
||||||
Role: string(auth.RoleAnonymous),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleUserAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
signupAllowed := s.config.EnableSignup
|
|
||||||
admin := v.user != nil && v.user.Role == auth.RoleAdmin
|
|
||||||
if !signupAllowed && !admin {
|
|
||||||
return errHTTPUnauthorized
|
|
||||||
}
|
|
||||||
body, err := util.Peek(r.Body, 4096) // FIXME
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer r.Body.Close()
|
|
||||||
var newAccount apiAccountCreateRequest
|
|
||||||
if err := json.NewDecoder(body).Decode(&newAccount); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.auth.AddUser(newAccount.Username, newAccount.Password, auth.RoleUser); err != nil { // TODO this should return a User
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
// FIXME return something
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAccountSettingsPost(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
if v.user == nil {
|
|
||||||
return errors.New("no user")
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
body, err := util.Peek(r.Body, 4096) // FIXME
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer r.Body.Close()
|
|
||||||
var newPrefs auth.UserPrefs
|
|
||||||
if err := json.NewDecoder(body).Decode(&newPrefs); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if v.user.Prefs == nil {
|
|
||||||
v.user.Prefs = &auth.UserPrefs{}
|
|
||||||
}
|
|
||||||
prefs := v.user.Prefs
|
|
||||||
if newPrefs.Language != "" {
|
|
||||||
prefs.Language = newPrefs.Language
|
|
||||||
}
|
|
||||||
if newPrefs.Notification != nil {
|
|
||||||
if prefs.Notification == nil {
|
|
||||||
prefs.Notification = &auth.UserNotificationPrefs{}
|
|
||||||
}
|
|
||||||
if newPrefs.Notification.DeleteAfter > 0 {
|
|
||||||
prefs.Notification.DeleteAfter = newPrefs.Notification.DeleteAfter
|
|
||||||
}
|
|
||||||
if newPrefs.Notification.Sound != "" {
|
|
||||||
prefs.Notification.Sound = newPrefs.Notification.Sound
|
|
||||||
}
|
|
||||||
if newPrefs.Notification.MinPriority > 0 {
|
|
||||||
prefs.Notification.MinPriority = newPrefs.Notification.MinPriority
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s.auth.ChangeSettings(v.user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
if v.user == nil {
|
|
||||||
return errors.New("no user")
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
body, err := util.Peek(r.Body, 4096) // FIXME
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer r.Body.Close()
|
|
||||||
var newSubscription auth.UserSubscription
|
|
||||||
if err := json.NewDecoder(body).Decode(&newSubscription); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if v.user.Prefs == nil {
|
|
||||||
v.user.Prefs = &auth.UserPrefs{}
|
|
||||||
}
|
|
||||||
newSubscription.ID = "" // Client cannot set ID
|
|
||||||
for _, subscription := range v.user.Prefs.Subscriptions {
|
|
||||||
if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic {
|
|
||||||
newSubscription = *subscription
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if newSubscription.ID == "" {
|
|
||||||
newSubscription.ID = util.RandomString(16)
|
|
||||||
v.user.Prefs.Subscriptions = append(v.user.Prefs.Subscriptions, &newSubscription)
|
|
||||||
if err := s.auth.ChangeSettings(v.user); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := json.NewEncoder(w).Encode(newSubscription); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
if v.user == nil {
|
|
||||||
return errors.New("no user")
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
matches := accountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path)
|
|
||||||
if len(matches) != 2 {
|
|
||||||
return errHTTPInternalErrorInvalidFilePath // FIXME
|
|
||||||
}
|
|
||||||
subscriptionID := matches[1]
|
|
||||||
if v.user.Prefs == nil || v.user.Prefs.Subscriptions == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
newSubscriptions := make([]*auth.UserSubscription, 0)
|
|
||||||
for _, subscription := range v.user.Prefs.Subscriptions {
|
|
||||||
if subscription.ID != subscriptionID {
|
|
||||||
newSubscriptions = append(newSubscriptions, subscription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(newSubscriptions) < len(v.user.Prefs.Subscriptions) {
|
|
||||||
v.user.Prefs.Subscriptions = newSubscriptions
|
|
||||||
if err := s.auth.ChangeSettings(v.user); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||||
r.URL.Path = webSiteDir + r.URL.Path
|
r.URL.Path = webSiteDir + r.URL.Path
|
||||||
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
|
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
|
||||||
|
|
236
server/server_account.go
Normal file
236
server/server_account.go
Normal file
|
@ -0,0 +1,236 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"heckel.io/ntfy/auth"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
signupAllowed := s.config.EnableSignup
|
||||||
|
admin := v.user != nil && v.user.Role == auth.RoleAdmin
|
||||||
|
if !signupAllowed && !admin {
|
||||||
|
return errHTTPUnauthorized
|
||||||
|
}
|
||||||
|
body, err := util.Peek(r.Body, 4096) // FIXME
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
var newAccount apiAccountCreateRequest
|
||||||
|
if err := json.NewDecoder(body).Decode(&newAccount); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.auth.AddUser(newAccount.Username, newAccount.Password, auth.RoleUser); err != nil { // TODO this should return a User
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
||||||
|
// FIXME return something
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
if v.user == nil {
|
||||||
|
return errHTTPUnauthorized
|
||||||
|
}
|
||||||
|
if err := s.auth.RemoveUser(v.user.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
||||||
|
// FIXME return something
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
if v.user == nil {
|
||||||
|
return errHTTPUnauthorized
|
||||||
|
}
|
||||||
|
body, err := util.Peek(r.Body, 4096) // FIXME
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
var newPassword apiAccountCreateRequest // Re-use!
|
||||||
|
if err := json.NewDecoder(body).Decode(&newPassword); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.auth.ChangePassword(v.user.Name, newPassword.Password); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
||||||
|
// FIXME return something
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountTokenGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
// TODO rate limit
|
||||||
|
if v.user == nil {
|
||||||
|
return errHTTPUnauthorized
|
||||||
|
}
|
||||||
|
token, err := s.auth.CreateToken(v.user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
||||||
|
response := &apiAccountTokenResponse{
|
||||||
|
Token: token,
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
// TODO rate limit
|
||||||
|
if v.user == nil || v.user.Token == "" {
|
||||||
|
return errHTTPUnauthorized
|
||||||
|
}
|
||||||
|
if err := s.auth.RemoveToken(v.user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountSettingsGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
||||||
|
response := &apiAccountSettingsResponse{}
|
||||||
|
if v.user != nil {
|
||||||
|
response.Username = v.user.Name
|
||||||
|
response.Role = string(v.user.Role)
|
||||||
|
if v.user.Prefs != nil {
|
||||||
|
if v.user.Prefs.Language != "" {
|
||||||
|
response.Language = v.user.Prefs.Language
|
||||||
|
}
|
||||||
|
if v.user.Prefs.Notification != nil {
|
||||||
|
response.Notification = v.user.Prefs.Notification
|
||||||
|
}
|
||||||
|
if v.user.Prefs.Subscriptions != nil {
|
||||||
|
response.Subscriptions = v.user.Prefs.Subscriptions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
response = &apiAccountSettingsResponse{
|
||||||
|
Username: auth.Everyone,
|
||||||
|
Role: string(auth.RoleAnonymous),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
if v.user == nil {
|
||||||
|
return errors.New("no user")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
||||||
|
body, err := util.Peek(r.Body, 4096) // FIXME
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
var newPrefs auth.UserPrefs
|
||||||
|
if err := json.NewDecoder(body).Decode(&newPrefs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v.user.Prefs == nil {
|
||||||
|
v.user.Prefs = &auth.UserPrefs{}
|
||||||
|
}
|
||||||
|
prefs := v.user.Prefs
|
||||||
|
if newPrefs.Language != "" {
|
||||||
|
prefs.Language = newPrefs.Language
|
||||||
|
}
|
||||||
|
if newPrefs.Notification != nil {
|
||||||
|
if prefs.Notification == nil {
|
||||||
|
prefs.Notification = &auth.UserNotificationPrefs{}
|
||||||
|
}
|
||||||
|
if newPrefs.Notification.DeleteAfter > 0 {
|
||||||
|
prefs.Notification.DeleteAfter = newPrefs.Notification.DeleteAfter
|
||||||
|
}
|
||||||
|
if newPrefs.Notification.Sound != "" {
|
||||||
|
prefs.Notification.Sound = newPrefs.Notification.Sound
|
||||||
|
}
|
||||||
|
if newPrefs.Notification.MinPriority > 0 {
|
||||||
|
prefs.Notification.MinPriority = newPrefs.Notification.MinPriority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.auth.ChangeSettings(v.user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
if v.user == nil {
|
||||||
|
return errors.New("no user")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
||||||
|
body, err := util.Peek(r.Body, 4096) // FIXME
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
var newSubscription auth.UserSubscription
|
||||||
|
if err := json.NewDecoder(body).Decode(&newSubscription); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v.user.Prefs == nil {
|
||||||
|
v.user.Prefs = &auth.UserPrefs{}
|
||||||
|
}
|
||||||
|
newSubscription.ID = "" // Client cannot set ID
|
||||||
|
for _, subscription := range v.user.Prefs.Subscriptions {
|
||||||
|
if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic {
|
||||||
|
newSubscription = *subscription
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if newSubscription.ID == "" {
|
||||||
|
newSubscription.ID = util.RandomString(16)
|
||||||
|
v.user.Prefs.Subscriptions = append(v.user.Prefs.Subscriptions, &newSubscription)
|
||||||
|
if err := s.auth.ChangeSettings(v.user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(w).Encode(newSubscription); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
if v.user == nil {
|
||||||
|
return errors.New("no user")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
||||||
|
matches := accountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path)
|
||||||
|
if len(matches) != 2 {
|
||||||
|
return errHTTPInternalErrorInvalidFilePath // FIXME
|
||||||
|
}
|
||||||
|
subscriptionID := matches[1]
|
||||||
|
if v.user.Prefs == nil || v.user.Prefs.Subscriptions == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
newSubscriptions := make([]*auth.UserSubscription, 0)
|
||||||
|
for _, subscription := range v.user.Prefs.Subscriptions {
|
||||||
|
if subscription.ID != subscriptionID {
|
||||||
|
newSubscriptions = append(newSubscriptions, subscription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(newSubscriptions) < len(v.user.Prefs.Subscriptions) {
|
||||||
|
v.user.Prefs.Subscriptions = newSubscriptions
|
||||||
|
if err := s.auth.ChangeSettings(v.user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -14,6 +14,7 @@
|
||||||
"message_bar_publish": "Publish message",
|
"message_bar_publish": "Publish message",
|
||||||
"nav_topics_title": "Subscribed topics",
|
"nav_topics_title": "Subscribed topics",
|
||||||
"nav_button_all_notifications": "All notifications",
|
"nav_button_all_notifications": "All notifications",
|
||||||
|
"nav_button_account": "Account",
|
||||||
"nav_button_settings": "Settings",
|
"nav_button_settings": "Settings",
|
||||||
"nav_button_documentation": "Documentation",
|
"nav_button_documentation": "Documentation",
|
||||||
"nav_button_publish_message": "Publish notification",
|
"nav_button_publish_message": "Publish notification",
|
||||||
|
|
|
@ -8,7 +8,7 @@ import {
|
||||||
topicUrlJsonPollWithSince,
|
topicUrlJsonPollWithSince,
|
||||||
accountSettingsUrl,
|
accountSettingsUrl,
|
||||||
accountTokenUrl,
|
accountTokenUrl,
|
||||||
userStatsUrl, accountSubscriptionUrl, accountSubscriptionSingleUrl, accountUrl
|
userStatsUrl, accountSubscriptionUrl, accountSubscriptionSingleUrl, accountUrl, accountPasswordUrl
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
import userManager from "./UserManager";
|
import userManager from "./UserManager";
|
||||||
|
|
||||||
|
@ -175,6 +175,33 @@ class Api {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteAccount(baseUrl, token) {
|
||||||
|
const url = accountUrl(baseUrl);
|
||||||
|
console.log(`[Api] Deleting user account ${url}`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: maybeWithBearerAuth({}, token)
|
||||||
|
});
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(baseUrl, token, password) {
|
||||||
|
const url = accountPasswordUrl(baseUrl);
|
||||||
|
console.log(`[Api] Changing account password ${url}`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: maybeWithBearerAuth({}, token),
|
||||||
|
body: JSON.stringify({
|
||||||
|
password: password
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getAccountSettings(baseUrl, token) {
|
async getAccountSettings(baseUrl, token) {
|
||||||
const url = accountSettingsUrl(baseUrl);
|
const url = accountSettingsUrl(baseUrl);
|
||||||
console.log(`[Api] Fetching user account ${url}`);
|
console.log(`[Api] Fetching user account ${url}`);
|
||||||
|
|
|
@ -20,6 +20,7 @@ export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/aut
|
||||||
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
|
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
|
||||||
export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
|
export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
|
||||||
export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`;
|
export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`;
|
||||||
|
export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`;
|
||||||
export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
|
export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
|
||||||
export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`;
|
export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`;
|
||||||
export const accountSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/subscription`;
|
export const accountSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/subscription`;
|
||||||
|
|
253
web/src/components/Account.js
Normal file
253
web/src/components/Account.js
Normal file
|
@ -0,0 +1,253 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import {Stack, useMediaQuery} from "@mui/material";
|
||||||
|
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 session from "../app/Session";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import theme from "./theme";
|
||||||
|
import {validUrl} from "../app/utils";
|
||||||
|
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 userManager from "../app/UserManager";
|
||||||
|
import api from "../app/Api";
|
||||||
|
import routes from "./routes";
|
||||||
|
|
||||||
|
const Account = () => {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Basics/>
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Basics = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Card sx={{p: 3}} aria-label={t("xxxxxxxxx")}>
|
||||||
|
<Typography variant="h5" sx={{marginBottom: 2}}>
|
||||||
|
Account
|
||||||
|
</Typography>
|
||||||
|
<PrefGroup>
|
||||||
|
<Pref labelId={"username"} title={"Username"}>{session.username()}</Pref>
|
||||||
|
<ChangePassword/>
|
||||||
|
<DeleteAccount/>
|
||||||
|
</PrefGroup>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChangePassword = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [dialogKey, setDialogKey] = useState(0);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const labelId = "prefChangePassword";
|
||||||
|
const handleDialogOpen = () => {
|
||||||
|
setDialogKey(prev => prev+1);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
const handleDialogCancel = () => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
};
|
||||||
|
const handleDialogSubmit = async (newPassword) => {
|
||||||
|
try {
|
||||||
|
await api.changePassword("http://localhost:2586", session.token(), newPassword);
|
||||||
|
setDialogOpen(false);
|
||||||
|
console.debug(`[Account] Password changed`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Account] Error changing password`, e);
|
||||||
|
// TODO show error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Pref labelId={labelId} title={"Password"}>
|
||||||
|
<Button variant="outlined" startIcon={<EditIcon />} onClick={handleDialogOpen}>
|
||||||
|
Change password
|
||||||
|
</Button>
|
||||||
|
<ChangePasswordDialog
|
||||||
|
key={`changePasswordDialog${dialogKey}`}
|
||||||
|
open={dialogOpen}
|
||||||
|
onCancel={handleDialogCancel}
|
||||||
|
onSubmit={handleDialogSubmit}
|
||||||
|
/>
|
||||||
|
</Pref>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChangePasswordDialog = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const changeButtonEnabled = (() => {
|
||||||
|
return newPassword.length > 0 && newPassword === confirmPassword;
|
||||||
|
})();
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||||
|
<DialogTitle>Change password</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
id="new-password"
|
||||||
|
label={t("New password")}
|
||||||
|
aria-label={t("xxxx")}
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={ev => setNewPassword(ev.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
id="confirm"
|
||||||
|
label={t("Confirm password")}
|
||||||
|
aria-label={t("xxx")}
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={ev => setConfirmPassword(ev.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel}>{t("Cancel")}</Button>
|
||||||
|
<Button onClick={() => props.onSubmit(newPassword)} disabled={!changeButtonEnabled}>{t("Change password")}</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeleteAccount = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [dialogKey, setDialogKey] = useState(0);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const labelId = "prefDeleteAccount";
|
||||||
|
const handleDialogOpen = () => {
|
||||||
|
setDialogKey(prev => prev+1);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
const handleDialogCancel = () => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
};
|
||||||
|
const handleDialogSubmit = async (newPassword) => {
|
||||||
|
try {
|
||||||
|
await api.deleteAccount("http://localhost:2586", session.token());
|
||||||
|
setDialogOpen(false);
|
||||||
|
console.debug(`[Account] Account deleted`);
|
||||||
|
// TODO delete local storage
|
||||||
|
session.reset();
|
||||||
|
window.location.href = routes.app;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Account] Error deleting account`, e);
|
||||||
|
// TODO show error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Pref labelId={labelId} title={t("Delete account")} description={t("This will permanently delete your account, including all data that is stored on the server.")}>
|
||||||
|
<Button variant="outlined" startIcon={<EditIcon />} onClick={handleDialogOpen}>
|
||||||
|
Delete account
|
||||||
|
</Button>
|
||||||
|
<DeleteAccountDialog
|
||||||
|
key={`deleteAccountDialog${dialogKey}`}
|
||||||
|
open={dialogOpen}
|
||||||
|
onCancel={handleDialogCancel}
|
||||||
|
onSubmit={handleDialogSubmit}
|
||||||
|
/>
|
||||||
|
</Pref>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeleteAccountDialog = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const buttonEnabled = username === session.username();
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||||
|
<DialogTitle>{t("Delete account")}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{t("This will permanently delete your account, including all data that is stored on the server. If you really want to proceed, please type {{username}} in the text box below.")}
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
id="account-delete-confirm"
|
||||||
|
label={t("Type '{{username}}' to delete account")}
|
||||||
|
aria-label={t("xxxx")}
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={ev => setUsername(ev.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel}>{t("prefs_users_dialog_button_cancel")}</Button>
|
||||||
|
<Button onClick={props.onSubmit} color="error" disabled={!buttonEnabled}>{t("Permanently delete account")}</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// FIXME duplicate code
|
||||||
|
|
||||||
|
const PrefGroup = (props) => {
|
||||||
|
return (
|
||||||
|
<div role="table">
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
const Pref = (props) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="row"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
marginTop: "10px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="cell"
|
||||||
|
id={props.labelId}
|
||||||
|
aria-label={props.title}
|
||||||
|
style={{
|
||||||
|
flex: '1 0 40%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingRight: '30px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div><b>{props.title}</b></div>
|
||||||
|
{props.description && <div><em>{props.description}</em></div>}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
role="cell"
|
||||||
|
style={{
|
||||||
|
flex: '1 0 calc(60% - 50px)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Account;
|
|
@ -302,7 +302,7 @@ const ProfileIcon = (props) => {
|
||||||
display: 'block',
|
display: 'block',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 14,
|
right: 19,
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
bgcolor: 'background.paper',
|
bgcolor: 'background.paper',
|
||||||
|
@ -314,14 +314,14 @@ const ProfileIcon = (props) => {
|
||||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||||
>
|
>
|
||||||
<MenuItem>
|
<MenuItem onClick={() => navigate(routes.account)}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<Person />
|
<Person />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<b>{session.username()}</b>
|
<b>{session.username()}</b>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Divider />
|
<Divider />
|
||||||
<MenuItem>
|
<MenuItem onClick={() => navigate(routes.settings)}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<Settings fontSize="small" />
|
<Settings fontSize="small" />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
|
|
|
@ -31,6 +31,8 @@ import prefs from "../app/Prefs";
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
import Pricing from "./Pricing";
|
import Pricing from "./Pricing";
|
||||||
import Signup from "./Signup";
|
import Signup from "./Signup";
|
||||||
|
import Account from "./Account";
|
||||||
|
import ResetPassword from "./ResetPassword";
|
||||||
|
|
||||||
// TODO races when two tabs are open
|
// TODO races when two tabs are open
|
||||||
// TODO investigate service workers
|
// TODO investigate service workers
|
||||||
|
@ -47,8 +49,10 @@ const App = () => {
|
||||||
<Route path={routes.pricing} element={<Pricing/>}/>
|
<Route path={routes.pricing} element={<Pricing/>}/>
|
||||||
<Route path={routes.login} element={<Login/>}/>
|
<Route path={routes.login} element={<Login/>}/>
|
||||||
<Route path={routes.signup} element={<Signup/>}/>
|
<Route path={routes.signup} element={<Signup/>}/>
|
||||||
|
<Route path={routes.resetPassword} element={<ResetPassword/>}/>
|
||||||
<Route element={<Layout/>}>
|
<Route element={<Layout/>}>
|
||||||
<Route path={routes.app} element={<AllSubscriptions/>}/>
|
<Route path={routes.app} element={<AllSubscriptions/>}/>
|
||||||
|
<Route path={routes.account} element={<Account/>}/>
|
||||||
<Route path={routes.settings} element={<Preferences/>}/>
|
<Route path={routes.settings} element={<Preferences/>}/>
|
||||||
<Route path={routes.subscription} element={<SingleSubscription/>}/>
|
<Route path={routes.subscription} element={<SingleSubscription/>}/>
|
||||||
<Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/>
|
<Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/>
|
||||||
|
|
|
@ -74,8 +74,8 @@ const Login = () => {
|
||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
<Box sx={{width: "100%"}}>
|
<Box sx={{width: "100%"}}>
|
||||||
<NavLink to="#" variant="body1" sx={{float: "left"}}>Reset password</NavLink>
|
<div style={{float: "left"}}><NavLink to={routes.resetPassword} variant="body1">Reset password</NavLink></div>
|
||||||
<div style={{float: "right"}}><NavLink to={routes.signup} variant="body1">Sign Up</NavLink></div>
|
<div style={{float: "right"}}><NavLink to={routes.signup} variant="body1">Sign up</NavLink></div>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {useState} from "react";
|
||||||
import ListItemButton from "@mui/material/ListItemButton";
|
import ListItemButton from "@mui/material/ListItemButton";
|
||||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||||
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
|
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
|
||||||
|
import Person from "@mui/icons-material/Person";
|
||||||
import ListItemText from "@mui/material/ListItemText";
|
import ListItemText from "@mui/material/ListItemText";
|
||||||
import Toolbar from "@mui/material/Toolbar";
|
import Toolbar from "@mui/material/Toolbar";
|
||||||
import Divider from "@mui/material/Divider";
|
import Divider from "@mui/material/Divider";
|
||||||
|
@ -25,6 +26,7 @@ import notifier from "../app/Notifier";
|
||||||
import config from "../app/config";
|
import config from "../app/config";
|
||||||
import ArticleIcon from '@mui/icons-material/Article';
|
import ArticleIcon from '@mui/icons-material/Article';
|
||||||
import {Trans, useTranslation} from "react-i18next";
|
import {Trans, useTranslation} from "react-i18next";
|
||||||
|
import session from "../app/Session";
|
||||||
|
|
||||||
const navWidth = 280;
|
const navWidth = 280;
|
||||||
|
|
||||||
|
@ -121,6 +123,11 @@ const NavList = (props) => {
|
||||||
/>
|
/>
|
||||||
<Divider sx={{my: 1}}/>
|
<Divider sx={{my: 1}}/>
|
||||||
</>}
|
</>}
|
||||||
|
{session.exists() &&
|
||||||
|
<ListItemButton onClick={() => navigate(routes.account)} selected={location.pathname === routes.account}>
|
||||||
|
<ListItemIcon><Person/></ListItemIcon>
|
||||||
|
<ListItemText primary={t("nav_button_account")}/>
|
||||||
|
</ListItemButton>}
|
||||||
<ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
|
<ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
|
||||||
<ListItemIcon><SettingsIcon/></ListItemIcon>
|
<ListItemIcon><SettingsIcon/></ListItemIcon>
|
||||||
<ListItemText primary={t("nav_button_settings")}/>
|
<ListItemText primary={t("nav_button_settings")}/>
|
||||||
|
|
66
web/src/components/ResetPassword.js
Normal file
66
web/src/components/ResetPassword.js
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import {Avatar, Link} from "@mui/material";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import api from "../app/Api";
|
||||||
|
import routes from "./routes";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import logo from "../img/ntfy2.svg";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import {NavLink} from "react-router-dom";
|
||||||
|
|
||||||
|
const ResetPassword = () => {
|
||||||
|
const handleSubmit = async (event) => {
|
||||||
|
//
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }}
|
||||||
|
src={logo}
|
||||||
|
variant="rounded"
|
||||||
|
/>
|
||||||
|
<Typography sx={{ typography: 'h6' }}>
|
||||||
|
Reset password
|
||||||
|
</Typography>
|
||||||
|
<Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
id="email"
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
sx={{mt: 2, mb: 2}}
|
||||||
|
>
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{mb: 4}}>
|
||||||
|
<NavLink to={routes.login} variant="body1">
|
||||||
|
< Return to sign in
|
||||||
|
</NavLink>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResetPassword;
|
|
@ -88,7 +88,7 @@ const Signup = () => {
|
||||||
</Box>
|
</Box>
|
||||||
<Typography sx={{mb: 4}}>
|
<Typography sx={{mb: 4}}>
|
||||||
<NavLink to={routes.login} variant="body1">
|
<NavLink to={routes.login} variant="body1">
|
||||||
Already have an account? Sign in
|
Already have an account? Sign in!
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -6,7 +6,9 @@ const routes = {
|
||||||
pricing: "/pricing",
|
pricing: "/pricing",
|
||||||
login: "/login",
|
login: "/login",
|
||||||
signup: "/signup",
|
signup: "/signup",
|
||||||
|
resetPassword: "/reset-password",
|
||||||
app: config.appRoot,
|
app: config.appRoot,
|
||||||
|
account: "/account",
|
||||||
settings: "/settings",
|
settings: "/settings",
|
||||||
subscription: "/:topic",
|
subscription: "/:topic",
|
||||||
subscriptionExternal: "/:baseUrl/:topic",
|
subscriptionExternal: "/:baseUrl/:topic",
|
||||||
|
|
Loading…
Reference in a new issue