diff --git a/cmd/user.go b/cmd/user.go index 2e5af5f4..84cba345 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -8,11 +8,13 @@ import ( "fmt" "os" "strings" + "time" "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db/pg" + "heckel.io/ntfy/v2/mail" "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" @@ -32,6 +34,11 @@ var flagsUser = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}), ) var cmdUser = &cli.Command{ @@ -98,6 +105,32 @@ Example: You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass directly the bcrypt hash. This is useful if you are updating users via scripts. +`, + }, + { + Name: "password-reset", + Aliases: []string{"reset"}, + Usage: "Generates a password reset link for a user", + UsageText: "ntfy user password-reset [--send-email] USERNAME", + Action: execUserPasswordReset, + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "send-email", Aliases: []string{"e"}, Usage: "also email the reset link to the user's primary email"}, + }, + Description: `Generate a password reset link for the given user and print it to stdout. + +The user completes the reset by opening the link in a browser and choosing a new password; +the admin never learns or chooses the new password. The link is single-use and expires after +one hour. This is an admin override of the self-service reset flow -- unlike self-service, it +does not require the user to have a verified primary email (the token is bound to the user). + +With --send-email, the link is additionally emailed to the user's primary email address (this +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 `, }, { @@ -286,6 +319,59 @@ func execUserChangePass(c *cli.Context) error { return nil } +func execUserPasswordReset(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") + } else if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } else if baseURL == "" { + return errors.New("base-url must be configured to generate a reset link") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + u, err := manager.User(username) + if errors.Is(err, user.ErrUserNotFound) { + return fmt.Errorf("user %s does not exist", username) + } else if err != nil { + return err + } + // Resolve the primary email up front if we need to send -- fail before creating a token + var primaryEmail string + if sendEmail { + primaryEmail, err = manager.PrimaryEmail(u.ID) + if err != nil { + return err + } else if primaryEmail == "" { + return fmt.Errorf("user %s has no primary email; cannot send reset link (omit --send-email to just print it)", username) + } + } + // The reset token is bound to the user, not an email -- so this works even with no SMTP + token, err := manager.CreateMagicLink(user.MagicLinkKindPasswordReset, u.ID, "", time.Hour) + if err != nil { + return err + } + link := baseURL + "/account/password/reset/" + token + fmt.Fprintln(c.App.Writer, link) + if sendEmail { + sender := mail.NewSender(&mail.Config{ + SMTPAddr: c.String("smtp-sender-addr"), + SMTPUser: c.String("smtp-sender-user"), + SMTPPass: c.String("smtp-sender-pass"), + From: c.String("smtp-sender-from"), + }) + if err := sender.SendPasswordReset(primaryEmail, link); err != nil { + return fmt.Errorf("failed to send reset email to %s: %w", primaryEmail, err) + } + fmt.Fprintf(c.App.ErrWriter, "reset link emailed to %s\n", primaryEmail) + } + return nil +} + func execUserChangeRole(c *cli.Context) error { username := c.Args().Get(0) role := user.Role(c.Args().Get(1)) diff --git a/cmd/user_test.go b/cmd/user_test.go index a6250b72..4dbca550 100644 --- a/cmd/user_test.go +++ b/cmd/user_test.go @@ -122,6 +122,49 @@ func TestCLI_User_Delete(t *testing.T) { require.Contains(t, err.Error(), "user phil does not exist") } +func TestCLI_User_PasswordReset(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, stdout, _ := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + + // 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.Contains(t, stdout.String(), "https://ntfy.example.com/account/password/reset/") +} + +func TestCLI_User_PasswordReset_NoBaseURL(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, _, _ := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + + app, _, _, _ = newTestApp() + err := runUserCommand(app, conf, "password-reset", "phil") + require.Error(t, err) + require.Contains(t, err.Error(), "base-url") +} + +func TestCLI_User_PasswordReset_SendEmailNoPrimary(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, _, _ := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + + // --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") + require.Error(t, err) + require.Contains(t, err.Error(), "no primary email") +} + 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/docs/releases.md b/docs/releases.md index 5af903d7..d2de3d07 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1948,6 +1948,23 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet +### ntfy server v2.25.0 (UNRELEASED) + +This release adds **password reset** via email, and reworks email verification to use durable, +link-based magic links (replacing the old in-memory 6-digit codes). Email stays optional at +signup; a user can reset their password only once they have a verified "primary" (recovery) +email. All of this rides on the existing SMTP configuration -- no new config flag. + +**Features:** + +* Add password reset via emailed magic link, with a "Forgot password?" link on the login page and a `ntfy user password-reset` CLI command for admins +* Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" (recovery) email with verified/unverified state in the account UI +* Auto-send a verification link to the billing email after a Stripe checkout, so paying users can set up password recovery + +**Bug fixes + maintenance:** + +* Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG + ### ntfy Android v1.25.x (UNRELEASED) This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out diff --git a/server/errors.go b/server/errors.go index 5ac6ce38..51bfbe21 100644 --- a/server/errors.go +++ b/server/errors.go @@ -146,6 +146,7 @@ var ( errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification link invalid or expired", "", nil} errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} + errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} diff --git a/server/server.go b/server/server.go index 95d2ecf7..3184478a 100644 --- a/server/server.go +++ b/server/server.go @@ -80,19 +80,20 @@ type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error var ( // If changed, don't forget to update Android App and auth_sqlite.go - topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /! - topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app! - externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic - webAppEmailVerifyRegex = regexp.MustCompile(`^/account/email/verify/[-_A-Za-z0-9]+$`) // Magic-link landing (served by the web app) - jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`) - ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`) - rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`) - wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`) - authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`) - publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) - updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`) - clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`) - sequenceIDRegex = topicRegex + topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /! + topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app! + externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic + webAppEmailVerifyRegex = regexp.MustCompile(`^/account/email/verify/[-_A-Za-z0-9]+$`) // Magic-link landing (served by the web app) + webAppPasswordResetRegex = regexp.MustCompile(`^/account/password/reset/[-_A-Za-z0-9]+$`) // Password-reset landing (served by the web app) + jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`) + ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`) + rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`) + wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`) + authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`) + publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) + updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`) + clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`) + sequenceIDRegex = topicRegex webConfigPath = "/config.js" webManifestPath = "/manifest.webmanifest" @@ -119,7 +120,10 @@ var ( apiAccountEmailVerifyPath = "/v1/account/email/verify" apiAccountEmailPrimaryPath = "/v1/account/email/primary" apiAccountEmailResendPath = "/v1/account/email/resend" - webAppEmailVerifyPathPrefix = "/account/email/verify/" // Browser landing route; raw token appended + apiAccountPasswordResetRequestPath = "/v1/account/password/reset/request" + apiAccountPasswordResetPath = "/v1/account/password/reset" + webAppEmailVerifyPathPrefix = "/account/email/verify/" // Browser landing route; raw token appended + webAppPasswordResetPathPrefix = "/account/password/reset/" // Browser landing route; raw token appended apiAccountBillingPortalPath = "/v1/account/billing/portal" apiAccountBillingWebhookPath = "/v1/account/billing/webhook" apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription" @@ -626,6 +630,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureUser(s.withAccountSync(s.handleAccountEmailSetPrimary))(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailResendPath { return s.ensureUser(s.ensureEmailsEnabled(s.handleAccountEmailResend))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordResetRequestPath { + return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountPasswordResetRequest))(w, r, v) // Unauthenticated + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordResetPath { + return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountPasswordReset))(w, r, v) // Unauthenticated } else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path { return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v) } else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path { @@ -668,8 +676,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeWS))(w, r, v) } else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) { return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v) - } else if r.Method == http.MethodGet && webAppEmailVerifyRegex.MatchString(r.URL.Path) { - return s.ensureWebEnabled(s.handleWebAppIndex)(w, r, v) // Magic-link landing page (client-side route) + } else if r.Method == http.MethodGet && (webAppEmailVerifyRegex.MatchString(r.URL.Path) || webAppPasswordResetRegex.MatchString(r.URL.Path)) { + return s.ensureWebEnabled(s.handleWebAppIndex)(w, r, v) // Magic-link landing pages (client-side routes) } else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) { return s.ensureWebEnabled(s.handleTopic)(w, r, v) } diff --git a/server/server_account.go b/server/server_account.go index fd3a5eba..f006f1ac 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -18,6 +18,7 @@ const ( syncTopicAccountSyncEvent = "sync" tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much emailVerificationTokenExpiry = 24 * time.Hour // Magic-link lifetime for email verification + passwordResetTokenExpiry = time.Hour // Magic-link lifetime for password reset (higher-privilege -> shorter) ) func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { @@ -771,6 +772,77 @@ func (s *Server) enqueueEmailVerification(userID, email string) error { return s.mailSender.SendEmailVerification(email, link) } +// handleAccountPasswordResetRequest starts a password reset (POST /v1/account/password/reset/request, +// unauthenticated). It resolves the identifier (username or primary email) to at most one account +// and emails a reset link to that account's primary email. The response is always a uniform 200, +// regardless of whether anything matched, so it cannot be used to probe for accounts. +func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *http.Request, v *visitor) error { + req, err := readJSONWithLimit[apiAccountPasswordResetRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + // Rate limit via the shared per-visitor account-creation bucket (no new limiter/config) + if !v.AccountCreationAllowed() { + return errHTTPTooManyRequestsLimitAccountCreation + } + v.AccountCreated() // Consume a token on every request (including no-match), to throttle probing + identifier := strings.TrimSpace(req.Identifier) + if identifier != "" && s.config.BaseURL != "" { + if userID, email, ok := s.resolveResetTarget(identifier); ok { + token, err := s.userManager.CreateMagicLink(user.MagicLinkKindPasswordReset, userID, "", passwordResetTokenExpiry) + if err != nil { + logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to create password reset token") + } else { + link := s.config.BaseURL + webAppPasswordResetPathPrefix + token + logvr(v, r).Tag(tagAccount).Field("user_id", userID).Info("Sending password reset link") + if err := s.mailSender.SendPasswordReset(email, link); err != nil { + logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send password reset email") + } + } + } else { + logvr(v, r).Tag(tagAccount).Debug("Password reset requested for unknown identifier (uniform response)") + } + } + return s.writeJSON(w, newSuccessResponse()) +} + +// resolveResetTarget resolves a reset identifier to a single account and its primary email. +// 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 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 + } + return "", "", false +} + +// handleAccountPasswordReset performs the reset (POST /v1/account/password/reset, unauthenticated): +// it validates the token and sets the new password. Existing access tokens stay valid. +func (s *Server) handleAccountPasswordReset(w http.ResponseWriter, r *http.Request, v *visitor) error { + req, err := readJSONWithLimit[apiAccountPasswordResetConfirmRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + if req.Token == "" { + return errHTTPBadRequestResetLinkInvalid + } else if req.Password == "" { + return errHTTPBadRequest + } + err = s.userManager.ResetPassword(req.Token, req.Password) + if errors.Is(err, user.ErrMagicLinkNotFound) { + return errHTTPBadRequestResetLinkInvalid + } else if err != nil { + return err + } + logvr(v, r).Tag(tagAccount).Info("Password reset performed") + return s.writeJSON(w, newSuccessResponse()) +} + // convertEmailAddress checks the email address against the user's verified email list. // If smtp-sender-verify is false (default), the email is passed through as-is for // backwards compatibility. If true, the user must be authenticated and the email must be diff --git a/server/server_account_email_test.go b/server/server_account_email_test.go index b594684d..e8bbc920 100644 --- a/server/server_account_email_test.go +++ b/server/server_account_email_test.go @@ -173,6 +173,89 @@ func TestAccount_Email_SetPrimaryCollision(t *testing.T) { }) } +// verifyEmailFor runs the full add->click flow so the user ends up with a verified primary email. +func verifyEmailFor(t *testing.T, s *Server, mailer *captureMailer, auth map[string]string, email string) { + require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", fmt.Sprintf(`{"email":"%s"}`, email), auth).Code) + token := tokenFromLink(t, mailer.verifyLinks[email], "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) +} + +// canLogin returns true if username/password authenticates (via the token-create endpoint). +func canLogin(t *testing.T, s *Server, username, password string) bool { + rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{"Authorization": util.BasicAuth(username, password)}) + return rr.Code == 200 +} + +func TestAccount_PasswordReset_ByUsername(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, auth := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + verifyEmailFor(t, s, mailer, auth, "ben@example.com") + + // Request reset by username + rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben"}`, nil) + require.Equal(t, 200, rr.Code) + token := tokenFromLink(t, mailer.resetLinks["ben@example.com"], "https://ntfy.example.com/account/password/reset/") + + // Confirm with a new password + rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil) + require.Equal(t, 200, rr.Code) + + require.True(t, canLogin(t, s, "ben", "brandnew")) + require.False(t, canLogin(t, s, "ben", "ben")) + }) +} + +func TestAccount_PasswordReset_ByEmail(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, auth := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + verifyEmailFor(t, s, mailer, auth, "ben@example.com") + + rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben@example.com"}`, nil) + require.Equal(t, 200, rr.Code) + token := tokenFromLink(t, mailer.resetLinks["ben@example.com"], "https://ntfy.example.com/account/password/reset/") + rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil) + require.Equal(t, 200, rr.Code) + require.True(t, canLogin(t, s, "ben", "brandnew")) + }) +} + +func TestAccount_PasswordReset_UnknownIdentifierUniform(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, _ := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + // Unknown identifier still returns a uniform 200, and no email is sent + rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ghost"}`, nil) + require.Equal(t, 200, rr.Code) + require.Empty(t, mailer.resetLinks) + }) +} + +func TestAccount_PasswordReset_NoPrimaryEmailNoSend(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, _ := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + // ben exists but has no verified primary email -> uniform 200, nothing sent + rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben"}`, nil) + require.Equal(t, 200, rr.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) + defer s.closeDatabases() + + rr := request(t, s, "POST", "/v1/account/password/reset", `{"token":"nope","password":"brandnew"}`, nil) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40054, toHTTPError(t, rr.Body.String()).Code) + }) +} + func TestAccount_Email_AddDuplicateVerified(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s, mailer, auth := newEmailTestServer(t, databaseURL) diff --git a/server/server_payments.go b/server/server_payments.go index 0226df4f..76d0b5ac 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -237,10 +237,40 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), string(interval), sub.CurrentPeriodEnd, sub.CancelAt); err != nil { return err } + // Offer email recovery: auto-send a verification link to the billing email (best-effort). + if sess.CustomerDetails != nil { + s.maybeEnqueueBillingEmailVerification(r, v, u.ID, sess.CustomerDetails.Email) + } http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther) return nil } +// maybeEnqueueBillingEmailVerification sends an email-verification link to a paying user's +// billing email, so they can use it for password recovery -- but only if they have no verified +// email yet and the billing email is not already the recovery email on another account. On a +// collision (or any other skip), the generic "no recovery email set" warning on the account page +// nudges the user to add one. This is best-effort: failures are logged, never surfaced. +func (s *Server) maybeEnqueueBillingEmailVerification(r *http.Request, v *visitor, userID, billingEmail string) { + if s.mailSender == nil || s.config.BaseURL == "" || billingEmail == "" || !emailAddressRegex.MatchString(billingEmail) { + return + } + emails, err := s.userManager.Emails(userID) + if err != nil { + logvr(v, r).Tag(tagStripe).Err(err).Warn("Failed to load emails for billing verification") + return + } else if len(emails) > 0 { + return // User already has a verified email -- don't nag + } + if _, err := s.userManager.UserIDByPrimaryEmail(billingEmail); err == nil { + logvr(v, r).Tag(tagStripe).Debug("Billing email is primary on another account, skipping auto-verification") + return // Collision: skip + let the generic no-recovery-email warning nudge instead + } + logvr(v, r).Tag(tagStripe).Field("email", billingEmail).Info("Sending verification link to billing email") + if err := s.enqueueEmailVerification(userID, billingEmail); err != nil { + logvr(v, r).Tag(tagStripe).Err(err).Warn("Failed to enqueue billing email verification") + } +} + // handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates // a user's tier accordingly. This endpoint only works if there is an existing subscription. func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { diff --git a/server/server_payments_email_test.go b/server/server_payments_email_test.go new file mode 100644 index 00000000..24fa7bb2 --- /dev/null +++ b/server/server_payments_email_test.go @@ -0,0 +1,114 @@ +//go:build !nopayments + +package server + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stripe/stripe-go/v74" + "heckel.io/ntfy/v2/user" +) + +// stripeCheckoutMock wires up a testStripeAPI for a successful checkout of user u, with the given +// billing email on the session's CustomerDetails. +func stripeCheckoutMock(u *user.User, billingEmail string) *testStripeAPI { + m := &testStripeAPI{} + m.On("GetSession", "SOMETOKEN").Return(&stripe.CheckoutSession{ + ClientReferenceID: u.ID, + Customer: &stripe.Customer{ID: "acct_5555"}, + Subscription: &stripe.Subscription{ID: "sub_1234"}, + CustomerDetails: &stripe.CheckoutSessionCustomerDetails{Email: billingEmail}, + }, nil) + m.On("GetSubscription", "sub_1234").Return(&stripe.Subscription{ + ID: "sub_1234", + Status: stripe.SubscriptionStatusActive, + CurrentPeriodEnd: 123456789, + Items: &stripe.SubscriptionItemList{ + Data: []*stripe.SubscriptionItem{ + {Price: &stripe.Price{ID: "price_1234", Recurring: &stripe.PriceRecurring{Interval: stripe.PriceRecurringIntervalMonth}}}, + }, + }, + }, nil) + m.On("UpdateCustomer", "acct_5555", mock.Anything).Return(&stripe.Customer{}, nil) + return m +} + +func newCheckoutEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, *user.User) { + c := newTestConfigWithAuthFile(t, databaseURL) + c.StripeSecretKey = "secret key" + c.BaseURL = "https://ntfy.example.com" + c.SMTPSenderAddr = "localhost:25" + c.SMTPSenderFrom = "noreply@example.com" + s := newTestServer(t, c) + mailer := newCaptureMailer() + s.mailSender = mailer + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_123", Code: "starter", StripeMonthlyPriceID: "price_1234", MessageLimit: 100, MessageExpiryDuration: time.Hour, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + u, err := s.userManager.User("phil") + require.Nil(t, err) + return s, mailer, u +} + +func TestPayments_Checkout_SendsBillingEmailVerification(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, u := newCheckoutEmailTestServer(t, databaseURL) + defer s.closeDatabases() + s.stripe = stripeCheckoutMock(u, "billing@example.com") + + rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil) + require.Equal(t, 303, rr.Code) + + // A verification link was auto-sent to the billing email; clicking it verifies + sets primary + link := mailer.verifyLinks["billing@example.com"] + require.NotEmpty(t, link) + 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) + + emails, err := s.userManager.Emails(u.ID) + require.Nil(t, err) + require.Equal(t, []string{"billing@example.com"}, emails) + primary, err := s.userManager.PrimaryEmail(u.ID) + require.Nil(t, err) + require.Equal(t, "billing@example.com", primary) + }) +} + +func TestPayments_Checkout_SkipsBillingEmailWhenAlreadyVerified(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, u := newCheckoutEmailTestServer(t, databaseURL) + defer s.closeDatabases() + s.stripe = stripeCheckoutMock(u, "billing@example.com") + + // User already has a verified email -> no auto-send on checkout + require.Nil(t, s.userManager.AddEmail(u.ID, "existing@example.com")) + + rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil) + require.Equal(t, 303, rr.Code) + require.Empty(t, mailer.verifyLinks) + }) +} + +func TestPayments_Checkout_SkipsBillingEmailWhenPrimaryElsewhere(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, u := newCheckoutEmailTestServer(t, databaseURL) + defer s.closeDatabases() + s.stripe = stripeCheckoutMock(u, "billing@example.com") + + // The billing email is already the recovery email on another account -> skip + require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false)) + alice, err := s.userManager.User("alice") + require.Nil(t, err) + require.Nil(t, s.userManager.AddEmail(alice.ID, "billing@example.com")) + require.Nil(t, s.userManager.SetPrimaryEmail(alice.ID, "billing@example.com")) + + rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil) + require.Equal(t, 303, rr.Code) + require.Empty(t, mailer.verifyLinks) + }) +} diff --git a/server/types.go b/server/types.go index b51e9f00..963cb127 100644 --- a/server/types.go +++ b/server/types.go @@ -238,6 +238,19 @@ type apiAccountEmailVerifyRequest struct { Token string `json:"token"` } +// apiAccountPasswordResetRequest is the body of the (unauthenticated) reset-request endpoint. +// The identifier is a username or a primary email address. +type apiAccountPasswordResetRequest struct { + Identifier string `json:"identifier"` +} + +// apiAccountPasswordResetConfirmRequest is the body of the (unauthenticated) reset-confirm +// endpoint, submitted from the set-new-password landing page. +type apiAccountPasswordResetConfirmRequest struct { + Token string `json:"token"` + Password string `json:"password"` +} + type apiAccountTier struct { Code string `json:"code"` Name string `json:"name"` diff --git a/user/magic_link_test.go b/user/magic_link_test.go index fd96bb1a..33053c05 100644 --- a/user/magic_link_test.go +++ b/user/magic_link_test.go @@ -239,6 +239,71 @@ func TestUser_MagicLink_Reaper(t *testing.T) { }) } +func TestUser_MagicLink_ResetPassword(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + require.Nil(t, err) + + // Old password works before reset + _, err = a.Authenticate("phil", "oldpass") + require.Nil(t, err) + + require.Nil(t, a.ResetPassword(raw, "newpass")) + + // New password works, old does not + _, err = a.Authenticate("phil", "newpass") + require.Nil(t, err) + _, err = a.Authenticate("phil", "oldpass") + require.ErrorIs(t, err, ErrUnauthenticated) + + // Token is single-use + require.ErrorIs(t, a.ResetPassword(raw, "againpass"), ErrMagicLinkNotFound) + }) +} + +func TestUser_MagicLink_ResetPassword_WrongKindRejected(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + // An email-verification token must not be usable for password reset... + verifyToken := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Hour) + require.ErrorIs(t, a.ResetPassword(verifyToken, "newpass"), ErrMagicLinkNotFound) + + // ...and a reset token must not be usable for email verification + resetToken, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + require.Nil(t, err) + _, err = a.VerifyEmail(resetToken) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + // Old password unchanged + _, err = a.Authenticate("phil", "oldpass") + require.Nil(t, err) + }) +} + +func TestUser_MagicLink_ResetPassword_Expired(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", -time.Minute) + require.Nil(t, err) + require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrMagicLinkNotFound) + _, err = a.Authenticate("phil", "oldpass") + require.Nil(t, err) + }) +} + func TestUser_MagicLink_UserIDByPrimaryEmail_NotFound(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 4c62f940..4a7866c1 100644 --- a/user/manager.go +++ b/user/manager.go @@ -1696,6 +1696,37 @@ func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) { return m, nil } +// ResetPassword consumes a password-reset magic link, identified by its raw token: after +// validating the token (kind + expiry), it sets the user's password and deletes the link in one +// transaction. Existing access tokens are intentionally left valid (only the password changes). +// Returns ErrMagicLinkNotFound if the token is invalid, expired, or not a reset token. +func (a *Manager) ResetPassword(rawToken, password string) error { + m, err := a.MagicLinkByHash(hashToken(rawToken)) + if err != nil { + return err + } + if m.Kind != MagicLinkKindPasswordReset || time.Now().Unix() > m.Expires { + return ErrMagicLinkNotFound + } + u, err := a.UserByID(m.UserID) + if err != nil { + return err + } + hash, err := a.maybeHashPassword(password, false) + if err != nil { + return err + } + return db.ExecTx(a.db, func(tx *sql.Tx) error { + if err := a.changePasswordHashTx(tx, u.Name, hash); err != nil { + return err + } + if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, m.TokenHash); err != nil { + return err + } + return nil + }) +} + // deleteExpiredMagicLinks removes magic links whose expiry has passed. Expiry is also enforced // on read, so this is purely housekeeping to bound table growth; it runs from the reaper loop. func (a *Manager) deleteExpiredMagicLinks() error { diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 2dff3100..ecae6954 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -27,6 +27,21 @@ "login_title": "Sign in to your ntfy account", "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_title": "Set a new password", + "reset_password_form_password": "New password", + "reset_password_form_confirm": "Confirm new password", + "reset_password_form_button_submit": "Set password", + "reset_password_form_passwords_no_match": "Passwords do not match", + "reset_password_form_error_invalid": "This reset link is invalid or has expired. Please request a new one.", + "reset_password_success_title": "Password changed", + "reset_password_success_description": "Your password has been changed. You can now sign in with your new password.", + "reset_password_button_login": "Sign in", "login_disabled": "Login is disabled", "action_bar_show_menu": "Show menu", "action_bar_logo_alt": "ntfy logo", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index e9d21d65..823eba53 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -6,6 +6,8 @@ import { accountEmailVerifyUrl, accountEmailPrimaryUrl, accountEmailResendUrl, + accountPasswordResetRequestUrl, + accountPasswordResetUrl, accountPasswordUrl, accountPhoneUrl, accountPhoneVerifyUrl, @@ -397,6 +399,32 @@ class AccountApi { }); } + // requestPasswordReset starts the (unauthenticated) reset flow. The identifier is a username or + // primary email. The server always responds uniformly, regardless of whether an account matched. + async requestPasswordReset(identifier) { + const url = accountPasswordResetRequestUrl(config.base_url); + console.log(`[AccountApi] Requesting password reset ${url}`); + await fetchOrThrow(url, { + method: "POST", + body: JSON.stringify({ + identifier, + }), + }); + } + + // resetPassword performs the (unauthenticated) reset from the set-new-password landing page. + async resetPassword(token, password) { + const url = accountPasswordResetUrl(config.base_url); + console.log(`[AccountApi] Resetting password ${url}`); + await fetchOrThrow(url, { + method: "POST", + body: JSON.stringify({ + token, + password, + }), + }); + } + async deleteEmail(email) { const url = accountEmailUrl(config.base_url); console.log(`[AccountApi] Deleting email ${url}`); diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 9bad68bf..13f50ffd 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -37,6 +37,8 @@ export const accountEmailUrl = (baseUrl) => `${baseUrl}/v1/account/email`; export const accountEmailVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/email/verify`; export const accountEmailPrimaryUrl = (baseUrl) => `${baseUrl}/v1/account/email/primary`; export const accountEmailResendUrl = (baseUrl) => `${baseUrl}/v1/account/email/resend`; +export const accountPasswordResetRequestUrl = (baseUrl) => `${baseUrl}/v1/account/password/reset/request`; +export const accountPasswordResetUrl = (baseUrl) => `${baseUrl}/v1/account/password/reset`; export const validUrl = (url) => url.match(/^https?:\/\/.+/); diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index ed1fff99..d9eb2d0b 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -21,6 +21,7 @@ import Login from "./Login"; import Signup from "./Signup"; import Account from "./Account"; import EmailVerify from "./EmailVerify"; +import PasswordReset from "./PasswordReset"; import initI18n from "../app/i18n"; // Translations! import prefs from "../app/Prefs"; import RTLCacheProvider from "./RTLCacheProvider"; @@ -65,6 +66,7 @@ const App = () => { } /> } /> } /> + } /> }> } /> } /> diff --git a/web/src/components/Login.jsx b/web/src/components/Login.jsx index 5c1af249..a95f9a14 100644 --- a/web/src/components/Login.jsx +++ b/web/src/components/Login.jsx @@ -1,6 +1,18 @@ import * as React from "react"; import { useState } from "react"; -import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "@mui/material"; +import { + Typography, + TextField, + Button, + Box, + IconButton, + InputAdornment, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, +} from "@mui/material"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import { NavLink } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -17,6 +29,7 @@ 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(); @@ -100,7 +113,13 @@ const Login = () => { )} - {/* This is where the password reset link would go */} + {config.enable_reset_password && ( +
+ +
+ )} {config.enable_signup && (
@@ -110,8 +129,67 @@ 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 new file mode 100644 index 00000000..0e004d04 --- /dev/null +++ b/web/src/components/PasswordReset.jsx @@ -0,0 +1,105 @@ +import * as React from "react"; +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 { useTranslation } from "react-i18next"; +import accountApi from "../app/AccountApi"; +import AvatarBox from "./AvatarBox"; +import routes from "./routes"; + +// PasswordReset is the magic-link landing page for setting a new password. There is no +// pre-validation: the form renders directly and an invalid/expired token surfaces as an error on +// submit. The raw token is stripped from the URL on load (kept out of history / Referer). +const PasswordReset = () => { + const { t } = useTranslation(); + const { token: tokenParam } = useParams(); + const token = useRef(tokenParam); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(""); + const [sending, setSending] = useState(false); + const [done, setDone] = useState(false); + + useEffect(() => { + // Strip the token from the URL bar immediately (keep it out of history / Referer) + window.history.replaceState(null, "", routes.login); + }, []); + + const handleSubmit = async (event) => { + event.preventDefault(); + if (password !== confirm) { + setError(t("reset_password_form_passwords_no_match")); + return; + } + try { + setSending(true); + setError(""); + await accountApi.resetPassword(token.current, password); + setDone(true); + } catch (e) { + console.log(`[PasswordReset] Reset failed`, e); + setError(t("reset_password_form_error_invalid")); + } finally { + setSending(false); + } + }; + + if (done) { + return ( + + + {t("reset_password_success_title")} + {t("reset_password_success_description")} + + + ); + } + + return ( + + {t("reset_password_title")} + + setPassword(ev.target.value.trim())} + autoComplete="new-password" + autoFocus + /> + setConfirm(ev.target.value.trim())} + autoComplete="new-password" + /> + + {error && ( + + + {error} + + )} + + + ); +}; + +export default PasswordReset; diff --git a/web/src/components/routes.js b/web/src/components/routes.js index d9c371eb..6e649df3 100644 --- a/web/src/components/routes.js +++ b/web/src/components/routes.js @@ -8,6 +8,7 @@ const routes = { account: "/account", settings: "/settings", emailVerify: "/account/email/verify/:token", + passwordReset: "/account/password/reset/:token", subscription: "/:topic", subscriptionExternal: "/:baseUrl/:topic", forSubscription: (subscription) => {