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
+11 -9
View File
@@ -44,7 +44,7 @@ var flagsUser = append(
var cmdUser = &cli.Command{ var cmdUser = &cli.Command{
Name: "user", Name: "user",
Usage: "Manage/show users", 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, Flags: flagsUser,
Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc), Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc),
Category: categoryServer, Category: categoryServer,
@@ -108,11 +108,11 @@ directly the bcrypt hash. This is useful if you are updating users via scripts.
`, `,
}, },
{ {
Name: "password-reset", Name: "reset-pass",
Aliases: []string{"reset"}, Aliases: []string{"rp"},
Usage: "Generates a password reset link for a user", Usage: "Generates a password reset link for a user",
UsageText: "ntfy user password-reset [--send-email] USERNAME", UsageText: "ntfy user reset-pass [--send-email] USERNAME",
Action: execUserPasswordReset, Action: execUserResetPass,
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.BoolFlag{Name: "send-email", Aliases: []string{"e"}, Usage: "also email the reset link to the user's primary email"}, &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. Requires base-url to be configured so an absolute link can be generated.
Example: Example:
ntfy user password-reset phil # Print a reset link for user phil ntfy user reset-pass 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 --send-email phil # Print and email the reset link
`, `,
}, },
{ {
@@ -319,12 +319,12 @@ func execUserChangePass(c *cli.Context) error {
return nil return nil
} }
func execUserPasswordReset(c *cli.Context) error { func execUserResetPass(c *cli.Context) error {
username := c.Args().Get(0) username := c.Args().Get(0)
sendEmail := c.Bool("send-email") sendEmail := c.Bool("send-email")
baseURL := strings.TrimSuffix(c.String("base-url"), "/") baseURL := strings.TrimSuffix(c.String("base-url"), "/")
if username == "" { 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 { } else if username == userEveryone || username == user.Everyone {
return errors.New("username not allowed") return errors.New("username not allowed")
} else if baseURL == "" { } else if baseURL == "" {
@@ -339,6 +339,8 @@ func execUserPasswordReset(c *cli.Context) error {
return fmt.Errorf("user %s does not exist", username) return fmt.Errorf("user %s does not exist", username)
} else if err != nil { } else if err != nil {
return err 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 // Resolve the primary email up front if we need to send -- fail before creating a token
var primaryEmail string var primaryEmail string
+26 -6
View File
@@ -122,7 +122,7 @@ func TestCLI_User_Delete(t *testing.T) {
require.Contains(t, err.Error(), "user phil does not exist") 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) s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port) 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 // Prints a working-looking reset link when base-url is set
app, _, stdout, _ = newTestApp() 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/") 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) s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port) 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")) require.Nil(t, runUserCommand(app, conf, "add", "phil"))
app, _, _, _ = newTestApp() app, _, _, _ = newTestApp()
err := runUserCommand(app, conf, "password-reset", "phil") err := runUserCommand(app, conf, "reset-pass", "phil")
require.Error(t, err) require.Error(t, err)
require.Contains(t, err.Error(), "base-url") 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) s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port) 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 // --send-email requires a primary email; phil has none
app, _, _, _ = newTestApp() 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.Error(t, err)
require.Contains(t, err.Error(), "no primary email") 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) { func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
configFile := filepath.Join(t.TempDir(), "server-dummy.yml") 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 require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml
+18 -10
View File
@@ -167,20 +167,24 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
if err != nil { if err != nil {
return err return err
} }
if len(emails) > 0 {
response.Emails = emails
}
primaryEmail, err := s.userManager.PrimaryEmail(u.ID) primaryEmail, err := s.userManager.PrimaryEmail(u.ID)
if err != nil { if err != nil {
return err return err
} }
response.PrimaryEmail = primaryEmail
pendingEmails, err := s.userManager.PendingEmails(u.ID) pendingEmails, err := s.userManager.PendingEmails(u.ID)
if err != nil { if err != nil {
return err return err
} }
if len(pendingEmails) > 0 { // Combine verified (with primary flag) and pending (unverified) into one list
response.PendingEmails = pendingEmails 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 { } else {
@@ -718,6 +722,8 @@ func (s *Server) handleAccountEmailSetPrimary(w http.ResponseWriter, r *http.Req
return err return err
} else if !emailAddressRegex.MatchString(req.Email) { } else if !emailAddressRegex.MatchString(req.Email) {
return errHTTPBadRequestEmailAddressInvalid 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") logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Setting primary email")
err = s.userManager.SetPrimaryEmail(u.ID, req.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 // 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). // 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) { 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 != "" { if primary, perr := s.userManager.PrimaryEmail(u.ID); perr == nil && primary != "" {
return u.ID, primary, true return u.ID, primary, true
} }
} }
if uid, err := s.userManager.UserIDByPrimaryEmail(identifier); err == nil { 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 return "", "", false
} }
@@ -834,8 +842,8 @@ func (s *Server) handleAccountPasswordReset(w http.ResponseWriter, r *http.Reque
return errHTTPBadRequest return errHTTPBadRequest
} }
err = s.userManager.ResetPassword(req.Token, req.Password) err = s.userManager.ResetPassword(req.Token, req.Password)
if errors.Is(err, user.ErrMagicLinkNotFound) { if errors.Is(err, user.ErrMagicLinkNotFound) || errors.Is(err, user.ErrProvisionedUserChange) {
return errHTTPBadRequestResetLinkInvalid return errHTTPBadRequestResetLinkInvalid // Generic 400 (provisioned users can't be reset; don't leak that)
} else if err != nil { } else if err != nil {
return err return err
} }
+100 -12
View File
@@ -57,6 +57,37 @@ func getAccount(t *testing.T, s *Server, auth map[string]string) *apiAccountResp
return account 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 { func tokenFromLink(t *testing.T, link, prefix string) string {
require.True(t, strings.HasPrefix(link, prefix), "link %q missing prefix %q", link, prefix) require.True(t, strings.HasPrefix(link, prefix), "link %q missing prefix %q", link, prefix)
return strings.TrimPrefix(link, prefix) return strings.TrimPrefix(link, prefix)
@@ -73,9 +104,9 @@ func TestAccount_Email_AddVerifySetsPrimary(t *testing.T) {
// Pending, not yet verified, no primary // Pending, not yet verified, no primary
account := getAccount(t, s, auth) account := getAccount(t, s, auth)
require.Equal(t, []string{"ben@example.com"}, account.PendingEmails) require.Equal(t, []string{"ben@example.com"}, pendingAddrs(account))
require.Empty(t, account.Emails) require.Empty(t, verifiedAddrs(account))
require.Equal(t, "", account.PrimaryEmail) require.Equal(t, "", primaryAddr(account))
// "Click" the captured link (unauthenticated POST) // "Click" the captured link (unauthenticated POST)
token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/") 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 // Now verified + primary, no longer pending
account = getAccount(t, s, auth) account = getAccount(t, s, auth)
require.Equal(t, []string{"ben@example.com"}, account.Emails) require.Equal(t, []string{"ben@example.com"}, verifiedAddrs(account))
require.Equal(t, "ben@example.com", account.PrimaryEmail) require.Equal(t, "ben@example.com", primaryAddr(account))
require.Empty(t, account.PendingEmails) require.Empty(t, pendingAddrs(account))
}) })
} }
@@ -111,13 +142,13 @@ func TestAccount_Email_DeletePending(t *testing.T) {
defer s.closeDatabases() defer s.closeDatabases()
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code) 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) // 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) require.Equal(t, 200, request(t, s, "DELETE", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
account := getAccount(t, s, auth) account := getAccount(t, s, auth)
require.Empty(t, account.PendingEmails) require.Empty(t, pendingAddrs(account))
require.Empty(t, account.Emails) 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) 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/") 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, 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 // 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)) 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/") 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) require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, aliceToken), nil).Code)
aliceAccount := getAccount(t, s, aliceAuth) aliceAccount := getAccount(t, s, aliceAuth)
require.Equal(t, []string{"shared@example.com"}, aliceAccount.Emails) require.Equal(t, []string{"shared@example.com"}, verifiedAddrs(aliceAccount))
require.Equal(t, "", aliceAccount.PrimaryEmail) require.Equal(t, "", primaryAddr(aliceAccount))
// alice trying to promote it to primary collides with ben's // 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) 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) { func TestAccount_PasswordReset_InvalidToken(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) { forEachBackend(t, func(t *testing.T, databaseURL string) {
s, _, _ := newEmailTestServer(t, databaseURL) s, _, _ := newEmailTestServer(t, databaseURL)
+2 -1
View File
@@ -238,7 +238,8 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
return err return err
} }
// Offer email recovery: auto-send a verification link to the billing email (best-effort). // 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) s.maybeEnqueueBillingEmailVerification(r, v, u.ID, sess.CustomerDetails.Email)
} }
http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther) http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)
+10 -3
View File
@@ -287,6 +287,15 @@ type apiAccountReservation struct {
Everyone string `json:"everyone"` 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 { type apiAccountBilling struct {
Customer bool `json:"customer"` Customer bool `json:"customer"`
Subscription bool `json:"subscription"` Subscription bool `json:"subscription"`
@@ -307,9 +316,7 @@ type apiAccountResponse struct {
Reservations []*apiAccountReservation `json:"reservations,omitempty"` Reservations []*apiAccountReservation `json:"reservations,omitempty"`
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
PhoneNumbers []string `json:"phone_numbers,omitempty"` PhoneNumbers []string `json:"phone_numbers,omitempty"`
Emails []string `json:"emails,omitempty"` Emails []*apiAccountEmailInfo `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
Tier *apiAccountTier `json:"tier,omitempty"` Tier *apiAccountTier `json:"tier,omitempty"`
Limits *apiAccountLimits `json:"limits,omitempty"` Limits *apiAccountLimits `json:"limits,omitempty"`
Stats *apiAccountStats `json:"stats,omitempty"` Stats *apiAccountStats `json:"stats,omitempty"`
+47
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) { func TestUser_MagicLink_ResetPassword_Expired(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll) a := newTestManager(t, newManager, PermissionDenyAll)
+13 -2
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 // 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 // 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 // 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 // transaction. A primary collision simply leaves the address verified but non-primary. Provisioned
// the consumed link. // 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) { func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) {
tokenHash := hashToken(rawToken) tokenHash := hashToken(rawToken)
m, err := a.MagicLinkByHash(tokenHash) 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 { if m.Kind != MagicLinkKindEmailVerify || time.Now().Unix() > m.Expires {
return nil, ErrMagicLinkNotFound 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 { err = db.ExecTx(a.db, func(tx *sql.Tx) error {
// Single use: delete the link, then add the (idempotent) verified address // Single use: delete the link, then add the (idempotent) verified address
if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, tokenHash); err != nil { 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 { if _, err := tx.Exec(a.queries.insertEmailIgnore, m.UserID, m.Email); err != nil {
return err 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. // 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 // 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). // 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 { if err != nil {
return err return err
} }
if u.Provisioned {
return ErrProvisionedUserChange // Provisioned users get their password from the config file, not reset
}
hash, err := a.maybeHashPassword(password, false) hash, err := a.maybeHashPassword(password, false)
if err != nil { if err != nil {
return err return err
+12 -6
View File
@@ -28,11 +28,14 @@
"login_form_button_submit": "Sign in", "login_form_button_submit": "Sign in",
"login_link_signup": "Sign up", "login_link_signup": "Sign up",
"login_link_forgot_password": "Forgot password?", "login_link_forgot_password": "Forgot password?",
"login_reset_dialog_title": "Reset password", "reset_password_request_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.", "reset_password_request_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", "reset_password_request_identifier_label": "Username or email",
"login_reset_dialog_button_submit": "Send reset link", "reset_password_request_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_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_title": "Set a new password",
"reset_password_form_password": "New password", "reset_password_form_password": "New password",
"reset_password_form_confirm": "Confirm 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_no_emails_yet": "No emails yet",
"account_basics_emails_copied_to_clipboard": "Email address copied to clipboard", "account_basics_emails_copied_to_clipboard": "Email address copied to clipboard",
"account_basics_emails_primary_badge": "Primary", "account_basics_emails_primary_badge": "Primary",
"account_basics_emails_chip_actions": "Click for actions",
"account_basics_emails_unverified": "unverified", "account_basics_emails_unverified": "unverified",
"account_basics_emails_set_primary": "Set as recovery email", "account_basics_emails_set_primary": "Set as recovery email",
"account_basics_emails_delete": "Remove", "account_basics_emails_delete": "Remove",
@@ -249,7 +253,8 @@
"account_basics_emails_resend": "Resend verification email", "account_basics_emails_resend": "Resend verification email",
"account_basics_emails_resent": "Verification email sent, check your inbox", "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_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_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_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", "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_with_tier": "(with {{tier}} tier)",
"account_basics_tier_admin_suffix_no_tier": "(no tier)", "account_basics_tier_admin_suffix_no_tier": "(no tier)",
"account_basics_tier_basic": "Basic", "account_basics_tier_basic": "Basic",
"account_basics_tier_provisioned": "Provisioned",
"account_basics_tier_free": "Free", "account_basics_tier_free": "Free",
"account_basics_tier_interval_monthly": "monthly", "account_basics_tier_interval_monthly": "monthly",
"account_basics_tier_interval_yearly": "annually", "account_basics_tier_interval_yearly": "annually",
+96 -57
View File
@@ -2,7 +2,6 @@ import * as React from "react";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { import {
Alert, Alert,
Box,
CardActions, CardActions,
CardContent, CardContent,
Chip, Chip,
@@ -32,7 +31,10 @@ import {
DialogContent, DialogContent,
TextField, TextField,
IconButton, IconButton,
Menu,
MenuItem, MenuItem,
ListItemIcon,
ListItemText,
DialogContentText, DialogContentText,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
@@ -294,6 +296,7 @@ const AccountType = () => {
> >
<div> <div>
{accountType} {accountType}
{account.provisioned && <Chip size="small" label={t("account_basics_tier_provisioned")} sx={{ ml: 1 }} />}
{account.billing?.paid_until && !account.billing?.cancel_at && ( {account.billing?.paid_until && !account.billing?.cancel_at && (
<Tooltip <Tooltip
title={t("account_basics_tier_paid_until", { title={t("account_basics_tier_paid_until", {
@@ -364,8 +367,23 @@ const Emails = () => {
const [dialogKey, setDialogKey] = useState(0); const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [snack, setSnack] = useState(""); // Non-empty shows a transient snackbar message 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 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 = () => { const handleDialogOpen = () => {
setDialogKey((prev) => prev + 1); setDialogKey((prev) => prev + 1);
setDialogOpen(true); setDialogOpen(true);
@@ -381,10 +399,12 @@ const Emails = () => {
}; };
// runEmailAction wraps an account API call with the shared error handling (redirect on // 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) => { const runEmailAction = async (fn, errorMessage) => {
try { try {
await fn(); await fn();
await accountApi.sync();
} catch (e) { } catch (e) {
console.log(`[Account] Email action failed`, e); console.log(`[Account] Email action failed`, e);
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
@@ -425,70 +445,82 @@ const Emails = () => {
); );
} }
const verifiedEmails = account?.emails ?? []; const emails = account?.emails ?? [];
const pendingEmails = account?.pending_emails ?? []; const verifiedEmails = emails.filter((e) => !e.pending);
const primaryEmail = account?.primary_email ?? ""; const pendingEmails = emails.filter((e) => e.pending);
const showNoRecoveryWarning = config.enable_reset_password && primaryEmail === ""; 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 ( 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}> <div aria-labelledby={labelId}>
{showNoRecoveryWarning && ( {verifiedEmails.map((email) => (
<Alert severity="warning" sx={{ mb: 1 }}> <Chip
{t("account_basics_emails_no_recovery_warning")} key={email.address}
</Alert> icon={email.primary ? <StarIcon /> : undefined}
)} label={
<Stack spacing={0.5}> <Tooltip title={t("account_basics_emails_chip_actions")}>
{verifiedEmails.map((email) => { <span>{email.address}</span>
const isPrimary = email === primaryEmail;
return (
<Box key={email} sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Typography sx={{ flexGrow: 1, overflowWrap: "anywhere" }}>{email}</Typography>
{isPrimary && <Chip size="small" color="primary" label={t("account_basics_emails_primary_badge")} />}
{!isPrimary && (
<Tooltip title={t("account_basics_emails_set_primary")}>
<IconButton size="small" aria-label={t("account_basics_emails_set_primary")} onClick={() => handleSetPrimary(email)}>
<StarBorderIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{isPrimary && <StarIcon fontSize="small" color="primary" sx={{ mx: 0.5 }} />}
<Tooltip title={t("common_copy_to_clipboard")}>
<IconButton size="small" aria-label={t("common_copy_to_clipboard")} onClick={() => handleCopy(email)}>
<ContentCopy fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={t("account_basics_emails_delete")}>
<IconButton size="small" aria-label={t("account_basics_emails_delete")} onClick={() => handleDelete(email)}>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
);
})}
{pendingEmails.map((email) => (
<Box key={email} sx={{ display: "flex", alignItems: "center", gap: 0.5, opacity: 0.65 }}>
<Typography sx={{ flexGrow: 1, overflowWrap: "anywhere" }}>
{email} <em>({t("account_basics_emails_unverified")})</em>
</Typography>
<Tooltip title={t("account_basics_emails_resend")}>
<IconButton size="small" aria-label={t("account_basics_emails_resend")} onClick={() => handleResend(email)}>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip> </Tooltip>
<Tooltip title={t("account_basics_emails_cancel")}> }
<IconButton size="small" aria-label={t("account_basics_emails_cancel")} onClick={() => handleDelete(email)}> variant="outlined"
<DeleteOutlineIcon fontSize="small" /> onClick={(ev) => openMenu(ev, email)}
</IconButton> 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> </Tooltip>
</Box> }
))} variant="outlined"
{verifiedEmails.length === 0 && pendingEmails.length === 0 && <em>{t("account_basics_emails_no_emails_yet")}</em>} onClick={(ev) => openMenu(ev, email)}
</Stack> 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")}> <IconButton onClick={handleDialogOpen} aria-label={t("account_basics_emails_dialog_title")}>
<AddIcon /> <AddIcon />
</IconButton> </IconButton>
{showNoRecoveryWarning && (
<Alert severity="warning" sx={{ mt: 1 }}>
{t("account_basics_emails_no_recovery_warning")}
</Alert>
)}
</div> </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} /> <AddEmailDialog key={`addEmailDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
<Portal> <Portal>
<Snackbar open={snack !== ""} autoHideDuration={3000} onClose={() => setSnack("")} message={snack} /> <Snackbar open={snack !== ""} autoHideDuration={3000} onClose={() => setSnack("")} message={snack} />
@@ -500,6 +532,7 @@ const Emails = () => {
const AddEmailDialog = (props) => { const AddEmailDialog = (props) => {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const { account } = useContext(AccountContext);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
@@ -512,6 +545,7 @@ const AddEmailDialog = (props) => {
try { try {
setSending(true); setSending(true);
await accountApi.startEmailVerification(email); await accountApi.startEmailVerification(email);
await accountApi.sync(); // Refresh so the new "(unverified)" address shows up immediately
setSent(true); setSent(true);
} catch (e) { } catch (e) {
console.log(`[Account] Error starting email verification`, e); console.log(`[Account] Error starting email verification`, e);
@@ -534,6 +568,11 @@ const AddEmailDialog = (props) => {
) : ( ) : (
<> <>
<DialogContentText>{t("account_basics_emails_dialog_description")}</DialogContentText> <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 <TextField
autoFocus autoFocus
margin="dense" margin="dense"
+2
View File
@@ -22,6 +22,7 @@ import Signup from "./Signup";
import Account from "./Account"; import Account from "./Account";
import EmailVerify from "./EmailVerify"; import EmailVerify from "./EmailVerify";
import PasswordReset from "./PasswordReset"; import PasswordReset from "./PasswordReset";
import ResetPassword from "./ResetPassword";
import initI18n from "../app/i18n"; // Translations! import initI18n from "../app/i18n"; // Translations!
import prefs from "../app/Prefs"; import prefs from "../app/Prefs";
import RTLCacheProvider from "./RTLCacheProvider"; import RTLCacheProvider from "./RTLCacheProvider";
@@ -65,6 +66,7 @@ const App = () => {
<Routes> <Routes>
<Route path={routes.login} element={<Login />} /> <Route path={routes.login} element={<Login />} />
<Route path={routes.signup} element={<Signup />} /> <Route path={routes.signup} element={<Signup />} />
<Route path={routes.resetPassword} element={<ResetPassword />} />
<Route path={routes.emailVerify} element={<EmailVerify />} /> <Route path={routes.emailVerify} element={<EmailVerify />} />
<Route path={routes.passwordReset} element={<PasswordReset />} /> <Route path={routes.passwordReset} element={<PasswordReset />} />
<Route element={<Layout />}> <Route element={<Layout />}>
+17 -14
View File
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { Typography, Button, Box, CircularProgress } from "@mui/material"; import { Typography, Button, Box, CircularProgress } from "@mui/material";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; 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 { useTranslation } from "react-i18next";
import accountApi from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import AvatarBox from "./AvatarBox"; import AvatarBox from "./AvatarBox";
@@ -16,6 +16,7 @@ import routes from "./routes";
const EmailVerify = () => { const EmailVerify = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { token } = useParams(); const { token } = useParams();
const navigate = useNavigate();
const [status, setStatus] = useState("verifying"); // "verifying" | "success" | "error" const [status, setStatus] = useState("verifying"); // "verifying" | "success" | "error"
const ran = useRef(false); const ran = useRef(false);
@@ -40,31 +41,33 @@ const EmailVerify = () => {
return ( return (
<AvatarBox> <AvatarBox>
{status === "verifying" && ( {status === "verifying" && (
<> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<CircularProgress sx={{ mb: 2 }} /> <CircularProgress size={24} />
<Typography sx={{ typography: "h6" }}>{t("email_verify_progress_title")}</Typography> <Typography sx={{ typography: "h6" }}>{t("email_verify_progress_title")}</Typography>
</> </Box>
)} )}
{status === "success" && ( {status === "success" && (
<> <>
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 48, mb: 1 }} /> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Typography sx={{ typography: "h6" }}>{t("email_verify_success_title")}</Typography> <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> <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")} {t("email_verify_button_account")}
</Button> </Button>
</> </>
)} )}
{status === "error" && ( {status === "error" && (
<> <>
<ErrorOutlineIcon color="error" sx={{ fontSize: 48, mb: 1 }} /> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Typography sx={{ typography: "h6" }}>{t("email_verify_error_title")}</Typography> <ErrorOutlineIcon color="error" sx={{ fontSize: 32 }} />
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("email_verify_error_description")}</Typography> <Typography sx={{ typography: "h6" }}>{t("email_verify_error_title")}</Typography>
<Box sx={{ mt: 2 }}>
<Button component={NavLink} to={routes.account} variant="contained">
{t("email_verify_button_account")}
</Button>
</Box> </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> </AvatarBox>
+3 -75
View File
@@ -1,18 +1,6 @@
import * as React from "react"; import * as React from "react";
import { useState } from "react"; import { useState } from "react";
import { import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "@mui/material";
Typography,
TextField,
Button,
Box,
IconButton,
InputAdornment,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
} from "@mui/material";
import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -29,7 +17,6 @@ const Login = () => {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
const handleSubmit = async (event) => { const handleSubmit = async (event) => {
event.preventDefault(); event.preventDefault();
@@ -115,9 +102,9 @@ const Login = () => {
<Box sx={{ width: "100%" }}> <Box sx={{ width: "100%" }}>
{config.enable_reset_password && ( {config.enable_reset_password && (
<div style={{ float: "left" }}> <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")} {t("login_link_forgot_password")}
</Button> </NavLink>
</div> </div>
)} )}
{config.enable_signup && ( {config.enable_signup && (
@@ -129,67 +116,8 @@ const Login = () => {
)} )}
</Box> </Box>
</Box> </Box>
<ForgotPasswordDialog open={resetOpen} onClose={() => setResetOpen(false)} />
</AvatarBox> </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; export default Login;
+7 -4
View File
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { Typography, TextField, Button, Box } from "@mui/material"; import { Typography, TextField, Button, Box } from "@mui/material";
import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; 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 { useTranslation } from "react-i18next";
import accountApi from "../app/AccountApi"; import accountApi from "../app/AccountApi";
import AvatarBox from "./AvatarBox"; import AvatarBox from "./AvatarBox";
@@ -15,6 +15,7 @@ import routes from "./routes";
const PasswordReset = () => { const PasswordReset = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { token: tokenParam } = useParams(); const { token: tokenParam } = useParams();
const navigate = useNavigate();
const token = useRef(tokenParam); const token = useRef(tokenParam);
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState(""); const [confirm, setConfirm] = useState("");
@@ -49,10 +50,12 @@ const PasswordReset = () => {
if (done) { if (done) {
return ( return (
<AvatarBox> <AvatarBox>
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 48, mb: 1 }} /> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Typography sx={{ typography: "h6" }}>{t("reset_password_success_title")}</Typography> <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> <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")} {t("reset_password_button_login")}
</Button> </Button>
</AvatarBox> </AvatarBox>
+95
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;
+1
View File
@@ -4,6 +4,7 @@ import { shortUrl } from "../app/utils";
const routes = { const routes = {
login: "/login", login: "/login",
signup: "/signup", signup: "/signup",
resetPassword: "/reset-password",
app: config.app_root, app: config.app_root,
account: "/account", account: "/account",
settings: "/settings", settings: "/settings",