| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163 |
- import * as React from "react";
- import { useContext, useState } from "react";
- import {
- Alert,
- CardActions,
- CardContent,
- Chip,
- FormControl,
- FormControlLabel,
- LinearProgress,
- Link,
- Portal,
- Radio,
- RadioGroup,
- Select,
- Snackbar,
- Stack,
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableRow,
- useMediaQuery,
- Tooltip,
- Typography,
- Container,
- Card,
- Button,
- Dialog,
- DialogTitle,
- DialogContent,
- TextField,
- IconButton,
- MenuItem,
- DialogContentText,
- useTheme,
- } from "@mui/material";
- import EditIcon from "@mui/icons-material/Edit";
- import { Trans, useTranslation } from "react-i18next";
- import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
- import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
- import humanizeDuration from "humanize-duration";
- import CelebrationIcon from "@mui/icons-material/Celebration";
- import CloseIcon from "@mui/icons-material/Close";
- import { ContentCopy, Public } from "@mui/icons-material";
- import AddIcon from "@mui/icons-material/Add";
- import routes from "./routes";
- import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
- import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
- import { Pref, PrefGroup } from "./Pref";
- import db from "../app/db";
- import UpgradeDialog from "./UpgradeDialog";
- import { AccountContext } from "./App";
- import DialogFooter from "./DialogFooter";
- import { Paragraph } from "./styles";
- import { IncorrectPasswordError, UnauthorizedError } from "../app/errors";
- import { ProChip } from "./SubscriptionPopup";
- import session from "../app/Session";
- const Account = () => {
- if (!session.exists()) {
- window.location.href = routes.app;
- return <></>;
- }
- return (
- <Container maxWidth="md" sx={{ marginTop: 3, marginBottom: 3 }}>
- <Stack spacing={3}>
- <Basics />
- <Stats />
- <Tokens />
- <Delete />
- </Stack>
- </Container>
- );
- };
- const Basics = () => {
- const { t } = useTranslation();
- return (
- <Card sx={{ p: 3 }} aria-label={t("account_basics_title")}>
- <Typography variant="h5" sx={{ marginBottom: 2 }}>
- {t("account_basics_title")}
- </Typography>
- <PrefGroup>
- <Username />
- <ChangePassword />
- <PhoneNumbers />
- <AccountType />
- </PrefGroup>
- </Card>
- );
- };
- const Username = () => {
- const { t } = useTranslation();
- const { account } = useContext(AccountContext);
- const labelId = "prefUsername";
- return (
- <Pref labelId={labelId} title={t("account_basics_username_title")} description={t("account_basics_username_description")}>
- <div aria-labelledby={labelId}>
- {session.username()}
- {account?.role === Role.ADMIN && (
- <>
- {" "}
- <Tooltip title={t("account_basics_username_admin_tooltip")}>
- <span style={{ cursor: "default" }}>👑</span>
- </Tooltip>
- </>
- )}
- </div>
- </Pref>
- );
- };
- const ChangePassword = () => {
- const { t } = useTranslation();
- const [dialogKey, setDialogKey] = useState(0);
- const [dialogOpen, setDialogOpen] = useState(false);
- const { account } = useContext(AccountContext);
- const labelId = "prefChangePassword";
- const handleDialogOpen = () => {
- setDialogKey((prev) => prev + 1);
- setDialogOpen(true);
- };
- const handleDialogClose = () => {
- setDialogOpen(false);
- };
- return (
- <Pref labelId={labelId} title={t("account_basics_password_title")} description={t("account_basics_password_description")}>
- <div aria-labelledby={labelId}>
- <Typography color="gray" sx={{ float: "left", fontSize: "0.7rem", lineHeight: "3.5" }}>
- ⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤
- </Typography>
- {!account?.provisioned ? (
- <IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}>
- <EditIcon />
- </IconButton>
- ) : (
- <Tooltip title={t("account_basics_cannot_edit_or_delete_provisioned_user")}>
- <span>
- <IconButton disabled>
- <EditIcon />
- </IconButton>
- </span>
- </Tooltip>
- )}
- </div>
- <ChangePasswordDialog key={`changePasswordDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
- </Pref>
- );
- };
- const ChangePasswordDialog = (props) => {
- const theme = useTheme();
- const { t } = useTranslation();
- const [error, setError] = useState("");
- const [currentPassword, setCurrentPassword] = useState("");
- const [newPassword, setNewPassword] = useState("");
- const [confirmPassword, setConfirmPassword] = useState("");
- const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
- const handleDialogSubmit = async () => {
- try {
- console.debug(`[Account] Changing password`);
- await accountApi.changePassword(currentPassword, newPassword);
- props.onClose();
- } catch (e) {
- console.log(`[Account] Error changing password`, e);
- if (e instanceof IncorrectPasswordError) {
- setError(t("account_basics_password_dialog_current_password_incorrect"));
- } else if (e instanceof UnauthorizedError) {
- await session.resetAndRedirect(routes.login);
- } else {
- setError(e.message);
- }
- }
- };
- return (
- <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
- <DialogTitle>{t("account_basics_password_dialog_title")}</DialogTitle>
- <DialogContent>
- <TextField
- margin="dense"
- id="current-password"
- label={t("account_basics_password_dialog_current_password_label")}
- aria-label={t("account_basics_password_dialog_current_password_label")}
- type="password"
- value={currentPassword}
- onChange={(ev) => setCurrentPassword(ev.target.value)}
- fullWidth
- variant="standard"
- />
- <TextField
- margin="dense"
- id="new-password"
- label={t("account_basics_password_dialog_new_password_label")}
- aria-label={t("account_basics_password_dialog_new_password_label")}
- type="password"
- value={newPassword}
- onChange={(ev) => setNewPassword(ev.target.value)}
- fullWidth
- variant="standard"
- />
- <TextField
- margin="dense"
- id="confirm"
- label={t("account_basics_password_dialog_confirm_password_label")}
- aria-label={t("account_basics_password_dialog_confirm_password_label")}
- type="password"
- value={confirmPassword}
- onChange={(ev) => setConfirmPassword(ev.target.value)}
- fullWidth
- variant="standard"
- />
- </DialogContent>
- <DialogFooter status={error}>
- <Button onClick={props.onClose}>{t("common_cancel")}</Button>
- <Button
- onClick={handleDialogSubmit}
- disabled={newPassword.length === 0 || currentPassword.length === 0 || newPassword !== confirmPassword}
- >
- {t("account_basics_password_dialog_button_submit")}
- </Button>
- </DialogFooter>
- </Dialog>
- );
- };
- const AccountType = () => {
- const { t, i18n } = useTranslation();
- const { account } = useContext(AccountContext);
- const [upgradeDialogKey, setUpgradeDialogKey] = useState(0);
- const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
- const [showPortalError, setShowPortalError] = useState(false);
- if (!account) {
- return <></>;
- }
- const handleUpgradeClick = () => {
- setUpgradeDialogKey((k) => k + 1);
- setUpgradeDialogOpen(true);
- };
- const handleManageBilling = async () => {
- try {
- const response = await accountApi.createBillingPortalSession();
- window.open(response.redirect_url, "billing_portal");
- } catch (e) {
- console.log(`[Account] Error opening billing portal`, e);
- if (e instanceof UnauthorizedError) {
- await session.resetAndRedirect(routes.login);
- } else {
- setShowPortalError(true);
- }
- }
- };
- let accountType;
- if (account.role === Role.ADMIN) {
- const tierSuffix = account.tier
- ? t("account_basics_tier_admin_suffix_with_tier", {
- tier: account.tier.name,
- })
- : t("account_basics_tier_admin_suffix_no_tier");
- accountType = `${t("account_basics_tier_admin")} ${tierSuffix}`;
- } else if (!account.tier) {
- accountType = config.enable_payments ? t("account_basics_tier_free") : t("account_basics_tier_basic");
- } else {
- accountType = account.tier.name;
- if (account.billing?.interval === SubscriptionInterval.MONTH) {
- accountType += ` (${t("account_basics_tier_interval_monthly")})`;
- } else if (account.billing?.interval === SubscriptionInterval.YEAR) {
- accountType += ` (${t("account_basics_tier_interval_yearly")})`;
- }
- }
- return (
- <Pref
- alignTop={account.billing?.status === SubscriptionStatus.PAST_DUE || account.billing?.cancel_at > 0}
- title={t("account_basics_tier_title")}
- description={t("account_basics_tier_description")}
- >
- <div>
- {accountType}
- {account.billing?.paid_until && !account.billing?.cancel_at && (
- <Tooltip
- title={t("account_basics_tier_paid_until", {
- date: formatShortDate(account.billing?.paid_until, i18n.language),
- })}
- >
- <span>
- <InfoIcon />
- </span>
- </Tooltip>
- )}
- {config.enable_payments && account.role === Role.USER && !account.billing?.subscription && (
- <Button
- variant="outlined"
- size="small"
- startIcon={<CelebrationIcon sx={{ color: "#55b86e" }} />}
- onClick={handleUpgradeClick}
- sx={{ ml: 1 }}
- >
- {t("account_basics_tier_upgrade_button")}
- </Button>
- )}
- {config.enable_payments && account.role === Role.USER && account.billing?.subscription && (
- <Button variant="outlined" size="small" onClick={handleUpgradeClick} sx={{ ml: 1 }}>
- {t("account_basics_tier_change_button")}
- </Button>
- )}
- {config.enable_payments && account.role === Role.USER && account.billing?.customer && (
- <Button variant="outlined" size="small" onClick={handleManageBilling} sx={{ ml: 1 }}>
- {t("account_basics_tier_manage_billing_button")}
- </Button>
- )}
- {config.enable_payments && (
- <UpgradeDialog
- key={`upgradeDialogFromAccount${upgradeDialogKey}`}
- open={upgradeDialogOpen}
- onCancel={() => setUpgradeDialogOpen(false)}
- />
- )}
- </div>
- {account.billing?.status === SubscriptionStatus.PAST_DUE && (
- <Alert severity="error" sx={{ mt: 1 }}>
- {t("account_basics_tier_payment_overdue")}
- </Alert>
- )}
- {account.billing?.cancel_at > 0 && (
- <Alert severity="warning" sx={{ mt: 1 }}>
- {t("account_basics_tier_canceled_subscription", {
- date: formatShortDate(account.billing.cancel_at, i18n.language),
- })}
- </Alert>
- )}
- <Portal>
- <Snackbar
- open={showPortalError}
- autoHideDuration={3000}
- onClose={() => setShowPortalError(false)}
- message={t("account_usage_cannot_create_portal_session")}
- />
- </Portal>
- </Pref>
- );
- };
- const PhoneNumbers = () => {
- const { t } = useTranslation();
- const { account } = useContext(AccountContext);
- const [dialogKey, setDialogKey] = useState(0);
- const [dialogOpen, setDialogOpen] = useState(false);
- const [snackOpen, setSnackOpen] = useState(false);
- const labelId = "prefPhoneNumbers";
- const handleDialogOpen = () => {
- setDialogKey((prev) => prev + 1);
- setDialogOpen(true);
- };
- const handleDialogClose = () => {
- setDialogOpen(false);
- };
- const handleCopy = (phoneNumber) => {
- navigator.clipboard.writeText(phoneNumber);
- setSnackOpen(true);
- };
- const handleDelete = async (phoneNumber) => {
- try {
- await accountApi.deletePhoneNumber(phoneNumber);
- } catch (e) {
- console.log(`[Account] Error deleting phone number`, e);
- if (e instanceof UnauthorizedError) {
- await session.resetAndRedirect(routes.login);
- }
- }
- };
- if (!config.enable_calls) {
- return null;
- }
- if (account?.limits.calls === 0) {
- return (
- <Pref
- title={
- <>
- {t("account_basics_phone_numbers_title")}
- {config.enable_payments && <ProChip />}
- </>
- }
- description={t("account_basics_phone_numbers_description")}
- >
- <em>{t("account_usage_calls_none")}</em>
- </Pref>
- );
- }
- return (
- <Pref labelId={labelId} title={t("account_basics_phone_numbers_title")} description={t("account_basics_phone_numbers_description")}>
- <div aria-labelledby={labelId}>
- {account?.phone_numbers?.map((phoneNumber) => (
- <Chip
- label={
- <Tooltip title={t("common_copy_to_clipboard")}>
- <span>{phoneNumber}</span>
- </Tooltip>
- }
- variant="outlined"
- onClick={() => handleCopy(phoneNumber)}
- onDelete={() => handleDelete(phoneNumber)}
- />
- ))}
- {!account?.phone_numbers && <em>{t("account_basics_phone_numbers_no_phone_numbers_yet")}</em>}
- <IconButton onClick={handleDialogOpen}>
- <AddIcon />
- </IconButton>
- </div>
- <AddPhoneNumberDialog key={`addPhoneNumberDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
- <Portal>
- <Snackbar
- open={snackOpen}
- autoHideDuration={3000}
- onClose={() => setSnackOpen(false)}
- message={t("account_basics_phone_numbers_copied_to_clipboard")}
- />
- </Portal>
- </Pref>
- );
- };
- const AddPhoneNumberDialog = (props) => {
- const theme = useTheme();
- const { t } = useTranslation();
- const [error, setError] = useState("");
- const [phoneNumber, setPhoneNumber] = useState("");
- const [channel, setChannel] = useState("sms");
- const [code, setCode] = useState("");
- const [sending, setSending] = useState(false);
- const [verificationCodeSent, setVerificationCodeSent] = useState(false);
- const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
- const verifyPhone = async () => {
- try {
- setSending(true);
- await accountApi.verifyPhoneNumber(phoneNumber, channel);
- setVerificationCodeSent(true);
- } catch (e) {
- console.log(`[Account] Error sending verification`, e);
- if (e instanceof UnauthorizedError) {
- await session.resetAndRedirect(routes.login);
- } else {
- setError(e.message);
- }
- } finally {
- setSending(false);
- }
- };
- const checkVerifyPhone = async () => {
- try {
- setSending(true);
- await accountApi.addPhoneNumber(phoneNumber, code);
- props.onClose();
- } catch (e) {
- console.log(`[Account] Error confirming verification`, e);
- if (e instanceof UnauthorizedError) {
- await session.resetAndRedirect(routes.login);
- } else {
- setError(e.message);
- }
- } finally {
- setSending(false);
- }
- };
- const handleDialogSubmit = async () => {
- if (!verificationCodeSent) {
- await verifyPhone();
- } else {
- await checkVerifyPhone();
- }
- };
- const handleCancel = () => {
- if (verificationCodeSent) {
- setVerificationCodeSent(false);
- setCode("");
- } else {
- props.onClose();
- }
- };
- return (
- <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
- <DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle>
- <DialogContent>
- <DialogContentText>{t("account_basics_phone_numbers_dialog_description")}</DialogContentText>
- {!verificationCodeSent && (
- <div style={{ display: "flex" }}>
- <TextField
- margin="dense"
- label={t("account_basics_phone_numbers_dialog_number_label")}
- aria-label={t("account_basics_phone_numbers_dialog_number_label")}
- placeholder={t("account_basics_phone_numbers_dialog_number_placeholder")}
- type="tel"
- value={phoneNumber}
- onChange={(ev) => setPhoneNumber(ev.target.value)}
- inputProps={{ inputMode: "tel", pattern: "+[0-9]*" }}
- variant="standard"
- sx={{ flexGrow: 1 }}
- />
- <FormControl sx={{ flexWrap: "nowrap" }}>
- <RadioGroup row sx={{ flexGrow: 1, marginTop: "8px", marginLeft: "5px" }}>
- <FormControlLabel
- value="sms"
- control={<Radio checked={channel === "sms"} onChange={(e) => setChannel(e.target.value)} />}
- label={t("account_basics_phone_numbers_dialog_channel_sms")}
- />
- <FormControlLabel
- value="call"
- control={<Radio checked={channel === "call"} onChange={(e) => setChannel(e.target.value)} />}
- label={t("account_basics_phone_numbers_dialog_channel_call")}
- sx={{ marginRight: 0 }}
- />
- </RadioGroup>
- </FormControl>
- </div>
- )}
- {verificationCodeSent && (
- <TextField
- margin="dense"
- label={t("account_basics_phone_numbers_dialog_code_label")}
- aria-label={t("account_basics_phone_numbers_dialog_code_label")}
- placeholder={t("account_basics_phone_numbers_dialog_code_placeholder")}
- type="text"
- value={code}
- onChange={(ev) => setCode(ev.target.value)}
- fullWidth
- inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
- variant="standard"
- />
- )}
- </DialogContent>
- <DialogFooter status={error}>
- <Button onClick={handleCancel}>{verificationCodeSent ? t("common_back") : t("common_cancel")}</Button>
- <Button onClick={handleDialogSubmit} disabled={sending || !/^\+\d+$/.test(phoneNumber)}>
- {!verificationCodeSent && channel === "sms" && t("account_basics_phone_numbers_dialog_verify_button_sms")}
- {!verificationCodeSent && channel === "call" && t("account_basics_phone_numbers_dialog_verify_button_call")}
- {verificationCodeSent && t("account_basics_phone_numbers_dialog_check_verification_button")}
- </Button>
- </DialogFooter>
- </Dialog>
- );
- };
- const Stats = () => {
- const { t, i18n } = useTranslation();
- const { account } = useContext(AccountContext);
- if (!account) {
- return <></>;
- }
- const normalize = (value, max) => Math.min((value / max) * 100, 100);
- return (
- <Card sx={{ p: 3 }} aria-label={t("account_usage_title")}>
- <Typography variant="h5" sx={{ marginBottom: 2 }}>
- {t("account_usage_title")}
- </Typography>
- <PrefGroup>
- {(account.role === Role.ADMIN || account.limits.reservations > 0) && (
- <Pref title={t("account_usage_reservations_title")}>
- <div>
- <Typography variant="body2" sx={{ float: "left" }}>
- {account.stats.reservations.toLocaleString()}
- </Typography>
- <Typography variant="body2" sx={{ float: "right" }}>
- {account.role === Role.USER
- ? t("account_usage_of_limit", {
- limit: account.limits.reservations.toLocaleString(),
- })
- : t("account_usage_unlimited")}
- </Typography>
- </div>
- <LinearProgress
- variant="determinate"
- value={
- account.role === Role.USER && account.limits.reservations > 0
- ? normalize(account.stats.reservations, account.limits.reservations)
- : 100
- }
- />
- </Pref>
- )}
- <Pref
- title={
- <>
- {t("account_usage_messages_title")}
- <Tooltip title={t("account_usage_limits_reset_daily")}>
- <span>
- <InfoIcon />
- </span>
- </Tooltip>
- </>
- }
- >
- <div>
- <Typography variant="body2" sx={{ float: "left" }}>
- {account.stats.messages.toLocaleString()}
- </Typography>
- <Typography variant="body2" sx={{ float: "right" }}>
- {account.role === Role.USER
- ? t("account_usage_of_limit", {
- limit: account.limits.messages.toLocaleString(),
- })
- : t("account_usage_unlimited")}
- </Typography>
- </div>
- <LinearProgress
- variant="determinate"
- value={account.role === Role.USER ? normalize(account.stats.messages, account.limits.messages) : 100}
- />
- </Pref>
- {config.enable_emails && (
- <Pref
- title={
- <>
- {t("account_usage_emails_title")}
- <Tooltip title={t("account_usage_limits_reset_daily")}>
- <span>
- <InfoIcon />
- </span>
- </Tooltip>
- </>
- }
- >
- <div>
- <Typography variant="body2" sx={{ float: "left" }}>
- {account.stats.emails.toLocaleString()}
- </Typography>
- <Typography variant="body2" sx={{ float: "right" }}>
- {account.role === Role.USER
- ? t("account_usage_of_limit", {
- limit: account.limits.emails.toLocaleString(),
- })
- : t("account_usage_unlimited")}
- </Typography>
- </div>
- <LinearProgress
- variant="determinate"
- value={account.role === Role.USER ? normalize(account.stats.emails, account.limits.emails) : 100}
- />
- </Pref>
- )}
- {config.enable_calls && (account.role === Role.ADMIN || account.limits.calls > 0) && (
- <Pref
- title={
- <>
- {t("account_usage_calls_title")}
- <Tooltip title={t("account_usage_limits_reset_daily")}>
- <span>
- <InfoIcon />
- </span>
- </Tooltip>
- </>
- }
- >
- <div>
- <Typography variant="body2" sx={{ float: "left" }}>
- {account.stats.calls.toLocaleString()}
- </Typography>
- <Typography variant="body2" sx={{ float: "right" }}>
- {account.role === Role.USER
- ? t("account_usage_of_limit", {
- limit: account.limits.calls.toLocaleString(),
- })
- : t("account_usage_unlimited")}
- </Typography>
- </div>
- <LinearProgress
- variant="determinate"
- value={account.role === Role.USER && account.limits.calls > 0 ? normalize(account.stats.calls, account.limits.calls) : 100}
- />
- </Pref>
- )}
- <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.resolvedLanguage,
- fallbacks: ["en"],
- }),
- })}
- >
- <div>
- <Typography variant="body2" sx={{ float: "left" }}>
- {formatBytes(account.stats.attachment_total_size)}
- </Typography>
- <Typography variant="body2" sx={{ float: "right" }}>
- {account.role === Role.USER
- ? t("account_usage_of_limit", {
- limit: formatBytes(account.limits.attachment_total_size),
- })
- : t("account_usage_unlimited")}
- </Typography>
- </div>
- <LinearProgress
- variant="determinate"
- value={account.role === Role.USER ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100}
- />
- </Pref>
- {config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 && (
- <Pref
- title={
- <>
- {t("account_usage_reservations_title")}
- {config.enable_payments && <ProChip />}
- </>
- }
- >
- <em>{t("account_usage_reservations_none")}</em>
- </Pref>
- )}
- {config.enable_calls && account.role === Role.USER && account.limits.calls === 0 && (
- <Pref
- title={
- <>
- {t("account_usage_calls_title")}
- {config.enable_payments && <ProChip />}
- </>
- }
- >
- <em>{t("account_usage_calls_none")}</em>
- </Pref>
- )}
- </PrefGroup>
- {account.role === Role.USER && account.limits.basis === LimitBasis.IP && (
- <Typography variant="body1">{t("account_usage_basis_ip_description")}</Typography>
- )}
- </Card>
- );
- };
- const InfoIcon = () => (
- <InfoOutlinedIcon
- sx={{
- verticalAlign: "middle",
- width: "18px",
- marginLeft: "4px",
- color: "gray",
- }}
- />
- );
- const Tokens = () => {
- const { t } = useTranslation();
- const { account } = useContext(AccountContext);
- const [dialogKey, setDialogKey] = useState(0);
- const [dialogOpen, setDialogOpen] = useState(false);
- const tokens = account?.tokens || [];
- const handleCreateClick = () => {
- setDialogKey((prev) => prev + 1);
- setDialogOpen(true);
- };
- const handleDialogClose = () => {
- setDialogOpen(false);
- };
- return (
- <Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}>
- <CardContent sx={{ paddingBottom: 1 }}>
- <Typography variant="h5" sx={{ marginBottom: 2 }}>
- {t("account_tokens_title")}
- </Typography>
- <Paragraph>
- <Trans
- i18nKey="account_tokens_description"
- components={{
- Link: <Link href="/docs/publish/#access-tokens" />,
- }}
- />
- </Paragraph>
- <div style={{ width: "100%", overflowX: "auto" }}>{tokens?.length > 0 && <TokensTable tokens={tokens} />}</div>
- </CardContent>
- <CardActions>
- <Button onClick={handleCreateClick}>{t("account_tokens_table_create_token_button")}</Button>
- </CardActions>
- <TokenDialog key={`tokenDialogCreate${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
- </Card>
- );
- };
- const TokensTable = (props) => {
- const { t, i18n } = useTranslation();
- const [snackOpen, setSnackOpen] = useState(false);
- const [upsertDialogKey, setUpsertDialogKey] = useState(0);
- const [upsertDialogOpen, setUpsertDialogOpen] = useState(false);
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
- const [selectedToken, setSelectedToken] = useState(null);
- const tokens = (props.tokens || []).sort((a, b) => {
- if (a.token === session.token()) {
- return -1;
- }
- if (b.token === session.token()) {
- return 1;
- }
- return a.token.localeCompare(b.token);
- });
- const handleEditClick = (token) => {
- setUpsertDialogKey((prev) => prev + 1);
- setSelectedToken(token);
- setUpsertDialogOpen(true);
- };
- const handleDialogClose = () => {
- setUpsertDialogOpen(false);
- setDeleteDialogOpen(false);
- setSelectedToken(null);
- };
- const handleDeleteClick = async (token) => {
- setSelectedToken(token);
- setDeleteDialogOpen(true);
- };
- const handleCopy = async (token) => {
- await navigator.clipboard.writeText(token);
- setSnackOpen(true);
- };
- return (
- <Table size="small" aria-label={t("account_tokens_title")}>
- <TableHead>
- <TableRow>
- <TableCell sx={{ paddingLeft: 0 }}>{t("account_tokens_table_token_header")}</TableCell>
- <TableCell>{t("account_tokens_table_label_header")}</TableCell>
- <TableCell>{t("account_tokens_table_expires_header")}</TableCell>
- <TableCell>{t("account_tokens_table_last_access_header")}</TableCell>
- <TableCell />
- </TableRow>
- </TableHead>
- <TableBody>
- {tokens.map((token) => (
- <TableRow key={token.token} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
- <TableCell
- component="th"
- scope="row"
- sx={{ paddingLeft: 0, whiteSpace: "nowrap" }}
- aria-label={t("account_tokens_table_token_header")}
- >
- <span>
- <span style={{ fontFamily: "Monospace", fontSize: "0.9rem" }}>{token.token.slice(0, 12)}</span>
- ...
- <Tooltip title={t("common_copy_to_clipboard")} placement="right">
- <IconButton onClick={() => handleCopy(token.token)}>
- <ContentCopy />
- </IconButton>
- </Tooltip>
- </span>
- </TableCell>
- <TableCell aria-label={t("account_tokens_table_label_header")}>
- {token.token === session.token() && <em>{t("account_tokens_table_current_session")}</em>}
- {token.token !== session.token() && (token.label || "-")}
- </TableCell>
- <TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_expires_header")}>
- {token.expires ? formatShortDateTime(token.expires, i18n.language) : <em>{t("account_tokens_table_never_expires")}</em>}
- </TableCell>
- <TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_last_access_header")}>
- <div style={{ display: "flex", alignItems: "center" }}>
- <span>{formatShortDateTime(token.last_access, i18n.language)}</span>
- <Tooltip
- title={t("account_tokens_table_last_origin_tooltip", {
- ip: token.last_origin,
- })}
- >
- <IconButton onClick={() => openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}>
- <Public />
- </IconButton>
- </Tooltip>
- </div>
- </TableCell>
- <TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
- {token.token !== session.token() && !token.provisioned && (
- <>
- <IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
- <EditIcon />
- </IconButton>
- <IconButton onClick={() => handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}>
- <CloseIcon />
- </IconButton>
- </>
- )}
- {token.token === session.token() && (
- <Tooltip title={t("account_tokens_table_cannot_delete_or_edit")}>
- <span>
- <IconButton disabled>
- <EditIcon />
- </IconButton>
- <IconButton disabled>
- <CloseIcon />
- </IconButton>
- </span>
- </Tooltip>
- )}
- {token.provisioned && (
- <Tooltip title={t("account_tokens_table_cannot_delete_or_edit_provisioned_token")}>
- <span>
- <IconButton disabled>
- <EditIcon />
- </IconButton>
- <IconButton disabled>
- <CloseIcon />
- </IconButton>
- </span>
- </Tooltip>
- )}
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- <Portal>
- <Snackbar
- open={snackOpen}
- autoHideDuration={3000}
- onClose={() => setSnackOpen(false)}
- message={t("account_tokens_table_copied_to_clipboard")}
- />
- </Portal>
- <TokenDialog key={`tokenDialogEdit${upsertDialogKey}`} open={upsertDialogOpen} token={selectedToken} onClose={handleDialogClose} />
- <TokenDeleteDialog open={deleteDialogOpen} token={selectedToken} onClose={handleDialogClose} />
- </Table>
- );
- };
- const TokenDialog = (props) => {
- const theme = useTheme();
- const { t } = useTranslation();
- const [error, setError] = useState("");
- const [label, setLabel] = useState(props.token?.label || "");
- const [expires, setExpires] = useState(props.token ? -1 : 0);
- const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
- const editMode = !!props.token;
- const handleSubmit = async () => {
- try {
- if (editMode) {
- await accountApi.updateToken(props.token.token, label, expires);
- } else {
- await accountApi.createToken(label, expires);
- }
- props.onClose();
- } catch (e) {
- console.log(`[Account] Error creating token`, e);
- if (e instanceof UnauthorizedError) {
- await session.resetAndRedirect(routes.login);
- } else {
- setError(e.message);
- }
- }
- };
- return (
- <Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
- <DialogTitle>{editMode ? t("account_tokens_dialog_title_edit") : t("account_tokens_dialog_title_create")}</DialogTitle>
- <DialogContent>
- <TextField
- margin="dense"
- id="token-label"
- label={t("account_tokens_dialog_label")}
- aria-label={t("account_delete_dialog_label")}
- type="text"
- value={label}
- onChange={(ev) => setLabel(ev.target.value)}
- fullWidth
- variant="standard"
- />
- <FormControl fullWidth variant="standard" sx={{ mt: 1 }}>
- <Select value={expires} onChange={(ev) => setExpires(ev.target.value)} aria-label={t("account_tokens_dialog_expires_label")}>
- {editMode && <MenuItem value={-1}>{t("account_tokens_dialog_expires_unchanged")}</MenuItem>}
- <MenuItem value={0}>{t("account_tokens_dialog_expires_never")}</MenuItem>
- <MenuItem value={21600}>{t("account_tokens_dialog_expires_x_hours", { hours: 6 })}</MenuItem>
- <MenuItem value={43200}>{t("account_tokens_dialog_expires_x_hours", { hours: 12 })}</MenuItem>
- <MenuItem value={259200}>{t("account_tokens_dialog_expires_x_days", { days: 3 })}</MenuItem>
- <MenuItem value={604800}>{t("account_tokens_dialog_expires_x_days", { days: 7 })}</MenuItem>
- <MenuItem value={2592000}>{t("account_tokens_dialog_expires_x_days", { days: 30 })}</MenuItem>
- <MenuItem value={7776000}>{t("account_tokens_dialog_expires_x_days", { days: 90 })}</MenuItem>
- <MenuItem value={15552000}>{t("account_tokens_dialog_expires_x_days", { days: 180 })}</MenuItem>
- </Select>
- </FormControl>
- </DialogContent>
- <DialogFooter status={error}>
- <Button onClick={props.onClose}>{t("account_tokens_dialog_button_cancel")}</Button>
- <Button onClick={handleSubmit}>
- {editMode ? t("account_tokens_dialog_button_update") : t("account_tokens_dialog_button_create")}
- </Button>
- </DialogFooter>
- </Dialog>
- );
- };
- const TokenDeleteDialog = (props) => {
- const { t } = useTranslation();
- const [error, setError] = useState("");
- const handleSubmit = async () => {
- try {
- await accountApi.deleteToken(props.token.token);
- props.onClose();
- } catch (e) {
- console.log(`[Account] Error deleting token`, e);
- if (e instanceof UnauthorizedError) {
- await session.resetAndRedirect(routes.login);
- } else {
- setError(e.message);
- }
- }
- };
- return (
- <Dialog open={props.open} onClose={props.onClose}>
- <DialogTitle>{t("account_tokens_delete_dialog_title")}</DialogTitle>
- <DialogContent>
- <DialogContentText>
- <Trans i18nKey="account_tokens_delete_dialog_description" />
- </DialogContentText>
- </DialogContent>
- <DialogFooter status={error}>
- <Button onClick={props.onClose}>{t("common_cancel")}</Button>
- <Button onClick={handleSubmit} color="error">
- {t("account_tokens_delete_dialog_submit_button")}
- </Button>
- </DialogFooter>
- </Dialog>
- );
- };
- 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>
- );
- };
- const DeleteAccount = () => {
- const { t } = useTranslation();
- const [dialogKey, setDialogKey] = useState(0);
- const [dialogOpen, setDialogOpen] = useState(false);
- const { account } = useContext(AccountContext);
- const handleDialogOpen = () => {
- setDialogKey((prev) => prev + 1);
- setDialogOpen(true);
- };
- const handleDialogClose = () => {
- setDialogOpen(false);
- };
- return (
- <Pref title={t("account_delete_title")} description={t("account_delete_description")}>
- <div>
- {!account?.provisioned ? (
- <Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} onClick={handleDialogOpen}>
- {t("account_delete_title")}
- </Button>
- ) : (
- <Tooltip title={t("account_basics_cannot_edit_or_delete_provisioned_user")}>
- <span>
- <Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} disabled>
- {t("account_delete_title")}
- </Button>
- </span>
- </Tooltip>
- )}
- </div>
- <DeleteAccountDialog key={`deleteAccountDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
- </Pref>
- );
- };
- const DeleteAccountDialog = (props) => {
- const theme = useTheme();
- const { t } = useTranslation();
- const { account } = useContext(AccountContext);
- const [error, setError] = useState("");
- const [password, setPassword] = useState("");
- const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
- const handleSubmit = async () => {
- try {
- await accountApi.delete(password);
- await db().delete();
- console.debug(`[Account] Account deleted`);
- await session.resetAndRedirect(routes.app);
- } catch (e) {
- console.log(`[Account] Error deleting account`, e);
- if (e instanceof IncorrectPasswordError) {
- setError(t("account_basics_password_dialog_current_password_incorrect"));
- } else if (e instanceof UnauthorizedError) {
- await session.resetAndRedirect(routes.login);
- } else {
- setError(e.message);
- }
- }
- };
- return (
- <Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
- <DialogTitle>{t("account_delete_title")}</DialogTitle>
- <DialogContent>
- <Typography variant="body1">{t("account_delete_dialog_description")}</Typography>
- <TextField
- margin="dense"
- id="account-delete-confirm"
- label={t("account_delete_dialog_label")}
- aria-label={t("account_delete_dialog_label")}
- type="password"
- value={password}
- onChange={(ev) => setPassword(ev.target.value)}
- fullWidth
- variant="standard"
- />
- {account?.billing?.subscription && (
- <Alert severity="warning" sx={{ mt: 1 }}>
- {t("account_delete_dialog_billing_warning")}
- </Alert>
- )}
- </DialogContent>
- <DialogFooter status={error}>
- <Button onClick={props.onClose}>{t("account_delete_dialog_button_cancel")}</Button>
- <Button onClick={handleSubmit} color="error" disabled={password.length === 0}>
- {t("account_delete_dialog_button_submit")}
- </Button>
- </DialogFooter>
- </Dialog>
- );
- };
- export default Account;
|