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

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