diff --git a/auth/auth_sqlite.go b/auth/auth_sqlite.go
index 870875a..c007dd7 100644
--- a/auth/auth_sqlite.go
+++ b/auth/auth_sqlite.go
@@ -81,11 +81,12 @@ const (
// Manager-related queries
const (
- insertUserQuery = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)`
- selectUsernamesQuery = `SELECT user FROM user ORDER BY role, user`
- updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
- updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
- deleteUserQuery = `DELETE FROM user WHERE user = ?`
+ insertUserQuery = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)`
+ selectUsernamesQuery = `SELECT user FROM user ORDER BY role, user`
+ updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
+ updateUserRoleQuery = `UPDATE user SET role = ? 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 = ?), ?, ?, ?)`
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 = ?)`
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 = ?), ?, ?)`
- deleteTokenQuery = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?`
- updateUserSettingsQuery = `UPDATE user SET settings = ? 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 = ?`
+ deleteUserTokenQuery = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?)`
)
// Schema management queries
@@ -250,10 +251,13 @@ func (a *SQLiteAuthManager) RemoveUser(username string) error {
if !AllowedUsername(username) {
return ErrInvalidArgument
}
- if _, err := a.db.Exec(deleteUserQuery, username); err != nil {
+ if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil {
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 nil
diff --git a/server/server.go b/server/server.go
index 3196a8a..120a8f9 100644
--- a/server/server.go
+++ b/server/server.go
@@ -39,13 +39,13 @@ import (
expire tokens
auto-refresh tokens from UI
reserve topics
+ rate limit for signup (2 per 24h)
+ handle invalid session token
+ update disallowed topics
Pages:
- Home
- - Signup
- - Sign-in
- Password reset
- Pricing
- - change password
- change email
-
@@ -92,6 +92,7 @@ var (
userStatsPath = "/user/stats" // FIXME get rid of this in favor of /user/account
accountPath = "/v1/account"
accountTokenPath = "/v1/account/token"
+ accountPasswordPath = "/v1/account/password"
accountSettingsPath = "/v1/account/settings"
accountSubscriptionPath = "/v1/account/subscription"
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 {
return s.handleUserStats(w, r, v)
} 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 {
return s.handleAccountTokenGet(w, r, v)
} 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 {
return s.handleAccountSettingsGet(w, r, v)
} 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 {
return s.handleAccountSubscriptionAdd(w, r, v)
} 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
}
-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 {
r.URL.Path = webSiteDir + r.URL.Path
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
diff --git a/server/server_account.go b/server/server_account.go
new file mode 100644
index 0000000..7a8f7cc
--- /dev/null
+++ b/server/server_account.go
@@ -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
+}
diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json
index 6b831b7..67337a3 100644
--- a/web/public/static/langs/en.json
+++ b/web/public/static/langs/en.json
@@ -14,6 +14,7 @@
"message_bar_publish": "Publish message",
"nav_topics_title": "Subscribed topics",
"nav_button_all_notifications": "All notifications",
+ "nav_button_account": "Account",
"nav_button_settings": "Settings",
"nav_button_documentation": "Documentation",
"nav_button_publish_message": "Publish notification",
diff --git a/web/src/app/Api.js b/web/src/app/Api.js
index 05a597b..36a8154 100644
--- a/web/src/app/Api.js
+++ b/web/src/app/Api.js
@@ -8,7 +8,7 @@ import {
topicUrlJsonPollWithSince,
accountSettingsUrl,
accountTokenUrl,
- userStatsUrl, accountSubscriptionUrl, accountSubscriptionSingleUrl, accountUrl
+ userStatsUrl, accountSubscriptionUrl, accountSubscriptionSingleUrl, accountUrl, accountPasswordUrl
} from "./utils";
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) {
const url = accountSettingsUrl(baseUrl);
console.log(`[Api] Fetching user account ${url}`);
diff --git a/web/src/app/utils.js b/web/src/app/utils.js
index 5a6c829..66c2b48 100644
--- a/web/src/app/utils.js
+++ b/web/src/app/utils.js
@@ -20,6 +20,7 @@ export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/aut
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
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 accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`;
export const accountSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/subscription`;
diff --git a/web/src/components/Account.js b/web/src/components/Account.js
new file mode 100644
index 0000000..1c27e1e
--- /dev/null
+++ b/web/src/components/Account.js
@@ -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 (
+