all repos

onasty @ 234f764

a one-time notes service
19 files changed, 476 insertions(+), 19 deletions(-)
feat(api): change email (#191)

* feat(mailer): confirm email change

* feat(api): implement request email change

* feat(api): implement change password

* test(e2e): change email

* fix(mailer): fix templates
Author: Olexandr Smirnov ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-08-22 17:29:55 +0300
Parent: 844e778
M cmd/api/main.go
···
        20
        20
         	"github.com/olexsmir/onasty/internal/oauth"

      
        21
        21
         	"github.com/olexsmir/onasty/internal/service/notesrv"

      
        22
        22
         	"github.com/olexsmir/onasty/internal/service/usersrv"

      
        
        23
        +	"github.com/olexsmir/onasty/internal/store/psql/changeemailrepo"

      
        23
        24
         	"github.com/olexsmir/onasty/internal/store/psql/noterepo"

      
        24
        25
         	"github.com/olexsmir/onasty/internal/store/psql/passwordtokrepo"

      
        25
        26
         	"github.com/olexsmir/onasty/internal/store/psql/sessionrepo"

      ···
        94
        95
         	sessionrepo := sessionrepo.New(psqlDB)

      
        95
        96
         	vertokrepo := vertokrepo.New(psqlDB)

      
        96
        97
         	pwdtokrepo := passwordtokrepo.NewPasswordResetTokenRepo(psqlDB)

      
        
        98
        +	changeemailrepo := changeemailrepo.New(psqlDB)

      
        97
        99
         

      
        98
        100
         	notecache := notecache.New(redisDB, cfg.CacheNoteTTL)

      
        99
        101
         	noterepo := noterepo.New(psqlDB)

      ···
        106
        108
         		sessionrepo,

      
        107
        109
         		vertokrepo,

      
        108
        110
         		pwdtokrepo,

      
        
        111
        +		changeemailrepo,

      
        109
        112
         		noterepo,

      
        110
        113
         		userPasswordHasher,

      
        111
        114
         		jwtTokenizer,

      ···
        116
        119
         		cfg.JwtRefreshTokenTTL,

      
        117
        120
         		cfg.VerificationTokenTTL,

      
        118
        121
         		cfg.ResetPasswordTokenTTL,

      
        
        122
        +		cfg.ChangeEmailTokenTTL,

      
        119
        123
         	)

      
        120
        124
         

      
        121
        125
         	rateLimiterConfig := ratelimit.Config{

      
M e2e/apiv1_auth_test.go
···
        1
        1
         package e2e_test

      
        2
        2
         

      
        3
        3
         import (

      
        
        4
        +	"crypto/rand"

      
        
        5
        +	"encoding/hex"

      
        
        6
        +	"fmt"

      
        4
        7
         	"net/http"

      
        5
        8
         	"time"

      
        6
        9
         

      ···
        447
        450
         	e.Equal(httpResp.Code, http.StatusBadRequest)

      
        448
        451
         }

      
        449
        452
         

      
        
        453
        +type apiv1AuthChangeEmailRequest struct {

      
        
        454
        +	NewEmail string `json:"new_email"`

      
        
        455
        +}

      
        
        456
        +

      
        
        457
        +func (e *AppTestSuite) TestAuthV1_ChangeEmail() {

      
        
        458
        +	oldEmail, newEmail := e.randomEmail(), e.randomEmail()

      
        
        459
        +	uid, toks := e.createAndSingIn(oldEmail, e.uuid())

      
        
        460
        +

      
        
        461
        +	// request email change

      
        
        462
        +	httpResp := e.httpRequest(

      
        
        463
        +		http.MethodPost,

      
        
        464
        +		"/api/v1/auth/change-email",

      
        
        465
        +		e.jsonify(apiv1AuthChangeEmailRequest{

      
        
        466
        +			NewEmail: newEmail,

      
        
        467
        +		}),

      
        
        468
        +		toks.AccessToken,

      
        
        469
        +	)

      
        
        470
        +	e.Equal(http.StatusOK, httpResp.Code)

      
        
        471
        +

      
        
        472
        +	token := e.getChangeEmailTokenByUserID(uid)

      
        
        473
        +	e.Empty(token.UsedAt)

      
        
        474
        +	e.Equal(mockMailStore[oldEmail], token.Token)

      
        
        475
        +

      
        
        476
        +	// confirm email change

      
        
        477
        +	httpResp = e.httpRequest(http.MethodGet, "/api/v1/auth/change-email/"+token.Token, nil)

      
        
        478
        +	e.Equal(http.StatusOK, httpResp.Code)

      
        
        479
        +

      
        
        480
        +	updatedToken := e.getChangeEmailTokenByUserID(uid)

      
        
        481
        +	e.NotEmpty(updatedToken.UsedAt)

      
        
        482
        +

      
        
        483
        +	dbUser := e.getUserByEmail(token.Extra)

      
        
        484
        +	e.Equal(dbUser.Email, newEmail)

      
        
        485
        +}

      
        
        486
        +

      
        
        487
        +func (e *AppTestSuite) TestAuthV1_ChangeEmail_wrongSameEmail() {

      
        
        488
        +	email := e.randomEmail()

      
        
        489
        +	_, toks := e.createAndSingIn(email, e.uuid())

      
        
        490
        +

      
        
        491
        +	// request email change

      
        
        492
        +	httpResp := e.httpRequest(

      
        
        493
        +		http.MethodPost,

      
        
        494
        +		"/api/v1/auth/change-email",

      
        
        495
        +		e.jsonify(apiv1AuthChangeEmailRequest{

      
        
        496
        +			NewEmail: email,

      
        
        497
        +		}),

      
        
        498
        +		toks.AccessToken,

      
        
        499
        +	)

      
        
        500
        +	e.Equal(http.StatusBadRequest, httpResp.Code)

      
        
        501
        +

      
        
        502
        +	var body errorResponse

      
        
        503
        +	e.readBodyAndUnjsonify(httpResp.Body, &body)

      
        
        504
        +

      
        
        505
        +	e.Equal(body.Message, models.ErrUserEmailIsAlreadyInUse.Error())

      
        
        506
        +}

      
        
        507
        +

      
        450
        508
         type getMeResponse struct {

      
        451
        509
         	Email        string    `json:"email"`

      
        452
        510
         	CreatedAt    time.Time `json:"created_at"`

      ···
        500
        558
         

      
        501
        559
         	return uid, body

      
        502
        560
         }

      
        
        561
        +

      
        
        562
        +func (e *AppTestSuite) randomEmail() string {

      
        
        563
        +	b := make([]byte, 4)

      
        
        564
        +	_, _ = rand.Read(b)

      
        
        565
        +	return fmt.Sprintf("user-%s@test.local", hex.EncodeToString(b))

      
        
        566
        +}

      
M e2e/e2e_test.go
···
        17
        17
         	"github.com/olexsmir/onasty/internal/logger"

      
        18
        18
         	"github.com/olexsmir/onasty/internal/service/notesrv"

      
        19
        19
         	"github.com/olexsmir/onasty/internal/service/usersrv"

      
        
        20
        +	"github.com/olexsmir/onasty/internal/store/psql/changeemailrepo"

      
        20
        21
         	"github.com/olexsmir/onasty/internal/store/psql/noterepo"

      
        21
        22
         	"github.com/olexsmir/onasty/internal/store/psql/passwordtokrepo"

      
        22
        23
         	"github.com/olexsmir/onasty/internal/store/psql/sessionrepo"

      ···
        102
        103
         	sessionrepo := sessionrepo.New(e.postgresDB)

      
        103
        104
         	vertokrepo := vertokrepo.New(e.postgresDB)

      
        104
        105
         	pwdtokrepo := passwordtokrepo.NewPasswordResetTokenRepo(e.postgresDB)

      
        
        106
        +	changeemailrepo := changeemailrepo.New(e.postgresDB)

      
        105
        107
         

      
        106
        108
         	stubOAuthProvider := newOauthProviderStub()

      
        107
        109
         

      ···
        116
        118
         		sessionrepo,

      
        117
        119
         		vertokrepo,

      
        118
        120
         		pwdtokrepo,

      
        
        121
        +		changeemailrepo,

      
        119
        122
         		noterepo,

      
        120
        123
         		e.hasher,

      
        121
        124
         		e.jwtTokenizer,

      ···
        126
        129
         		cfg.JwtRefreshTokenTTL,

      
        127
        130
         		cfg.VerificationTokenTTL,

      
        128
        131
         		cfg.ResetPasswordTokenTTL,

      
        
        132
        +		cfg.ChangeEmailTokenTTL,

      
        129
        133
         	)

      
        130
        134
         

      
        131
        135
         	// for testing purposes, it's ok to have high values ig

      
M e2e/e2e_utils_db_test.go
···
        157
        157
         

      
        158
        158
         type userVerificationToken struct {

      
        159
        159
         	Token  string

      
        
        160
        +	Extra  string // Extra field (optional)

      
        160
        161
         	UsedAt *time.Time

      
        161
        162
         }

      
        162
        163
         

      ···
        187
        188
         	e.require.NoError(err)

      
        188
        189
         	return r

      
        189
        190
         }

      
        
        191
        +

      
        
        192
        +func (e *AppTestSuite) getChangeEmailTokenByUserID(u uuid.UUID) userVerificationToken {

      
        
        193
        +	query, args, err := pgq.

      
        
        194
        +		Select("token", "new_email", "used_at").

      
        
        195
        +		From("change_email_tokens").

      
        
        196
        +		Where(pgq.Eq{"user_id": u.String()}).

      
        
        197
        +		Limit(1).

      
        
        198
        +		SQL()

      
        
        199
        +

      
        
        200
        +	e.require.NoError(err)

      
        
        201
        +	var r userVerificationToken

      
        
        202
        +	err = e.postgresDB.QueryRow(e.ctx, query, args...).Scan(&r.Token, &r.Extra, &r.UsedAt)

      
        
        203
        +	e.require.NoError(err)

      
        
        204
        +	return r

      
        
        205
        +}

      
M e2e/mailer_mock_test.go
···
        31
        31
         	mockMailStore[i.Receiver] = i.Token

      
        32
        32
         	return nil

      
        33
        33
         }

      
        
        34
        +

      
        
        35
        +func (m *mailerMockService) SendChangeEmailConfirmation(

      
        
        36
        +	_ context.Context,

      
        
        37
        +	i mailermq.SendChangeEmailConfirmationRequest,

      
        
        38
        +) error {

      
        
        39
        +	mockMailStore[i.Receiver] = i.Token

      
        
        40
        +	return nil

      
        
        41
        +}

      
M internal/config/config.go
···
        53
        53
         

      
        54
        54
         	VerificationTokenTTL  time.Duration

      
        55
        55
         	ResetPasswordTokenTTL time.Duration

      
        
        56
        +	ChangeEmailTokenTTL   time.Duration

      
        56
        57
         

      
        57
        58
         	MetricsEnabled bool

      
        58
        59
         	MetricsPort    int

      ···
        112
        113
         

      
        113
        114
         		VerificationTokenTTL:  mustParseDuration(getenvOrDefault("VERIFICATION_TOKEN_TTL", "24h")),

      
        114
        115
         		ResetPasswordTokenTTL: mustParseDuration(getenvOrDefault("RESET_PASSWORD_TOKEN_TTL", "1h")),

      
        
        116
        +		ChangeEmailTokenTTL:   mustParseDuration(getenvOrDefault("CHANGE_EMAIL_TOKEN_TTL", "24h")),

      
        115
        117
         

      
        116
        118
         		MetricsPort:    mustGetenvOrDefaultInt("METRICS_PORT", 3001),

      
        117
        119
         		MetricsEnabled: getenvOrDefault("METRICS_ENABLED", "true") == "true",

      
M internal/dtos/user.go
···
        50
        50
         	LastLoginAt  time.Time

      
        51
        51
         	NotesCreated int

      
        52
        52
         }

      
        
        53
        +

      
        
        54
        +type ChangeEmail struct {

      
        
        55
        +	NewEmail string

      
        
        56
        +}

      
M internal/events/mailermq/mailermq.go
···
        17
        17
         

      
        18
        18
         	// SendPasswordResetEmail sends an email with a password reset token to the user.

      
        19
        19
         	SendPasswordResetEmail(ctx context.Context, input SendPasswordResetEmailRequest) error

      
        
        20
        +

      
        
        21
        +	// SendChangeEmailVerification sends an email with a change email verification token to the user.

      
        
        22
        +	SendChangeEmailConfirmation(ctx context.Context, inp SendChangeEmailConfirmationRequest) error

      
        20
        23
         }

      
        21
        24
         

      
        22
        25
         type MailerMQ struct {

      ···
        93
        96
         

      
        94
        97
         	return events.CheckRespForError(resp)

      
        95
        98
         }

      
        
        99
        +

      
        
        100
        +type SendChangeEmailConfirmationRequest struct {

      
        
        101
        +	Receiver string

      
        
        102
        +	Token    string

      
        
        103
        +	NewEmail string

      
        
        104
        +}

      
        
        105
        +

      
        
        106
        +func (m MailerMQ) SendChangeEmailConfirmation(

      
        
        107
        +	ctx context.Context,

      
        
        108
        +	inp SendChangeEmailConfirmationRequest,

      
        
        109
        +) error {

      
        
        110
        +	req, err := json.Marshal(sendRequest{

      
        
        111
        +		RequestID:    reqid.GetContext(ctx),

      
        
        112
        +		Receiver:     inp.Receiver,

      
        
        113
        +		TemplateName: "confirm_email_change",

      
        
        114
        +		Options: map[string]string{

      
        
        115
        +			"token": inp.Token,

      
        
        116
        +			"email": inp.NewEmail,

      
        
        117
        +		},

      
        
        118
        +	})

      
        
        119
        +	if err != nil {

      
        
        120
        +		return err

      
        
        121
        +	}

      
        
        122
        +

      
        
        123
        +	resp, err := m.nc.RequestWithContext(ctx, sendTopic, req)

      
        
        124
        +	if err != nil {

      
        
        125
        +		return err

      
        
        126
        +	}

      
        
        127
        +

      
        
        128
        +	return events.CheckRespForError(resp)

      
        
        129
        +}

      
M internal/models/tokens.go
···
        2
        2
         

      
        3
        3
         import (

      
        4
        4
         	"errors"

      
        
        5
        +	"net/mail"

      
        5
        6
         	"time"

      
        6
        7
         

      
        7
        8
         	"github.com/gofrs/uuid/v5"

      ···
        10
        11
         var (

      
        11
        12
         	ErrResetPasswordTokenExpired  = errors.New("reset password token expired")

      
        12
        13
         	ErrResetPasswordTokenNotFound = errors.New("reset password token not found")

      
        
        14
        +

      
        
        15
        +	ErrChangeEmailTokenExpired       = errors.New("change email token expired")

      
        
        16
        +	ErrChangeEmailTokenNotFound      = errors.New("change email token not found")

      
        
        17
        +	ErrChangeEmailTokenIsAlreadyUsed = errors.New("change email token is already used")

      
        13
        18
         )

      
        14
        19
         

      
        15
        20
         type ResetPasswordToken struct {

      ···
        29
        34
         	CreatedAt time.Time

      
        30
        35
         	ExpiresAt time.Time

      
        31
        36
         }

      
        
        37
        +

      
        
        38
        +type ChangeEmailToken struct {

      
        
        39
        +	UserID    uuid.UUID

      
        
        40
        +	Token     string

      
        
        41
        +	NewEmail  string

      
        
        42
        +	CreatedAt time.Time

      
        
        43
        +	ExpiresAt time.Time

      
        
        44
        +}

      
        
        45
        +

      
        
        46
        +func (c ChangeEmailToken) IsExpired() bool {

      
        
        47
        +	return c.ExpiresAt.Before(time.Now())

      
        
        48
        +}

      
        
        49
        +

      
        
        50
        +func (c ChangeEmailToken) Validate() error {

      
        
        51
        +	_, err := mail.ParseAddress(c.NewEmail)

      
        
        52
        +	if err != nil {

      
        
        53
        +		return ErrUserInvalidEmail

      
        
        54
        +	}

      
        
        55
        +

      
        
        56
        +	return nil

      
        
        57
        +}

      
M internal/service/usersrv/usersrv.go
···
        13
        13
         	"github.com/olexsmir/onasty/internal/jwtutil"

      
        14
        14
         	"github.com/olexsmir/onasty/internal/models"

      
        15
        15
         	"github.com/olexsmir/onasty/internal/oauth"

      
        
        16
        +	"github.com/olexsmir/onasty/internal/store/psql/changeemailrepo"

      
        16
        17
         	"github.com/olexsmir/onasty/internal/store/psql/noterepo"

      
        17
        18
         	"github.com/olexsmir/onasty/internal/store/psql/passwordtokrepo"

      
        18
        19
         	"github.com/olexsmir/onasty/internal/store/psql/sessionrepo"

      ···
        49
        50
         	// ResetPassword resets the user's password using the provided reset token.

      
        50
        51
         	ResetPassword(ctx context.Context, inp dtos.ResetPassword) error

      
        51
        52
         

      
        
        53
        +	RequestEmailChange(ctx context.Context, userID uuid.UUID, inp dtos.ChangeEmail) error

      
        
        54
        +

      
        
        55
        +	ChangeEmail(ctx context.Context, token string) error

      
        
        56
        +

      
        52
        57
         	// GetOAuthURL retrieves the OAuth URL for the specified provider.

      
        53
        58
         	GetOAuthURL(providerName string) (dtos.OAuthRedirect, error)

      
        54
        59
         

      ···
        74
        79
         var _ UserServicer = (*UserSrv)(nil)

      
        75
        80
         

      
        76
        81
         type UserSrv struct {

      
        77
        
        -	userstore    userepo.UserStorer

      
        78
        
        -	sessionstore sessionrepo.SessionStorer

      
        79
        
        -	vertokrepo   vertokrepo.VerificationTokenStorer

      
        80
        
        -	pwdtokrepo   passwordtokrepo.PasswordResetTokenStorer

      
        81
        
        -	notestore    noterepo.NoteStorer

      
        82
        
        -	cache        usercache.UserCacheer

      
        
        82
        +	userstore       userepo.UserStorer

      
        
        83
        +	sessionstore    sessionrepo.SessionStorer

      
        
        84
        +	vertokrepo      vertokrepo.VerificationTokenStorer

      
        
        85
        +	pwdtokrepo      passwordtokrepo.PasswordResetTokenStorer

      
        
        86
        +	changeemailrepo changeemailrepo.ChangeEmailStorer

      
        
        87
        +	notestore       noterepo.NoteStorer

      
        
        88
        +	cache           usercache.UserCacheer

      
        83
        89
         

      
        84
        90
         	hasher       hasher.Hasher

      
        85
        91
         	jwtTokenizer jwtutil.JWTTokenizer

      ···
        91
        97
         	refreshTokenTTL       time.Duration

      
        92
        98
         	verificationTokenTTL  time.Duration

      
        93
        99
         	resetPasswordTokenTTL time.Duration

      
        
        100
        +	changeEmailTokenTTL   time.Duration

      
        94
        101
         }

      
        95
        102
         

      
        96
        103
         func New(

      ···
        98
        105
         	sessionstore sessionrepo.SessionStorer,

      
        99
        106
         	vertokrepo vertokrepo.VerificationTokenStorer,

      
        100
        107
         	pwdtokrepo passwordtokrepo.PasswordResetTokenStorer,

      
        
        108
        +	changeemailrepo changeemailrepo.ChangeEmailStorer,

      
        101
        109
         	notestore noterepo.NoteStorer,

      
        102
        110
         	hasher hasher.Hasher,

      
        103
        111
         	jwtTokenizer jwtutil.JWTTokenizer,

      
        104
        112
         	mailermq mailermq.Mailer,

      
        105
        113
         	cache usercache.UserCacheer,

      
        106
        114
         	googleOauth, githubOauth oauth.Provider,

      
        107
        
        -	refreshTokenTTL, verificationTokenTTL, resetPasswordTokenTTL time.Duration,

      
        
        115
        +	refreshTokenTTL, verificationTokenTTL, resetPasswordTokenTTL, changeEmailTokenTTL time.Duration,

      
        108
        116
         ) *UserSrv {

      
        109
        117
         	return &UserSrv{

      
        110
        118
         		userstore:             userstore,

      
        111
        119
         		sessionstore:          sessionstore,

      
        112
        120
         		vertokrepo:            vertokrepo,

      
        113
        121
         		pwdtokrepo:            pwdtokrepo,

      
        
        122
        +		changeemailrepo:       changeemailrepo,

      
        114
        123
         		notestore:             notestore,

      
        115
        124
         		cache:                 cache,

      
        116
        125
         		hasher:                hasher,

      ···
        121
        130
         		refreshTokenTTL:       refreshTokenTTL,

      
        122
        131
         		verificationTokenTTL:  verificationTokenTTL,

      
        123
        132
         		resetPasswordTokenTTL: resetPasswordTokenTTL,

      
        
        133
        +		changeEmailTokenTTL:   changeEmailTokenTTL,

      
        124
        134
         	}

      
        125
        135
         }

      
        126
        136
         

      ···
        312
        322
         	}

      
        313
        323
         

      
        314
        324
         	return u.userstore.SetPassword(ctx, uid, hashedPassword)

      
        
        325
        +}

      
        
        326
        +

      
        
        327
        +func (u *UserSrv) RequestEmailChange(

      
        
        328
        +	ctx context.Context,

      
        
        329
        +	userID uuid.UUID,

      
        
        330
        +	inp dtos.ChangeEmail,

      
        
        331
        +) error {

      
        
        332
        +	user, err := u.userstore.GetByID(ctx, userID)

      
        
        333
        +	if err != nil {

      
        
        334
        +		return err

      
        
        335
        +	}

      
        
        336
        +

      
        
        337
        +	if user.Email == inp.NewEmail {

      
        
        338
        +		return models.ErrUserEmailIsAlreadyInUse

      
        
        339
        +	}

      
        
        340
        +

      
        
        341
        +	token := uuid.Must(uuid.NewV4()).String()

      
        
        342
        +	changeEmailInput := models.ChangeEmailToken{

      
        
        343
        +		UserID:    userID,

      
        
        344
        +		Token:     token,

      
        
        345
        +		NewEmail:  inp.NewEmail,

      
        
        346
        +		CreatedAt: time.Now(),

      
        
        347
        +		ExpiresAt: time.Now().Add(u.changeEmailTokenTTL),

      
        
        348
        +	}

      
        
        349
        +	if err := changeEmailInput.Validate(); err != nil {

      
        
        350
        +		return err

      
        
        351
        +	}

      
        
        352
        +

      
        
        353
        +	if err := u.changeemailrepo.Create(ctx, changeEmailInput); err != nil {

      
        
        354
        +		return err

      
        
        355
        +	}

      
        
        356
        +

      
        
        357
        +	if err := u.mailermq.SendChangeEmailConfirmation(ctx, mailermq.SendChangeEmailConfirmationRequest{

      
        
        358
        +		Receiver: user.Email,

      
        
        359
        +		Token:    token,

      
        
        360
        +		NewEmail: inp.NewEmail,

      
        
        361
        +	}); err != nil {

      
        
        362
        +		return err

      
        
        363
        +	}

      
        
        364
        +

      
        
        365
        +	return nil

      
        
        366
        +}

      
        
        367
        +

      
        
        368
        +func (u *UserSrv) ChangeEmail(ctx context.Context, givenToken string) error {

      
        
        369
        +	token, err := u.changeemailrepo.GetByToken(ctx, givenToken)

      
        
        370
        +	if err != nil {

      
        
        371
        +		return err

      
        
        372
        +	}

      
        
        373
        +

      
        
        374
        +	user, err := u.userstore.GetByID(ctx, token.UserID)

      
        
        375
        +	if err != nil {

      
        
        376
        +		return err

      
        
        377
        +	}

      
        
        378
        +

      
        
        379
        +	if user.Email == token.NewEmail {

      
        
        380
        +		return models.ErrUserEmailIsAlreadyInUse

      
        
        381
        +	}

      
        
        382
        +

      
        
        383
        +	if err := u.userstore.SetEmail(ctx, token.UserID, token.NewEmail); err != nil {

      
        
        384
        +		return err

      
        
        385
        +	}

      
        
        386
        +

      
        
        387
        +	if err := u.changeemailrepo.MarkAsUsed(ctx, token.Token, time.Now()); err != nil {

      
        
        388
        +		return err

      
        
        389
        +	}

      
        
        390
        +

      
        
        391
        +	return nil

      
        315
        392
         }

      
        316
        393
         

      
        317
        394
         func (u *UserSrv) Verify(ctx context.Context, verificationKey string) error {

      
A internal/store/psql/changeemailrepo/changeemailrepo.go
···
        
        1
        +package changeemailrepo

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"errors"

      
        
        6
        +	"time"

      
        
        7
        +

      
        
        8
        +	"github.com/jackc/pgx/v4"

      
        
        9
        +	"github.com/olexsmir/onasty/internal/models"

      
        
        10
        +	"github.com/olexsmir/onasty/internal/store/psqlutil"

      
        
        11
        +)

      
        
        12
        +

      
        
        13
        +type ChangeEmailStorer interface {

      
        
        14
        +	// Create create a change email token.

      
        
        15
        +	Create(ctx context.Context, input models.ChangeEmailToken) error

      
        
        16
        +

      
        
        17
        +	// GetByToken returns change email token by its token.

      
        
        18
        +	// Returns [models.ErrChangeEmailTokenNotFound] if not found.

      
        
        19
        +	GetByToken(ctx context.Context, token string) (models.ChangeEmailToken, error)

      
        
        20
        +

      
        
        21
        +	// MarkAsUsed marks change email token as used.

      
        
        22
        +	// If not found, returns [models.ErrChangeEmailTokenNotFound].

      
        
        23
        +	// If token is already used, returns [models.ErrChangeEmailTokenIsAlreadyUsed].

      
        
        24
        +	// If token is expired, returns [models.ErrChangeEmailTokenExpired]

      
        
        25
        +	MarkAsUsed(ctx context.Context, token string, usedAT time.Time) error

      
        
        26
        +}

      
        
        27
        +

      
        
        28
        +var _ ChangeEmailStorer = (*ChangeEmailRepo)(nil)

      
        
        29
        +

      
        
        30
        +type ChangeEmailRepo struct {

      
        
        31
        +	db *psqlutil.DB

      
        
        32
        +}

      
        
        33
        +

      
        
        34
        +func New(db *psqlutil.DB) *ChangeEmailRepo {

      
        
        35
        +	return &ChangeEmailRepo{

      
        
        36
        +		db: db,

      
        
        37
        +	}

      
        
        38
        +}

      
        
        39
        +

      
        
        40
        +func (c *ChangeEmailRepo) Create(ctx context.Context, inp models.ChangeEmailToken) error {

      
        
        41
        +	query := `--sql

      
        
        42
        +insert into change_email_tokens (user_id, new_email, token, created_at, expires_at)

      
        
        43
        +values ($1, $2, $3, $4, $5)

      
        
        44
        +`

      
        
        45
        +

      
        
        46
        +	_, err := c.db.Exec(ctx, query,

      
        
        47
        +		inp.UserID, inp.NewEmail, inp.Token, inp.CreatedAt, inp.ExpiresAt)

      
        
        48
        +	return err

      
        
        49
        +}

      
        
        50
        +

      
        
        51
        +func (c *ChangeEmailRepo) GetByToken(

      
        
        52
        +	ctx context.Context,

      
        
        53
        +	token string,

      
        
        54
        +) (models.ChangeEmailToken, error) {

      
        
        55
        +	query := `--sql

      
        
        56
        +select user_id, new_email, token, created_at, expires_at

      
        
        57
        +from change_email_tokens

      
        
        58
        +where token = $1

      
        
        59
        +`

      
        
        60
        +

      
        
        61
        +	var res models.ChangeEmailToken

      
        
        62
        +	err := c.db.QueryRow(ctx, query, token).

      
        
        63
        +		Scan(&res.UserID, &res.NewEmail, &res.Token, &res.CreatedAt, &res.ExpiresAt)

      
        
        64
        +	if errors.Is(err, pgx.ErrNoRows) {

      
        
        65
        +		return models.ChangeEmailToken{}, models.ErrChangeEmailTokenNotFound

      
        
        66
        +	}

      
        
        67
        +

      
        
        68
        +	return res, err

      
        
        69
        +}

      
        
        70
        +

      
        
        71
        +func (c *ChangeEmailRepo) MarkAsUsed(ctx context.Context, token string, usedAT time.Time) error {

      
        
        72
        +	tx, err := c.db.Begin(ctx)

      
        
        73
        +	if err != nil {

      
        
        74
        +		return err

      
        
        75
        +	}

      
        
        76
        +	defer tx.Rollback(ctx) //nolint:errcheck

      
        
        77
        +

      
        
        78
        +	var isUsed bool

      
        
        79
        +	var expiresAt time.Time

      
        
        80
        +	err = tx.QueryRow(ctx,

      
        
        81
        +		"select (used_at is not null), expires_at from change_email_tokens where token = $1",

      
        
        82
        +		token).

      
        
        83
        +		Scan(&isUsed, &expiresAt)

      
        
        84
        +	if err != nil {

      
        
        85
        +		if errors.Is(err, pgx.ErrNoRows) {

      
        
        86
        +			return models.ErrChangeEmailTokenNotFound

      
        
        87
        +		}

      
        
        88
        +		return err

      
        
        89
        +	}

      
        
        90
        +

      
        
        91
        +	if isUsed {

      
        
        92
        +		return models.ErrChangeEmailTokenIsAlreadyUsed

      
        
        93
        +	}

      
        
        94
        +

      
        
        95
        +	if time.Now().After(expiresAt) {

      
        
        96
        +		return models.ErrChangeEmailTokenExpired

      
        
        97
        +	}

      
        
        98
        +

      
        
        99
        +	query := `--sql

      
        
        100
        +update change_email_tokens

      
        
        101
        +set used_at = $1

      
        
        102
        +where token = $2`

      
        
        103
        +

      
        
        104
        +	_, err = tx.Exec(ctx, query, usedAT, token)

      
        
        105
        +	if err != nil {

      
        
        106
        +		return err

      
        
        107
        +	}

      
        
        108
        +

      
        
        109
        +	return tx.Commit(ctx)

      
        
        110
        +}

      
M internal/store/psql/passwordtokrepo/passwordtokrepo.go
···
        42
        42
         

      
        43
        43
         func (r *PasswordResetTokenRepo) Create(ctx context.Context, token models.ResetPasswordToken,

      
        44
        44
         ) error {

      
        45
        
        -	query, aggs, err := pgq.

      
        
        45
        +	query, args, err := pgq.

      
        46
        46
         		Insert("password_reset_tokens").

      
        47
        47
         		Columns("user_id", "token", "created_at", "expires_at").

      
        48
        48
         		Values(token.UserID, token.Token, token.CreatedAt, token.ExpiresAt).

      ···
        51
        51
         		return err

      
        52
        52
         	}

      
        53
        53
         

      
        54
        
        -	_, err = r.db.Exec(ctx, query, aggs...)

      
        
        54
        +	_, err = r.db.Exec(ctx, query, args...)

      
        55
        55
         	return err

      
        56
        56
         }

      
        57
        57
         

      
M internal/store/psql/userepo/userepo.go
···
        38
        38
         	// password should be hashed

      
        39
        39
         	SetPassword(ctx context.Context, userID uuid.UUID, newPassword string) error

      
        40
        40
         

      
        
        41
        +	// SetEmail sets new email for user by their id

      
        
        42
        +	SetEmail(ctx context.Context, userID uuid.UUID, email string) error

      
        
        43
        +

      
        41
        44
         	GetByOAuthID(ctx context.Context, provider, providerID string) (models.User, error)

      
        42
        45
         	LinkOAuthIdentity(ctx context.Context, userID uuid.UUID, provider, providerID string) error

      
        43
        46
         

      ···
        217
        220
         		return err

      
        218
        221
         	}

      
        219
        222
         

      
        220
        
        -	_, err = r.db.Exec(ctx, query, args...)

      
        221
        
        -	return err

      
        
        223
        +	ct, err := r.db.Exec(ctx, query, args...)

      
        
        224
        +	if err != nil {

      
        
        225
        +		return err

      
        
        226
        +	}

      
        
        227
        +

      
        
        228
        +	if ct.RowsAffected() == 0 {

      
        
        229
        +		return models.ErrUserNotFound

      
        
        230
        +	}

      
        
        231
        +

      
        
        232
        +	return nil

      
        
        233
        +}

      
        
        234
        +

      
        
        235
        +func (r *UserRepo) SetEmail(ctx context.Context, userID uuid.UUID, email string) error {

      
        
        236
        +	query, args, err := pgq.

      
        
        237
        +		Update("users").

      
        
        238
        +		Set("email", email).

      
        
        239
        +		Where(pgq.Eq{"id": userID.String()}).

      
        
        240
        +		SQL()

      
        
        241
        +	if err != nil {

      
        
        242
        +		return err

      
        
        243
        +	}

      
        
        244
        +

      
        
        245
        +	ct, err := r.db.Exec(ctx, query, args...)

      
        
        246
        +	if err != nil {

      
        
        247
        +		return err

      
        
        248
        +	}

      
        
        249
        +

      
        
        250
        +	if ct.RowsAffected() == 0 {

      
        
        251
        +		return models.ErrUserNotFound

      
        
        252
        +	}

      
        
        253
        +

      
        
        254
        +	return nil

      
        222
        255
         }

      
        223
        256
         

      
        224
        257
         func (r *UserRepo) CheckIfUserExists(ctx context.Context, id uuid.UUID) (bool, error) {

      
M internal/transport/http/apiv1/apiv1.go
···
        53
        53
         			oauth.GET("/:provider/callback", a.oauthCallbackHandler)

      
        54
        54
         		}

      
        55
        55
         

      
        
        56
        +		auth.GET("/change-email/:token", a.changeEmailHandler)

      
        56
        57
         		authorized := auth.Group("/", a.authorizedMiddleware)

      
        57
        58
         		{

      
        58
        59
         			authorized.POST("/logout", a.logOutHandler)

      
        59
        60
         			authorized.POST("/logout/all", a.logOutAllHandler)

      
        60
        61
         			authorized.POST("/change-password", a.changePasswordHandler)

      
        
        62
        +			authorized.POST("/change-email", a.requestEmailChangeHandler)

      
        61
        63
         		}

      
        62
        64
         	}

      
        63
        65
         

      
M internal/transport/http/apiv1/auth.go
···
        220
        220
         	c.Status(http.StatusOK)

      
        221
        221
         }

      
        222
        222
         

      
        
        223
        +type changeEmailRequest struct {

      
        
        224
        +	NewEmail string `json:"new_email"`

      
        
        225
        +}

      
        
        226
        +

      
        
        227
        +func (a *APIV1) requestEmailChangeHandler(c *gin.Context) {

      
        
        228
        +	var req changeEmailRequest

      
        
        229
        +	if err := c.ShouldBindJSON(&req); err != nil {

      
        
        230
        +		newError(c, http.StatusBadRequest, "invalid request")

      
        
        231
        +		return

      
        
        232
        +	}

      
        
        233
        +

      
        
        234
        +	if err := a.usersrv.RequestEmailChange(

      
        
        235
        +		c.Request.Context(),

      
        
        236
        +		a.getUserID(c),

      
        
        237
        +		dtos.ChangeEmail{

      
        
        238
        +			NewEmail: req.NewEmail,

      
        
        239
        +		}); err != nil {

      
        
        240
        +		errorResponse(c, err)

      
        
        241
        +		return

      
        
        242
        +	}

      
        
        243
        +

      
        
        244
        +	c.Status(http.StatusOK)

      
        
        245
        +}

      
        
        246
        +

      
        
        247
        +func (a *APIV1) changeEmailHandler(c *gin.Context) {

      
        
        248
        +	if err := a.usersrv.ChangeEmail(c.Request.Context(), c.Param("token")); err != nil {

      
        
        249
        +		errorResponse(c, err)

      
        
        250
        +		return

      
        
        251
        +	}

      
        
        252
        +

      
        
        253
        +	c.String(http.StatusOK, "email changed")

      
        
        254
        +}

      
        
        255
        +

      
        223
        256
         const oatuhStateCookie = "oauth_state"

      
        224
        257
         

      
        225
        258
         func (a *APIV1) oauthLoginHandler(c *gin.Context) {

      
M mailer/README.md
···
        39
        39
           - `token` the token that is used in verification link

      
        40
        40
         - `reset_password`

      
        41
        41
           - `token` the token that is used in password reset link

      
        
        42
        +- `confirm_email_change`

      
        
        43
        +  - `email` the email user want to set as new

      
        
        44
        +  - `token` the token that is used in confirm link

      
M mailer/template.go
···
        20
        20
         		return emailVerificationTemplate(appURL), nil

      
        21
        21
         	case "reset_password":

      
        22
        22
         		return passwordResetTemplate(frontendURL), nil

      
        
        23
        +	case "confirm_email_change":

      
        
        24
        +		return confirmEmailChangeTemplate(appURL), nil

      
        23
        25
         	default:

      
        24
        26
         		return nil, ErrInvalidTemplate

      
        25
        27
         	}

      ···
        27
        29
         

      
        28
        30
         func emailVerificationTemplate(appURL string) TemplateFunc {

      
        29
        31
         	return func(opts map[string]string) Template {

      
        
        32
        +		link := fmt.Sprintf("%[1]s/api/v1/auth/verify/%[2]s", appURL, opts["token"])

      
        
        33
        +

      
        30
        34
         		return Template{

      
        31
        35
         			Subject: "Onasty: verify your email",

      
        32
        36
         			Body: fmt.Sprintf(`To verify your email, please follow this link:

      
        33
        
        -<a href="%[1]s/api/v1/auth/verify/%[2]s">%[1]s/api/v1/auth/verify/%[2]s</a>

      
        34
        
        -<br />

      
        35
        
        -<br />

      
        36
        
        -This link will expire after 24 hours.`, appURL, opts["token"]),

      
        
        37
        +<a href="%[1]s">%[1]s</a>

      
        
        38
        +<br>

      
        
        39
        +<br>

      
        
        40
        +This link will expire after 24 hours.`, link),

      
        37
        41
         		}

      
        38
        42
         	}

      
        39
        43
         }

      
        40
        44
         

      
        41
        45
         func passwordResetTemplate(frontendURL string) TemplateFunc {

      
        42
        46
         	return func(opts map[string]string) Template {

      
        
        47
        +		link := fmt.Sprintf("%[1]s/auth?token=%[2]s", frontendURL, opts["token"])

      
        
        48
        +

      
        43
        49
         		return Template{

      
        44
        50
         			Subject: "Onasty: reset your password",

      
        45
        51
         			Body: fmt.Sprintf(`To reset your password, use this api:

      
        46
        
        -<a href="%[1]s/auth?token=%[2]s">%[1]s/auth?token=%[2]s</a>

      
        47
        
        -<br />

      
        48
        
        -<br />

      
        49
        
        -This link will expire after an hour.`, frontendURL, opts["token"]),

      
        
        52
        +<a href="%[1]s">%[1]s</a>

      
        
        53
        +<br>

      
        
        54
        +<br>

      
        
        55
        +This link will expire after an hour.`, link),

      
        
        56
        +		}

      
        
        57
        +	}

      
        
        58
        +}

      
        
        59
        +

      
        
        60
        +func confirmEmailChangeTemplate(appURL string) TemplateFunc {

      
        
        61
        +	return func(opts map[string]string) Template {

      
        
        62
        +		link := fmt.Sprintf("%[1]s/api/v1/auth/change-email/%[2]s", appURL, opts["token"])

      
        
        63
        +

      
        
        64
        +		return Template{

      
        
        65
        +			Subject: "Onasty: confirm your email change",

      
        
        66
        +			Body: fmt.Sprintf(`

      
        
        67
        +It seems like you have changed your email address to %[1]s.

      
        
        68
        +<br>

      
        
        69
        +To confirm this change, please follow this link:

      
        
        70
        +<a href="%[2]s">%[2]s</a>

      
        
        71
        +<br>

      
        
        72
        +<br>

      
        
        73
        +If you did not request email change, you can ignore this message.

      
        
        74
        +<br>

      
        
        75
        +This link will expire after 24 hours.

      
        
        76
        +`, opts["email"], link),

      
        50
        77
         		}

      
        51
        78
         	}

      
        52
        79
         }

      
A migrations/20250821143449_change_email_tokens.down.sql
···
        
        1
        +DROP TABLE change_email_tokens;

      
A migrations/20250821143449_change_email_tokens.up.sql
···
        
        1
        +CREATE TABLE change_email_tokens (

      
        
        2
        +    id uuid PRIMARY KEY DEFAULT uuid_generate_v4 (),

      
        
        3
        +    user_id uuid NOT NULL REFERENCES users (id),

      
        
        4
        +    token varchar(255) NOT NULL UNIQUE,

      
        
        5
        +    new_email varchar(255) NOT NULL UNIQUE,

      
        
        6
        +    created_at timestamptz NOT NULL DEFAULT now(),

      
        
        7
        +    expires_at timestamptz NOT NULL,

      
        
        8
        +    used_at timestamptz DEFAULT NULL

      
        
        9
        +);