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

View File

@@ -44,7 +44,7 @@ var flagsUser = append(
var cmdUser = &cli.Command{
Name: "user",
Usage: "Manage/show users",
UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
UsageText: "ntfy user [list|add|remove|change-pass|reset-pass|change-role] ...",
Flags: flagsUser,
Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc),
Category: categoryServer,
@@ -108,11 +108,11 @@ directly the bcrypt hash. This is useful if you are updating users via scripts.
`,
},
{
Name: "password-reset",
Aliases: []string{"reset"},
Name: "reset-pass",
Aliases: []string{"rp"},
Usage: "Generates a password reset link for a user",
UsageText: "ntfy user password-reset [--send-email] USERNAME",
Action: execUserPasswordReset,
UsageText: "ntfy user reset-pass [--send-email] USERNAME",
Action: execUserResetPass,
Flags: []cli.Flag{
&cli.BoolFlag{Name: "send-email", Aliases: []string{"e"}, Usage: "also email the reset link to the user's primary email"},
},
@@ -129,8 +129,8 @@ requires SMTP to be configured and the user to have a verified primary email).
Requires base-url to be configured so an absolute link can be generated.
Example:
ntfy user password-reset phil # Print a reset link for user phil
ntfy user password-reset --send-email phil # Print and email the reset link
ntfy user reset-pass phil # Print a reset link for user phil
ntfy user reset-pass --send-email phil # Print and email the reset link
`,
},
{
@@ -319,12 +319,12 @@ func execUserChangePass(c *cli.Context) error {
return nil
}
func execUserPasswordReset(c *cli.Context) error {
func execUserResetPass(c *cli.Context) error {
username := c.Args().Get(0)
sendEmail := c.Bool("send-email")
baseURL := strings.TrimSuffix(c.String("base-url"), "/")
if username == "" {
return errors.New("username expected, type 'ntfy user password-reset --help' for help")
return errors.New("username expected, type 'ntfy user reset-pass --help' for help")
} else if username == userEveryone || username == user.Everyone {
return errors.New("username not allowed")
} else if baseURL == "" {
@@ -339,6 +339,8 @@ func execUserPasswordReset(c *cli.Context) error {
return fmt.Errorf("user %s does not exist", username)
} else if err != nil {
return err
} else if u.Provisioned {
return fmt.Errorf("user %s is provisioned in the config file; its password cannot be reset", username)
}
// Resolve the primary email up front if we need to send -- fail before creating a token
var primaryEmail string

View File

@@ -122,7 +122,7 @@ func TestCLI_User_Delete(t *testing.T) {
require.Contains(t, err.Error(), "user phil does not exist")
}
func TestCLI_User_PasswordReset(t *testing.T) {
func TestCLI_User_ResetPass(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
@@ -132,11 +132,11 @@ func TestCLI_User_PasswordReset(t *testing.T) {
// Prints a working-looking reset link when base-url is set
app, _, stdout, _ = newTestApp()
require.Nil(t, runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "password-reset", "phil"))
require.Nil(t, runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "phil"))
require.Contains(t, stdout.String(), "https://ntfy.example.com/account/password/reset/")
}
func TestCLI_User_PasswordReset_NoBaseURL(t *testing.T) {
func TestCLI_User_ResetPass_NoBaseURL(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
@@ -145,12 +145,12 @@ func TestCLI_User_PasswordReset_NoBaseURL(t *testing.T) {
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
app, _, _, _ = newTestApp()
err := runUserCommand(app, conf, "password-reset", "phil")
err := runUserCommand(app, conf, "reset-pass", "phil")
require.Error(t, err)
require.Contains(t, err.Error(), "base-url")
}
func TestCLI_User_PasswordReset_SendEmailNoPrimary(t *testing.T) {
func TestCLI_User_ResetPass_SendEmailNoPrimary(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
@@ -160,11 +160,31 @@ func TestCLI_User_PasswordReset_SendEmailNoPrimary(t *testing.T) {
// --send-email requires a primary email; phil has none
app, _, _, _ = newTestApp()
err := runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "password-reset", "--send-email", "phil")
err := runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "--send-email", "phil")
require.Error(t, err)
require.Contains(t, err.Error(), "no primary email")
}
func TestCLI_User_ResetPass_ProvisionedRejected(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
// Seed a provisioned user into the auth database via config provisioning
m, err := user.NewSQLiteManager(conf.AuthFile, "", &user.Config{
ProvisionEnabled: true,
Users: []*user.User{
{Name: "provuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser},
},
})
require.Nil(t, err)
require.Nil(t, m.Close())
app, _, _, _ := newTestApp()
err = runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "provuser")
require.Error(t, err)
require.Contains(t, err.Error(), "provisioned")
}
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
configFile := filepath.Join(t.TempDir(), "server-dummy.yml")
require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml

View File

@@ -167,20 +167,24 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
if err != nil {
return err
}
if len(emails) > 0 {
response.Emails = emails
}
primaryEmail, err := s.userManager.PrimaryEmail(u.ID)
if err != nil {
return err
}
response.PrimaryEmail = primaryEmail
pendingEmails, err := s.userManager.PendingEmails(u.ID)
if err != nil {
return err
}
if len(pendingEmails) > 0 {
response.PendingEmails = pendingEmails
// Combine verified (with primary flag) and pending (unverified) into one list
emailInfos := make([]*apiAccountEmailInfo, 0, len(emails)+len(pendingEmails))
for _, email := range emails {
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Primary: email == primaryEmail})
}
for _, email := range pendingEmails {
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Pending: true})
}
if len(emailInfos) > 0 {
response.Emails = emailInfos
}
}
} else {
@@ -718,6 +722,8 @@ func (s *Server) handleAccountEmailSetPrimary(w http.ResponseWriter, r *http.Req
return err
} else if !emailAddressRegex.MatchString(req.Email) {
return errHTTPBadRequestEmailAddressInvalid
} else if u.Provisioned {
return errHTTPConflictProvisionedUserChange // Provisioned users can't reset, so a recovery email is meaningless
}
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Setting primary email")
err = s.userManager.SetPrimaryEmail(u.ID, req.Email)
@@ -810,13 +816,15 @@ func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *htt
// The identifier is tried first as a username, then as a primary email address. It returns
// ok=false if no account with a primary email matches (reset requires a verified primary email).
func (s *Server) resolveResetTarget(identifier string) (userID string, email string, ok bool) {
if u, err := s.userManager.User(identifier); err == nil && u != nil {
if u, err := s.userManager.User(identifier); err == nil && u != nil && !u.Provisioned {
if primary, perr := s.userManager.PrimaryEmail(u.ID); perr == nil && primary != "" {
return u.ID, primary, true
}
}
if uid, err := s.userManager.UserIDByPrimaryEmail(identifier); err == nil {
return uid, identifier, true
if u, uerr := s.userManager.UserByID(uid); uerr == nil && !u.Provisioned {
return uid, identifier, true
}
}
return "", "", false
}
@@ -834,8 +842,8 @@ func (s *Server) handleAccountPasswordReset(w http.ResponseWriter, r *http.Reque
return errHTTPBadRequest
}
err = s.userManager.ResetPassword(req.Token, req.Password)
if errors.Is(err, user.ErrMagicLinkNotFound) {
return errHTTPBadRequestResetLinkInvalid
if errors.Is(err, user.ErrMagicLinkNotFound) || errors.Is(err, user.ErrProvisionedUserChange) {
return errHTTPBadRequestResetLinkInvalid // Generic 400 (provisioned users can't be reset; don't leak that)
} else if err != nil {
return err
}

View File

@@ -57,6 +57,37 @@ func getAccount(t *testing.T, s *Server, auth map[string]string) *apiAccountResp
return account
}
// verifiedAddrs / pendingAddrs / primaryAddr extract the addresses from the structured email
// list returned by GET /v1/account, so assertions stay readable.
func verifiedAddrs(account *apiAccountResponse) []string {
addrs := make([]string, 0)
for _, e := range account.Emails {
if !e.Pending {
addrs = append(addrs, e.Address)
}
}
return addrs
}
func pendingAddrs(account *apiAccountResponse) []string {
addrs := make([]string, 0)
for _, e := range account.Emails {
if e.Pending {
addrs = append(addrs, e.Address)
}
}
return addrs
}
func primaryAddr(account *apiAccountResponse) string {
for _, e := range account.Emails {
if e.Primary {
return e.Address
}
}
return ""
}
func tokenFromLink(t *testing.T, link, prefix string) string {
require.True(t, strings.HasPrefix(link, prefix), "link %q missing prefix %q", link, prefix)
return strings.TrimPrefix(link, prefix)
@@ -73,9 +104,9 @@ func TestAccount_Email_AddVerifySetsPrimary(t *testing.T) {
// Pending, not yet verified, no primary
account := getAccount(t, s, auth)
require.Equal(t, []string{"ben@example.com"}, account.PendingEmails)
require.Empty(t, account.Emails)
require.Equal(t, "", account.PrimaryEmail)
require.Equal(t, []string{"ben@example.com"}, pendingAddrs(account))
require.Empty(t, verifiedAddrs(account))
require.Equal(t, "", primaryAddr(account))
// "Click" the captured link (unauthenticated POST)
token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/")
@@ -84,9 +115,9 @@ func TestAccount_Email_AddVerifySetsPrimary(t *testing.T) {
// Now verified + primary, no longer pending
account = getAccount(t, s, auth)
require.Equal(t, []string{"ben@example.com"}, account.Emails)
require.Equal(t, "ben@example.com", account.PrimaryEmail)
require.Empty(t, account.PendingEmails)
require.Equal(t, []string{"ben@example.com"}, verifiedAddrs(account))
require.Equal(t, "ben@example.com", primaryAddr(account))
require.Empty(t, pendingAddrs(account))
})
}
@@ -111,13 +142,13 @@ func TestAccount_Email_DeletePending(t *testing.T) {
defer s.closeDatabases()
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
require.Equal(t, []string{"ben@example.com"}, getAccount(t, s, auth).PendingEmails)
require.Equal(t, []string{"ben@example.com"}, pendingAddrs(getAccount(t, s, auth)))
// Deleting the pending address clears it (no verification ever happened)
require.Equal(t, 200, request(t, s, "DELETE", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
account := getAccount(t, s, auth)
require.Empty(t, account.PendingEmails)
require.Empty(t, account.Emails)
require.Empty(t, pendingAddrs(account))
require.Empty(t, verifiedAddrs(account))
})
}
@@ -154,7 +185,7 @@ func TestAccount_Email_SetPrimaryCollision(t *testing.T) {
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, auth).Code)
benToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/")
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, benToken), nil).Code)
require.Equal(t, "shared@example.com", getAccount(t, s, auth).PrimaryEmail)
require.Equal(t, "shared@example.com", primaryAddr(getAccount(t, s, auth)))
// alice verifies the same address -> allowed as secondary, but it is not her primary
require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false))
@@ -163,8 +194,8 @@ func TestAccount_Email_SetPrimaryCollision(t *testing.T) {
aliceToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/")
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, aliceToken), nil).Code)
aliceAccount := getAccount(t, s, aliceAuth)
require.Equal(t, []string{"shared@example.com"}, aliceAccount.Emails)
require.Equal(t, "", aliceAccount.PrimaryEmail)
require.Equal(t, []string{"shared@example.com"}, verifiedAddrs(aliceAccount))
require.Equal(t, "", primaryAddr(aliceAccount))
// alice trying to promote it to primary collides with ben's
rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"shared@example.com"}`, aliceAuth)
@@ -245,6 +276,63 @@ func TestAccount_PasswordReset_NoPrimaryEmailNoSend(t *testing.T) {
})
}
func TestAccount_Email_ProvisionedNoPrimary(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
hash, err := user.HashPassword("provpass")
require.Nil(t, err)
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderAddr = "localhost:25"
conf.SMTPSenderFrom = "noreply@example.com"
conf.BaseURL = "https://ntfy.example.com"
conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}}
s := newTestServer(t, conf)
mailer := newCaptureMailer()
s.mailSender = mailer
defer s.closeDatabases()
auth := map[string]string{"Authorization": util.BasicAuth("prov", "provpass")}
// A provisioned user can verify an email, but it must NOT become their primary
verifyEmailFor(t, s, mailer, auth, "prov@example.com")
account := getAccount(t, s, auth)
require.Equal(t, []string{"prov@example.com"}, verifiedAddrs(account))
require.Equal(t, "", primaryAddr(account))
// Explicitly setting it primary is rejected
rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"prov@example.com"}`, auth)
require.Equal(t, 409, rr.Code)
require.Equal(t, 40905, toHTTPError(t, rr.Body.String()).Code)
})
}
func TestAccount_PasswordReset_ProvisionedUserNoSend(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
// Provision a user via config (AuthUsers), with email sending enabled
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderAddr = "localhost:25"
conf.SMTPSenderFrom = "noreply@example.com"
conf.BaseURL = "https://ntfy.example.com"
conf.AuthUsers = []*user.User{
{Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser},
}
s := newTestServer(t, conf)
mailer := newCaptureMailer()
s.mailSender = mailer
defer s.closeDatabases()
// Give the provisioned user a verified primary email anyway
prov, err := s.userManager.User("prov")
require.Nil(t, err)
require.True(t, prov.Provisioned)
require.Nil(t, s.userManager.AddEmail(prov.ID, "prov@example.com"))
require.Nil(t, s.userManager.SetPrimaryEmail(prov.ID, "prov@example.com"))
// Reset request by username and by email -> uniform 200, but no email sent (can't reset)
require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov"}`, nil).Code)
require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov@example.com"}`, nil).Code)
require.Empty(t, mailer.resetLinks)
})
}
func TestAccount_PasswordReset_InvalidToken(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, _, _ := newEmailTestServer(t, databaseURL)

View File

@@ -238,7 +238,8 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
return err
}
// Offer email recovery: auto-send a verification link to the billing email (best-effort).
if sess.CustomerDetails != nil {
// Provisioned users can't reset their password, so recovery setup doesn't apply to them.
if sess.CustomerDetails != nil && !u.Provisioned {
s.maybeEnqueueBillingEmailVerification(r, v, u.ID, sess.CustomerDetails.Email)
}
http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)

View File

@@ -287,6 +287,15 @@ type apiAccountReservation struct {
Everyone string `json:"everyone"`
}
// apiAccountEmailInfo describes one email address on the account, as returned by GET /v1/account.
// Verified addresses have pending=false; exactly one verified address may be primary (the
// recovery email). Pending addresses are awaiting a magic-link click and are never primary.
type apiAccountEmailInfo struct {
Address string `json:"address"`
Primary bool `json:"primary,omitempty"`
Pending bool `json:"pending,omitempty"`
}
type apiAccountBilling struct {
Customer bool `json:"customer"`
Subscription bool `json:"subscription"`
@@ -307,9 +316,7 @@ type apiAccountResponse struct {
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
PhoneNumbers []string `json:"phone_numbers,omitempty"`
Emails []string `json:"emails,omitempty"`
PrimaryEmail string `json:"primary_email,omitempty"` // The verified recovery email, if set
PendingEmails []string `json:"pending_emails,omitempty"` // Unverified addresses awaiting a magic-link click
Emails []*apiAccountEmailInfo `json:"emails,omitempty"`
Tier *apiAccountTier `json:"tier,omitempty"`
Limits *apiAccountLimits `json:"limits,omitempty"`
Stats *apiAccountStats `json:"stats,omitempty"`

View File

@@ -289,6 +289,53 @@ func TestUser_MagicLink_ResetPassword_WrongKindRejected(t *testing.T) {
})
}
func TestUser_MagicLink_VerifyEmail_ProvisionedNoPrimary(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManagerFromConfig(t, newManager, &Config{
DefaultAccess: PermissionDenyAll,
ProvisionEnabled: true,
Users: []*User{
{Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
},
})
prov, err := a.User("prov")
require.Nil(t, err)
// A provisioned user can verify an email (for notifications), but it must NOT become primary
_, err = a.VerifyEmail(addVerifyLink(t, a, prov.ID, "prov@example.com", time.Hour))
require.Nil(t, err)
emails, err := a.Emails(prov.ID)
require.Nil(t, err)
require.Equal(t, []string{"prov@example.com"}, emails)
primary, err := a.PrimaryEmail(prov.ID)
require.Nil(t, err)
require.Equal(t, "", primary)
})
}
func TestUser_MagicLink_ResetPassword_ProvisionedRejected(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
// Provisioned users come from the config file (ProvisionEnabled), not AddUser
a := newTestManagerFromConfig(t, newManager, &Config{
DefaultAccess: PermissionDenyAll,
ProvisionEnabled: true,
Users: []*User{
{Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
},
})
prov, err := a.User("prov")
require.Nil(t, err)
require.True(t, prov.Provisioned)
// A reset token can be created, but consuming it must be rejected for a provisioned user
// (their password comes from the config file, like change-pass).
raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, prov.ID, "", time.Hour)
require.Nil(t, err)
require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrProvisionedUserChange)
})
}
func TestUser_MagicLink_ResetPassword_Expired(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)

View File

@@ -1648,8 +1648,9 @@ func (a *Manager) DeleteEmailVerification(userID, email string) error {
// validating the token (kind + expiry), it deletes the link, adds the address to the user's
// verified emails, and -- if the user has no primary email yet and the address is not already
// primary on another account -- promotes the new address to primary. All mutations run in one
// transaction. A primary collision simply leaves the address verified but non-primary. Returns
// the consumed link.
// transaction. A primary collision simply leaves the address verified but non-primary. Provisioned
// users never get a primary (the recovery email is meaningless for them -- they can't reset).
// Returns the consumed link.
func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) {
tokenHash := hashToken(rawToken)
m, err := a.MagicLinkByHash(tokenHash)
@@ -1659,6 +1660,10 @@ func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) {
if m.Kind != MagicLinkKindEmailVerify || time.Now().Unix() > m.Expires {
return nil, ErrMagicLinkNotFound
}
u, err := a.UserByID(m.UserID)
if err != nil {
return nil, err
}
err = db.ExecTx(a.db, func(tx *sql.Tx) error {
// Single use: delete the link, then add the (idempotent) verified address
if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, tokenHash); err != nil {
@@ -1667,6 +1672,9 @@ func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) {
if _, err := tx.Exec(a.queries.insertEmailIgnore, m.UserID, m.Email); err != nil {
return err
}
if u.Provisioned {
return nil // Provisioned users don't get a primary (recovery) email
}
// Promote to primary only if the user has none yet and the address is globally free.
// We check with SELECTs rather than catching a unique violation, because Postgres aborts
// the whole transaction on any constraint error (which would undo the verified-email add).
@@ -1712,6 +1720,9 @@ func (a *Manager) ResetPassword(rawToken, password string) error {
if err != nil {
return err
}
if u.Provisioned {
return ErrProvisionedUserChange // Provisioned users get their password from the config file, not reset
}
hash, err := a.maybeHashPassword(password, false)
if err != nil {
return err

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",

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"

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 />}>

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>

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;

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>

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;

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",