mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-13 15:15:24 +00:00
Phase 2, email verification rework, ui stuff
This commit is contained in:
@@ -4,6 +4,8 @@ import {
|
||||
accountBillingSubscriptionUrl,
|
||||
accountEmailUrl,
|
||||
accountEmailVerifyUrl,
|
||||
accountEmailPrimaryUrl,
|
||||
accountEmailResendUrl,
|
||||
accountPasswordUrl,
|
||||
accountPhoneUrl,
|
||||
accountPhoneVerifyUrl,
|
||||
@@ -342,9 +344,11 @@ class AccountApi {
|
||||
});
|
||||
}
|
||||
|
||||
async verifyEmail(email) {
|
||||
const url = accountEmailVerifyUrl(config.base_url);
|
||||
console.log(`[AccountApi] Sending email verification ${url}`);
|
||||
// startEmailVerification begins adding an email: the server stores a pending verification and
|
||||
// emails a magic link. The address is not verified until the link is clicked.
|
||||
async startEmailVerification(email) {
|
||||
const url = accountEmailUrl(config.base_url);
|
||||
console.log(`[AccountApi] Starting email verification ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "PUT",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
@@ -354,15 +358,41 @@ class AccountApi {
|
||||
});
|
||||
}
|
||||
|
||||
async addEmail(email, code) {
|
||||
const url = accountEmailUrl(config.base_url);
|
||||
console.log(`[AccountApi] Adding email with verification code ${url}`);
|
||||
// verifyEmailToken performs verification from the magic-link landing page. It is unauthenticated:
|
||||
// the token identifies the account, so this works even when clicked from a logged-out browser.
|
||||
async verifyEmailToken(token) {
|
||||
const url = accountEmailVerifyUrl(config.base_url);
|
||||
console.log(`[AccountApi] Verifying email token ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "PUT",
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// resendEmailVerification re-sends the magic link for a pending (unverified) address.
|
||||
async resendEmailVerification(email) {
|
||||
const url = accountEmailResendUrl(config.base_url);
|
||||
console.log(`[AccountApi] Resending email verification ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "POST",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// setPrimaryEmail marks an already-verified address as the primary (recovery) email.
|
||||
async setPrimaryEmail(email) {
|
||||
const url = accountEmailPrimaryUrl(config.base_url);
|
||||
console.log(`[AccountApi] Setting primary email ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
method: "POST",
|
||||
headers: withBearerAuth({}, session.token()),
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
+11
-1
@@ -51,7 +51,15 @@ export class EmailVerificationCodeInvalidError extends Error {
|
||||
static CODE = 40051; // errHTTPBadRequestEmailVerificationCodeInvalid
|
||||
|
||||
constructor() {
|
||||
super("Email verification code invalid or expired");
|
||||
super("Email verification link invalid or expired");
|
||||
}
|
||||
}
|
||||
|
||||
export class EmailPrimaryElsewhereError extends Error {
|
||||
static CODE = 40908; // errHTTPConflictEmailPrimaryElsewhere
|
||||
|
||||
constructor() {
|
||||
super("Email address is the recovery email on another account");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +81,8 @@ export const throwAppError = async (response) => {
|
||||
throw new IncorrectPasswordError();
|
||||
} else if (error.code === EmailVerificationCodeInvalidError.CODE) {
|
||||
throw new EmailVerificationCodeInvalidError();
|
||||
} else if (error.code === EmailPrimaryElsewhereError.CODE) {
|
||||
throw new EmailPrimaryElsewhereError();
|
||||
} else if (error?.error) {
|
||||
throw new Error(`Error ${error.code}: ${error.error}`);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
|
||||
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
|
||||
export const accountEmailUrl = (baseUrl) => `${baseUrl}/v1/account/email`;
|
||||
export const accountEmailVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/email/verify`;
|
||||
export const accountEmailPrimaryUrl = (baseUrl) => `${baseUrl}/v1/account/email/primary`;
|
||||
export const accountEmailResendUrl = (baseUrl) => `${baseUrl}/v1/account/email/resend`;
|
||||
|
||||
export const validUrl = (url) => url.match(/^https?:\/\/.+/);
|
||||
|
||||
|
||||
+118
-101
@@ -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>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import Messaging from "./Messaging";
|
||||
import Login from "./Login";
|
||||
import Signup from "./Signup";
|
||||
import Account from "./Account";
|
||||
import EmailVerify from "./EmailVerify";
|
||||
import initI18n from "../app/i18n"; // Translations!
|
||||
import prefs from "../app/Prefs";
|
||||
import RTLCacheProvider from "./RTLCacheProvider";
|
||||
@@ -63,6 +64,7 @@ const App = () => {
|
||||
<Routes>
|
||||
<Route path={routes.login} element={<Login />} />
|
||||
<Route path={routes.signup} element={<Signup />} />
|
||||
<Route path={routes.emailVerify} element={<EmailVerify />} />
|
||||
<Route element={<Layout />}>
|
||||
<Route path={routes.app} element={<AllSubscriptions />} />
|
||||
<Route path={routes.account} element={<Account />} />
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as React from "react";
|
||||
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 { useTranslation } from "react-i18next";
|
||||
import accountApi from "../app/AccountApi";
|
||||
import AvatarBox from "./AvatarBox";
|
||||
import routes from "./routes";
|
||||
|
||||
// EmailVerify is the magic-link landing page for email verification. It performs the verification
|
||||
// via a POST (the GET that loads this page has no side effects, so link prefetchers / scanners
|
||||
// cannot consume the single-use token). The raw token is stripped from the URL on load to keep
|
||||
// it out of browser history and Referer headers.
|
||||
const EmailVerify = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = useParams();
|
||||
const [status, setStatus] = useState("verifying"); // "verifying" | "success" | "error"
|
||||
const ran = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (ran.current) {
|
||||
return; // Guard against double-invoke (e.g. React StrictMode) consuming the token twice
|
||||
}
|
||||
ran.current = true;
|
||||
// Strip the token from the URL immediately (keep it out of history / Referer)
|
||||
window.history.replaceState(null, "", routes.account);
|
||||
(async () => {
|
||||
try {
|
||||
await accountApi.verifyEmailToken(token);
|
||||
setStatus("success");
|
||||
} catch (e) {
|
||||
console.log(`[EmailVerify] Verification failed`, e);
|
||||
setStatus("error");
|
||||
}
|
||||
})();
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<AvatarBox>
|
||||
{status === "verifying" && (
|
||||
<>
|
||||
<CircularProgress sx={{ mb: 2 }} />
|
||||
<Typography sx={{ typography: "h6" }}>{t("email_verify_progress_title")}</Typography>
|
||||
</>
|
||||
)}
|
||||
{status === "success" && (
|
||||
<>
|
||||
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 48, mb: 1 }} />
|
||||
<Typography sx={{ typography: "h6" }}>{t("email_verify_success_title")}</Typography>
|
||||
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("email_verify_success_description")}</Typography>
|
||||
<Button component={NavLink} to={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>
|
||||
</>
|
||||
)}
|
||||
</AvatarBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailVerify;
|
||||
@@ -7,6 +7,7 @@ const routes = {
|
||||
app: config.app_root,
|
||||
account: "/account",
|
||||
settings: "/settings",
|
||||
emailVerify: "/account/email/verify/:token",
|
||||
subscription: "/:topic",
|
||||
subscriptionExternal: "/:baseUrl/:topic",
|
||||
forSubscription: (subscription) => {
|
||||
|
||||
Reference in New Issue
Block a user