Change password, delete account, etc.

This commit is contained in:
binwiederhier 2022-12-15 22:07:04 -05:00
parent 8ff168283c
commit 81a8efcca3
14 changed files with 628 additions and 214 deletions

View file

@ -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

View file

@ -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)

236
server/server_account.go Normal file
View 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
}

View file

@ -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",

View file

@ -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}`);

View file

@ -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`;

View 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;

View file

@ -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' }}
>
<MenuItem>
<MenuItem onClick={() => navigate(routes.account)}>
<ListItemIcon>
<Person />
</ListItemIcon>
<b>{session.username()}</b>
</MenuItem>
<Divider />
<MenuItem>
<MenuItem onClick={() => navigate(routes.settings)}>
<ListItemIcon>
<Settings fontSize="small" />
</ListItemIcon>

View file

@ -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 = () => {
<Route path={routes.pricing} element={<Pricing/>}/>
<Route path={routes.login} element={<Login/>}/>
<Route path={routes.signup} element={<Signup/>}/>
<Route path={routes.resetPassword} element={<ResetPassword/>}/>
<Route element={<Layout/>}>
<Route path={routes.app} element={<AllSubscriptions/>}/>
<Route path={routes.account} element={<Account/>}/>
<Route path={routes.settings} element={<Preferences/>}/>
<Route path={routes.subscription} element={<SingleSubscription/>}/>
<Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/>

View file

@ -74,8 +74,8 @@ const Login = () => {
Sign in
</Button>
<Box sx={{width: "100%"}}>
<NavLink to="#" variant="body1" sx={{float: "left"}}>Reset password</NavLink>
<div style={{float: "right"}}><NavLink to={routes.signup} variant="body1">Sign Up</NavLink></div>
<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>
</Box>
</Box>
</Box>

View file

@ -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) => {
/>
<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}>
<ListItemIcon><SettingsIcon/></ListItemIcon>
<ListItemText primary={t("nav_button_settings")}/>

View 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">
&lt; Return to sign in
</NavLink>
</Typography>
</Box>
);
}
export default ResetPassword;

View file

@ -88,7 +88,7 @@ const Signup = () => {
</Box>
<Typography sx={{mb: 4}}>
<NavLink to={routes.login} variant="body1">
Already have an account? Sign in
Already have an account? Sign in!
</NavLink>
</Typography>
</Box>

View file

@ -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",