mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-11 06:15:21 +00:00
Provide email during signup
This commit is contained in:
@@ -37,6 +37,9 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if newAccount.Email != "" && !emailAddressRegex.MatchString(newAccount.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil {
|
||||
return errHTTPConflictUserExists
|
||||
}
|
||||
@@ -48,6 +51,16 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
|
||||
return err
|
||||
}
|
||||
v.AccountCreated()
|
||||
// If an email was provided and email sending is configured, start verification (best-effort).
|
||||
// The address becomes the primary email on verify (the new account has no primary yet); a
|
||||
// failure to send must not fail signup, so we only log it.
|
||||
if newAccount.Email != "" && s.mailSender != nil {
|
||||
if u, err := s.userManager.User(newAccount.Username); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to load new user for email verification")
|
||||
} else if err := s.enqueueEmailVerification(u.ID, newAccount.Email); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send signup email verification")
|
||||
}
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
|
||||
@@ -276,6 +276,56 @@ func TestAccount_PasswordReset_NoPrimaryEmailNoSend(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Signup_WithEmail_SendsVerification(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.EnableSignup = true
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailSender = mailer
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Sign up with an optional email -> account created and a verification link sent
|
||||
rr := request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass","email":"emma@example.com"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
link := mailer.verifyLinks["emma@example.com"]
|
||||
require.NotEmpty(t, link)
|
||||
|
||||
// Verifying the link makes it the (first) primary email
|
||||
token := tokenFromLink(t, link, "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
|
||||
account := getAccount(t, s, map[string]string{"Authorization": util.BasicAuth("emma", "emmapass")})
|
||||
require.Equal(t, []string{"emma@example.com"}, verifiedAddrs(account))
|
||||
require.Equal(t, "emma@example.com", primaryAddr(account))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Signup_WithoutEmail_NoSend(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.EnableSignup = true
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailSender = mailer
|
||||
defer s.closeDatabases()
|
||||
|
||||
// No email -> account created, nothing sent
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass"}`, nil).Code)
|
||||
require.Empty(t, mailer.verifyLinks)
|
||||
|
||||
// Invalid email -> rejected
|
||||
rr := request(t, s, "POST", "/v1/account", `{"username":"otto","password":"ottopass","email":"not-an-email"}`, nil)
|
||||
require.Equal(t, 400, rr.Code)
|
||||
require.Equal(t, 40050, toHTTPError(t, rr.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_ProvisionedNoPrimary(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
hash, err := user.HashPassword("provpass")
|
||||
|
||||
@@ -185,6 +185,7 @@ type apiAccessResetRequest struct {
|
||||
type apiAccountCreateRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"` // Optional; if set (and SMTP configured), a verification link is sent
|
||||
}
|
||||
|
||||
type apiAccountPasswordChangeRequest struct {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"version_update_available_description": "The ntfy server has been updated. Please refresh the page.",
|
||||
"signup_title": "Create a ntfy account",
|
||||
"signup_form_username": "Username",
|
||||
"signup_form_email": "Email (optional, for account recovery)",
|
||||
"signup_form_password": "Password",
|
||||
"signup_form_confirm_password": "Confirm password",
|
||||
"signup_form_button_submit": "Sign up",
|
||||
@@ -246,13 +247,16 @@
|
||||
"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_chip_actions_primary": "Primary address, can be used for account recovery and notifications. Click for actions.",
|
||||
"account_basics_emails_chip_actions_verified": "Can be used for notifications. Click for actions.",
|
||||
"account_basics_emails_chip_actions_unverified": "Unverified address, check your inbox to verify. Click for actions.",
|
||||
"account_basics_emails_unverified": "unverified",
|
||||
"account_basics_emails_set_primary": "Set as primary email",
|
||||
"account_basics_emails_delete": "Remove",
|
||||
"account_basics_emails_delete": "Remove address",
|
||||
"account_basics_emails_cancel": "Cancel",
|
||||
"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 primary email on another account",
|
||||
"account_basics_emails_primary_elsewhere": "This email address is used as the primary address on another account",
|
||||
"account_basics_emails_no_recovery_warning": "Add at least one email address to ensure you can recover your account if you lose your password.",
|
||||
"account_basics_emails_no_primary_warning": "Add a primary email address to ensure you can recover your account if you lose your password.",
|
||||
"account_basics_emails_provisioned_info": "Provisioned users cannot add a primary email address, but you can still add an email address for notifications.",
|
||||
|
||||
@@ -69,11 +69,12 @@ class AccountApi {
|
||||
});
|
||||
}
|
||||
|
||||
async create(username, password) {
|
||||
async create(username, password, email) {
|
||||
const url = accountUrl(config.base_url);
|
||||
const body = JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
email: email || "",
|
||||
});
|
||||
console.log(`[AccountApi] Creating user account ${url}`);
|
||||
await fetchOrThrow(url, {
|
||||
|
||||
@@ -448,7 +448,8 @@ const Emails = () => {
|
||||
}
|
||||
|
||||
const emails = account?.emails ?? [];
|
||||
const verifiedEmails = emails.filter((e) => !e.pending);
|
||||
// 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
|
||||
@@ -467,7 +468,9 @@ const Emails = () => {
|
||||
key={email.address}
|
||||
icon={email.primary ? <StarIcon /> : undefined}
|
||||
label={
|
||||
<Tooltip title={t("account_basics_emails_chip_actions")}>
|
||||
<Tooltip
|
||||
title={email.primary ? t("account_basics_emails_chip_actions_primary") : t("account_basics_emails_chip_actions_verified")}
|
||||
>
|
||||
<span>{email.address}</span>
|
||||
</Tooltip>
|
||||
}
|
||||
@@ -481,7 +484,7 @@ const Emails = () => {
|
||||
<Chip
|
||||
key={email.address}
|
||||
label={
|
||||
<Tooltip title={t("account_basics_emails_chip_actions")}>
|
||||
<Tooltip title={t("account_basics_emails_chip_actions_unverified")}>
|
||||
<span>
|
||||
{email.address} <em>({t("account_basics_emails_unverified")})</em>
|
||||
</span>
|
||||
@@ -531,6 +534,12 @@ const Emails = () => {
|
||||
<ListItemText>{t("account_basics_emails_resend")}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={() => runMenuAction(handleDelete)}>
|
||||
<ListItemIcon>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("account_basics_emails_delete")}</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<AddEmailDialog key={`addEmailDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
|
||||
<Portal>
|
||||
|
||||
@@ -15,6 +15,7 @@ const Signup = () => {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@@ -24,7 +25,7 @@ const Signup = () => {
|
||||
event.preventDefault();
|
||||
const user = { username, password };
|
||||
try {
|
||||
await accountApi.create(user.username, user.password);
|
||||
await accountApi.create(user.username, user.password, email);
|
||||
const token = await accountApi.login(user);
|
||||
console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`);
|
||||
await session.store(user.username, token);
|
||||
@@ -64,6 +65,18 @@ const Signup = () => {
|
||||
onChange={(ev) => setUsername(ev.target.value.trim())}
|
||||
autoFocus
|
||||
/>
|
||||
{config.enable_emails && (
|
||||
<TextField
|
||||
margin="dense"
|
||||
fullWidth
|
||||
id="email"
|
||||
label={t("signup_form_email")}
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(ev) => setEmail(ev.target.value.trim())}
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
margin="dense"
|
||||
required
|
||||
|
||||
Reference in New Issue
Block a user