From 1367829f806ad82b04f91c6fc94218628f0c294c Mon Sep 17 00:00:00 2001 From: fabio Date: Sun, 26 Jul 2026 17:28:51 +0200 Subject: [PATCH] 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. --- admin/admin.go | 14 +- assicurazione.md | 12 + auth/credentials.go | 30 ++ frontend/src/components/DrawerUserCard.vue | 7 +- .../src/components/ProfileStatusBadge.vue | 23 ++ frontend/src/css/app.css | 18 + frontend/src/encore/client.ts | 41 +++ frontend/src/encore/zod.ts | 10 + frontend/src/i18n/index.ts | 55 +++ frontend/src/pages/LoginPage.vue | 33 +- frontend/src/pages/PasswordResetPage.vue | 132 +++++++ frontend/src/pages/ProfilePage.vue | 323 +++++++++++++++++- frontend/src/pages/admin/ProfilesPage.vue | 92 ++++- .../pages/admin/dialogs/EditProfileDialog.vue | 9 + frontend/src/router/routes.ts | 1 + frontend/src/stores/layout-store.ts | 62 ++++ .../10_add_personal_data_cap.up.sql | 1 + profiles/personal_data.go | 14 +- registration/mails/password_reset_html.tmpl | 12 + .../mails/password_reset_subject.tmpl | 1 + registration/mails/password_reset_text.tmpl | 8 + .../2_create_password_reset_tokens.up.sql | 11 + registration/registration.go | 182 +++++++++- 23 files changed, 1042 insertions(+), 49 deletions(-) create mode 100644 assicurazione.md create mode 100644 frontend/src/components/ProfileStatusBadge.vue create mode 100644 frontend/src/pages/PasswordResetPage.vue create mode 100644 frontend/src/stores/layout-store.ts create mode 100644 profiles/migrations/10_add_personal_data_cap.up.sql create mode 100644 registration/mails/password_reset_html.tmpl create mode 100644 registration/mails/password_reset_subject.tmpl create mode 100644 registration/mails/password_reset_text.tmpl create mode 100644 registration/migrations/2_create_password_reset_tokens.up.sql diff --git a/admin/admin.go b/admin/admin.go index 2a6e340..c0cad42 100644 --- a/admin/admin.go +++ b/admin/admin.go @@ -243,6 +243,7 @@ type PersonalData struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` Address string `json:"address"` + Cap string `json:"cap"` City string `json:"city"` Country string `json:"country"` CreatedAt time.Time `json:"created_at"` @@ -253,6 +254,7 @@ type PersonalDataParams struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` Address string `json:"address"` + Cap string `json:"cap"` City string `json:"city"` Country string `json:"country"` } @@ -263,9 +265,9 @@ type PersonalDataParams struct { func GetPersonalData(ctx context.Context, userID uuid.UUID) (*PersonalData, error) { pd := PersonalData{UserID: userID} err := profilesDB.QueryRow(ctx, ` - SELECT first_name, last_name, address, city, country, created_at, updated_at + SELECT first_name, last_name, address, cap, city, country, created_at, updated_at FROM personal_data WHERE user_id = $1 - `, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &pd.City, &pd.Country, &pd.CreatedAt, &pd.UpdatedAt) + `, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &pd.Cap, &pd.City, &pd.Country, &pd.CreatedAt, &pd.UpdatedAt) if errors.Is(err, sqldb.ErrNoRows) { return nil, &errs.Error{Code: errs.NotFound, Message: "personal data not found"} } @@ -284,21 +286,23 @@ func UpsertPersonalData(ctx context.Context, userID uuid.UUID, p *PersonalDataPa FirstName: p.FirstName, LastName: p.LastName, Address: p.Address, + Cap: p.Cap, City: p.City, Country: p.Country, } err := profilesDB.QueryRow(ctx, ` - INSERT INTO personal_data (user_id, first_name, last_name, address, city, country) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO personal_data (user_id, first_name, last_name, address, cap, city, country) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (user_id) DO UPDATE SET first_name = EXCLUDED.first_name, last_name = EXCLUDED.last_name, address = EXCLUDED.address, + cap = EXCLUDED.cap, city = EXCLUDED.city, country = EXCLUDED.country, updated_at = NOW() RETURNING created_at, updated_at - `, userID, p.FirstName, p.LastName, p.Address, p.City, p.Country).Scan(&pd.CreatedAt, &pd.UpdatedAt) + `, userID, p.FirstName, p.LastName, p.Address, p.Cap, p.City, p.Country).Scan(&pd.CreatedAt, &pd.UpdatedAt) if isForeignKeyViolation(err) { return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"} } diff --git a/assicurazione.md b/assicurazione.md new file mode 100644 index 0000000..c83cb3f --- /dev/null +++ b/assicurazione.md @@ -0,0 +1,12 @@ +N. sinistro 2026 7196966 + +1. descrizione dell'incidente + + procedevo salendo verso Breno sulla strada cantonale (classificata strada di montagna). Affrontando la curva al tornante che prima di arrivare al negozio "Bottega Breno". La careggiata in questo tornante non ofre visibilità è necessario osservare la massima prudenza, cosa che percorrendo regolarmente questa strada applico regolarmente. Ho affrontato la curva come sempre con prudenza e tenendo la destra in modo adeguato. Essendo la careggiata non larga a sufficienza per incrociare due veicoli senza che uno si debba fermare. Inoltre visto la dimensione ridotta non c'è la segalazione di careggiata. (linea bianca al centro). Il veicolo che procedeva in discesa non mi ha concesso la precedenza, non si è fermato e mi ha urtato la parte frontale sinistra. + + allego alla risposta alcune fotografie indicative e lo schizzo. + + 2. secondo lei, a chi è attribuibile la colpa e perche? + a mio avviso la colpa è attribuibile al vostro assicurato. Avrebbe dovuto prestare prudenza e rispettare la precedenza. + + Manno 3.07.2026 \ No newline at end of file diff --git a/auth/credentials.go b/auth/credentials.go index 6f1c358..b895685 100644 --- a/auth/credentials.go +++ b/auth/credentials.go @@ -36,6 +36,11 @@ type SetPasswordParams struct { Password string `json:"password" encore:"sensitive"` } +type SetUserPasswordParams struct { + UserID uuid.UUID `json:"user_id"` + Password string `json:"password" encore:"sensitive"` +} + // SetPassword changes the password for the authenticated user. // //encore:api auth method=PUT path=/auth/password @@ -60,3 +65,28 @@ func SetPassword(ctx context.Context, p *SetPasswordParams) error { } return nil } + +// SetUserPassword changes a user's password from trusted internal flows. +// +//encore:api private method=PUT path=/auth/internal/users/:userID/password +func SetUserPassword(ctx context.Context, userID uuid.UUID, p *SetUserPasswordParams) error { + if p.UserID != uuid.Nil && p.UserID != userID { + return &errs.Error{Code: errs.InvalidArgument, Message: "user id mismatch"} + } + + hash, err := hashPassword(p.Password) + if err != nil { + return errs.WrapCode(err, errs.Internal, "failed to hash password") + } + + res, err := db.Exec(ctx, ` + UPDATE credentials SET password_hash = $2, updated_at = NOW() WHERE user_id = $1 + `, userID, hash) + if err != nil { + return errs.WrapCode(err, errs.Internal, "failed to update password") + } + if res.RowsAffected() == 0 { + return &errs.Error{Code: errs.NotFound, Message: "credentials not found"} + } + return nil +} diff --git a/frontend/src/components/DrawerUserCard.vue b/frontend/src/components/DrawerUserCard.vue index c89aeec..6dc31d1 100644 --- a/frontend/src/components/DrawerUserCard.vue +++ b/frontend/src/components/DrawerUserCard.vue @@ -28,8 +28,7 @@ dense icon="edit" :aria-label="t('actions.editProfile')" - to="/profile" - @click.stop + @click.stop.prevent="editProfile" > {{ t('actions.editProfile') }} @@ -67,6 +66,10 @@ async function logout() { profilesStore.logout(); await router.push('/'); } + +async function editProfile() { + await router.push({ path: '/profile', query: { mode: 'edit' } }); +} + + diff --git a/frontend/src/pages/admin/dialogs/EditProfileDialog.vue b/frontend/src/pages/admin/dialogs/EditProfileDialog.vue index 9300289..f151acb 100644 --- a/frontend/src/pages/admin/dialogs/EditProfileDialog.vue +++ b/frontend/src/pages/admin/dialogs/EditProfileDialog.vue @@ -53,6 +53,12 @@ :error="Boolean(personalFormErrors.address)" :error-message="personalFormErrors.address" /> + ({ first_name: '', last_name: '', address: '', + cap: '', city: '', country: '', }); @@ -196,6 +203,7 @@ function resetPersonalForm(data?: PersonalDataParams) { personalForm.first_name = data?.first_name ?? ''; personalForm.last_name = data?.last_name ?? ''; personalForm.address = data?.address ?? ''; + personalForm.cap = data?.cap ?? ''; personalForm.city = data?.city ?? ''; personalForm.country = data?.country ?? ''; clearPersonalFormErrors(); @@ -234,6 +242,7 @@ function clearPersonalFormErrors() { personalFormErrors.first_name = ''; personalFormErrors.last_name = ''; personalFormErrors.address = ''; + personalFormErrors.cap = ''; personalFormErrors.city = ''; personalFormErrors.country = ''; } diff --git a/frontend/src/router/routes.ts b/frontend/src/router/routes.ts index 2712c7a..a8992df 100644 --- a/frontend/src/router/routes.ts +++ b/frontend/src/router/routes.ts @@ -8,6 +8,7 @@ const routes: RouteRecordRaw[] = [ { path: '', component: () => import('@/pages/IndexPage.vue') }, { path: 'second', component: () => import('@/pages/SecondPage.vue') }, { path: 'login', component: () => import('@/pages/LoginPage.vue') }, + { path: 'password-reset', component: () => import('@/pages/PasswordResetPage.vue') }, { path: 'register', component: () => import('@/pages/RegisterPage.vue') }, { path: 'welcome', component: () => import('@/pages/WelcomePage.vue') }, { path: 'profile', component: () => import('@/pages/ProfilePage.vue') }, diff --git a/frontend/src/stores/layout-store.ts b/frontend/src/stores/layout-store.ts new file mode 100644 index 0000000..021b990 --- /dev/null +++ b/frontend/src/stores/layout-store.ts @@ -0,0 +1,62 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; + +export const useLayoutStore = defineStore('layout', () => { + const bodyWidth = ref(0); + const bodyHeight = ref(0); + + let bodyResizeObserver: ResizeObserver | null = null; + let listeners = 0; + + function updateBodySize() { + if (typeof document === 'undefined') { + return; + } + + const rect = document.body.getBoundingClientRect(); + bodyWidth.value = Math.floor(rect.width); + bodyHeight.value = Math.floor(rect.height); + } + + function startBodyResizeListener() { + listeners += 1; + + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + updateBodySize(); + + if (bodyResizeObserver) { + return; + } + + bodyResizeObserver = new ResizeObserver(() => { + updateBodySize(); + }); + bodyResizeObserver.observe(document.body); + window.addEventListener('resize', updateBodySize); + } + + function stopBodyResizeListener() { + listeners = Math.max(0, listeners - 1); + + if (listeners > 0) { + return; + } + + bodyResizeObserver?.disconnect(); + bodyResizeObserver = null; + + if (typeof window !== 'undefined') { + window.removeEventListener('resize', updateBodySize); + } + } + + return { + bodyWidth, + bodyHeight, + startBodyResizeListener, + stopBodyResizeListener, + }; +}); diff --git a/profiles/migrations/10_add_personal_data_cap.up.sql b/profiles/migrations/10_add_personal_data_cap.up.sql new file mode 100644 index 0000000..a31634b --- /dev/null +++ b/profiles/migrations/10_add_personal_data_cap.up.sql @@ -0,0 +1 @@ +ALTER TABLE personal_data ADD COLUMN cap TEXT NOT NULL DEFAULT ''; diff --git a/profiles/personal_data.go b/profiles/personal_data.go index 935bd82..e0bee9c 100644 --- a/profiles/personal_data.go +++ b/profiles/personal_data.go @@ -18,6 +18,7 @@ type PersonalData struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` Address string `json:"address"` + Cap string `json:"cap"` City string `json:"city"` Country string `json:"country"` CreatedAt time.Time `json:"created_at"` @@ -29,6 +30,7 @@ type PersonalDataParams struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` Address string `json:"address"` + Cap string `json:"cap"` City string `json:"city"` Country string `json:"country"` } @@ -43,9 +45,9 @@ func GetPersonalData(ctx context.Context) (*PersonalData, error) { } pd := PersonalData{UserID: userID} err = db.QueryRow(ctx, ` - SELECT first_name, last_name, address, city, country, created_at, updated_at + SELECT first_name, last_name, address, cap, city, country, created_at, updated_at FROM personal_data WHERE user_id = $1 - `, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &pd.City, &pd.Country, &pd.CreatedAt, &pd.UpdatedAt) + `, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &pd.Cap, &pd.City, &pd.Country, &pd.CreatedAt, &pd.UpdatedAt) if errors.Is(err, sqldb.ErrNoRows) { return nil, &errs.Error{Code: errs.NotFound, Message: "personal data not found"} } @@ -68,21 +70,23 @@ func UpsertPersonalData(ctx context.Context, p *PersonalDataParams) (*PersonalDa FirstName: p.FirstName, LastName: p.LastName, Address: p.Address, + Cap: p.Cap, City: p.City, Country: p.Country, } err = db.QueryRow(ctx, ` - INSERT INTO personal_data (user_id, first_name, last_name, address, city, country) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO personal_data (user_id, first_name, last_name, address, cap, city, country) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (user_id) DO UPDATE SET first_name = EXCLUDED.first_name, last_name = EXCLUDED.last_name, address = EXCLUDED.address, + cap = EXCLUDED.cap, city = EXCLUDED.city, country = EXCLUDED.country, updated_at = NOW() RETURNING created_at, updated_at - `, userID, p.FirstName, p.LastName, p.Address, p.City, p.Country).Scan(&pd.CreatedAt, &pd.UpdatedAt) + `, userID, p.FirstName, p.LastName, p.Address, p.Cap, p.City, p.Country).Scan(&pd.CreatedAt, &pd.UpdatedAt) if isForeignKeyViolation(err) { return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"} } diff --git a/registration/mails/password_reset_html.tmpl b/registration/mails/password_reset_html.tmpl new file mode 100644 index 0000000..7f1a4a3 --- /dev/null +++ b/registration/mails/password_reset_html.tmpl @@ -0,0 +1,12 @@ + + + +

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.

+ + diff --git a/registration/mails/password_reset_subject.tmpl b/registration/mails/password_reset_subject.tmpl new file mode 100644 index 0000000..04fbf6b --- /dev/null +++ b/registration/mails/password_reset_subject.tmpl @@ -0,0 +1 @@ +Reset your password diff --git a/registration/mails/password_reset_text.tmpl b/registration/mails/password_reset_text.tmpl new file mode 100644 index 0000000..55529a9 --- /dev/null +++ b/registration/mails/password_reset_text.tmpl @@ -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. diff --git a/registration/migrations/2_create_password_reset_tokens.up.sql b/registration/migrations/2_create_password_reset_tokens.up.sql new file mode 100644 index 0000000..a841507 --- /dev/null +++ b/registration/migrations/2_create_password_reset_tokens.up.sql @@ -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); diff --git a/registration/registration.go b/registration/registration.go index fe38881..dc47129 100644 --- a/registration/registration.go +++ b/registration/registration.go @@ -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