Phase 2, email verification rework, ui stuff

This commit is contained in:
binwiederhier
2026-06-12 11:40:59 -04:00
parent 44dac47d76
commit 30dd4840a2
19 changed files with 789 additions and 332 deletions
+118 -101
View File
@@ -2,6 +2,7 @@ import * as React from "react";
import { useContext, useState } from "react";
import {
Alert,
Box,
CardActions,
CardContent,
Chip,
@@ -38,6 +39,9 @@ import {
import EditIcon from "@mui/icons-material/Edit";
import { Trans, useTranslation } from "react-i18next";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
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";
@@ -52,7 +56,7 @@ import UpgradeDialog from "./UpgradeDialog";
import { AccountContext } from "./App";
import DialogFooter from "./DialogFooter";
import { Paragraph } from "./styles";
import { EmailVerificationCodeInvalidError, IncorrectPasswordError, UnauthorizedError } from "../app/errors";
import { EmailPrimaryElsewhereError, IncorrectPasswordError, UnauthorizedError } from "../app/errors";
import { ProChip } from "./SubscriptionPopup";
import session from "../app/Session";
@@ -359,7 +363,7 @@ const Emails = () => {
const { account } = useContext(AccountContext);
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const [snackOpen, setSnackOpen] = useState(false);
const [snack, setSnack] = useState(""); // Non-empty shows a transient snackbar message
const labelId = "prefVerifiedEmails";
const handleDialogOpen = () => {
@@ -373,20 +377,34 @@ const Emails = () => {
const handleCopy = (email) => {
copyToClipboard(email);
setSnackOpen(true);
setSnack(t("account_basics_emails_copied_to_clipboard"));
};
const handleDelete = async (email) => {
// runEmailAction wraps an account API call with the shared error handling (redirect on
// unauthorized, surface a message otherwise). The account list refreshes via the sync event.
const runEmailAction = async (fn, errorMessage) => {
try {
await accountApi.deleteEmail(email);
await fn();
} catch (e) {
console.log(`[Account] Error deleting email`, 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_email_verify) {
return null;
}
@@ -407,35 +425,73 @@ const Emails = () => {
);
}
const verifiedEmails = account?.emails ?? [];
const pendingEmails = account?.pending_emails ?? [];
const primaryEmail = account?.primary_email ?? "";
const showNoRecoveryWarning = config.enable_reset_password && primaryEmail === "";
return (
<Pref labelId={labelId} title={t("account_basics_emails_title")} description={t("account_basics_emails_description")}>
<div aria-labelledby={labelId}>
{account?.emails?.map((email) => (
<Chip
key={email}
label={
<Tooltip title={t("common_copy_to_clipboard")}>
<span>{email}</span>
{showNoRecoveryWarning && (
<Alert severity="warning" sx={{ mb: 1 }}>
{t("account_basics_emails_no_recovery_warning")}
</Alert>
)}
<Stack spacing={0.5}>
{verifiedEmails.map((email) => {
const isPrimary = email === primaryEmail;
return (
<Box key={email} sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Typography sx={{ flexGrow: 1, overflowWrap: "anywhere" }}>{email}</Typography>
{isPrimary && <Chip size="small" color="primary" label={t("account_basics_emails_primary_badge")} />}
{!isPrimary && (
<Tooltip title={t("account_basics_emails_set_primary")}>
<IconButton size="small" aria-label={t("account_basics_emails_set_primary")} onClick={() => handleSetPrimary(email)}>
<StarBorderIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{isPrimary && <StarIcon fontSize="small" color="primary" sx={{ mx: 0.5 }} />}
<Tooltip title={t("common_copy_to_clipboard")}>
<IconButton size="small" aria-label={t("common_copy_to_clipboard")} onClick={() => handleCopy(email)}>
<ContentCopy fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={t("account_basics_emails_delete")}>
<IconButton size="small" aria-label={t("account_basics_emails_delete")} onClick={() => handleDelete(email)}>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
);
})}
{pendingEmails.map((email) => (
<Box key={email} sx={{ display: "flex", alignItems: "center", gap: 0.5, opacity: 0.65 }}>
<Typography sx={{ flexGrow: 1, overflowWrap: "anywhere" }}>
{email} <em>({t("account_basics_emails_unverified")})</em>
</Typography>
<Tooltip title={t("account_basics_emails_resend")}>
<IconButton size="small" aria-label={t("account_basics_emails_resend")} onClick={() => handleResend(email)}>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
}
variant="outlined"
onClick={() => handleCopy(email)}
onDelete={() => handleDelete(email)}
/>
))}
{!account?.emails && <em>{t("account_basics_emails_no_emails_yet")}</em>}
<IconButton onClick={handleDialogOpen}>
<Tooltip title={t("account_basics_emails_cancel")}>
<IconButton size="small" aria-label={t("account_basics_emails_cancel")} onClick={() => handleDelete(email)}>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
))}
{verifiedEmails.length === 0 && pendingEmails.length === 0 && <em>{t("account_basics_emails_no_emails_yet")}</em>}
</Stack>
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_emails_dialog_title")}>
<AddIcon />
</IconButton>
</div>
<AddEmailDialog key={`addEmailDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
<Portal>
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message={t("account_basics_emails_copied_to_clipboard")}
/>
<Snackbar open={snack !== ""} autoHideDuration={3000} onClose={() => setSnack("")} message={snack} />
</Portal>
</Pref>
);
@@ -446,18 +502,19 @@ const AddEmailDialog = (props) => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [sending, setSending] = useState(false);
const [verificationCodeSent, setVerificationCodeSent] = useState(false);
const [sent, setSent] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const verifyEmail = async () => {
// 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);
await accountApi.verifyEmail(email);
setVerificationCodeSent(true);
await accountApi.startEmailVerification(email);
setSent(true);
} catch (e) {
console.log(`[Account] Error sending email verification`, e);
console.log(`[Account] Error starting email verification`, e);
if (e instanceof UnauthorizedError) {
await session.resetAndRedirect(routes.login);
} else {
@@ -468,81 +525,41 @@ const AddEmailDialog = (props) => {
}
};
const checkVerifyEmail = async () => {
try {
setSending(true);
await accountApi.addEmail(email, code);
props.onClose();
} catch (e) {
console.log(`[Account] Error confirming email verification`, e);
if (e instanceof UnauthorizedError) {
await session.resetAndRedirect(routes.login);
} else if (e instanceof EmailVerificationCodeInvalidError) {
setError(t("account_basics_emails_dialog_code_invalid"));
} else {
setError(e.message);
}
} finally {
setSending(false);
}
};
const handleDialogSubmit = async () => {
if (!verificationCodeSent) {
await verifyEmail();
} else {
await checkVerifyEmail();
}
};
const handleCancel = () => {
if (verificationCodeSent) {
setVerificationCodeSent(false);
setCode("");
} else {
props.onClose();
}
};
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
<DialogTitle>{t("account_basics_emails_dialog_title")}</DialogTitle>
<DialogContent>
<DialogContentText>{t("account_basics_emails_dialog_description")}</DialogContentText>
{!verificationCodeSent && (
<TextField
margin="dense"
label={t("account_basics_emails_dialog_email_label")}
aria-label={t("account_basics_emails_dialog_email_label")}
placeholder={t("account_basics_emails_dialog_email_placeholder")}
type="email"
value={email}
onChange={(ev) => setEmail(ev.target.value)}
fullWidth
variant="standard"
/>
)}
{verificationCodeSent && (
<TextField
margin="dense"
label={t("account_basics_emails_dialog_code_label")}
aria-label={t("account_basics_emails_dialog_code_label")}
placeholder={t("account_basics_emails_dialog_code_placeholder")}
type="text"
value={code}
onChange={(ev) => setCode(ev.target.value)}
fullWidth
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
variant="standard"
/>
{sent ? (
<DialogContentText>{t("account_basics_emails_dialog_check_inbox")}</DialogContentText>
) : (
<>
<DialogContentText>{t("account_basics_emails_dialog_description")}</DialogContentText>
<TextField
autoFocus
margin="dense"
label={t("account_basics_emails_dialog_email_label")}
aria-label={t("account_basics_emails_dialog_email_label")}
placeholder={t("account_basics_emails_dialog_email_placeholder")}
type="email"
value={email}
onChange={(ev) => setEmail(ev.target.value)}
fullWidth
variant="standard"
/>
</>
)}
</DialogContent>
<DialogFooter status={error}>
<Button onClick={handleCancel}>{verificationCodeSent ? t("common_back") : t("common_cancel")}</Button>
<Button onClick={handleDialogSubmit} disabled={sending || !/^[^\s,;]+@[^\s,;]+$/.test(email)}>
{!verificationCodeSent && t("account_basics_emails_dialog_verify_button")}
{verificationCodeSent && t("account_basics_emails_dialog_check_verification_button")}
</Button>
{sent ? (
<Button onClick={props.onClose}>{t("common_close")}</Button>
) : (
<>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} disabled={sending || !/^[^\s,;]+@[^\s,;]+$/.test(email)}>
{t("account_basics_emails_dialog_verify_button")}
</Button>
</>
)}
</DialogFooter>
</Dialog>
);