feat: implement password reset functionality

- Added PasswordResetPage component for user password reset.
- Integrated password reset link in LoginPage with a router link.
- Created API endpoints for requesting and confirming password resets.
- Added email templates for password reset notifications.
- Updated database schema to support password reset tokens.
- Enhanced ProfilePage and EditProfileDialog to include personal data fields.
- Introduced layout store to manage body dimensions for responsive design.
- Added validation for personal data fields in profile management.
This commit is contained in:
fabio
2026-07-26 17:28:51 +02:00
parent 167be9b0b3
commit 1367829f80
23 changed files with 1042 additions and 49 deletions

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html>
<body>
<p>Hi {{ .Name }},</p>
<p>We received a request to reset the password for {{ .Email }}.</p>
<p>
Set a new password by opening this link:<br />
<a href="{{ .PasswordResetURL }}">{{ .PasswordResetURL }}</a>
</p>
<p>If you did not request this, you can ignore this email.</p>
</body>
</html>

View File

@@ -0,0 +1 @@
Reset your password

View File

@@ -0,0 +1,8 @@
Hi {{ .Name }},
We received a request to reset the password for {{ .Email }}.
Set a new password by opening this link:
{{ .PasswordResetURL }}
If you did not request this, you can ignore this email.

View File

@@ -0,0 +1,11 @@
CREATE TABLE password_reset_tokens (
token UUID PRIMARY KEY,
user_id UUID NOT NULL,
email TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ
);
CREATE INDEX password_reset_tokens_user_id_idx ON password_reset_tokens (user_id);
CREATE INDEX password_reset_tokens_email_idx ON password_reset_tokens (email);

View File

@@ -13,6 +13,7 @@ import (
texttemplate "text/template"
"time"
authsvc "encore.app/auth"
mailssvc "encore.app/mails"
profilessvc "encore.app/profiles"
"encore.dev/beta/errs"
@@ -30,8 +31,9 @@ var db = sqldb.NewDatabase("registration", sqldb.DatabaseConfig{
var profilesDB = sqldb.Named("profiles")
const (
welcomeTokenTTL = 24 * time.Hour
frontendBaseURL = "http://localhost:9000/#"
welcomeTokenTTL = 24 * time.Hour
passwordResetTokenTTL = time.Hour
frontendBaseURL = "http://localhost:9000/#"
)
type RegisterParams struct {
@@ -51,6 +53,23 @@ type WelcomeResponse struct {
UsedAt time.Time `json:"used_at"`
}
type PasswordResetRequestParams struct {
Email string `json:"email"`
}
type PasswordResetRequestResponse struct {
EmailSent bool `json:"email_sent"`
}
type PasswordResetConfirmParams struct {
Password string `json:"password" encore:"sensitive"`
}
type PasswordResetConfirmResponse struct {
Email string `json:"email"`
Reset bool `json:"reset"`
}
type EmailAvailabilityResponse struct {
Email string `json:"email"`
Available bool `json:"available"`
@@ -155,6 +174,92 @@ func ConfirmWelcome(ctx context.Context, token uuid.UUID) (*WelcomeResponse, err
return &WelcomeResponse{Email: email, Confirmed: true, UsedAt: confirmedAt}, nil
}
// RequestPasswordReset creates a reset token and queues an email when the account exists.
//
//encore:api public method=POST path=/registration/password-reset
func RequestPasswordReset(ctx context.Context, p *PasswordResetRequestParams) (*PasswordResetRequestResponse, error) {
email, err := normalizeEmail(p.Email)
if err != nil {
return nil, err
}
var profile profilessvc.Profile
err = profilesDB.QueryRow(ctx, `
SELECT user_id, email, display_name, avatar_url, role, status, is_artist, created_at, updated_at
FROM user_profiles
WHERE email = $1
`, email).Scan(
&profile.UserID,
&profile.Email,
&profile.DisplayName,
&profile.AvatarURL,
&profile.Role,
&profile.Status,
&profile.IsArtist,
&profile.CreatedAt,
&profile.UpdatedAt,
)
if errors.Is(err, sqldb.ErrNoRows) {
return &PasswordResetRequestResponse{EmailSent: true}, nil
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to load profile")
}
token, err := createPasswordResetToken(ctx, &profile)
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to create password reset token")
}
mail, err := passwordResetMail(&profile, token)
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to render password reset email")
}
if _, err := mailssvc.SendTransactional(ctx, mail); err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to queue password reset email")
}
return &PasswordResetRequestResponse{EmailSent: true}, nil
}
// ConfirmPasswordReset validates a reset token and changes the account password.
//
//encore:api public method=POST path=/registration/password-reset/:token
func ConfirmPasswordReset(ctx context.Context, token uuid.UUID, p *PasswordResetConfirmParams) (*PasswordResetConfirmResponse, error) {
var userID uuid.UUID
var email string
var expiresAt time.Time
var usedAt *time.Time
err := db.QueryRow(ctx, `
SELECT user_id, email, expires_at, used_at FROM password_reset_tokens WHERE token = $1
`, token).Scan(&userID, &email, &expiresAt, &usedAt)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.NotFound, Message: "password reset token not found"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to load password reset token")
}
if usedAt != nil {
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "password reset token already used"}
}
if time.Now().After(expiresAt) {
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "password reset token has expired"}
}
if err := authsvc.SetUserPassword(ctx, userID, &authsvc.SetUserPasswordParams{UserID: userID, Password: p.Password}); err != nil {
return nil, err
}
_, err = db.Exec(ctx, `
UPDATE password_reset_tokens SET used_at = NOW() WHERE token = $1
`, token)
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to consume password reset token")
}
return &PasswordResetConfirmResponse{Email: email, Reset: true}, nil
}
func createWelcomeToken(ctx context.Context, profile *profilessvc.Profile) (uuid.UUID, error) {
token, err := uuid.NewV4()
if err != nil {
@@ -167,6 +272,30 @@ func createWelcomeToken(ctx context.Context, profile *profilessvc.Profile) (uuid
return token, err
}
func createPasswordResetToken(ctx context.Context, profile *profilessvc.Profile) (uuid.UUID, error) {
token, err := uuid.NewV4()
if err != nil {
return uuid.Nil, err
}
_, err = db.Exec(ctx, `
INSERT INTO password_reset_tokens (token, user_id, email, expires_at)
VALUES ($1, $2, $3, $4)
`, token, profile.UserID, profile.Email, time.Now().Add(passwordResetTokenTTL))
return token, err
}
func normalizeEmail(email string) (string, error) {
email = strings.TrimSpace(email)
if email == "" {
return "", &errs.Error{Code: errs.InvalidArgument, Message: "email is required"}
}
parsed, err := mail.ParseAddress(email)
if err != nil {
return "", &errs.Error{Code: errs.InvalidArgument, Message: "invalid email"}
}
return parsed.Address, nil
}
func emailDomain(email string) (string, bool) {
_, domain, ok := strings.Cut(email, "@")
domain = strings.TrimSpace(strings.TrimSuffix(domain, "."))
@@ -234,7 +363,52 @@ type welcomeMailData struct {
WelcomeURL string
}
func renderTextTemplate(name string, data welcomeMailData) (string, error) {
func passwordResetMail(profile *profilessvc.Profile, token uuid.UUID) (*mailssvc.TransactionalMailParams, error) {
name := profile.DisplayName
if name == "" {
name = profile.Email
}
data := passwordResetMailData{
Name: name,
Email: profile.Email,
UserID: profile.UserID.String(),
PasswordResetURL: frontendBaseURL + "/password-reset?token=" + token.String(),
}
subject, err := renderTextTemplate("mails/password_reset_subject.tmpl", data)
if err != nil {
return nil, err
}
textBody, err := renderTextTemplate("mails/password_reset_text.tmpl", data)
if err != nil {
return nil, err
}
htmlBody, err := renderHTMLTemplate("mails/password_reset_html.tmpl", data)
if err != nil {
return nil, err
}
return &mailssvc.TransactionalMailParams{
To: profile.Email,
Subject: subject,
TextBody: textBody,
HTMLBody: htmlBody,
Metadata: map[string]string{
"kind": "password_reset",
"user_id": profile.UserID.String(),
},
}, nil
}
type passwordResetMailData struct {
Name string
Email string
UserID string
PasswordResetURL string
}
func renderTextTemplate(name string, data any) (string, error) {
tmpl, err := texttemplate.ParseFS(mailTemplates, name)
if err != nil {
return "", err
@@ -246,7 +420,7 @@ func renderTextTemplate(name string, data welcomeMailData) (string, error) {
return strings.TrimSpace(out.String()), nil
}
func renderHTMLTemplate(name string, data welcomeMailData) (string, error) {
func renderHTMLTemplate(name string, data any) (string, error) {
tmpl, err := htmltemplate.ParseFS(mailTemplates, name)
if err != nil {
return "", err