Lots of refinement

This commit is contained in:
binwiederhier
2026-06-12 17:36:40 -04:00
parent 33ae31055c
commit 4516adea36
16 changed files with 460 additions and 199 deletions
+12 -6
View File
@@ -28,11 +28,14 @@
"login_form_button_submit": "Sign in",
"login_link_signup": "Sign up",
"login_link_forgot_password": "Forgot password?",
"login_reset_dialog_title": "Reset password",
"login_reset_dialog_description": "Enter your username or email address. If an account exists, we'll email a link to reset your password.",
"login_reset_dialog_identifier_label": "Username or email",
"login_reset_dialog_button_submit": "Send reset link",
"login_reset_dialog_sent": "If an account exists, we've emailed a link to reset your password. Please check your inbox.",
"reset_password_request_title": "Reset password",
"reset_password_request_description": "Enter your username or email address. If an account exists, we'll email a link to reset your password.",
"reset_password_request_identifier_label": "Username or email",
"reset_password_request_button_submit": "Send reset link",
"reset_password_sent_title": "Check your inbox",
"reset_password_sent_description": "If an account exists, we've emailed a link to reset your password.",
"reset_password_back_to_login": "Back to sign-in",
"reset_password_disabled": "Password reset is not enabled on this server.",
"reset_password_title": "Set a new password",
"reset_password_form_password": "New password",
"reset_password_form_confirm": "Confirm new password",
@@ -242,6 +245,7 @@
"account_basics_emails_no_emails_yet": "No emails yet",
"account_basics_emails_copied_to_clipboard": "Email address copied to clipboard",
"account_basics_emails_primary_badge": "Primary",
"account_basics_emails_chip_actions": "Click for actions",
"account_basics_emails_unverified": "unverified",
"account_basics_emails_set_primary": "Set as recovery email",
"account_basics_emails_delete": "Remove",
@@ -249,7 +253,8 @@
"account_basics_emails_resend": "Resend verification email",
"account_basics_emails_resent": "Verification email sent, check your inbox",
"account_basics_emails_primary_elsewhere": "This email is the recovery email on another account",
"account_basics_emails_no_recovery_warning": "No recovery email set. You will not be able to reset your password. Add and verify an email below, then set it as your recovery email.",
"account_basics_emails_no_recovery_warning": "Add a recovery email address to ensure you can reset your password.",
"account_basics_emails_provisioned_info": "Provisioned users cannot add a recovery email address.",
"account_basics_emails_dialog_title": "Add email address",
"account_basics_emails_dialog_description": "Enter an email address to add it to your account. We will send a verification link to confirm it is yours.",
"account_basics_emails_dialog_email_label": "Email address",
@@ -267,6 +272,7 @@
"account_basics_tier_admin_suffix_with_tier": "(with {{tier}} tier)",
"account_basics_tier_admin_suffix_no_tier": "(no tier)",
"account_basics_tier_basic": "Basic",
"account_basics_tier_provisioned": "Provisioned",
"account_basics_tier_free": "Free",
"account_basics_tier_interval_monthly": "monthly",
"account_basics_tier_interval_yearly": "annually",
+96 -57
View File
@@ -2,7 +2,6 @@ import * as React from "react";
import { useContext, useState } from "react";
import {
Alert,
Box,
CardActions,
CardContent,
Chip,
@@ -32,7 +31,10 @@ import {
DialogContent,
TextField,
IconButton,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
DialogContentText,
useTheme,
} from "@mui/material";
@@ -294,6 +296,7 @@ const AccountType = () => {
>
<div>
{accountType}
{account.provisioned && <Chip size="small" label={t("account_basics_tier_provisioned")} sx={{ ml: 1 }} />}
{account.billing?.paid_until && !account.billing?.cancel_at && (
<Tooltip
title={t("account_basics_tier_paid_until", {
@@ -364,8 +367,23 @@ const Emails = () => {
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);
@@ -381,10 +399,12 @@ const Emails = () => {
};
// 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.
// unauthorized, surface a message otherwise). On success it refetches the account so the email
// list reflects the change immediately, rather than waiting for the async sync event.
const runEmailAction = async (fn, errorMessage) => {
try {
await fn();
await accountApi.sync();
} catch (e) {
console.log(`[Account] Email action failed`, e);
if (e instanceof UnauthorizedError) {
@@ -425,70 +445,82 @@ const Emails = () => {
);
}
const verifiedEmails = account?.emails ?? [];
const pendingEmails = account?.pending_emails ?? [];
const primaryEmail = account?.primary_email ?? "";
const showNoRecoveryWarning = config.enable_reset_password && primaryEmail === "";
const emails = account?.emails ?? [];
const verifiedEmails = emails.filter((e) => !e.pending);
const pendingEmails = emails.filter((e) => e.pending);
const primaryEmail = verifiedEmails.find((e) => e.primary)?.address ?? "";
// Provisioned users get their password from the server config and cannot reset it, so they don't
// get the "no recovery email" nudge (the Add-email dialog explains the recovery-email limitation).
const showNoRecoveryWarning = config.enable_reset_password && primaryEmail === "" && !account?.provisioned;
return (
<Pref labelId={labelId} title={t("account_basics_emails_title")} description={t("account_basics_emails_description")}>
<Pref labelId={labelId} alignTop title={t("account_basics_emails_title")} description={t("account_basics_emails_description")}>
<div aria-labelledby={labelId}>
{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>
{verifiedEmails.map((email) => (
<Chip
key={email.address}
icon={email.primary ? <StarIcon /> : undefined}
label={
<Tooltip title={t("account_basics_emails_chip_actions")}>
<span>{email.address}</span>
</Tooltip>
<Tooltip title={t("account_basics_emails_cancel")}>
<IconButton size="small" aria-label={t("account_basics_emails_cancel")} onClick={() => handleDelete(email)}>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
}
variant="outlined"
onClick={(ev) => openMenu(ev, email)}
onDelete={() => handleDelete(email.address)}
sx={email.primary ? { "& .MuiChip-icon": { color: "primary.main" } } : undefined}
/>
))}
{pendingEmails.map((email) => (
<Chip
key={email.address}
label={
<Tooltip title={t("account_basics_emails_chip_actions")}>
<span>
{email.address} <em>({t("account_basics_emails_unverified")})</em>
</span>
</Tooltip>
</Box>
))}
{verifiedEmails.length === 0 && pendingEmails.length === 0 && <em>{t("account_basics_emails_no_emails_yet")}</em>}
</Stack>
}
variant="outlined"
onClick={(ev) => openMenu(ev, email)}
onDelete={() => handleDelete(email.address)}
sx={{ opacity: 0.7 }}
/>
))}
{verifiedEmails.length === 0 && pendingEmails.length === 0 && <em>{t("account_basics_emails_no_emails_yet")}</em>}
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_emails_dialog_title")}>
<AddIcon />
</IconButton>
{showNoRecoveryWarning && (
<Alert severity="warning" sx={{ mt: 1 }}>
{t("account_basics_emails_no_recovery_warning")}
</Alert>
)}
</div>
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={closeMenu}>
<MenuItem onClick={() => runMenuAction(handleCopy)}>
<ListItemIcon>
<ContentCopy fontSize="small" />
</ListItemIcon>
<ListItemText>{t("common_copy_to_clipboard")}</ListItemText>
</MenuItem>
{menuEmail && !menuEmail.pending && !menuEmail.primary && !account?.provisioned && (
<MenuItem onClick={() => runMenuAction(handleSetPrimary)}>
<ListItemIcon>
<StarBorderIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t("account_basics_emails_set_primary")}</ListItemText>
</MenuItem>
)}
{menuEmail && menuEmail.pending && (
<MenuItem onClick={() => runMenuAction(handleResend)}>
<ListItemIcon>
<RefreshIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t("account_basics_emails_resend")}</ListItemText>
</MenuItem>
)}
</Menu>
<AddEmailDialog key={`addEmailDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
<Portal>
<Snackbar open={snack !== ""} autoHideDuration={3000} onClose={() => setSnack("")} message={snack} />
@@ -500,6 +532,7 @@ const Emails = () => {
const AddEmailDialog = (props) => {
const theme = useTheme();
const { t } = useTranslation();
const { account } = useContext(AccountContext);
const [error, setError] = useState("");
const [email, setEmail] = useState("");
const [sending, setSending] = useState(false);
@@ -512,6 +545,7 @@ const AddEmailDialog = (props) => {
try {
setSending(true);
await accountApi.startEmailVerification(email);
await accountApi.sync(); // Refresh so the new "(unverified)" address shows up immediately
setSent(true);
} catch (e) {
console.log(`[Account] Error starting email verification`, e);
@@ -534,6 +568,11 @@ const AddEmailDialog = (props) => {
) : (
<>
<DialogContentText>{t("account_basics_emails_dialog_description")}</DialogContentText>
{config.enable_reset_password && account?.provisioned && (
<Alert severity="info" sx={{ mt: 1 }}>
{t("account_basics_emails_provisioned_info")}
</Alert>
)}
<TextField
autoFocus
margin="dense"
+2
View File
@@ -22,6 +22,7 @@ import Signup from "./Signup";
import Account from "./Account";
import EmailVerify from "./EmailVerify";
import PasswordReset from "./PasswordReset";
import ResetPassword from "./ResetPassword";
import initI18n from "../app/i18n"; // Translations!
import prefs from "../app/Prefs";
import RTLCacheProvider from "./RTLCacheProvider";
@@ -65,6 +66,7 @@ const App = () => {
<Routes>
<Route path={routes.login} element={<Login />} />
<Route path={routes.signup} element={<Signup />} />
<Route path={routes.resetPassword} element={<ResetPassword />} />
<Route path={routes.emailVerify} element={<EmailVerify />} />
<Route path={routes.passwordReset} element={<PasswordReset />} />
<Route element={<Layout />}>
+17 -14
View File
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { Typography, Button, Box, CircularProgress } from "@mui/material";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import { useParams, NavLink } from "react-router-dom";
import { useParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import accountApi from "../app/AccountApi";
import AvatarBox from "./AvatarBox";
@@ -16,6 +16,7 @@ import routes from "./routes";
const EmailVerify = () => {
const { t } = useTranslation();
const { token } = useParams();
const navigate = useNavigate();
const [status, setStatus] = useState("verifying"); // "verifying" | "success" | "error"
const ran = useRef(false);
@@ -40,31 +41,33 @@ const EmailVerify = () => {
return (
<AvatarBox>
{status === "verifying" && (
<>
<CircularProgress sx={{ mb: 2 }} />
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<CircularProgress size={24} />
<Typography sx={{ typography: "h6" }}>{t("email_verify_progress_title")}</Typography>
</>
</Box>
)}
{status === "success" && (
<>
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 48, mb: 1 }} />
<Typography sx={{ typography: "h6" }}>{t("email_verify_success_title")}</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 32 }} />
<Typography sx={{ typography: "h6" }}>{t("email_verify_success_title")}</Typography>
</Box>
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("email_verify_success_description")}</Typography>
<Button component={NavLink} to={routes.account} variant="contained" sx={{ mt: 2 }}>
<Button onClick={() => navigate(routes.account)} variant="contained" sx={{ mt: 2 }}>
{t("email_verify_button_account")}
</Button>
</>
)}
{status === "error" && (
<>
<ErrorOutlineIcon color="error" sx={{ fontSize: 48, mb: 1 }} />
<Typography sx={{ typography: "h6" }}>{t("email_verify_error_title")}</Typography>
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("email_verify_error_description")}</Typography>
<Box sx={{ mt: 2 }}>
<Button component={NavLink} to={routes.account} variant="contained">
{t("email_verify_button_account")}
</Button>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<ErrorOutlineIcon color="error" sx={{ fontSize: 32 }} />
<Typography sx={{ typography: "h6" }}>{t("email_verify_error_title")}</Typography>
</Box>
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("email_verify_error_description")}</Typography>
<Button onClick={() => navigate(routes.account)} variant="contained" sx={{ mt: 2 }}>
{t("email_verify_button_account")}
</Button>
</>
)}
</AvatarBox>
+3 -75
View File
@@ -1,18 +1,6 @@
import * as React from "react";
import { useState } from "react";
import {
Typography,
TextField,
Button,
Box,
IconButton,
InputAdornment,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
} from "@mui/material";
import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "@mui/material";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { NavLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
@@ -29,7 +17,6 @@ const Login = () => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
const handleSubmit = async (event) => {
event.preventDefault();
@@ -115,9 +102,9 @@ const Login = () => {
<Box sx={{ width: "100%" }}>
{config.enable_reset_password && (
<div style={{ float: "left" }}>
<Button variant="text" onClick={() => setResetOpen(true)} sx={{ textTransform: "none", p: 0, minWidth: 0 }}>
<NavLink to={routes.resetPassword} variant="body1">
{t("login_link_forgot_password")}
</Button>
</NavLink>
</div>
)}
{config.enable_signup && (
@@ -129,67 +116,8 @@ const Login = () => {
)}
</Box>
</Box>
<ForgotPasswordDialog open={resetOpen} onClose={() => setResetOpen(false)} />
</AvatarBox>
);
};
// ForgotPasswordDialog collects a username/email and asks the server to email a reset link. The
// response is uniform, so the dialog always shows the same "if an account exists" confirmation.
const ForgotPasswordDialog = (props) => {
const { t } = useTranslation();
const [identifier, setIdentifier] = useState("");
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const handleSubmit = async () => {
try {
setSending(true);
await accountApi.requestPasswordReset(identifier);
} catch (e) {
console.log(`[Login] Password reset request failed`, e);
} finally {
setSending(false);
setSent(true); // Uniform outcome regardless of success/failure (enumeration-safe)
}
};
return (
<Dialog open={props.open} onClose={props.onClose}>
<DialogTitle>{t("login_reset_dialog_title")}</DialogTitle>
<DialogContent>
{sent ? (
<DialogContentText>{t("login_reset_dialog_sent")}</DialogContentText>
) : (
<>
<DialogContentText>{t("login_reset_dialog_description")}</DialogContentText>
<TextField
autoFocus
margin="dense"
label={t("login_reset_dialog_identifier_label")}
type="text"
value={identifier}
onChange={(ev) => setIdentifier(ev.target.value.trim())}
fullWidth
variant="standard"
/>
</>
)}
</DialogContent>
<DialogActions>
{sent ? (
<Button onClick={props.onClose}>{t("common_close")}</Button>
) : (
<>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} disabled={sending || identifier === ""}>
{t("login_reset_dialog_button_submit")}
</Button>
</>
)}
</DialogActions>
</Dialog>
);
};
export default Login;
+7 -4
View File
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { Typography, TextField, Button, Box } from "@mui/material";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import { useParams, NavLink } from "react-router-dom";
import { useParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import accountApi from "../app/AccountApi";
import AvatarBox from "./AvatarBox";
@@ -15,6 +15,7 @@ import routes from "./routes";
const PasswordReset = () => {
const { t } = useTranslation();
const { token: tokenParam } = useParams();
const navigate = useNavigate();
const token = useRef(tokenParam);
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
@@ -49,10 +50,12 @@ const PasswordReset = () => {
if (done) {
return (
<AvatarBox>
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 48, mb: 1 }} />
<Typography sx={{ typography: "h6" }}>{t("reset_password_success_title")}</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 32 }} />
<Typography sx={{ typography: "h6" }}>{t("reset_password_success_title")}</Typography>
</Box>
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("reset_password_success_description")}</Typography>
<Button component={NavLink} to={routes.login} variant="contained" sx={{ mt: 2 }}>
<Button onClick={() => navigate(routes.login)} variant="contained" sx={{ mt: 2 }}>
{t("reset_password_button_login")}
</Button>
</AvatarBox>
+95
View File
@@ -0,0 +1,95 @@
import * as React from "react";
import { useState } from "react";
import { TextField, Button, Box, Typography } from "@mui/material";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import { NavLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import accountApi from "../app/AccountApi";
import AvatarBox from "./AvatarBox";
import routes from "./routes";
// ResetPassword is the standalone "request a password reset" page, reached from the login page.
// It collects a username/email and asks the server to email a reset link. The response is uniform,
// so the page always shows the same confirmation. Completing the reset happens on the separate
// PasswordReset landing page that the emailed link points to.
const ResetPassword = () => {
const { t } = useTranslation();
const [identifier, setIdentifier] = useState("");
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const handleSubmit = async (event) => {
event.preventDefault();
try {
setSending(true);
await accountApi.requestPasswordReset(identifier);
} catch (e) {
console.log(`[ResetPassword] Request failed`, e);
} finally {
setSending(false);
setSent(true); // Uniform outcome regardless of success/failure (enumeration-safe)
}
};
if (!config.enable_reset_password) {
return (
<AvatarBox>
<Typography sx={{ typography: "h6" }}>{t("reset_password_disabled")}</Typography>
<Typography sx={{ mt: 2 }}>
<NavLink to={routes.login} variant="body1">
{t("reset_password_back_to_login")}
</NavLink>
</Typography>
</AvatarBox>
);
}
if (sent) {
return (
<AvatarBox>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 32 }} />
<Typography sx={{ typography: "h6" }}>{t("reset_password_sent_title")}</Typography>
</Box>
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("reset_password_sent_description")}</Typography>
<Typography sx={{ mt: 2, mb: 4 }}>
<NavLink to={routes.login} variant="body1">
{t("reset_password_back_to_login")}
</NavLink>
</Typography>
</AvatarBox>
);
}
return (
<AvatarBox>
<Typography sx={{ typography: "h6" }}>{t("reset_password_request_title")}</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
<Typography sx={{ mt: 1, mb: 1.5 }}>{t("reset_password_request_description")}</Typography>
<TextField
margin="dense"
required
fullWidth
id="identifier"
label={t("reset_password_request_identifier_label")}
name="identifier"
value={identifier}
onChange={(ev) => setIdentifier(ev.target.value.trim())}
autoFocus
/>
<Button type="submit" fullWidth variant="contained" disabled={sending || identifier === ""} sx={{ mt: 2, mb: 2 }}>
{t("reset_password_request_button_submit")}
</Button>
</Box>
{config.enable_login && (
<Typography sx={{ mb: 4 }}>
<NavLink to={routes.login} variant="body1">
{t("reset_password_back_to_login")}
</NavLink>
</Typography>
)}
</AvatarBox>
);
};
export default ResetPassword;
+1
View File
@@ -4,6 +4,7 @@ import { shortUrl } from "../app/utils";
const routes = {
login: "/login",
signup: "/signup",
resetPassword: "/reset-password",
app: config.app_root,
account: "/account",
settings: "/settings",