Account.jsx 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163
  1. import * as React from "react";
  2. import { useContext, useState } from "react";
  3. import {
  4. Alert,
  5. CardActions,
  6. CardContent,
  7. Chip,
  8. FormControl,
  9. FormControlLabel,
  10. LinearProgress,
  11. Link,
  12. Portal,
  13. Radio,
  14. RadioGroup,
  15. Select,
  16. Snackbar,
  17. Stack,
  18. Table,
  19. TableBody,
  20. TableCell,
  21. TableHead,
  22. TableRow,
  23. useMediaQuery,
  24. Tooltip,
  25. Typography,
  26. Container,
  27. Card,
  28. Button,
  29. Dialog,
  30. DialogTitle,
  31. DialogContent,
  32. TextField,
  33. IconButton,
  34. MenuItem,
  35. DialogContentText,
  36. useTheme,
  37. } from "@mui/material";
  38. import EditIcon from "@mui/icons-material/Edit";
  39. import { Trans, useTranslation } from "react-i18next";
  40. import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
  41. import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
  42. import humanizeDuration from "humanize-duration";
  43. import CelebrationIcon from "@mui/icons-material/Celebration";
  44. import CloseIcon from "@mui/icons-material/Close";
  45. import { ContentCopy, Public } from "@mui/icons-material";
  46. import AddIcon from "@mui/icons-material/Add";
  47. import routes from "./routes";
  48. import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
  49. import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
  50. import { Pref, PrefGroup } from "./Pref";
  51. import db from "../app/db";
  52. import UpgradeDialog from "./UpgradeDialog";
  53. import { AccountContext } from "./App";
  54. import DialogFooter from "./DialogFooter";
  55. import { Paragraph } from "./styles";
  56. import { IncorrectPasswordError, UnauthorizedError } from "../app/errors";
  57. import { ProChip } from "./SubscriptionPopup";
  58. import session from "../app/Session";
  59. const Account = () => {
  60. if (!session.exists()) {
  61. window.location.href = routes.app;
  62. return <></>;
  63. }
  64. return (
  65. <Container maxWidth="md" sx={{ marginTop: 3, marginBottom: 3 }}>
  66. <Stack spacing={3}>
  67. <Basics />
  68. <Stats />
  69. <Tokens />
  70. <Delete />
  71. </Stack>
  72. </Container>
  73. );
  74. };
  75. const Basics = () => {
  76. const { t } = useTranslation();
  77. return (
  78. <Card sx={{ p: 3 }} aria-label={t("account_basics_title")}>
  79. <Typography variant="h5" sx={{ marginBottom: 2 }}>
  80. {t("account_basics_title")}
  81. </Typography>
  82. <PrefGroup>
  83. <Username />
  84. <ChangePassword />
  85. <PhoneNumbers />
  86. <AccountType />
  87. </PrefGroup>
  88. </Card>
  89. );
  90. };
  91. const Username = () => {
  92. const { t } = useTranslation();
  93. const { account } = useContext(AccountContext);
  94. const labelId = "prefUsername";
  95. return (
  96. <Pref labelId={labelId} title={t("account_basics_username_title")} description={t("account_basics_username_description")}>
  97. <div aria-labelledby={labelId}>
  98. {session.username()}
  99. {account?.role === Role.ADMIN && (
  100. <>
  101. {" "}
  102. <Tooltip title={t("account_basics_username_admin_tooltip")}>
  103. <span style={{ cursor: "default" }}>👑</span>
  104. </Tooltip>
  105. </>
  106. )}
  107. </div>
  108. </Pref>
  109. );
  110. };
  111. const ChangePassword = () => {
  112. const { t } = useTranslation();
  113. const [dialogKey, setDialogKey] = useState(0);
  114. const [dialogOpen, setDialogOpen] = useState(false);
  115. const { account } = useContext(AccountContext);
  116. const labelId = "prefChangePassword";
  117. const handleDialogOpen = () => {
  118. setDialogKey((prev) => prev + 1);
  119. setDialogOpen(true);
  120. };
  121. const handleDialogClose = () => {
  122. setDialogOpen(false);
  123. };
  124. return (
  125. <Pref labelId={labelId} title={t("account_basics_password_title")} description={t("account_basics_password_description")}>
  126. <div aria-labelledby={labelId}>
  127. <Typography color="gray" sx={{ float: "left", fontSize: "0.7rem", lineHeight: "3.5" }}>
  128. ⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤
  129. </Typography>
  130. {!account?.provisioned ? (
  131. <IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}>
  132. <EditIcon />
  133. </IconButton>
  134. ) : (
  135. <Tooltip title={t("account_basics_cannot_edit_or_delete_provisioned_user")}>
  136. <span>
  137. <IconButton disabled>
  138. <EditIcon />
  139. </IconButton>
  140. </span>
  141. </Tooltip>
  142. )}
  143. </div>
  144. <ChangePasswordDialog key={`changePasswordDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
  145. </Pref>
  146. );
  147. };
  148. const ChangePasswordDialog = (props) => {
  149. const theme = useTheme();
  150. const { t } = useTranslation();
  151. const [error, setError] = useState("");
  152. const [currentPassword, setCurrentPassword] = useState("");
  153. const [newPassword, setNewPassword] = useState("");
  154. const [confirmPassword, setConfirmPassword] = useState("");
  155. const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
  156. const handleDialogSubmit = async () => {
  157. try {
  158. console.debug(`[Account] Changing password`);
  159. await accountApi.changePassword(currentPassword, newPassword);
  160. props.onClose();
  161. } catch (e) {
  162. console.log(`[Account] Error changing password`, e);
  163. if (e instanceof IncorrectPasswordError) {
  164. setError(t("account_basics_password_dialog_current_password_incorrect"));
  165. } else if (e instanceof UnauthorizedError) {
  166. await session.resetAndRedirect(routes.login);
  167. } else {
  168. setError(e.message);
  169. }
  170. }
  171. };
  172. return (
  173. <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
  174. <DialogTitle>{t("account_basics_password_dialog_title")}</DialogTitle>
  175. <DialogContent>
  176. <TextField
  177. margin="dense"
  178. id="current-password"
  179. label={t("account_basics_password_dialog_current_password_label")}
  180. aria-label={t("account_basics_password_dialog_current_password_label")}
  181. type="password"
  182. value={currentPassword}
  183. onChange={(ev) => setCurrentPassword(ev.target.value)}
  184. fullWidth
  185. variant="standard"
  186. />
  187. <TextField
  188. margin="dense"
  189. id="new-password"
  190. label={t("account_basics_password_dialog_new_password_label")}
  191. aria-label={t("account_basics_password_dialog_new_password_label")}
  192. type="password"
  193. value={newPassword}
  194. onChange={(ev) => setNewPassword(ev.target.value)}
  195. fullWidth
  196. variant="standard"
  197. />
  198. <TextField
  199. margin="dense"
  200. id="confirm"
  201. label={t("account_basics_password_dialog_confirm_password_label")}
  202. aria-label={t("account_basics_password_dialog_confirm_password_label")}
  203. type="password"
  204. value={confirmPassword}
  205. onChange={(ev) => setConfirmPassword(ev.target.value)}
  206. fullWidth
  207. variant="standard"
  208. />
  209. </DialogContent>
  210. <DialogFooter status={error}>
  211. <Button onClick={props.onClose}>{t("common_cancel")}</Button>
  212. <Button
  213. onClick={handleDialogSubmit}
  214. disabled={newPassword.length === 0 || currentPassword.length === 0 || newPassword !== confirmPassword}
  215. >
  216. {t("account_basics_password_dialog_button_submit")}
  217. </Button>
  218. </DialogFooter>
  219. </Dialog>
  220. );
  221. };
  222. const AccountType = () => {
  223. const { t, i18n } = useTranslation();
  224. const { account } = useContext(AccountContext);
  225. const [upgradeDialogKey, setUpgradeDialogKey] = useState(0);
  226. const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
  227. const [showPortalError, setShowPortalError] = useState(false);
  228. if (!account) {
  229. return <></>;
  230. }
  231. const handleUpgradeClick = () => {
  232. setUpgradeDialogKey((k) => k + 1);
  233. setUpgradeDialogOpen(true);
  234. };
  235. const handleManageBilling = async () => {
  236. try {
  237. const response = await accountApi.createBillingPortalSession();
  238. window.open(response.redirect_url, "billing_portal");
  239. } catch (e) {
  240. console.log(`[Account] Error opening billing portal`, e);
  241. if (e instanceof UnauthorizedError) {
  242. await session.resetAndRedirect(routes.login);
  243. } else {
  244. setShowPortalError(true);
  245. }
  246. }
  247. };
  248. let accountType;
  249. if (account.role === Role.ADMIN) {
  250. const tierSuffix = account.tier
  251. ? t("account_basics_tier_admin_suffix_with_tier", {
  252. tier: account.tier.name,
  253. })
  254. : t("account_basics_tier_admin_suffix_no_tier");
  255. accountType = `${t("account_basics_tier_admin")} ${tierSuffix}`;
  256. } else if (!account.tier) {
  257. accountType = config.enable_payments ? t("account_basics_tier_free") : t("account_basics_tier_basic");
  258. } else {
  259. accountType = account.tier.name;
  260. if (account.billing?.interval === SubscriptionInterval.MONTH) {
  261. accountType += ` (${t("account_basics_tier_interval_monthly")})`;
  262. } else if (account.billing?.interval === SubscriptionInterval.YEAR) {
  263. accountType += ` (${t("account_basics_tier_interval_yearly")})`;
  264. }
  265. }
  266. return (
  267. <Pref
  268. alignTop={account.billing?.status === SubscriptionStatus.PAST_DUE || account.billing?.cancel_at > 0}
  269. title={t("account_basics_tier_title")}
  270. description={t("account_basics_tier_description")}
  271. >
  272. <div>
  273. {accountType}
  274. {account.billing?.paid_until && !account.billing?.cancel_at && (
  275. <Tooltip
  276. title={t("account_basics_tier_paid_until", {
  277. date: formatShortDate(account.billing?.paid_until, i18n.language),
  278. })}
  279. >
  280. <span>
  281. <InfoIcon />
  282. </span>
  283. </Tooltip>
  284. )}
  285. {config.enable_payments && account.role === Role.USER && !account.billing?.subscription && (
  286. <Button
  287. variant="outlined"
  288. size="small"
  289. startIcon={<CelebrationIcon sx={{ color: "#55b86e" }} />}
  290. onClick={handleUpgradeClick}
  291. sx={{ ml: 1 }}
  292. >
  293. {t("account_basics_tier_upgrade_button")}
  294. </Button>
  295. )}
  296. {config.enable_payments && account.role === Role.USER && account.billing?.subscription && (
  297. <Button variant="outlined" size="small" onClick={handleUpgradeClick} sx={{ ml: 1 }}>
  298. {t("account_basics_tier_change_button")}
  299. </Button>
  300. )}
  301. {config.enable_payments && account.role === Role.USER && account.billing?.customer && (
  302. <Button variant="outlined" size="small" onClick={handleManageBilling} sx={{ ml: 1 }}>
  303. {t("account_basics_tier_manage_billing_button")}
  304. </Button>
  305. )}
  306. {config.enable_payments && (
  307. <UpgradeDialog
  308. key={`upgradeDialogFromAccount${upgradeDialogKey}`}
  309. open={upgradeDialogOpen}
  310. onCancel={() => setUpgradeDialogOpen(false)}
  311. />
  312. )}
  313. </div>
  314. {account.billing?.status === SubscriptionStatus.PAST_DUE && (
  315. <Alert severity="error" sx={{ mt: 1 }}>
  316. {t("account_basics_tier_payment_overdue")}
  317. </Alert>
  318. )}
  319. {account.billing?.cancel_at > 0 && (
  320. <Alert severity="warning" sx={{ mt: 1 }}>
  321. {t("account_basics_tier_canceled_subscription", {
  322. date: formatShortDate(account.billing.cancel_at, i18n.language),
  323. })}
  324. </Alert>
  325. )}
  326. <Portal>
  327. <Snackbar
  328. open={showPortalError}
  329. autoHideDuration={3000}
  330. onClose={() => setShowPortalError(false)}
  331. message={t("account_usage_cannot_create_portal_session")}
  332. />
  333. </Portal>
  334. </Pref>
  335. );
  336. };
  337. const PhoneNumbers = () => {
  338. const { t } = useTranslation();
  339. const { account } = useContext(AccountContext);
  340. const [dialogKey, setDialogKey] = useState(0);
  341. const [dialogOpen, setDialogOpen] = useState(false);
  342. const [snackOpen, setSnackOpen] = useState(false);
  343. const labelId = "prefPhoneNumbers";
  344. const handleDialogOpen = () => {
  345. setDialogKey((prev) => prev + 1);
  346. setDialogOpen(true);
  347. };
  348. const handleDialogClose = () => {
  349. setDialogOpen(false);
  350. };
  351. const handleCopy = (phoneNumber) => {
  352. navigator.clipboard.writeText(phoneNumber);
  353. setSnackOpen(true);
  354. };
  355. const handleDelete = async (phoneNumber) => {
  356. try {
  357. await accountApi.deletePhoneNumber(phoneNumber);
  358. } catch (e) {
  359. console.log(`[Account] Error deleting phone number`, e);
  360. if (e instanceof UnauthorizedError) {
  361. await session.resetAndRedirect(routes.login);
  362. }
  363. }
  364. };
  365. if (!config.enable_calls) {
  366. return null;
  367. }
  368. if (account?.limits.calls === 0) {
  369. return (
  370. <Pref
  371. title={
  372. <>
  373. {t("account_basics_phone_numbers_title")}
  374. {config.enable_payments && <ProChip />}
  375. </>
  376. }
  377. description={t("account_basics_phone_numbers_description")}
  378. >
  379. <em>{t("account_usage_calls_none")}</em>
  380. </Pref>
  381. );
  382. }
  383. return (
  384. <Pref labelId={labelId} title={t("account_basics_phone_numbers_title")} description={t("account_basics_phone_numbers_description")}>
  385. <div aria-labelledby={labelId}>
  386. {account?.phone_numbers?.map((phoneNumber) => (
  387. <Chip
  388. label={
  389. <Tooltip title={t("common_copy_to_clipboard")}>
  390. <span>{phoneNumber}</span>
  391. </Tooltip>
  392. }
  393. variant="outlined"
  394. onClick={() => handleCopy(phoneNumber)}
  395. onDelete={() => handleDelete(phoneNumber)}
  396. />
  397. ))}
  398. {!account?.phone_numbers && <em>{t("account_basics_phone_numbers_no_phone_numbers_yet")}</em>}
  399. <IconButton onClick={handleDialogOpen}>
  400. <AddIcon />
  401. </IconButton>
  402. </div>
  403. <AddPhoneNumberDialog key={`addPhoneNumberDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
  404. <Portal>
  405. <Snackbar
  406. open={snackOpen}
  407. autoHideDuration={3000}
  408. onClose={() => setSnackOpen(false)}
  409. message={t("account_basics_phone_numbers_copied_to_clipboard")}
  410. />
  411. </Portal>
  412. </Pref>
  413. );
  414. };
  415. const AddPhoneNumberDialog = (props) => {
  416. const theme = useTheme();
  417. const { t } = useTranslation();
  418. const [error, setError] = useState("");
  419. const [phoneNumber, setPhoneNumber] = useState("");
  420. const [channel, setChannel] = useState("sms");
  421. const [code, setCode] = useState("");
  422. const [sending, setSending] = useState(false);
  423. const [verificationCodeSent, setVerificationCodeSent] = useState(false);
  424. const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
  425. const verifyPhone = async () => {
  426. try {
  427. setSending(true);
  428. await accountApi.verifyPhoneNumber(phoneNumber, channel);
  429. setVerificationCodeSent(true);
  430. } catch (e) {
  431. console.log(`[Account] Error sending verification`, e);
  432. if (e instanceof UnauthorizedError) {
  433. await session.resetAndRedirect(routes.login);
  434. } else {
  435. setError(e.message);
  436. }
  437. } finally {
  438. setSending(false);
  439. }
  440. };
  441. const checkVerifyPhone = async () => {
  442. try {
  443. setSending(true);
  444. await accountApi.addPhoneNumber(phoneNumber, code);
  445. props.onClose();
  446. } catch (e) {
  447. console.log(`[Account] Error confirming verification`, e);
  448. if (e instanceof UnauthorizedError) {
  449. await session.resetAndRedirect(routes.login);
  450. } else {
  451. setError(e.message);
  452. }
  453. } finally {
  454. setSending(false);
  455. }
  456. };
  457. const handleDialogSubmit = async () => {
  458. if (!verificationCodeSent) {
  459. await verifyPhone();
  460. } else {
  461. await checkVerifyPhone();
  462. }
  463. };
  464. const handleCancel = () => {
  465. if (verificationCodeSent) {
  466. setVerificationCodeSent(false);
  467. setCode("");
  468. } else {
  469. props.onClose();
  470. }
  471. };
  472. return (
  473. <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
  474. <DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle>
  475. <DialogContent>
  476. <DialogContentText>{t("account_basics_phone_numbers_dialog_description")}</DialogContentText>
  477. {!verificationCodeSent && (
  478. <div style={{ display: "flex" }}>
  479. <TextField
  480. margin="dense"
  481. label={t("account_basics_phone_numbers_dialog_number_label")}
  482. aria-label={t("account_basics_phone_numbers_dialog_number_label")}
  483. placeholder={t("account_basics_phone_numbers_dialog_number_placeholder")}
  484. type="tel"
  485. value={phoneNumber}
  486. onChange={(ev) => setPhoneNumber(ev.target.value)}
  487. inputProps={{ inputMode: "tel", pattern: "+[0-9]*" }}
  488. variant="standard"
  489. sx={{ flexGrow: 1 }}
  490. />
  491. <FormControl sx={{ flexWrap: "nowrap" }}>
  492. <RadioGroup row sx={{ flexGrow: 1, marginTop: "8px", marginLeft: "5px" }}>
  493. <FormControlLabel
  494. value="sms"
  495. control={<Radio checked={channel === "sms"} onChange={(e) => setChannel(e.target.value)} />}
  496. label={t("account_basics_phone_numbers_dialog_channel_sms")}
  497. />
  498. <FormControlLabel
  499. value="call"
  500. control={<Radio checked={channel === "call"} onChange={(e) => setChannel(e.target.value)} />}
  501. label={t("account_basics_phone_numbers_dialog_channel_call")}
  502. sx={{ marginRight: 0 }}
  503. />
  504. </RadioGroup>
  505. </FormControl>
  506. </div>
  507. )}
  508. {verificationCodeSent && (
  509. <TextField
  510. margin="dense"
  511. label={t("account_basics_phone_numbers_dialog_code_label")}
  512. aria-label={t("account_basics_phone_numbers_dialog_code_label")}
  513. placeholder={t("account_basics_phone_numbers_dialog_code_placeholder")}
  514. type="text"
  515. value={code}
  516. onChange={(ev) => setCode(ev.target.value)}
  517. fullWidth
  518. inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
  519. variant="standard"
  520. />
  521. )}
  522. </DialogContent>
  523. <DialogFooter status={error}>
  524. <Button onClick={handleCancel}>{verificationCodeSent ? t("common_back") : t("common_cancel")}</Button>
  525. <Button onClick={handleDialogSubmit} disabled={sending || !/^\+\d+$/.test(phoneNumber)}>
  526. {!verificationCodeSent && channel === "sms" && t("account_basics_phone_numbers_dialog_verify_button_sms")}
  527. {!verificationCodeSent && channel === "call" && t("account_basics_phone_numbers_dialog_verify_button_call")}
  528. {verificationCodeSent && t("account_basics_phone_numbers_dialog_check_verification_button")}
  529. </Button>
  530. </DialogFooter>
  531. </Dialog>
  532. );
  533. };
  534. const Stats = () => {
  535. const { t, i18n } = useTranslation();
  536. const { account } = useContext(AccountContext);
  537. if (!account) {
  538. return <></>;
  539. }
  540. const normalize = (value, max) => Math.min((value / max) * 100, 100);
  541. return (
  542. <Card sx={{ p: 3 }} aria-label={t("account_usage_title")}>
  543. <Typography variant="h5" sx={{ marginBottom: 2 }}>
  544. {t("account_usage_title")}
  545. </Typography>
  546. <PrefGroup>
  547. {(account.role === Role.ADMIN || account.limits.reservations > 0) && (
  548. <Pref title={t("account_usage_reservations_title")}>
  549. <div>
  550. <Typography variant="body2" sx={{ float: "left" }}>
  551. {account.stats.reservations.toLocaleString()}
  552. </Typography>
  553. <Typography variant="body2" sx={{ float: "right" }}>
  554. {account.role === Role.USER
  555. ? t("account_usage_of_limit", {
  556. limit: account.limits.reservations.toLocaleString(),
  557. })
  558. : t("account_usage_unlimited")}
  559. </Typography>
  560. </div>
  561. <LinearProgress
  562. variant="determinate"
  563. value={
  564. account.role === Role.USER && account.limits.reservations > 0
  565. ? normalize(account.stats.reservations, account.limits.reservations)
  566. : 100
  567. }
  568. />
  569. </Pref>
  570. )}
  571. <Pref
  572. title={
  573. <>
  574. {t("account_usage_messages_title")}
  575. <Tooltip title={t("account_usage_limits_reset_daily")}>
  576. <span>
  577. <InfoIcon />
  578. </span>
  579. </Tooltip>
  580. </>
  581. }
  582. >
  583. <div>
  584. <Typography variant="body2" sx={{ float: "left" }}>
  585. {account.stats.messages.toLocaleString()}
  586. </Typography>
  587. <Typography variant="body2" sx={{ float: "right" }}>
  588. {account.role === Role.USER
  589. ? t("account_usage_of_limit", {
  590. limit: account.limits.messages.toLocaleString(),
  591. })
  592. : t("account_usage_unlimited")}
  593. </Typography>
  594. </div>
  595. <LinearProgress
  596. variant="determinate"
  597. value={account.role === Role.USER ? normalize(account.stats.messages, account.limits.messages) : 100}
  598. />
  599. </Pref>
  600. {config.enable_emails && (
  601. <Pref
  602. title={
  603. <>
  604. {t("account_usage_emails_title")}
  605. <Tooltip title={t("account_usage_limits_reset_daily")}>
  606. <span>
  607. <InfoIcon />
  608. </span>
  609. </Tooltip>
  610. </>
  611. }
  612. >
  613. <div>
  614. <Typography variant="body2" sx={{ float: "left" }}>
  615. {account.stats.emails.toLocaleString()}
  616. </Typography>
  617. <Typography variant="body2" sx={{ float: "right" }}>
  618. {account.role === Role.USER
  619. ? t("account_usage_of_limit", {
  620. limit: account.limits.emails.toLocaleString(),
  621. })
  622. : t("account_usage_unlimited")}
  623. </Typography>
  624. </div>
  625. <LinearProgress
  626. variant="determinate"
  627. value={account.role === Role.USER ? normalize(account.stats.emails, account.limits.emails) : 100}
  628. />
  629. </Pref>
  630. )}
  631. {config.enable_calls && (account.role === Role.ADMIN || account.limits.calls > 0) && (
  632. <Pref
  633. title={
  634. <>
  635. {t("account_usage_calls_title")}
  636. <Tooltip title={t("account_usage_limits_reset_daily")}>
  637. <span>
  638. <InfoIcon />
  639. </span>
  640. </Tooltip>
  641. </>
  642. }
  643. >
  644. <div>
  645. <Typography variant="body2" sx={{ float: "left" }}>
  646. {account.stats.calls.toLocaleString()}
  647. </Typography>
  648. <Typography variant="body2" sx={{ float: "right" }}>
  649. {account.role === Role.USER
  650. ? t("account_usage_of_limit", {
  651. limit: account.limits.calls.toLocaleString(),
  652. })
  653. : t("account_usage_unlimited")}
  654. </Typography>
  655. </div>
  656. <LinearProgress
  657. variant="determinate"
  658. value={account.role === Role.USER && account.limits.calls > 0 ? normalize(account.stats.calls, account.limits.calls) : 100}
  659. />
  660. </Pref>
  661. )}
  662. <Pref
  663. alignTop
  664. title={t("account_usage_attachment_storage_title")}
  665. description={t("account_usage_attachment_storage_description", {
  666. filesize: formatBytes(account.limits.attachment_file_size),
  667. expiry: humanizeDuration(account.limits.attachment_expiry_duration * 1000, {
  668. language: i18n.resolvedLanguage,
  669. fallbacks: ["en"],
  670. }),
  671. })}
  672. >
  673. <div>
  674. <Typography variant="body2" sx={{ float: "left" }}>
  675. {formatBytes(account.stats.attachment_total_size)}
  676. </Typography>
  677. <Typography variant="body2" sx={{ float: "right" }}>
  678. {account.role === Role.USER
  679. ? t("account_usage_of_limit", {
  680. limit: formatBytes(account.limits.attachment_total_size),
  681. })
  682. : t("account_usage_unlimited")}
  683. </Typography>
  684. </div>
  685. <LinearProgress
  686. variant="determinate"
  687. value={account.role === Role.USER ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100}
  688. />
  689. </Pref>
  690. {config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 && (
  691. <Pref
  692. title={
  693. <>
  694. {t("account_usage_reservations_title")}
  695. {config.enable_payments && <ProChip />}
  696. </>
  697. }
  698. >
  699. <em>{t("account_usage_reservations_none")}</em>
  700. </Pref>
  701. )}
  702. {config.enable_calls && account.role === Role.USER && account.limits.calls === 0 && (
  703. <Pref
  704. title={
  705. <>
  706. {t("account_usage_calls_title")}
  707. {config.enable_payments && <ProChip />}
  708. </>
  709. }
  710. >
  711. <em>{t("account_usage_calls_none")}</em>
  712. </Pref>
  713. )}
  714. </PrefGroup>
  715. {account.role === Role.USER && account.limits.basis === LimitBasis.IP && (
  716. <Typography variant="body1">{t("account_usage_basis_ip_description")}</Typography>
  717. )}
  718. </Card>
  719. );
  720. };
  721. const InfoIcon = () => (
  722. <InfoOutlinedIcon
  723. sx={{
  724. verticalAlign: "middle",
  725. width: "18px",
  726. marginLeft: "4px",
  727. color: "gray",
  728. }}
  729. />
  730. );
  731. const Tokens = () => {
  732. const { t } = useTranslation();
  733. const { account } = useContext(AccountContext);
  734. const [dialogKey, setDialogKey] = useState(0);
  735. const [dialogOpen, setDialogOpen] = useState(false);
  736. const tokens = account?.tokens || [];
  737. const handleCreateClick = () => {
  738. setDialogKey((prev) => prev + 1);
  739. setDialogOpen(true);
  740. };
  741. const handleDialogClose = () => {
  742. setDialogOpen(false);
  743. };
  744. return (
  745. <Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}>
  746. <CardContent sx={{ paddingBottom: 1 }}>
  747. <Typography variant="h5" sx={{ marginBottom: 2 }}>
  748. {t("account_tokens_title")}
  749. </Typography>
  750. <Paragraph>
  751. <Trans
  752. i18nKey="account_tokens_description"
  753. components={{
  754. Link: <Link href="/docs/publish/#access-tokens" />,
  755. }}
  756. />
  757. </Paragraph>
  758. <div style={{ width: "100%", overflowX: "auto" }}>{tokens?.length > 0 && <TokensTable tokens={tokens} />}</div>
  759. </CardContent>
  760. <CardActions>
  761. <Button onClick={handleCreateClick}>{t("account_tokens_table_create_token_button")}</Button>
  762. </CardActions>
  763. <TokenDialog key={`tokenDialogCreate${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
  764. </Card>
  765. );
  766. };
  767. const TokensTable = (props) => {
  768. const { t, i18n } = useTranslation();
  769. const [snackOpen, setSnackOpen] = useState(false);
  770. const [upsertDialogKey, setUpsertDialogKey] = useState(0);
  771. const [upsertDialogOpen, setUpsertDialogOpen] = useState(false);
  772. const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
  773. const [selectedToken, setSelectedToken] = useState(null);
  774. const tokens = (props.tokens || []).sort((a, b) => {
  775. if (a.token === session.token()) {
  776. return -1;
  777. }
  778. if (b.token === session.token()) {
  779. return 1;
  780. }
  781. return a.token.localeCompare(b.token);
  782. });
  783. const handleEditClick = (token) => {
  784. setUpsertDialogKey((prev) => prev + 1);
  785. setSelectedToken(token);
  786. setUpsertDialogOpen(true);
  787. };
  788. const handleDialogClose = () => {
  789. setUpsertDialogOpen(false);
  790. setDeleteDialogOpen(false);
  791. setSelectedToken(null);
  792. };
  793. const handleDeleteClick = async (token) => {
  794. setSelectedToken(token);
  795. setDeleteDialogOpen(true);
  796. };
  797. const handleCopy = async (token) => {
  798. await navigator.clipboard.writeText(token);
  799. setSnackOpen(true);
  800. };
  801. return (
  802. <Table size="small" aria-label={t("account_tokens_title")}>
  803. <TableHead>
  804. <TableRow>
  805. <TableCell sx={{ paddingLeft: 0 }}>{t("account_tokens_table_token_header")}</TableCell>
  806. <TableCell>{t("account_tokens_table_label_header")}</TableCell>
  807. <TableCell>{t("account_tokens_table_expires_header")}</TableCell>
  808. <TableCell>{t("account_tokens_table_last_access_header")}</TableCell>
  809. <TableCell />
  810. </TableRow>
  811. </TableHead>
  812. <TableBody>
  813. {tokens.map((token) => (
  814. <TableRow key={token.token} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
  815. <TableCell
  816. component="th"
  817. scope="row"
  818. sx={{ paddingLeft: 0, whiteSpace: "nowrap" }}
  819. aria-label={t("account_tokens_table_token_header")}
  820. >
  821. <span>
  822. <span style={{ fontFamily: "Monospace", fontSize: "0.9rem" }}>{token.token.slice(0, 12)}</span>
  823. ...
  824. <Tooltip title={t("common_copy_to_clipboard")} placement="right">
  825. <IconButton onClick={() => handleCopy(token.token)}>
  826. <ContentCopy />
  827. </IconButton>
  828. </Tooltip>
  829. </span>
  830. </TableCell>
  831. <TableCell aria-label={t("account_tokens_table_label_header")}>
  832. {token.token === session.token() && <em>{t("account_tokens_table_current_session")}</em>}
  833. {token.token !== session.token() && (token.label || "-")}
  834. </TableCell>
  835. <TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_expires_header")}>
  836. {token.expires ? formatShortDateTime(token.expires, i18n.language) : <em>{t("account_tokens_table_never_expires")}</em>}
  837. </TableCell>
  838. <TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_last_access_header")}>
  839. <div style={{ display: "flex", alignItems: "center" }}>
  840. <span>{formatShortDateTime(token.last_access, i18n.language)}</span>
  841. <Tooltip
  842. title={t("account_tokens_table_last_origin_tooltip", {
  843. ip: token.last_origin,
  844. })}
  845. >
  846. <IconButton onClick={() => openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}>
  847. <Public />
  848. </IconButton>
  849. </Tooltip>
  850. </div>
  851. </TableCell>
  852. <TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
  853. {token.token !== session.token() && !token.provisioned && (
  854. <>
  855. <IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
  856. <EditIcon />
  857. </IconButton>
  858. <IconButton onClick={() => handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}>
  859. <CloseIcon />
  860. </IconButton>
  861. </>
  862. )}
  863. {token.token === session.token() && (
  864. <Tooltip title={t("account_tokens_table_cannot_delete_or_edit")}>
  865. <span>
  866. <IconButton disabled>
  867. <EditIcon />
  868. </IconButton>
  869. <IconButton disabled>
  870. <CloseIcon />
  871. </IconButton>
  872. </span>
  873. </Tooltip>
  874. )}
  875. {token.provisioned && (
  876. <Tooltip title={t("account_tokens_table_cannot_delete_or_edit_provisioned_token")}>
  877. <span>
  878. <IconButton disabled>
  879. <EditIcon />
  880. </IconButton>
  881. <IconButton disabled>
  882. <CloseIcon />
  883. </IconButton>
  884. </span>
  885. </Tooltip>
  886. )}
  887. </TableCell>
  888. </TableRow>
  889. ))}
  890. </TableBody>
  891. <Portal>
  892. <Snackbar
  893. open={snackOpen}
  894. autoHideDuration={3000}
  895. onClose={() => setSnackOpen(false)}
  896. message={t("account_tokens_table_copied_to_clipboard")}
  897. />
  898. </Portal>
  899. <TokenDialog key={`tokenDialogEdit${upsertDialogKey}`} open={upsertDialogOpen} token={selectedToken} onClose={handleDialogClose} />
  900. <TokenDeleteDialog open={deleteDialogOpen} token={selectedToken} onClose={handleDialogClose} />
  901. </Table>
  902. );
  903. };
  904. const TokenDialog = (props) => {
  905. const theme = useTheme();
  906. const { t } = useTranslation();
  907. const [error, setError] = useState("");
  908. const [label, setLabel] = useState(props.token?.label || "");
  909. const [expires, setExpires] = useState(props.token ? -1 : 0);
  910. const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
  911. const editMode = !!props.token;
  912. const handleSubmit = async () => {
  913. try {
  914. if (editMode) {
  915. await accountApi.updateToken(props.token.token, label, expires);
  916. } else {
  917. await accountApi.createToken(label, expires);
  918. }
  919. props.onClose();
  920. } catch (e) {
  921. console.log(`[Account] Error creating token`, e);
  922. if (e instanceof UnauthorizedError) {
  923. await session.resetAndRedirect(routes.login);
  924. } else {
  925. setError(e.message);
  926. }
  927. }
  928. };
  929. return (
  930. <Dialog open={props.open} onClose={props.onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
  931. <DialogTitle>{editMode ? t("account_tokens_dialog_title_edit") : t("account_tokens_dialog_title_create")}</DialogTitle>
  932. <DialogContent>
  933. <TextField
  934. margin="dense"
  935. id="token-label"
  936. label={t("account_tokens_dialog_label")}
  937. aria-label={t("account_delete_dialog_label")}
  938. type="text"
  939. value={label}
  940. onChange={(ev) => setLabel(ev.target.value)}
  941. fullWidth
  942. variant="standard"
  943. />
  944. <FormControl fullWidth variant="standard" sx={{ mt: 1 }}>
  945. <Select value={expires} onChange={(ev) => setExpires(ev.target.value)} aria-label={t("account_tokens_dialog_expires_label")}>
  946. {editMode && <MenuItem value={-1}>{t("account_tokens_dialog_expires_unchanged")}</MenuItem>}
  947. <MenuItem value={0}>{t("account_tokens_dialog_expires_never")}</MenuItem>
  948. <MenuItem value={21600}>{t("account_tokens_dialog_expires_x_hours", { hours: 6 })}</MenuItem>
  949. <MenuItem value={43200}>{t("account_tokens_dialog_expires_x_hours", { hours: 12 })}</MenuItem>
  950. <MenuItem value={259200}>{t("account_tokens_dialog_expires_x_days", { days: 3 })}</MenuItem>
  951. <MenuItem value={604800}>{t("account_tokens_dialog_expires_x_days", { days: 7 })}</MenuItem>
  952. <MenuItem value={2592000}>{t("account_tokens_dialog_expires_x_days", { days: 30 })}</MenuItem>
  953. <MenuItem value={7776000}>{t("account_tokens_dialog_expires_x_days", { days: 90 })}</MenuItem>
  954. <MenuItem value={15552000}>{t("account_tokens_dialog_expires_x_days", { days: 180 })}</MenuItem>
  955. </Select>
  956. </FormControl>
  957. </DialogContent>
  958. <DialogFooter status={error}>
  959. <Button onClick={props.onClose}>{t("account_tokens_dialog_button_cancel")}</Button>
  960. <Button onClick={handleSubmit}>
  961. {editMode ? t("account_tokens_dialog_button_update") : t("account_tokens_dialog_button_create")}
  962. </Button>
  963. </DialogFooter>
  964. </Dialog>
  965. );
  966. };
  967. const TokenDeleteDialog = (props) => {
  968. const { t } = useTranslation();
  969. const [error, setError] = useState("");
  970. const handleSubmit = async () => {
  971. try {
  972. await accountApi.deleteToken(props.token.token);
  973. props.onClose();
  974. } catch (e) {
  975. console.log(`[Account] Error deleting token`, e);
  976. if (e instanceof UnauthorizedError) {
  977. await session.resetAndRedirect(routes.login);
  978. } else {
  979. setError(e.message);
  980. }
  981. }
  982. };
  983. return (
  984. <Dialog open={props.open} onClose={props.onClose}>
  985. <DialogTitle>{t("account_tokens_delete_dialog_title")}</DialogTitle>
  986. <DialogContent>
  987. <DialogContentText>
  988. <Trans i18nKey="account_tokens_delete_dialog_description" />
  989. </DialogContentText>
  990. </DialogContent>
  991. <DialogFooter status={error}>
  992. <Button onClick={props.onClose}>{t("common_cancel")}</Button>
  993. <Button onClick={handleSubmit} color="error">
  994. {t("account_tokens_delete_dialog_submit_button")}
  995. </Button>
  996. </DialogFooter>
  997. </Dialog>
  998. );
  999. };
  1000. const Delete = () => {
  1001. const { t } = useTranslation();
  1002. return (
  1003. <Card sx={{ p: 3 }} aria-label={t("account_delete_title")}>
  1004. <Typography variant="h5" sx={{ marginBottom: 2 }}>
  1005. {t("account_delete_title")}
  1006. </Typography>
  1007. <PrefGroup>
  1008. <DeleteAccount />
  1009. </PrefGroup>
  1010. </Card>
  1011. );
  1012. };
  1013. const DeleteAccount = () => {
  1014. const { t } = useTranslation();
  1015. const [dialogKey, setDialogKey] = useState(0);
  1016. const [dialogOpen, setDialogOpen] = useState(false);
  1017. const { account } = useContext(AccountContext);
  1018. const handleDialogOpen = () => {
  1019. setDialogKey((prev) => prev + 1);
  1020. setDialogOpen(true);
  1021. };
  1022. const handleDialogClose = () => {
  1023. setDialogOpen(false);
  1024. };
  1025. return (
  1026. <Pref title={t("account_delete_title")} description={t("account_delete_description")}>
  1027. <div>
  1028. {!account?.provisioned ? (
  1029. <Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} onClick={handleDialogOpen}>
  1030. {t("account_delete_title")}
  1031. </Button>
  1032. ) : (
  1033. <Tooltip title={t("account_basics_cannot_edit_or_delete_provisioned_user")}>
  1034. <span>
  1035. <Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} disabled>
  1036. {t("account_delete_title")}
  1037. </Button>
  1038. </span>
  1039. </Tooltip>
  1040. )}
  1041. </div>
  1042. <DeleteAccountDialog key={`deleteAccountDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
  1043. </Pref>
  1044. );
  1045. };
  1046. const DeleteAccountDialog = (props) => {
  1047. const theme = useTheme();
  1048. const { t } = useTranslation();
  1049. const { account } = useContext(AccountContext);
  1050. const [error, setError] = useState("");
  1051. const [password, setPassword] = useState("");
  1052. const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
  1053. const handleSubmit = async () => {
  1054. try {
  1055. await accountApi.delete(password);
  1056. await db().delete();
  1057. console.debug(`[Account] Account deleted`);
  1058. await session.resetAndRedirect(routes.app);
  1059. } catch (e) {
  1060. console.log(`[Account] Error deleting account`, e);
  1061. if (e instanceof IncorrectPasswordError) {
  1062. setError(t("account_basics_password_dialog_current_password_incorrect"));
  1063. } else if (e instanceof UnauthorizedError) {
  1064. await session.resetAndRedirect(routes.login);
  1065. } else {
  1066. setError(e.message);
  1067. }
  1068. }
  1069. };
  1070. return (
  1071. <Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
  1072. <DialogTitle>{t("account_delete_title")}</DialogTitle>
  1073. <DialogContent>
  1074. <Typography variant="body1">{t("account_delete_dialog_description")}</Typography>
  1075. <TextField
  1076. margin="dense"
  1077. id="account-delete-confirm"
  1078. label={t("account_delete_dialog_label")}
  1079. aria-label={t("account_delete_dialog_label")}
  1080. type="password"
  1081. value={password}
  1082. onChange={(ev) => setPassword(ev.target.value)}
  1083. fullWidth
  1084. variant="standard"
  1085. />
  1086. {account?.billing?.subscription && (
  1087. <Alert severity="warning" sx={{ mt: 1 }}>
  1088. {t("account_delete_dialog_billing_warning")}
  1089. </Alert>
  1090. )}
  1091. </DialogContent>
  1092. <DialogFooter status={error}>
  1093. <Button onClick={props.onClose}>{t("account_delete_dialog_button_cancel")}</Button>
  1094. <Button onClick={handleSubmit} color="error" disabled={password.length === 0}>
  1095. {t("account_delete_dialog_button_submit")}
  1096. </Button>
  1097. </DialogFooter>
  1098. </Dialog>
  1099. );
  1100. };
  1101. export default Account;