import * as React from "react"; import { useContext, useState } from "react"; import { Alert, Box, 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, Menu, MenuItem, ListItemIcon, ListItemText, DialogContentText, useTheme, } from "@mui/material"; import EditIcon from "@mui/icons-material/Edit"; import { Trans, useTranslation } from "react-i18next"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlineOutlined"; import StarIcon from "@mui/icons-material/Star"; import StarBorderIcon from "@mui/icons-material/StarBorder"; import RefreshIcon from "@mui/icons-material/Refresh"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import CelebrationIcon from "@mui/icons-material/Celebration"; import CloseIcon from "@mui/icons-material/Close"; import ContentCopy from "@mui/icons-material/ContentCopy"; import Public from "@mui/icons-material/Public"; import AddIcon from "@mui/icons-material/Add"; import routes from "./routes"; import { copyToClipboard, formatBytes, formatDate, formatDateTime, formatShortDuration, 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 "./AccountContext"; import { usePrefCache } from "./PrefCache"; import DialogFooter from "./DialogFooter"; import { Paragraph } from "./styles"; import { EmailPrimaryElsewhereError, 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 ( ); }; const Basics = () => { const { t } = useTranslation(); return ( {t("account_basics_title")} ); }; const Username = () => { const { t } = useTranslation(); const { account } = useContext(AccountContext); const labelId = "prefUsername"; return (
{session.username()} {account?.role === Role.ADMIN && ( <> {" "} 👑 )}
); }; 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 (
⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤ {!account?.provisioned ? ( ) : ( )}
); }; 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 ( {t("account_basics_password_dialog_title")} setCurrentPassword(ev.target.value)} fullWidth variant="standard" /> setNewPassword(ev.target.value)} fullWidth variant="standard" /> setConfirmPassword(ev.target.value)} fullWidth variant="standard" /> ); }; const AccountType = () => { const { t } = useTranslation(); const { dateFormat } = usePrefCache(); 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); } } }; // The account type is a base label ("Admin", "Basic", "Free", or the tier name) plus an optional // qualifier chip (admin tier status, or the billing interval). let accountType; let qualifierChip; if (account.role === Role.ADMIN) { accountType = t("account_basics_tier_admin"); qualifierChip = account.tier ? t("account_basics_tier_admin_suffix_with_tier", { tier: account.tier.name }) : t("account_basics_tier_admin_suffix_no_tier"); } 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) { qualifierChip = t("account_basics_tier_interval_monthly"); } else if (account.billing?.interval === SubscriptionInterval.YEAR) { qualifierChip = t("account_basics_tier_interval_yearly"); } } return ( 0} title={t("account_basics_tier_title")} description={t("account_basics_tier_description")} >
{accountType} {qualifierChip && } {account.provisioned && } {account.billing?.paid_until && !account.billing?.cancel_at && ( )} {config.enable_payments && account.role === Role.USER && !account.billing?.subscription && ( )} {config.enable_payments && account.role === Role.USER && account.billing?.subscription && ( )} {config.enable_payments && account.role === Role.USER && account.billing?.customer && ( )} {config.enable_payments && ( setUpgradeDialogOpen(false)} /> )}
{account.billing?.status === SubscriptionStatus.PAST_DUE && ( {t("account_basics_tier_payment_overdue")} )} {account.billing?.cancel_at > 0 && ( {t("account_basics_tier_canceled_subscription", { date: formatDate(account.billing.cancel_at, dateFormat), })} )} setShowPortalError(false)} message={t("account_usage_cannot_create_portal_session")} />
); }; const Emails = () => { const { t } = useTranslation(); const { account } = useContext(AccountContext); const [dialogKey, setDialogKey] = useState(0); const [dialogOpen, setDialogOpen] = useState(false); const [snack, setSnack] = useState(""); // Non-empty shows a transient snackbar message const [menuAnchor, setMenuAnchor] = useState(null); // Chip element the actions menu is anchored to const [menuEmail, setMenuEmail] = useState(null); // The email the open menu acts on const labelId = "prefVerifiedEmails"; const openMenu = (ev, email) => { setMenuAnchor(ev.currentTarget); setMenuEmail(email); }; const closeMenu = () => { setMenuAnchor(null); setMenuEmail(null); }; const runMenuAction = (fn) => { closeMenu(); fn(menuEmail.address); }; const handleDialogOpen = () => { setDialogKey((prev) => prev + 1); setDialogOpen(true); }; const handleDialogClose = () => { setDialogOpen(false); }; const handleCopy = (email) => { copyToClipboard(email); setSnack(t("account_basics_emails_copied_to_clipboard")); }; // runEmailAction wraps an email account action with the shared error handling used by the // delete/set-primary/resend handlers (redirect on unauthorized, surface a message otherwise). // The account view refreshes via the server's sync event, so there's no explicit refetch here. const runEmailAction = async (fn, errorMessage) => { try { await fn(); } catch (e) { console.log(`[Account] Email action failed`, e); if (e instanceof UnauthorizedError) { await session.resetAndRedirect(routes.login); } else if (e instanceof EmailPrimaryElsewhereError) { setSnack(t("account_basics_emails_primary_elsewhere")); } else { setSnack(errorMessage ?? e.message); } } }; const handleDelete = (email) => runEmailAction(() => accountApi.deleteEmail(email)); const handleSetPrimary = (email) => runEmailAction(() => accountApi.setPrimaryEmail(email)); const handleResend = (email) => runEmailAction(async () => { await accountApi.resendEmailVerification(email); setSnack(t("account_basics_emails_resent")); }); if (!config.enable_emails) { return null; } if (account?.limits.emails === 0) { return ( {t("account_basics_emails_title")} {config.enable_payments && } } description={t("account_basics_emails_description")} > {t("account_usage_emails_none")} ); } const emails = account?.emails ?? []; // Verified addresses, primary always first const verifiedEmails = emails.filter((e) => !e.pending).sort((a, b) => (b.primary ? 1 : 0) - (a.primary ? 1 : 0)); const pendingEmails = emails.filter((e) => e.pending); const primaryEmail = verifiedEmails.find((e) => e.primary)?.address ?? ""; // Recovery nudges (skipped for provisioned users -- they can't reset, and the Add-email dialog // explains the limitation): prompt for a first email when there are none, or for a primary when // there are emails but none is primary. const recoveryRelevant = config.enable_reset_password && !account?.provisioned; const hasNoEmails = verifiedEmails.length === 0 && pendingEmails.length === 0; const showNoEmailWarning = recoveryRelevant && hasNoEmails; const showNoPrimaryWarning = recoveryRelevant && !hasNoEmails && primaryEmail === ""; return (
{verifiedEmails.map((email) => ( : undefined} label={ {email.address} } variant="outlined" onClick={(ev) => openMenu(ev, email)} onDelete={() => handleDelete(email.address)} sx={email.primary ? { "& .MuiChip-icon": { color: "#fbc02d" } } : undefined} /> ))} {pendingEmails.map((email) => ( {email.address} ({t("account_basics_emails_unverified")}) } variant="outlined" onClick={(ev) => openMenu(ev, email)} onDelete={() => handleDelete(email.address)} sx={{ opacity: 0.7 }} /> ))} {verifiedEmails.length === 0 && pendingEmails.length === 0 && {t("account_basics_emails_no_emails_yet")}} {showNoEmailWarning && ( {t("account_basics_emails_no_recovery_warning")} )} {showNoPrimaryWarning && ( {t("account_basics_emails_no_primary_warning")} )}
runMenuAction(handleCopy)}> {t("common_copy_to_clipboard")} {menuEmail && !menuEmail.pending && !menuEmail.primary && ( runMenuAction(handleSetPrimary)}> {t("account_basics_emails_set_primary")} )} {menuEmail && menuEmail.pending && ( runMenuAction(handleResend)}> {t("account_basics_emails_resend")} )} runMenuAction(handleDelete)}> {t("account_basics_emails_delete")} setSnack("")} message={snack} />
); }; const AddEmailDialog = (props) => { const theme = useTheme(); const { t } = useTranslation(); const [error, setError] = useState(""); const [email, setEmail] = useState(""); const [sending, setSending] = useState(false); const [sent, setSent] = useState(false); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); // handleSubmit starts verification: the server emails a magic link. The pending address shows // up in the account list as "(unverified)" once the account refreshes. const handleSubmit = async () => { try { setSending(true); setError(""); // Clear any error from a previous attempt await accountApi.startEmailVerification(email); setSent(true); } catch (e) { console.log(`[Account] Error starting email verification`, e); if (e instanceof UnauthorizedError) { await session.resetAndRedirect(routes.login); } else { setError(e.message); } } finally { setSending(false); } }; return ( {t("account_basics_emails_dialog_title")} {sent ? ( {t("account_basics_emails_dialog_check_inbox")} ) : ( <> {t("account_basics_emails_dialog_description")} setEmail(ev.target.value)} fullWidth variant="standard" /> )} {sent ? ( ) : ( <> )} ); }; 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) => { copyToClipboard(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 ( {t("account_basics_phone_numbers_title")} {config.enable_payments && } } description={t("account_basics_phone_numbers_description")} > {t("account_usage_calls_none")} ); } return (
{account?.phone_numbers?.map((phoneNumber) => ( {phoneNumber} } variant="outlined" onClick={() => handleCopy(phoneNumber)} onDelete={() => handleDelete(phoneNumber)} /> ))} {!account?.phone_numbers && {t("account_basics_phone_numbers_no_phone_numbers_yet")}}
setSnackOpen(false)} message={t("account_basics_phone_numbers_copied_to_clipboard")} />
); }; 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 ( {t("account_basics_phone_numbers_dialog_title")} {t("account_basics_phone_numbers_dialog_description")} {!verificationCodeSent && (
setPhoneNumber(ev.target.value)} slotProps={{ htmlInput: { inputMode: "tel", pattern: "+[0-9]*" } }} variant="standard" sx={{ flexGrow: 1 }} /> setChannel(e.target.value)} />} label={t("account_basics_phone_numbers_dialog_channel_sms")} /> setChannel(e.target.value)} />} label={t("account_basics_phone_numbers_dialog_channel_call")} sx={{ marginRight: 0 }} />
)} {verificationCodeSent && ( setCode(ev.target.value)} fullWidth slotProps={{ htmlInput: { inputMode: "numeric", pattern: "[0-9]*" } }} variant="standard" /> )}
); }; const Stats = () => { const { t, i18n } = useTranslation(); const { account } = useContext(AccountContext); if (!account) { return <>; } const normalize = (value, max) => Math.min((value / max) * 100, 100); return ( {t("account_usage_title")} {(account.role === Role.ADMIN || account.limits.reservations > 0) && (
{account.stats.reservations.toLocaleString()} {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.reservations.toLocaleString(), }) : t("account_usage_unlimited")}
0 ? normalize(account.stats.reservations, account.limits.reservations) : 100 } />
)} {t("account_usage_messages_title")} } >
{account.stats.messages.toLocaleString()} {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages.toLocaleString(), }) : t("account_usage_unlimited")}
{config.enable_emails && ( {t("account_usage_emails_title")} } >
{account.stats.emails.toLocaleString()} {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails.toLocaleString(), }) : t("account_usage_unlimited")}
)} {config.enable_calls && (account.role === Role.ADMIN || account.limits.calls > 0) && ( {t("account_usage_calls_title")} } >
{account.stats.calls.toLocaleString()} {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.calls.toLocaleString(), }) : t("account_usage_unlimited")}
0 ? normalize(account.stats.calls, account.limits.calls) : 100} />
)}
{formatBytes(account.stats.attachment_total_size)} {account.role === Role.USER ? t("account_usage_of_limit", { limit: formatBytes(account.limits.attachment_total_size), }) : t("account_usage_unlimited")}
{config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 && ( {t("account_usage_reservations_title")} {config.enable_payments && } } > {t("account_usage_reservations_none")} )} {config.enable_calls && account.role === Role.USER && account.limits.calls === 0 && ( {t("account_usage_calls_title")} {config.enable_payments && } } > {t("account_usage_calls_none")} )}
{account.role === Role.USER && account.limits.basis === LimitBasis.IP && ( {t("account_usage_basis_ip_description")} )}
); }; const InfoIcon = () => ( ); 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 ( {t("account_tokens_title")} , }} />
{tokens?.length > 0 && }
); }; const TokensTable = (props) => { const { t } = useTranslation(); const { dateFormat, timeFormat } = usePrefCache(); 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) => { copyToClipboard(token); setSnackOpen(true); }; return ( {t("account_tokens_table_token_header")} {t("account_tokens_table_label_header")} {t("account_tokens_table_expires_header")} {t("account_tokens_table_last_access_header")} {tokens.map((token) => { const hasLastAccess = Number.isFinite(token.last_access) && token.last_access > 0; const hasLastOrigin = !!token.last_origin; return ( {token.token.slice(0, 12)} ... handleCopy(token.token)}> {token.token === session.token() && {t("account_tokens_table_current_session")}} {token.token !== session.token() && (token.label || "-")} {token.expires ? formatDateTime(token.expires, dateFormat, timeFormat) : {t("account_tokens_table_never_expires")}}
{hasLastAccess ? {formatDateTime(token.last_access, dateFormat, timeFormat)} : -} {hasLastOrigin && ( openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}> )}
{token.token !== session.token() && !token.provisioned && ( <> handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}> handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}> )} {token.token === session.token() && ( )} {token.provisioned && ( )}
); })}
setSnackOpen(false)} message={t("account_tokens_table_copied_to_clipboard")} />
); }; 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 ( {editMode ? t("account_tokens_dialog_title_edit") : t("account_tokens_dialog_title_create")} setLabel(ev.target.value)} fullWidth variant="standard" /> ); }; 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 ( {t("account_tokens_delete_dialog_title")} ); }; const Delete = () => { const { t } = useTranslation(); return ( {t("account_delete_title")} ); }; 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 (
{!account?.provisioned ? ( ) : ( )}
); }; 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, { fade: true }); } 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 ( {t("account_delete_title")} {t("account_delete_dialog_description")} setPassword(ev.target.value)} fullWidth variant="standard" /> {account?.billing?.subscription && ( {t("account_delete_dialog_billing_warning")} )} ); }; export default Account;