mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-07-30 15:39:35 -04:00
Lots of refinement
This commit is contained in:
20
cmd/user.go
20
cmd/user.go
@@ -44,7 +44,7 @@ var flagsUser = append(
|
||||
var cmdUser = &cli.Command{
|
||||
Name: "user",
|
||||
Usage: "Manage/show users",
|
||||
UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
|
||||
UsageText: "ntfy user [list|add|remove|change-pass|reset-pass|change-role] ...",
|
||||
Flags: flagsUser,
|
||||
Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc),
|
||||
Category: categoryServer,
|
||||
@@ -108,11 +108,11 @@ directly the bcrypt hash. This is useful if you are updating users via scripts.
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "password-reset",
|
||||
Aliases: []string{"reset"},
|
||||
Name: "reset-pass",
|
||||
Aliases: []string{"rp"},
|
||||
Usage: "Generates a password reset link for a user",
|
||||
UsageText: "ntfy user password-reset [--send-email] USERNAME",
|
||||
Action: execUserPasswordReset,
|
||||
UsageText: "ntfy user reset-pass [--send-email] USERNAME",
|
||||
Action: execUserResetPass,
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "send-email", Aliases: []string{"e"}, Usage: "also email the reset link to the user's primary email"},
|
||||
},
|
||||
@@ -129,8 +129,8 @@ requires SMTP to be configured and the user to have a verified primary email).
|
||||
Requires base-url to be configured so an absolute link can be generated.
|
||||
|
||||
Example:
|
||||
ntfy user password-reset phil # Print a reset link for user phil
|
||||
ntfy user password-reset --send-email phil # Print and email the reset link
|
||||
ntfy user reset-pass phil # Print a reset link for user phil
|
||||
ntfy user reset-pass --send-email phil # Print and email the reset link
|
||||
`,
|
||||
},
|
||||
{
|
||||
@@ -319,12 +319,12 @@ func execUserChangePass(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func execUserPasswordReset(c *cli.Context) error {
|
||||
func execUserResetPass(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
sendEmail := c.Bool("send-email")
|
||||
baseURL := strings.TrimSuffix(c.String("base-url"), "/")
|
||||
if username == "" {
|
||||
return errors.New("username expected, type 'ntfy user password-reset --help' for help")
|
||||
return errors.New("username expected, type 'ntfy user reset-pass --help' for help")
|
||||
} else if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
} else if baseURL == "" {
|
||||
@@ -339,6 +339,8 @@ func execUserPasswordReset(c *cli.Context) error {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
} else if u.Provisioned {
|
||||
return fmt.Errorf("user %s is provisioned in the config file; its password cannot be reset", username)
|
||||
}
|
||||
// Resolve the primary email up front if we need to send -- fail before creating a token
|
||||
var primaryEmail string
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestCLI_User_Delete(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "user phil does not exist")
|
||||
}
|
||||
|
||||
func TestCLI_User_PasswordReset(t *testing.T) {
|
||||
func TestCLI_User_ResetPass(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
@@ -132,11 +132,11 @@ func TestCLI_User_PasswordReset(t *testing.T) {
|
||||
|
||||
// Prints a working-looking reset link when base-url is set
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "password-reset", "phil"))
|
||||
require.Nil(t, runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "phil"))
|
||||
require.Contains(t, stdout.String(), "https://ntfy.example.com/account/password/reset/")
|
||||
}
|
||||
|
||||
func TestCLI_User_PasswordReset_NoBaseURL(t *testing.T) {
|
||||
func TestCLI_User_ResetPass_NoBaseURL(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
@@ -145,12 +145,12 @@ func TestCLI_User_PasswordReset_NoBaseURL(t *testing.T) {
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
|
||||
app, _, _, _ = newTestApp()
|
||||
err := runUserCommand(app, conf, "password-reset", "phil")
|
||||
err := runUserCommand(app, conf, "reset-pass", "phil")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "base-url")
|
||||
}
|
||||
|
||||
func TestCLI_User_PasswordReset_SendEmailNoPrimary(t *testing.T) {
|
||||
func TestCLI_User_ResetPass_SendEmailNoPrimary(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
@@ -160,11 +160,31 @@ func TestCLI_User_PasswordReset_SendEmailNoPrimary(t *testing.T) {
|
||||
|
||||
// --send-email requires a primary email; phil has none
|
||||
app, _, _, _ = newTestApp()
|
||||
err := runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "password-reset", "--send-email", "phil")
|
||||
err := runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "--send-email", "phil")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "no primary email")
|
||||
}
|
||||
|
||||
func TestCLI_User_ResetPass_ProvisionedRejected(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
// Seed a provisioned user into the auth database via config provisioning
|
||||
m, err := user.NewSQLiteManager(conf.AuthFile, "", &user.Config{
|
||||
ProvisionEnabled: true,
|
||||
Users: []*user.User{
|
||||
{Name: "provuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser},
|
||||
},
|
||||
})
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, m.Close())
|
||||
|
||||
app, _, _, _ := newTestApp()
|
||||
err = runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "provuser")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "provisioned")
|
||||
}
|
||||
|
||||
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
|
||||
configFile := filepath.Join(t.TempDir(), "server-dummy.yml")
|
||||
require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml
|
||||
|
||||
@@ -167,20 +167,24 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(emails) > 0 {
|
||||
response.Emails = emails
|
||||
}
|
||||
primaryEmail, err := s.userManager.PrimaryEmail(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response.PrimaryEmail = primaryEmail
|
||||
pendingEmails, err := s.userManager.PendingEmails(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(pendingEmails) > 0 {
|
||||
response.PendingEmails = pendingEmails
|
||||
// Combine verified (with primary flag) and pending (unverified) into one list
|
||||
emailInfos := make([]*apiAccountEmailInfo, 0, len(emails)+len(pendingEmails))
|
||||
for _, email := range emails {
|
||||
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Primary: email == primaryEmail})
|
||||
}
|
||||
for _, email := range pendingEmails {
|
||||
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Pending: true})
|
||||
}
|
||||
if len(emailInfos) > 0 {
|
||||
response.Emails = emailInfos
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -718,6 +722,8 @@ func (s *Server) handleAccountEmailSetPrimary(w http.ResponseWriter, r *http.Req
|
||||
return err
|
||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
} else if u.Provisioned {
|
||||
return errHTTPConflictProvisionedUserChange // Provisioned users can't reset, so a recovery email is meaningless
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Setting primary email")
|
||||
err = s.userManager.SetPrimaryEmail(u.ID, req.Email)
|
||||
@@ -810,13 +816,15 @@ func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *htt
|
||||
// The identifier is tried first as a username, then as a primary email address. It returns
|
||||
// ok=false if no account with a primary email matches (reset requires a verified primary email).
|
||||
func (s *Server) resolveResetTarget(identifier string) (userID string, email string, ok bool) {
|
||||
if u, err := s.userManager.User(identifier); err == nil && u != nil {
|
||||
if u, err := s.userManager.User(identifier); err == nil && u != nil && !u.Provisioned {
|
||||
if primary, perr := s.userManager.PrimaryEmail(u.ID); perr == nil && primary != "" {
|
||||
return u.ID, primary, true
|
||||
}
|
||||
}
|
||||
if uid, err := s.userManager.UserIDByPrimaryEmail(identifier); err == nil {
|
||||
return uid, identifier, true
|
||||
if u, uerr := s.userManager.UserByID(uid); uerr == nil && !u.Provisioned {
|
||||
return uid, identifier, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
@@ -834,8 +842,8 @@ func (s *Server) handleAccountPasswordReset(w http.ResponseWriter, r *http.Reque
|
||||
return errHTTPBadRequest
|
||||
}
|
||||
err = s.userManager.ResetPassword(req.Token, req.Password)
|
||||
if errors.Is(err, user.ErrMagicLinkNotFound) {
|
||||
return errHTTPBadRequestResetLinkInvalid
|
||||
if errors.Is(err, user.ErrMagicLinkNotFound) || errors.Is(err, user.ErrProvisionedUserChange) {
|
||||
return errHTTPBadRequestResetLinkInvalid // Generic 400 (provisioned users can't be reset; don't leak that)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -57,6 +57,37 @@ func getAccount(t *testing.T, s *Server, auth map[string]string) *apiAccountResp
|
||||
return account
|
||||
}
|
||||
|
||||
// verifiedAddrs / pendingAddrs / primaryAddr extract the addresses from the structured email
|
||||
// list returned by GET /v1/account, so assertions stay readable.
|
||||
func verifiedAddrs(account *apiAccountResponse) []string {
|
||||
addrs := make([]string, 0)
|
||||
for _, e := range account.Emails {
|
||||
if !e.Pending {
|
||||
addrs = append(addrs, e.Address)
|
||||
}
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
func pendingAddrs(account *apiAccountResponse) []string {
|
||||
addrs := make([]string, 0)
|
||||
for _, e := range account.Emails {
|
||||
if e.Pending {
|
||||
addrs = append(addrs, e.Address)
|
||||
}
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
func primaryAddr(account *apiAccountResponse) string {
|
||||
for _, e := range account.Emails {
|
||||
if e.Primary {
|
||||
return e.Address
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func tokenFromLink(t *testing.T, link, prefix string) string {
|
||||
require.True(t, strings.HasPrefix(link, prefix), "link %q missing prefix %q", link, prefix)
|
||||
return strings.TrimPrefix(link, prefix)
|
||||
@@ -73,9 +104,9 @@ func TestAccount_Email_AddVerifySetsPrimary(t *testing.T) {
|
||||
|
||||
// Pending, not yet verified, no primary
|
||||
account := getAccount(t, s, auth)
|
||||
require.Equal(t, []string{"ben@example.com"}, account.PendingEmails)
|
||||
require.Empty(t, account.Emails)
|
||||
require.Equal(t, "", account.PrimaryEmail)
|
||||
require.Equal(t, []string{"ben@example.com"}, pendingAddrs(account))
|
||||
require.Empty(t, verifiedAddrs(account))
|
||||
require.Equal(t, "", primaryAddr(account))
|
||||
|
||||
// "Click" the captured link (unauthenticated POST)
|
||||
token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||
@@ -84,9 +115,9 @@ func TestAccount_Email_AddVerifySetsPrimary(t *testing.T) {
|
||||
|
||||
// Now verified + primary, no longer pending
|
||||
account = getAccount(t, s, auth)
|
||||
require.Equal(t, []string{"ben@example.com"}, account.Emails)
|
||||
require.Equal(t, "ben@example.com", account.PrimaryEmail)
|
||||
require.Empty(t, account.PendingEmails)
|
||||
require.Equal(t, []string{"ben@example.com"}, verifiedAddrs(account))
|
||||
require.Equal(t, "ben@example.com", primaryAddr(account))
|
||||
require.Empty(t, pendingAddrs(account))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -111,13 +142,13 @@ func TestAccount_Email_DeletePending(t *testing.T) {
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
|
||||
require.Equal(t, []string{"ben@example.com"}, getAccount(t, s, auth).PendingEmails)
|
||||
require.Equal(t, []string{"ben@example.com"}, pendingAddrs(getAccount(t, s, auth)))
|
||||
|
||||
// Deleting the pending address clears it (no verification ever happened)
|
||||
require.Equal(t, 200, request(t, s, "DELETE", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
|
||||
account := getAccount(t, s, auth)
|
||||
require.Empty(t, account.PendingEmails)
|
||||
require.Empty(t, account.Emails)
|
||||
require.Empty(t, pendingAddrs(account))
|
||||
require.Empty(t, verifiedAddrs(account))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,7 +185,7 @@ func TestAccount_Email_SetPrimaryCollision(t *testing.T) {
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, auth).Code)
|
||||
benToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, benToken), nil).Code)
|
||||
require.Equal(t, "shared@example.com", getAccount(t, s, auth).PrimaryEmail)
|
||||
require.Equal(t, "shared@example.com", primaryAddr(getAccount(t, s, auth)))
|
||||
|
||||
// alice verifies the same address -> allowed as secondary, but it is not her primary
|
||||
require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false))
|
||||
@@ -163,8 +194,8 @@ func TestAccount_Email_SetPrimaryCollision(t *testing.T) {
|
||||
aliceToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, aliceToken), nil).Code)
|
||||
aliceAccount := getAccount(t, s, aliceAuth)
|
||||
require.Equal(t, []string{"shared@example.com"}, aliceAccount.Emails)
|
||||
require.Equal(t, "", aliceAccount.PrimaryEmail)
|
||||
require.Equal(t, []string{"shared@example.com"}, verifiedAddrs(aliceAccount))
|
||||
require.Equal(t, "", primaryAddr(aliceAccount))
|
||||
|
||||
// alice trying to promote it to primary collides with ben's
|
||||
rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"shared@example.com"}`, aliceAuth)
|
||||
@@ -245,6 +276,63 @@ func TestAccount_PasswordReset_NoPrimaryEmailNoSend(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_ProvisionedNoPrimary(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
hash, err := user.HashPassword("provpass")
|
||||
require.Nil(t, err)
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}}
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailSender = mailer
|
||||
defer s.closeDatabases()
|
||||
auth := map[string]string{"Authorization": util.BasicAuth("prov", "provpass")}
|
||||
|
||||
// A provisioned user can verify an email, but it must NOT become their primary
|
||||
verifyEmailFor(t, s, mailer, auth, "prov@example.com")
|
||||
account := getAccount(t, s, auth)
|
||||
require.Equal(t, []string{"prov@example.com"}, verifiedAddrs(account))
|
||||
require.Equal(t, "", primaryAddr(account))
|
||||
|
||||
// Explicitly setting it primary is rejected
|
||||
rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"prov@example.com"}`, auth)
|
||||
require.Equal(t, 409, rr.Code)
|
||||
require.Equal(t, 40905, toHTTPError(t, rr.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_ProvisionedUserNoSend(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
// Provision a user via config (AuthUsers), with email sending enabled
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
conf.AuthUsers = []*user.User{
|
||||
{Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser},
|
||||
}
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailSender = mailer
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Give the provisioned user a verified primary email anyway
|
||||
prov, err := s.userManager.User("prov")
|
||||
require.Nil(t, err)
|
||||
require.True(t, prov.Provisioned)
|
||||
require.Nil(t, s.userManager.AddEmail(prov.ID, "prov@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(prov.ID, "prov@example.com"))
|
||||
|
||||
// Reset request by username and by email -> uniform 200, but no email sent (can't reset)
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov"}`, nil).Code)
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov@example.com"}`, nil).Code)
|
||||
require.Empty(t, mailer.resetLinks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_InvalidToken(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, _, _ := newEmailTestServer(t, databaseURL)
|
||||
|
||||
@@ -238,7 +238,8 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
|
||||
return err
|
||||
}
|
||||
// Offer email recovery: auto-send a verification link to the billing email (best-effort).
|
||||
if sess.CustomerDetails != nil {
|
||||
// Provisioned users can't reset their password, so recovery setup doesn't apply to them.
|
||||
if sess.CustomerDetails != nil && !u.Provisioned {
|
||||
s.maybeEnqueueBillingEmailVerification(r, v, u.ID, sess.CustomerDetails.Email)
|
||||
}
|
||||
http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)
|
||||
|
||||
@@ -287,6 +287,15 @@ type apiAccountReservation struct {
|
||||
Everyone string `json:"everyone"`
|
||||
}
|
||||
|
||||
// apiAccountEmailInfo describes one email address on the account, as returned by GET /v1/account.
|
||||
// Verified addresses have pending=false; exactly one verified address may be primary (the
|
||||
// recovery email). Pending addresses are awaiting a magic-link click and are never primary.
|
||||
type apiAccountEmailInfo struct {
|
||||
Address string `json:"address"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
Pending bool `json:"pending,omitempty"`
|
||||
}
|
||||
|
||||
type apiAccountBilling struct {
|
||||
Customer bool `json:"customer"`
|
||||
Subscription bool `json:"subscription"`
|
||||
@@ -307,9 +316,7 @@ type apiAccountResponse struct {
|
||||
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
||||
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
|
||||
PhoneNumbers []string `json:"phone_numbers,omitempty"`
|
||||
Emails []string `json:"emails,omitempty"`
|
||||
PrimaryEmail string `json:"primary_email,omitempty"` // The verified recovery email, if set
|
||||
PendingEmails []string `json:"pending_emails,omitempty"` // Unverified addresses awaiting a magic-link click
|
||||
Emails []*apiAccountEmailInfo `json:"emails,omitempty"`
|
||||
Tier *apiAccountTier `json:"tier,omitempty"`
|
||||
Limits *apiAccountLimits `json:"limits,omitempty"`
|
||||
Stats *apiAccountStats `json:"stats,omitempty"`
|
||||
|
||||
@@ -289,6 +289,53 @@ func TestUser_MagicLink_ResetPassword_WrongKindRejected(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_VerifyEmail_ProvisionedNoPrimary(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManagerFromConfig(t, newManager, &Config{
|
||||
DefaultAccess: PermissionDenyAll,
|
||||
ProvisionEnabled: true,
|
||||
Users: []*User{
|
||||
{Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
||||
},
|
||||
})
|
||||
prov, err := a.User("prov")
|
||||
require.Nil(t, err)
|
||||
|
||||
// A provisioned user can verify an email (for notifications), but it must NOT become primary
|
||||
_, err = a.VerifyEmail(addVerifyLink(t, a, prov.ID, "prov@example.com", time.Hour))
|
||||
require.Nil(t, err)
|
||||
|
||||
emails, err := a.Emails(prov.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []string{"prov@example.com"}, emails)
|
||||
primary, err := a.PrimaryEmail(prov.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "", primary)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_ResetPassword_ProvisionedRejected(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
// Provisioned users come from the config file (ProvisionEnabled), not AddUser
|
||||
a := newTestManagerFromConfig(t, newManager, &Config{
|
||||
DefaultAccess: PermissionDenyAll,
|
||||
ProvisionEnabled: true,
|
||||
Users: []*User{
|
||||
{Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
||||
},
|
||||
})
|
||||
prov, err := a.User("prov")
|
||||
require.Nil(t, err)
|
||||
require.True(t, prov.Provisioned)
|
||||
|
||||
// A reset token can be created, but consuming it must be rejected for a provisioned user
|
||||
// (their password comes from the config file, like change-pass).
|
||||
raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, prov.ID, "", time.Hour)
|
||||
require.Nil(t, err)
|
||||
require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrProvisionedUserChange)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_MagicLink_ResetPassword_Expired(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
|
||||
a := newTestManager(t, newManager, PermissionDenyAll)
|
||||
|
||||
@@ -1648,8 +1648,9 @@ func (a *Manager) DeleteEmailVerification(userID, email string) error {
|
||||
// validating the token (kind + expiry), it deletes the link, adds the address to the user's
|
||||
// verified emails, and -- if the user has no primary email yet and the address is not already
|
||||
// primary on another account -- promotes the new address to primary. All mutations run in one
|
||||
// transaction. A primary collision simply leaves the address verified but non-primary. Returns
|
||||
// the consumed link.
|
||||
// transaction. A primary collision simply leaves the address verified but non-primary. Provisioned
|
||||
// users never get a primary (the recovery email is meaningless for them -- they can't reset).
|
||||
// Returns the consumed link.
|
||||
func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) {
|
||||
tokenHash := hashToken(rawToken)
|
||||
m, err := a.MagicLinkByHash(tokenHash)
|
||||
@@ -1659,6 +1660,10 @@ func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) {
|
||||
if m.Kind != MagicLinkKindEmailVerify || time.Now().Unix() > m.Expires {
|
||||
return nil, ErrMagicLinkNotFound
|
||||
}
|
||||
u, err := a.UserByID(m.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
// Single use: delete the link, then add the (idempotent) verified address
|
||||
if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, tokenHash); err != nil {
|
||||
@@ -1667,6 +1672,9 @@ func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) {
|
||||
if _, err := tx.Exec(a.queries.insertEmailIgnore, m.UserID, m.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Provisioned {
|
||||
return nil // Provisioned users don't get a primary (recovery) email
|
||||
}
|
||||
// Promote to primary only if the user has none yet and the address is globally free.
|
||||
// We check with SELECTs rather than catching a unique violation, because Postgres aborts
|
||||
// the whole transaction on any constraint error (which would undo the verified-email add).
|
||||
@@ -1712,6 +1720,9 @@ func (a *Manager) ResetPassword(rawToken, password string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Provisioned {
|
||||
return ErrProvisionedUserChange // Provisioned users get their password from the config file, not reset
|
||||
}
|
||||
hash, err := a.maybeHashPassword(password, false)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -28,11 +28,14 @@
|
||||
"login_form_button_submit": "Sign in",
|
||||
"login_link_signup": "Sign up",
|
||||
"login_link_forgot_password": "Forgot password?",
|
||||
"login_reset_dialog_title": "Reset password",
|
||||
"login_reset_dialog_description": "Enter your username or email address. If an account exists, we'll email a link to reset your password.",
|
||||
"login_reset_dialog_identifier_label": "Username or email",
|
||||
"login_reset_dialog_button_submit": "Send reset link",
|
||||
"login_reset_dialog_sent": "If an account exists, we've emailed a link to reset your password. Please check your inbox.",
|
||||
"reset_password_request_title": "Reset password",
|
||||
"reset_password_request_description": "Enter your username or email address. If an account exists, we'll email a link to reset your password.",
|
||||
"reset_password_request_identifier_label": "Username or email",
|
||||
"reset_password_request_button_submit": "Send reset link",
|
||||
"reset_password_sent_title": "Check your inbox",
|
||||
"reset_password_sent_description": "If an account exists, we've emailed a link to reset your password.",
|
||||
"reset_password_back_to_login": "Back to sign-in",
|
||||
"reset_password_disabled": "Password reset is not enabled on this server.",
|
||||
"reset_password_title": "Set a new password",
|
||||
"reset_password_form_password": "New password",
|
||||
"reset_password_form_confirm": "Confirm new password",
|
||||
@@ -242,6 +245,7 @@
|
||||
"account_basics_emails_no_emails_yet": "No emails yet",
|
||||
"account_basics_emails_copied_to_clipboard": "Email address copied to clipboard",
|
||||
"account_basics_emails_primary_badge": "Primary",
|
||||
"account_basics_emails_chip_actions": "Click for actions",
|
||||
"account_basics_emails_unverified": "unverified",
|
||||
"account_basics_emails_set_primary": "Set as recovery email",
|
||||
"account_basics_emails_delete": "Remove",
|
||||
@@ -249,7 +253,8 @@
|
||||
"account_basics_emails_resend": "Resend verification email",
|
||||
"account_basics_emails_resent": "Verification email sent, check your inbox",
|
||||
"account_basics_emails_primary_elsewhere": "This email is the recovery email on another account",
|
||||
"account_basics_emails_no_recovery_warning": "No recovery email set. You will not be able to reset your password. Add and verify an email below, then set it as your recovery email.",
|
||||
"account_basics_emails_no_recovery_warning": "Add a recovery email address to ensure you can reset your password.",
|
||||
"account_basics_emails_provisioned_info": "Provisioned users cannot add a recovery email address.",
|
||||
"account_basics_emails_dialog_title": "Add email address",
|
||||
"account_basics_emails_dialog_description": "Enter an email address to add it to your account. We will send a verification link to confirm it is yours.",
|
||||
"account_basics_emails_dialog_email_label": "Email address",
|
||||
@@ -267,6 +272,7 @@
|
||||
"account_basics_tier_admin_suffix_with_tier": "(with {{tier}} tier)",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(no tier)",
|
||||
"account_basics_tier_basic": "Basic",
|
||||
"account_basics_tier_provisioned": "Provisioned",
|
||||
"account_basics_tier_free": "Free",
|
||||
"account_basics_tier_interval_monthly": "monthly",
|
||||
"account_basics_tier_interval_yearly": "annually",
|
||||
|
||||
@@ -2,7 +2,6 @@ import * as React from "react";
|
||||
import { useContext, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
CardActions,
|
||||
CardContent,
|
||||
Chip,
|
||||
@@ -32,7 +31,10 @@ import {
|
||||
DialogContent,
|
||||
TextField,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
DialogContentText,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
@@ -294,6 +296,7 @@ const AccountType = () => {
|
||||
>
|
||||
<div>
|
||||
{accountType}
|
||||
{account.provisioned && <Chip size="small" label={t("account_basics_tier_provisioned")} sx={{ ml: 1 }} />}
|
||||
{account.billing?.paid_until && !account.billing?.cancel_at && (
|
||||
<Tooltip
|
||||
title={t("account_basics_tier_paid_until", {
|
||||
@@ -364,8 +367,23 @@ const Emails = () => {
|
||||
const [dialogKey, setDialogKey] = useState(0);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [snack, setSnack] = useState(""); // Non-empty shows a transient snackbar message
|
||||
const [menuAnchor, setMenuAnchor] = useState(null); // Chip element the actions menu is anchored to
|
||||
const [menuEmail, setMenuEmail] = useState(null); // The email the open menu acts on
|
||||
const labelId = "prefVerifiedEmails";
|
||||
|
||||
const openMenu = (ev, email) => {
|
||||
setMenuAnchor(ev.currentTarget);
|
||||
setMenuEmail(email);
|
||||
};
|
||||
const closeMenu = () => {
|
||||
setMenuAnchor(null);
|
||||
setMenuEmail(null);
|
||||
};
|
||||
const runMenuAction = (fn) => {
|
||||
closeMenu();
|
||||
fn(menuEmail.address);
|
||||
};
|
||||
|
||||
const handleDialogOpen = () => {
|
||||
setDialogKey((prev) => prev + 1);
|
||||
setDialogOpen(true);
|
||||
@@ -381,10 +399,12 @@ const Emails = () => {
|
||||
};
|
||||
|
||||
// runEmailAction wraps an account API call with the shared error handling (redirect on
|
||||
// unauthorized, surface a message otherwise). The account list refreshes via the sync event.
|
||||
// unauthorized, surface a message otherwise). On success it refetches the account so the email
|
||||
// list reflects the change immediately, rather than waiting for the async sync event.
|
||||
const runEmailAction = async (fn, errorMessage) => {
|
||||
try {
|
||||
await fn();
|
||||
await accountApi.sync();
|
||||
} catch (e) {
|
||||
console.log(`[Account] Email action failed`, e);
|
||||
if (e instanceof UnauthorizedError) {
|
||||
@@ -425,70 +445,82 @@ const Emails = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const verifiedEmails = account?.emails ?? [];
|
||||
const pendingEmails = account?.pending_emails ?? [];
|
||||
const primaryEmail = account?.primary_email ?? "";
|
||||
const showNoRecoveryWarning = config.enable_reset_password && primaryEmail === "";
|
||||
const emails = account?.emails ?? [];
|
||||
const verifiedEmails = emails.filter((e) => !e.pending);
|
||||
const pendingEmails = emails.filter((e) => e.pending);
|
||||
const primaryEmail = verifiedEmails.find((e) => e.primary)?.address ?? "";
|
||||
// Provisioned users get their password from the server config and cannot reset it, so they don't
|
||||
// get the "no recovery email" nudge (the Add-email dialog explains the recovery-email limitation).
|
||||
const showNoRecoveryWarning = config.enable_reset_password && primaryEmail === "" && !account?.provisioned;
|
||||
|
||||
return (
|
||||
<Pref labelId={labelId} title={t("account_basics_emails_title")} description={t("account_basics_emails_description")}>
|
||||
<Pref labelId={labelId} alignTop title={t("account_basics_emails_title")} description={t("account_basics_emails_description")}>
|
||||
<div aria-labelledby={labelId}>
|
||||
{showNoRecoveryWarning && (
|
||||
<Alert severity="warning" sx={{ mb: 1 }}>
|
||||
{t("account_basics_emails_no_recovery_warning")}
|
||||
</Alert>
|
||||
)}
|
||||
<Stack spacing={0.5}>
|
||||
{verifiedEmails.map((email) => {
|
||||
const isPrimary = email === primaryEmail;
|
||||
return (
|
||||
<Box key={email} sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<Typography sx={{ flexGrow: 1, overflowWrap: "anywhere" }}>{email}</Typography>
|
||||
{isPrimary && <Chip size="small" color="primary" label={t("account_basics_emails_primary_badge")} />}
|
||||
{!isPrimary && (
|
||||
<Tooltip title={t("account_basics_emails_set_primary")}>
|
||||
<IconButton size="small" aria-label={t("account_basics_emails_set_primary")} onClick={() => handleSetPrimary(email)}>
|
||||
<StarBorderIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isPrimary && <StarIcon fontSize="small" color="primary" sx={{ mx: 0.5 }} />}
|
||||
<Tooltip title={t("common_copy_to_clipboard")}>
|
||||
<IconButton size="small" aria-label={t("common_copy_to_clipboard")} onClick={() => handleCopy(email)}>
|
||||
<ContentCopy fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("account_basics_emails_delete")}>
|
||||
<IconButton size="small" aria-label={t("account_basics_emails_delete")} onClick={() => handleDelete(email)}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{pendingEmails.map((email) => (
|
||||
<Box key={email} sx={{ display: "flex", alignItems: "center", gap: 0.5, opacity: 0.65 }}>
|
||||
<Typography sx={{ flexGrow: 1, overflowWrap: "anywhere" }}>
|
||||
{email} <em>({t("account_basics_emails_unverified")})</em>
|
||||
</Typography>
|
||||
<Tooltip title={t("account_basics_emails_resend")}>
|
||||
<IconButton size="small" aria-label={t("account_basics_emails_resend")} onClick={() => handleResend(email)}>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{verifiedEmails.map((email) => (
|
||||
<Chip
|
||||
key={email.address}
|
||||
icon={email.primary ? <StarIcon /> : undefined}
|
||||
label={
|
||||
<Tooltip title={t("account_basics_emails_chip_actions")}>
|
||||
<span>{email.address}</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("account_basics_emails_cancel")}>
|
||||
<IconButton size="small" aria-label={t("account_basics_emails_cancel")} onClick={() => handleDelete(email)}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
variant="outlined"
|
||||
onClick={(ev) => openMenu(ev, email)}
|
||||
onDelete={() => handleDelete(email.address)}
|
||||
sx={email.primary ? { "& .MuiChip-icon": { color: "primary.main" } } : undefined}
|
||||
/>
|
||||
))}
|
||||
{pendingEmails.map((email) => (
|
||||
<Chip
|
||||
key={email.address}
|
||||
label={
|
||||
<Tooltip title={t("account_basics_emails_chip_actions")}>
|
||||
<span>
|
||||
{email.address} <em>({t("account_basics_emails_unverified")})</em>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
))}
|
||||
{verifiedEmails.length === 0 && pendingEmails.length === 0 && <em>{t("account_basics_emails_no_emails_yet")}</em>}
|
||||
</Stack>
|
||||
}
|
||||
variant="outlined"
|
||||
onClick={(ev) => openMenu(ev, email)}
|
||||
onDelete={() => handleDelete(email.address)}
|
||||
sx={{ opacity: 0.7 }}
|
||||
/>
|
||||
))}
|
||||
{verifiedEmails.length === 0 && pendingEmails.length === 0 && <em>{t("account_basics_emails_no_emails_yet")}</em>}
|
||||
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_emails_dialog_title")}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
{showNoRecoveryWarning && (
|
||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||
{t("account_basics_emails_no_recovery_warning")}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={closeMenu}>
|
||||
<MenuItem onClick={() => runMenuAction(handleCopy)}>
|
||||
<ListItemIcon>
|
||||
<ContentCopy fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("common_copy_to_clipboard")}</ListItemText>
|
||||
</MenuItem>
|
||||
{menuEmail && !menuEmail.pending && !menuEmail.primary && !account?.provisioned && (
|
||||
<MenuItem onClick={() => runMenuAction(handleSetPrimary)}>
|
||||
<ListItemIcon>
|
||||
<StarBorderIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("account_basics_emails_set_primary")}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
{menuEmail && menuEmail.pending && (
|
||||
<MenuItem onClick={() => runMenuAction(handleResend)}>
|
||||
<ListItemIcon>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("account_basics_emails_resend")}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
<AddEmailDialog key={`addEmailDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
|
||||
<Portal>
|
||||
<Snackbar open={snack !== ""} autoHideDuration={3000} onClose={() => setSnack("")} message={snack} />
|
||||
@@ -500,6 +532,7 @@ const Emails = () => {
|
||||
const AddEmailDialog = (props) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { account } = useContext(AccountContext);
|
||||
const [error, setError] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
@@ -512,6 +545,7 @@ const AddEmailDialog = (props) => {
|
||||
try {
|
||||
setSending(true);
|
||||
await accountApi.startEmailVerification(email);
|
||||
await accountApi.sync(); // Refresh so the new "(unverified)" address shows up immediately
|
||||
setSent(true);
|
||||
} catch (e) {
|
||||
console.log(`[Account] Error starting email verification`, e);
|
||||
@@ -534,6 +568,11 @@ const AddEmailDialog = (props) => {
|
||||
) : (
|
||||
<>
|
||||
<DialogContentText>{t("account_basics_emails_dialog_description")}</DialogContentText>
|
||||
{config.enable_reset_password && account?.provisioned && (
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
{t("account_basics_emails_provisioned_info")}
|
||||
</Alert>
|
||||
)}
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
|
||||
@@ -22,6 +22,7 @@ import Signup from "./Signup";
|
||||
import Account from "./Account";
|
||||
import EmailVerify from "./EmailVerify";
|
||||
import PasswordReset from "./PasswordReset";
|
||||
import ResetPassword from "./ResetPassword";
|
||||
import initI18n from "../app/i18n"; // Translations!
|
||||
import prefs from "../app/Prefs";
|
||||
import RTLCacheProvider from "./RTLCacheProvider";
|
||||
@@ -65,6 +66,7 @@ const App = () => {
|
||||
<Routes>
|
||||
<Route path={routes.login} element={<Login />} />
|
||||
<Route path={routes.signup} element={<Signup />} />
|
||||
<Route path={routes.resetPassword} element={<ResetPassword />} />
|
||||
<Route path={routes.emailVerify} element={<EmailVerify />} />
|
||||
<Route path={routes.passwordReset} element={<PasswordReset />} />
|
||||
<Route element={<Layout />}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Typography, Button, Box, CircularProgress } from "@mui/material";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
|
||||
import { useParams, NavLink } from "react-router-dom";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import accountApi from "../app/AccountApi";
|
||||
import AvatarBox from "./AvatarBox";
|
||||
@@ -16,6 +16,7 @@ import routes from "./routes";
|
||||
const EmailVerify = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState("verifying"); // "verifying" | "success" | "error"
|
||||
const ran = useRef(false);
|
||||
|
||||
@@ -40,31 +41,33 @@ const EmailVerify = () => {
|
||||
return (
|
||||
<AvatarBox>
|
||||
{status === "verifying" && (
|
||||
<>
|
||||
<CircularProgress sx={{ mb: 2 }} />
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<CircularProgress size={24} />
|
||||
<Typography sx={{ typography: "h6" }}>{t("email_verify_progress_title")}</Typography>
|
||||
</>
|
||||
</Box>
|
||||
)}
|
||||
{status === "success" && (
|
||||
<>
|
||||
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 48, mb: 1 }} />
|
||||
<Typography sx={{ typography: "h6" }}>{t("email_verify_success_title")}</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 32 }} />
|
||||
<Typography sx={{ typography: "h6" }}>{t("email_verify_success_title")}</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("email_verify_success_description")}</Typography>
|
||||
<Button component={NavLink} to={routes.account} variant="contained" sx={{ mt: 2 }}>
|
||||
<Button onClick={() => navigate(routes.account)} variant="contained" sx={{ mt: 2 }}>
|
||||
{t("email_verify_button_account")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<>
|
||||
<ErrorOutlineIcon color="error" sx={{ fontSize: 48, mb: 1 }} />
|
||||
<Typography sx={{ typography: "h6" }}>{t("email_verify_error_title")}</Typography>
|
||||
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("email_verify_error_description")}</Typography>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button component={NavLink} to={routes.account} variant="contained">
|
||||
{t("email_verify_button_account")}
|
||||
</Button>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<ErrorOutlineIcon color="error" sx={{ fontSize: 32 }} />
|
||||
<Typography sx={{ typography: "h6" }}>{t("email_verify_error_title")}</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("email_verify_error_description")}</Typography>
|
||||
<Button onClick={() => navigate(routes.account)} variant="contained" sx={{ mt: 2 }}>
|
||||
{t("email_verify_button_account")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</AvatarBox>
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Box,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "@mui/material";
|
||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -29,7 +17,6 @@ const Login = () => {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -115,9 +102,9 @@ const Login = () => {
|
||||
<Box sx={{ width: "100%" }}>
|
||||
{config.enable_reset_password && (
|
||||
<div style={{ float: "left" }}>
|
||||
<Button variant="text" onClick={() => setResetOpen(true)} sx={{ textTransform: "none", p: 0, minWidth: 0 }}>
|
||||
<NavLink to={routes.resetPassword} variant="body1">
|
||||
{t("login_link_forgot_password")}
|
||||
</Button>
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
{config.enable_signup && (
|
||||
@@ -129,67 +116,8 @@ const Login = () => {
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<ForgotPasswordDialog open={resetOpen} onClose={() => setResetOpen(false)} />
|
||||
</AvatarBox>
|
||||
);
|
||||
};
|
||||
|
||||
// ForgotPasswordDialog collects a username/email and asks the server to email a reset link. The
|
||||
// response is uniform, so the dialog always shows the same "if an account exists" confirmation.
|
||||
const ForgotPasswordDialog = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setSending(true);
|
||||
await accountApi.requestPasswordReset(identifier);
|
||||
} catch (e) {
|
||||
console.log(`[Login] Password reset request failed`, e);
|
||||
} finally {
|
||||
setSending(false);
|
||||
setSent(true); // Uniform outcome regardless of success/failure (enumeration-safe)
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onClose={props.onClose}>
|
||||
<DialogTitle>{t("login_reset_dialog_title")}</DialogTitle>
|
||||
<DialogContent>
|
||||
{sent ? (
|
||||
<DialogContentText>{t("login_reset_dialog_sent")}</DialogContentText>
|
||||
) : (
|
||||
<>
|
||||
<DialogContentText>{t("login_reset_dialog_description")}</DialogContentText>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label={t("login_reset_dialog_identifier_label")}
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(ev) => setIdentifier(ev.target.value.trim())}
|
||||
fullWidth
|
||||
variant="standard"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{sent ? (
|
||||
<Button onClick={props.onClose}>{t("common_close")}</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
|
||||
<Button onClick={handleSubmit} disabled={sending || identifier === ""}>
|
||||
{t("login_reset_dialog_button_submit")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Typography, TextField, Button, Box } from "@mui/material";
|
||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||
import { useParams, NavLink } from "react-router-dom";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import accountApi from "../app/AccountApi";
|
||||
import AvatarBox from "./AvatarBox";
|
||||
@@ -15,6 +15,7 @@ import routes from "./routes";
|
||||
const PasswordReset = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token: tokenParam } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const token = useRef(tokenParam);
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
@@ -49,10 +50,12 @@ const PasswordReset = () => {
|
||||
if (done) {
|
||||
return (
|
||||
<AvatarBox>
|
||||
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 48, mb: 1 }} />
|
||||
<Typography sx={{ typography: "h6" }}>{t("reset_password_success_title")}</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 32 }} />
|
||||
<Typography sx={{ typography: "h6" }}>{t("reset_password_success_title")}</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ mt: 1, textAlign: "center" }}>{t("reset_password_success_description")}</Typography>
|
||||
<Button component={NavLink} to={routes.login} variant="contained" sx={{ mt: 2 }}>
|
||||
<Button onClick={() => navigate(routes.login)} variant="contained" sx={{ mt: 2 }}>
|
||||
{t("reset_password_button_login")}
|
||||
</Button>
|
||||
</AvatarBox>
|
||||
|
||||
95
web/src/components/ResetPassword.jsx
Normal file
95
web/src/components/ResetPassword.jsx
Normal 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;
|
||||
@@ -4,6 +4,7 @@ import { shortUrl } from "../app/utils";
|
||||
const routes = {
|
||||
login: "/login",
|
||||
signup: "/signup",
|
||||
resetPassword: "/reset-password",
|
||||
app: config.app_root,
|
||||
account: "/account",
|
||||
settings: "/settings",
|
||||
|
||||
Reference in New Issue
Block a user