diff --git a/cmd/user.go b/cmd/user.go index 84cba345..8c65221d 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -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 diff --git a/cmd/user_test.go b/cmd/user_test.go index 4dbca550..c5e1c44c 100644 --- a/cmd/user_test.go +++ b/cmd/user_test.go @@ -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 diff --git a/server/server_account.go b/server/server_account.go index f006f1ac..214a71ba 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -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 } diff --git a/server/server_account_email_test.go b/server/server_account_email_test.go index e8bbc920..38ed8d84 100644 --- a/server/server_account_email_test.go +++ b/server/server_account_email_test.go @@ -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) diff --git a/server/server_payments.go b/server/server_payments.go index 76d0b5ac..4b19a091 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -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) diff --git a/server/types.go b/server/types.go index 963cb127..08687fbb 100644 --- a/server/types.go +++ b/server/types.go @@ -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"` diff --git a/user/magic_link_test.go b/user/magic_link_test.go index 33053c05..4a2506c4 100644 --- a/user/magic_link_test.go +++ b/user/magic_link_test.go @@ -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) diff --git a/user/manager.go b/user/manager.go index 4a7866c1..0f0064d5 100644 --- a/user/manager.go +++ b/user/manager.go @@ -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 diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index ecae6954..cc78f3d4 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -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", diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index a45dce09..e9e2168e 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -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 = () => { >
{accountType} + {account.provisioned && } {account.billing?.paid_until && !account.billing?.cancel_at && ( { 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 ( - +
- {showNoRecoveryWarning && ( - - {t("account_basics_emails_no_recovery_warning")} - - )} - - {verifiedEmails.map((email) => { - const isPrimary = email === primaryEmail; - return ( - - {email} - {isPrimary && } - {!isPrimary && ( - - handleSetPrimary(email)}> - - - - )} - {isPrimary && } - - handleCopy(email)}> - - - - - handleDelete(email)}> - - - - - ); - })} - {pendingEmails.map((email) => ( - - - {email} ({t("account_basics_emails_unverified")}) - - - handleResend(email)}> - - + {verifiedEmails.map((email) => ( + : undefined} + label={ + + {email.address} - - handleDelete(email)}> - - + } + variant="outlined" + onClick={(ev) => openMenu(ev, email)} + onDelete={() => handleDelete(email.address)} + sx={email.primary ? { "& .MuiChip-icon": { color: "primary.main" } } : undefined} + /> + ))} + {pendingEmails.map((email) => ( + + + {email.address} ({t("account_basics_emails_unverified")}) + - - ))} - {verifiedEmails.length === 0 && pendingEmails.length === 0 && {t("account_basics_emails_no_emails_yet")}} - + } + variant="outlined" + onClick={(ev) => openMenu(ev, email)} + onDelete={() => handleDelete(email.address)} + sx={{ opacity: 0.7 }} + /> + ))} + {verifiedEmails.length === 0 && pendingEmails.length === 0 && {t("account_basics_emails_no_emails_yet")}} + {showNoRecoveryWarning && ( + + {t("account_basics_emails_no_recovery_warning")} + + )}
+ + runMenuAction(handleCopy)}> + + + + {t("common_copy_to_clipboard")} + + {menuEmail && !menuEmail.pending && !menuEmail.primary && !account?.provisioned && ( + runMenuAction(handleSetPrimary)}> + + + + {t("account_basics_emails_set_primary")} + + )} + {menuEmail && menuEmail.pending && ( + runMenuAction(handleResend)}> + + + + {t("account_basics_emails_resend")} + + )} + 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) => { ) : ( <> {t("account_basics_emails_dialog_description")} + {config.enable_reset_password && account?.provisioned && ( + + {t("account_basics_emails_provisioned_info")} + + )} { } /> } /> + } /> } /> } /> }> diff --git a/web/src/components/EmailVerify.jsx b/web/src/components/EmailVerify.jsx index ce70a8d5..8013652e 100644 --- a/web/src/components/EmailVerify.jsx +++ b/web/src/components/EmailVerify.jsx @@ -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 ( {status === "verifying" && ( - <> - + + {t("email_verify_progress_title")} - + )} {status === "success" && ( <> - - {t("email_verify_success_title")} + + + {t("email_verify_success_title")} + {t("email_verify_success_description")} - )} {status === "error" && ( <> - - {t("email_verify_error_title")} - {t("email_verify_error_description")} - - + + + {t("email_verify_error_title")} + {t("email_verify_error_description")} + )} diff --git a/web/src/components/Login.jsx b/web/src/components/Login.jsx index a95f9a14..9bf8e1c5 100644 --- a/web/src/components/Login.jsx +++ b/web/src/components/Login.jsx @@ -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 = () => { {config.enable_reset_password && (
- +
)} {config.enable_signup && ( @@ -129,67 +116,8 @@ const Login = () => { )}
- setResetOpen(false)} /> ); }; -// 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 ( - - {t("login_reset_dialog_title")} - - {sent ? ( - {t("login_reset_dialog_sent")} - ) : ( - <> - {t("login_reset_dialog_description")} - setIdentifier(ev.target.value.trim())} - fullWidth - variant="standard" - /> - - )} - - - {sent ? ( - - ) : ( - <> - - - - )} - - - ); -}; - export default Login; diff --git a/web/src/components/PasswordReset.jsx b/web/src/components/PasswordReset.jsx index 0e004d04..c2a2613e 100644 --- a/web/src/components/PasswordReset.jsx +++ b/web/src/components/PasswordReset.jsx @@ -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 ( - - {t("reset_password_success_title")} + + + {t("reset_password_success_title")} + {t("reset_password_success_description")} - diff --git a/web/src/components/ResetPassword.jsx b/web/src/components/ResetPassword.jsx new file mode 100644 index 00000000..05d95e21 --- /dev/null +++ b/web/src/components/ResetPassword.jsx @@ -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 ( + + {t("reset_password_disabled")} + + + {t("reset_password_back_to_login")} + + + + ); + } + + if (sent) { + return ( + + + + {t("reset_password_sent_title")} + + {t("reset_password_sent_description")} + + + {t("reset_password_back_to_login")} + + + + ); + } + + return ( + + {t("reset_password_request_title")} + + {t("reset_password_request_description")} + setIdentifier(ev.target.value.trim())} + autoFocus + /> + + + {config.enable_login && ( + + + {t("reset_password_back_to_login")} + + + )} + + ); +}; + +export default ResetPassword; diff --git a/web/src/components/routes.js b/web/src/components/routes.js index 6e649df3..463fb237 100644 --- a/web/src/components/routes.js +++ b/web/src/components/routes.js @@ -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",