diff --git a/auth/auth_sqlite.go b/auth/auth_sqlite.go index 870875ab..c007dd7d 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 3196a8ab..120a8f99 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 00000000..7a8f7cca --- /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 6b831b79..67337a31 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 05a597bd..36a81545 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 5a6c8296..66c2b48d 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 00000000..1c27e1e5 --- /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 ( + + + + + + + ); +}; + +const Basics = () => { + const { t } = useTranslation(); + return ( + + + Account + + + {session.username()} + + + + + ); +}; + +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 ( + + + + + ) +}; + +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 ( + + Change password + + setNewPassword(ev.target.value)} + fullWidth + variant="standard" + /> + setConfirmPassword(ev.target.value)} + fullWidth + variant="standard" + /> + + + + + + + ); +}; + +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 ( + + + + + ) +}; + +const DeleteAccountDialog = (props) => { + const { t } = useTranslation(); + const [username, setUsername] = useState(""); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const buttonEnabled = username === session.username(); + return ( + + {t("Delete account")} + + + {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.")} + + setUsername(ev.target.value)} + fullWidth + variant="standard" + /> + + + + + + + ); +}; + + +// FIXME duplicate code + +const PrefGroup = (props) => { + return ( +
+ {props.children} +
+ ) +}; + +const Pref = (props) => { + return ( +
+
+
{props.title}
+ {props.description &&
{props.description}
} +
+
+ {props.children} +
+
+ ); +}; + +export default Account; diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js index 87483c24..97025002 100644 --- a/web/src/components/ActionBar.js +++ b/web/src/components/ActionBar.js @@ -302,7 +302,7 @@ const ProfileIcon = (props) => { display: 'block', position: 'absolute', top: 0, - right: 14, + right: 19, width: 10, height: 10, bgcolor: 'background.paper', @@ -314,14 +314,14 @@ const ProfileIcon = (props) => { transformOrigin={{ horizontal: 'right', vertical: 'top' }} anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} > - + navigate(routes.account)}> {session.username()} - + navigate(routes.settings)}> diff --git a/web/src/components/App.js b/web/src/components/App.js index d3b98969..f0f0b647 100644 --- a/web/src/components/App.js +++ b/web/src/components/App.js @@ -31,6 +31,8 @@ import prefs from "../app/Prefs"; import session from "../app/Session"; import Pricing from "./Pricing"; import Signup from "./Signup"; +import Account from "./Account"; +import ResetPassword from "./ResetPassword"; // TODO races when two tabs are open // TODO investigate service workers @@ -47,8 +49,10 @@ const App = () => { }/> }/> }/> + }/> }> }/> + }/> }/> }/> }/> diff --git a/web/src/components/Login.js b/web/src/components/Login.js index 5d5fce2c..e2ab80b5 100644 --- a/web/src/components/Login.js +++ b/web/src/components/Login.js @@ -74,8 +74,8 @@ const Login = () => { Sign in - Reset password -
Sign Up
+
Reset password
+
Sign up
diff --git a/web/src/components/Navigation.js b/web/src/components/Navigation.js index 8a22e344..5cef785c 100644 --- a/web/src/components/Navigation.js +++ b/web/src/components/Navigation.js @@ -4,6 +4,7 @@ import {useState} from "react"; import ListItemButton from "@mui/material/ListItemButton"; import ListItemIcon from "@mui/material/ListItemIcon"; import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; +import Person from "@mui/icons-material/Person"; import ListItemText from "@mui/material/ListItemText"; import Toolbar from "@mui/material/Toolbar"; import Divider from "@mui/material/Divider"; @@ -25,6 +26,7 @@ import notifier from "../app/Notifier"; import config from "../app/config"; import ArticleIcon from '@mui/icons-material/Article'; import {Trans, useTranslation} from "react-i18next"; +import session from "../app/Session"; const navWidth = 280; @@ -121,6 +123,11 @@ const NavList = (props) => { /> } + {session.exists() && + navigate(routes.account)} selected={location.pathname === routes.account}> + + + } navigate(routes.settings)} selected={location.pathname === routes.settings}> diff --git a/web/src/components/ResetPassword.js b/web/src/components/ResetPassword.js new file mode 100644 index 00000000..9d25e624 --- /dev/null +++ b/web/src/components/ResetPassword.js @@ -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 ( + + + + Reset password + + + + + + + + < Return to sign in + + + + ); +} + +export default ResetPassword; diff --git a/web/src/components/Signup.js b/web/src/components/Signup.js index 990423f5..624a35c0 100644 --- a/web/src/components/Signup.js +++ b/web/src/components/Signup.js @@ -88,7 +88,7 @@ const Signup = () => { - Already have an account? Sign in + Already have an account? Sign in! diff --git a/web/src/components/routes.js b/web/src/components/routes.js index b88d8f99..4802a602 100644 --- a/web/src/components/routes.js +++ b/web/src/components/routes.js @@ -6,7 +6,9 @@ const routes = { pricing: "/pricing", login: "/login", signup: "/signup", + resetPassword: "/reset-password", app: config.appRoot, + account: "/account", settings: "/settings", subscription: "/:topic", subscriptionExternal: "/:baseUrl/:topic",