ntfy/web/src/components/Account.js

436 lines
18 KiB
JavaScript
Raw Normal View History

2022-12-16 04:07:04 +01:00
import * as React from 'react';
2023-01-10 02:37:13 +01:00
import {useContext, useState} from 'react';
2023-01-16 05:29:46 +01:00
import {Alert, LinearProgress, Stack, useMediaQuery} from "@mui/material";
2022-12-17 19:49:32 +01:00
import Tooltip from '@mui/material/Tooltip';
2022-12-16 04:07:04 +01:00
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";
2022-12-17 19:49:32 +01:00
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
2022-12-16 04:07:04 +01:00
import theme from "./theme";
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 routes from "./routes";
2022-12-17 19:49:32 +01:00
import IconButton from "@mui/material/IconButton";
2023-01-16 05:29:46 +01:00
import {formatBytes, formatShortDateTime} from "../app/utils";
2022-12-25 17:59:44 +01:00
import accountApi, {UnauthorizedError} from "../app/AccountApi";
2023-01-05 21:20:44 +01:00
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
2022-12-29 14:20:53 +01:00
import {Pref, PrefGroup} from "./Pref";
2023-01-05 21:20:44 +01:00
import db from "../app/db";
2023-01-09 21:40:46 +01:00
import i18n from "i18next";
import humanizeDuration from "humanize-duration";
import UpgradeDialog from "./UpgradeDialog";
import CelebrationIcon from "@mui/icons-material/Celebration";
2023-01-10 02:37:13 +01:00
import {AccountContext} from "./App";
2023-01-16 05:29:46 +01:00
import {Warning, WarningAmber} from "@mui/icons-material";
2022-12-16 04:07:04 +01:00
const Account = () => {
2022-12-19 15:59:32 +01:00
if (!session.exists()) {
window.location.href = routes.app;
return <></>;
}
2022-12-16 04:07:04 +01:00
return (
<Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
<Stack spacing={3}>
<Basics/>
2022-12-17 19:49:32 +01:00
<Stats/>
<Delete/>
2022-12-16 04:07:04 +01:00
</Stack>
</Container>
);
};
const Basics = () => {
const { t } = useTranslation();
return (
2022-12-29 08:32:05 +01:00
<Card sx={{p: 3}} aria-label={t("account_basics_title")}>
2022-12-16 04:07:04 +01:00
<Typography variant="h5" sx={{marginBottom: 2}}>
2022-12-29 08:32:05 +01:00
{t("account_basics_title")}
2022-12-16 04:07:04 +01:00
</Typography>
<PrefGroup>
2022-12-17 19:49:32 +01:00
<Username/>
2022-12-16 04:07:04 +01:00
<ChangePassword/>
2022-12-17 19:49:32 +01:00
</PrefGroup>
</Card>
);
};
const Username = () => {
const { t } = useTranslation();
2023-01-10 02:37:13 +01:00
const { account } = useContext(AccountContext);
2022-12-29 14:20:53 +01:00
const labelId = "prefUsername";
2022-12-17 19:49:32 +01:00
return (
2022-12-29 14:20:53 +01:00
<Pref labelId={labelId} title={t("account_basics_username_title")} description={t("account_basics_username_description")}>
<div aria-labelledby={labelId}>
2022-12-17 19:49:32 +01:00
{session.username()}
{account?.role === "admin"
2022-12-29 08:32:05 +01:00
? <>{" "}<Tooltip title={t("account_basics_username_admin_tooltip")}><span style={{cursor: "default"}}>👑</span></Tooltip></>
2022-12-17 19:49:32 +01:00
: ""}
</div>
</Pref>
)
};
2022-12-16 04:07:04 +01:00
const ChangePassword = () => {
const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
2022-12-29 14:20:53 +01:00
const labelId = "prefChangePassword";
2022-12-29 08:32:05 +01:00
2022-12-16 04:07:04 +01:00
const handleDialogOpen = () => {
setDialogKey(prev => prev+1);
setDialogOpen(true);
};
2022-12-29 08:32:05 +01:00
2022-12-16 04:07:04 +01:00
const handleDialogCancel = () => {
setDialogOpen(false);
};
2022-12-29 08:32:05 +01:00
2022-12-16 04:07:04 +01:00
const handleDialogSubmit = async (newPassword) => {
try {
2022-12-25 17:59:44 +01:00
await accountApi.changePassword(newPassword);
2022-12-16 04:07:04 +01:00
setDialogOpen(false);
console.debug(`[Account] Password changed`);
} catch (e) {
console.log(`[Account] Error changing password`, e);
if ((e instanceof UnauthorizedError)) {
session.resetAndRedirect(routes.login);
}
2022-12-16 04:07:04 +01:00
// TODO show error
}
};
2022-12-29 08:32:05 +01:00
2022-12-16 04:07:04 +01:00
return (
2022-12-29 14:20:53 +01:00
<Pref labelId={labelId} title={t("account_basics_password_title")} description={t("account_basics_password_description")}>
<div aria-labelledby={labelId}>
2022-12-17 19:49:32 +01:00
<Typography color="gray" sx={{float: "left", fontSize: "0.7rem", lineHeight: "3.5"}}></Typography>
2022-12-29 08:32:05 +01:00
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}>
2022-12-17 19:49:32 +01:00
<EditIcon/>
</IconButton>
</div>
2022-12-16 04:07:04 +01:00
<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}>
2022-12-29 08:32:05 +01:00
<DialogTitle>{t("account_basics_password_dialog_title")}</DialogTitle>
2022-12-16 04:07:04 +01:00
<DialogContent>
<TextField
margin="dense"
id="new-password"
2022-12-29 08:32:05 +01:00
label={t("account_basics_password_dialog_new_password_label")}
aria-label={t("account_basics_password_dialog_new_password_label")}
2022-12-16 04:07:04 +01:00
type="password"
value={newPassword}
onChange={ev => setNewPassword(ev.target.value)}
fullWidth
variant="standard"
/>
<TextField
margin="dense"
id="confirm"
2022-12-29 08:32:05 +01:00
label={t("account_basics_password_dialog_confirm_password_label")}
aria-label={t("account_basics_password_dialog_confirm_password_label")}
2022-12-16 04:07:04 +01:00
type="password"
value={confirmPassword}
onChange={ev => setConfirmPassword(ev.target.value)}
fullWidth
variant="standard"
/>
</DialogContent>
<DialogActions>
2022-12-29 08:32:05 +01:00
<Button onClick={props.onCancel}>{t("account_basics_password_dialog_button_cancel")}</Button>
<Button onClick={() => props.onSubmit(newPassword)} disabled={!changeButtonEnabled}>{t("account_basics_password_dialog_button_submit")}</Button>
2022-12-16 04:07:04 +01:00
</DialogActions>
</Dialog>
);
};
2022-12-29 08:32:05 +01:00
const Stats = () => {
const { t } = useTranslation();
2023-01-10 02:37:13 +01:00
const { account } = useContext(AccountContext);
2023-01-09 21:40:46 +01:00
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
2023-01-14 12:43:44 +01:00
2022-12-29 08:32:05 +01:00
if (!account) {
return <></>;
}
2023-01-14 12:43:44 +01:00
const normalize = (value, max) => {
return Math.min(value / max * 100, 100);
};
const handleManageBilling = async () => {
try {
const response = await accountApi.createBillingPortalSession();
2023-01-16 05:29:46 +01:00
window.open(response.redirect_url, "billing_portal");
2023-01-14 12:43:44 +01:00
} catch (e) {
console.log(`[Account] Error changing password`, e);
if ((e instanceof UnauthorizedError)) {
session.resetAndRedirect(routes.login);
}
// TODO show error
}
};
2022-12-29 08:32:05 +01:00
return (
<Card sx={{p: 3}} aria-label={t("account_usage_title")}>
<Typography variant="h5" sx={{marginBottom: 2}}>
{t("account_usage_title")}
</Typography>
<PrefGroup>
2023-01-16 05:29:46 +01:00
<Pref
alignTop={account.billing?.status === "past_due"}
title={t("account_usage_tier_title")}
>
2022-12-29 08:32:05 +01:00
<div>
2023-01-09 21:40:46 +01:00
{account.role === "admin" &&
<>
{t("account_usage_tier_admin")}
{" "}{account.tier ? `(with ${account.tier.name} tier)` : `(no tier)`}
</>
}
2023-01-09 23:56:51 +01:00
{account.role === "user" && account.tier && account.tier.name}
{account.role === "user" && !account.tier && t("account_usage_tier_none")}
2023-01-09 21:40:46 +01:00
{config.enable_payments && account.role === "user" && (!account.tier || !account.tier.paid) &&
<Button
variant="outlined"
size="small"
startIcon={<CelebrationIcon sx={{ color: "#55b86e" }}/>}
onClick={() => setUpgradeDialogOpen(true)}
sx={{ml: 1}}
>{t("account_usage_tier_upgrade_button")}</Button>
}
{config.enable_payments && account.role === "user" && account.tier?.paid &&
2023-01-16 05:29:46 +01:00
<Button
variant="outlined"
size="small"
onClick={() => setUpgradeDialogOpen(true)}
sx={{ml: 1}}
>{t("account_usage_tier_change_button")}</Button>
}
{config.enable_payments && account.role === "user" && account.billing?.customer &&
<Button
variant="outlined"
size="small"
onClick={handleManageBilling}
sx={{ml: 1}}
>{t("account_usage_manage_billing_button")}</Button>
2023-01-09 21:40:46 +01:00
}
<UpgradeDialog
open={upgradeDialogOpen}
onCancel={() => setUpgradeDialogOpen(false)}
/>
2022-12-29 08:32:05 +01:00
</div>
2023-01-16 05:29:46 +01:00
{account.billing?.status === "past_due" &&
<Alert severity="error" sx={{mt: 1}}>{t("account_usage_tier_payment_overdue")}</Alert>
}
2022-12-29 08:32:05 +01:00
</Pref>
2023-01-09 21:40:46 +01:00
{account.role !== "admin" &&
<Pref title={t("account_usage_reservations_title")}>
{account.limits.reservations > 0 &&
<>
<div>
<Typography variant="body2"
sx={{float: "left"}}>{account.stats.reservations}</Typography>
<Typography variant="body2"
sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")}</Typography>
</div>
<LinearProgress
variant="determinate"
value={account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
/>
</>
}
{account.limits.reservations === 0 &&
<em>No reserved topics for this account</em>
}
</Pref>
}
2023-01-05 21:20:44 +01:00
<Pref title={
<>
{t("account_usage_messages_title")}
<Tooltip title={t("account_usage_limits_reset_daily")}><span><InfoIcon/></span></Tooltip>
</>
}>
2022-12-29 08:32:05 +01:00
<div>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.messages}</Typography>
2023-01-09 21:40:46 +01:00
<Typography variant="body2" sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}</Typography>
2022-12-29 08:32:05 +01:00
</div>
2023-01-05 02:34:22 +01:00
<LinearProgress
variant="determinate"
2023-01-09 21:40:46 +01:00
value={account.role === "user" ? normalize(account.stats.messages, account.limits.messages) : 100}
2023-01-05 02:34:22 +01:00
/>
2022-12-29 08:32:05 +01:00
</Pref>
2023-01-05 21:20:44 +01:00
<Pref title={
<>
{t("account_usage_emails_title")}
<Tooltip title={t("account_usage_limits_reset_daily")}><span><InfoIcon/></span></Tooltip>
</>
}>
2022-12-29 08:32:05 +01:00
<div>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.emails}</Typography>
2023-01-10 02:37:13 +01:00
<Typography variant="body2" sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", { limit: account.limits.emails }) : t("account_usage_unlimited")}</Typography>
2022-12-29 08:32:05 +01:00
</div>
2023-01-05 02:34:22 +01:00
<LinearProgress
variant="determinate"
2023-01-10 02:37:13 +01:00
value={account.role === "user" ? normalize(account.stats.emails, account.limits.emails) : 100}
2023-01-05 02:34:22 +01:00
/>
2022-12-29 08:32:05 +01:00
</Pref>
2023-01-09 21:40:46 +01:00
<Pref
alignTop
title={t("account_usage_attachment_storage_title")}
description={t("account_usage_attachment_storage_description", {
filesize: formatBytes(account.limits.attachment_file_size),
expiry: humanizeDuration(account.limits.attachment_expiry_duration * 1000, {
language: i18n.language,
fallbacks: ["en"]
})
})}
>
2022-12-29 08:32:05 +01:00
<div>
<Typography variant="body2" sx={{float: "left"}}>{formatBytes(account.stats.attachment_total_size)}</Typography>
2023-01-10 02:37:13 +01:00
<Typography variant="body2" sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", { limit: formatBytes(account.limits.attachment_total_size) }) : t("account_usage_unlimited")}</Typography>
2022-12-29 08:32:05 +01:00
</div>
2023-01-05 02:34:22 +01:00
<LinearProgress
variant="determinate"
2023-01-10 02:37:13 +01:00
value={account.role === "user" ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100}
2023-01-05 02:34:22 +01:00
/>
2022-12-29 08:32:05 +01:00
</Pref>
</PrefGroup>
2023-01-10 02:37:13 +01:00
{account.role === "user" && account.limits.basis === "ip" &&
2022-12-29 08:32:05 +01:00
<Typography variant="body1">
2023-01-09 21:40:46 +01:00
{t("account_usage_basis_ip_description")}
2022-12-29 08:32:05 +01:00
</Typography>
}
</Card>
);
};
2023-01-05 21:20:44 +01:00
const InfoIcon = () => {
return (
<InfoOutlinedIcon sx={{
verticalAlign: "bottom",
width: "18px",
marginLeft: "4px",
color: "gray"
}}/>
);
}
2022-12-29 08:32:05 +01:00
const Delete = () => {
const { t } = useTranslation();
return (
<Card sx={{p: 3}} aria-label={t("account_delete_title")}>
<Typography variant="h5" sx={{marginBottom: 2}}>
{t("account_delete_title")}
</Typography>
<PrefGroup>
<DeleteAccount/>
</PrefGroup>
</Card>
);
};
2022-12-16 04:07:04 +01:00
const DeleteAccount = () => {
const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
2022-12-29 08:32:05 +01:00
2022-12-16 04:07:04 +01:00
const handleDialogOpen = () => {
setDialogKey(prev => prev+1);
setDialogOpen(true);
};
2022-12-29 08:32:05 +01:00
2022-12-16 04:07:04 +01:00
const handleDialogCancel = () => {
setDialogOpen(false);
};
2022-12-29 08:32:05 +01:00
const handleDialogSubmit = async () => {
2022-12-16 04:07:04 +01:00
try {
2022-12-25 17:59:44 +01:00
await accountApi.delete();
2022-12-28 21:51:09 +01:00
await db.delete();
2022-12-16 04:07:04 +01:00
setDialogOpen(false);
console.debug(`[Account] Account deleted`);
session.resetAndRedirect(routes.app);
2022-12-16 04:07:04 +01:00
} catch (e) {
console.log(`[Account] Error deleting account`, e);
if ((e instanceof UnauthorizedError)) {
session.resetAndRedirect(routes.login);
}
2022-12-16 04:07:04 +01:00
// TODO show error
}
};
2022-12-29 08:32:05 +01:00
2022-12-16 04:07:04 +01:00
return (
2022-12-29 08:32:05 +01:00
<Pref title={t("account_delete_title")} description={t("account_delete_description")}>
2022-12-17 19:49:32 +01:00
<div>
<Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} onClick={handleDialogOpen}>
2022-12-29 08:32:05 +01:00
{t("account_delete_title")}
2022-12-17 19:49:32 +01:00
</Button>
</div>
2022-12-16 04:07:04 +01:00
<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}>
2022-12-29 08:32:05 +01:00
<DialogTitle>{t("account_delete_title")}</DialogTitle>
2022-12-16 04:07:04 +01:00
<DialogContent>
<Typography variant="body1">
2022-12-29 08:32:05 +01:00
{t("account_delete_dialog_description", { username: session.username()})}
2022-12-16 04:07:04 +01:00
</Typography>
<TextField
margin="dense"
id="account-delete-confirm"
2022-12-29 08:32:05 +01:00
label={t("account_delete_dialog_label", { username: session.username()})}
aria-label={t("account_delete_dialog_label", { username: session.username()})}
2022-12-16 04:07:04 +01:00
type="text"
value={username}
onChange={ev => setUsername(ev.target.value)}
fullWidth
variant="standard"
/>
</DialogContent>
<DialogActions>
2022-12-29 08:32:05 +01:00
<Button onClick={props.onCancel}>{t("account_delete_dialog_button_cancel")}</Button>
<Button onClick={props.onSubmit} color="error" disabled={!buttonEnabled}>{t("account_delete_dialog_button_submit")}</Button>
2022-12-16 04:07:04 +01:00
</DialogActions>
</Dialog>
);
};
export default Account;