diff --git a/apps/api/internal/handler/account_test.go b/apps/api/internal/handler/account_test.go new file mode 100644 index 00000000..1cd33e25 --- /dev/null +++ b/apps/api/internal/handler/account_test.go @@ -0,0 +1,41 @@ +package handler_test + +import ( + "context" + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/store" + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/require" +) + +// Deactivating the current account returns 204, evicts the session (a follow-up +// authenticated request is rejected), and flips is_active off. Covers #209. +func TestAuth_DeactivateMe(t *testing.T) { + ts := testutil.NewTestServer(t) + u := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, u) + + rr := ts.POST("/api/users/me/deactivate/", nil, session) + require.Equal(t, http.StatusNoContent, rr.Code, "body=%s", rr.Body.String()) + + // The session was evicted, so the next authenticated call is unauthorized. + rr2 := ts.GET("/api/users/me/", session) + require.Equal(t, http.StatusUnauthorized, rr2.Code) + + got, err := store.NewUserStore(ts.DB).GetByID(context.Background(), u.ID) + require.NoError(t, err) + require.False(t, got.IsActive) +} + +// Requesting an email change with no SMTP configured returns 503 (email infra +// is required to deliver the code). +func TestAuth_RequestEmailChange_RequiresSMTP(t *testing.T) { + ts := testutil.NewTestServer(t) + u := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, u) + + rr := ts.POST("/api/users/me/change-email/", map[string]any{"new_email": "new@example.com"}, session) + require.Equal(t, http.StatusServiceUnavailable, rr.Code, "body=%s", rr.Body.String()) +} diff --git a/apps/api/internal/handler/auth.go b/apps/api/internal/handler/auth.go index eb18bffe..35f19fe8 100644 --- a/apps/api/internal/handler/auth.go +++ b/apps/api/internal/handler/auth.go @@ -19,6 +19,7 @@ import ( "github.com/Devlaner/devlane/api/internal/model" "github.com/Devlaner/devlane/api/internal/queue" "github.com/Devlaner/devlane/api/internal/redis" + "github.com/Devlaner/devlane/api/internal/service" "github.com/Devlaner/devlane/api/internal/store" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -27,6 +28,7 @@ import ( type AuthHandler struct { Auth *auth.Service + Account *service.AccountService Settings *store.InstanceSettingStore Winv *store.WorkspaceInviteStore Ws *store.WorkspaceStore @@ -389,6 +391,121 @@ func (h *AuthHandler) UpdateNotificationPreferences(c *gin.Context) { c.JSON(http.StatusOK, notifPrefResponse(*p)) } +// DeactivateMe deactivates the authenticated user's account and signs them out +// everywhere. Reactivation is an admin/support action, not self-service. +// POST /api/users/me/deactivate/ +func (h *AuthHandler) DeactivateMe(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + if h.Account == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Not configured"}) + return + } + if err := h.Account.Deactivate(c.Request.Context(), user.ID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to deactivate account"}) + return + } + clearSessionCookie(c) + c.Status(http.StatusNoContent) +} + +// RequestEmailChange starts a verified email change: it stores a pending change +// and emails a one-time code to the new address. +// POST /api/users/me/change-email/ +func (h *AuthHandler) RequestEmailChange(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + if h.Account == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Not configured"}) + return + } + var body struct { + NewEmail string `json:"new_email" binding:"required,email"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + return + } + ctx := c.Request.Context() + if !h.smtpConfigured(ctx) { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Outbound email is not configured. Set SMTP (host) in Instance admin → Email."}) + return + } + if h.Queue == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Email queue unavailable. Start RabbitMQ and check RABBITMQ_URL."}) + return + } + code, err := h.Account.RequestEmailChange(ctx, user.ID, body.NewEmail) + if err != nil { + switch err { + case service.ErrSameEmail, service.ErrEmailInUse: + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + default: + h.log().Error("request email change", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start email change"}) + } + return + } + to := strings.ToLower(strings.TrimSpace(body.NewEmail)) + bodyText := fmt.Sprintf( + "Use this code to confirm your new Devlane email address: %s\n\nThis code expires in 15 minutes. If you did not request it, you can ignore this email.\n", + code, + ) + if err := h.Queue.PublishSendEmail(ctx, queue.SendEmailPayload{ + To: to, + Subject: "Confirm your new Devlane email", + Body: bodyText, + Kind: "email_change", + Extra: map[string]string{"user_id": user.ID.String()}, + }); err != nil { + h.log().Error("email change enqueue", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send code"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "A confirmation code has been sent to the new address."}) +} + +// VerifyEmailChange confirms a pending email change with the emailed code. +// POST /api/users/me/change-email/verify/ +func (h *AuthHandler) VerifyEmailChange(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + if h.Account == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Not configured"}) + return + } + var body struct { + Code string `json:"code" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + return + } + newEmail, err := h.Account.ConfirmEmailChange(c.Request.Context(), user.ID, body.Code) + if err != nil { + switch err { + case service.ErrInvalidEmailCode, service.ErrNoPendingEmailChange: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired code"}) + case service.ErrEmailInUse: + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + default: + h.log().Error("verify email change", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to change email"}) + } + return + } + c.JSON(http.StatusOK, gin.H{"email": newEmail}) +} + // ListTokens returns the current user's API tokens (without secret values). // GET /api/users/me/tokens/ func (h *AuthHandler) ListTokens(c *gin.Context) { diff --git a/apps/api/internal/model/email_change_request.go b/apps/api/internal/model/email_change_request.go new file mode 100644 index 00000000..1f4d08bf --- /dev/null +++ b/apps/api/internal/model/email_change_request.go @@ -0,0 +1,31 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// EmailChangeRequest is a pending, unverified email change for a user. There is +// at most one per user; requesting again replaces the previous one. The +// verification code is stored hashed and the row expires at ExpiresAt. +type EmailChangeRequest struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex" json:"user_id"` + NewEmail string `gorm:"column:new_email;type:varchar(255);not null" json:"new_email"` + CodeHash string `gorm:"column:code_hash;type:varchar(255);not null" json:"-"` + Attempts int `gorm:"column:attempts;not null;default:0" json:"-"` + ExpiresAt time.Time `gorm:"column:expires_at;not null" json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (EmailChangeRequest) TableName() string { return "email_change_requests" } + +func (r *EmailChangeRequest) BeforeCreate(tx *gorm.DB) error { + if r.ID == uuid.Nil { + r.ID = uuid.New() + } + return nil +} diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index 1f68227c..d85bf7e1 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -102,6 +102,7 @@ func New(cfg Config) *gin.Engine { authSvc := auth.NewService(userStore, sessionStore, passwordResetTokenStore) authSvc.SetAccountStore(accountStore) authSvc.SetApiTokenStore(apiTokenStore) + accountSvc := service.NewAccountService(userStore, sessionStore, store.NewEmailChangeRequestStore(cfg.DB), cfg.MagicCodeSecret) appBaseURL := cfg.AppBaseURL if appBaseURL == "" { appBaseURL = cfg.CORSAllowOrigin @@ -109,6 +110,7 @@ func New(cfg Config) *gin.Engine { authHandler := &handler.AuthHandler{ Auth: authSvc, + Account: accountSvc, Settings: instanceSettingStore, Winv: workspaceInviteStore, Ws: workspaceStore, @@ -266,6 +268,9 @@ func New(cfg Config) *gin.Engine { api.POST("/users/me/set-password/", authHandler.SetPassword) api.GET("/users/me/notification-preferences/", authHandler.GetNotificationPreferences) api.PUT("/users/me/notification-preferences/", authHandler.UpdateNotificationPreferences) + api.POST("/users/me/deactivate/", authHandler.DeactivateMe) + api.POST("/users/me/change-email/", middleware.RateLimit(cfg.Redis, "changeemailreq", 10, 15*time.Minute), authHandler.RequestEmailChange) + api.POST("/users/me/change-email/verify/", middleware.RateLimit(cfg.Redis, "changeemailverify", 10, 15*time.Minute), authHandler.VerifyEmailChange) api.GET("/workspaces/:slug/notification-preferences/", notifPrefHandler.GetWorkspace) api.PUT("/workspaces/:slug/notification-preferences/", notifPrefHandler.UpdateWorkspace) api.GET("/workspaces/:slug/projects/:projectId/notification-preferences/", notifPrefHandler.GetProject) diff --git a/apps/api/internal/service/account.go b/apps/api/internal/service/account.go new file mode 100644 index 00000000..3ca1fbbe --- /dev/null +++ b/apps/api/internal/service/account.go @@ -0,0 +1,199 @@ +package service + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "math/big" + "strings" + "time" + + "github.com/Devlaner/devlane/api/internal/auth" + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/google/uuid" + "gorm.io/gorm" +) + +var ( + ErrAccountUserNotFound = errors.New("user not found") + ErrEmailInUse = errors.New("that email address is already in use") + ErrSameEmail = errors.New("that is already your email address") + ErrInvalidEmailCode = errors.New("invalid or expired code") + ErrNoPendingEmailChange = errors.New("no pending email change") +) + +// emailChangeCodeTTL bounds how long a verification code is valid, and +// maxEmailChangeAttempts bounds how many wrong guesses a code tolerates before +// it is invalidated (defense in depth alongside route rate limiting). +const ( + emailChangeCodeTTL = 15 * time.Minute + maxEmailChangeAttempts = 5 +) + +// AccountService handles self-service account actions: deactivation and the +// verified email-change flow. +type AccountService struct { + users *store.UserStore + sessions *store.SessionStore + changes *store.EmailChangeRequestStore + secret string +} + +func NewAccountService(users *store.UserStore, sessions *store.SessionStore, changes *store.EmailChangeRequestStore, secret string) *AccountService { + return &AccountService{users: users, sessions: sessions, changes: changes, secret: secret} +} + +// userByID returns the user or ErrAccountUserNotFound, normalizing the store's +// gorm.ErrRecordNotFound. +func (s *AccountService) userByID(ctx context.Context, userID uuid.UUID) (*model.User, error) { + u, err := s.users.GetByID(ctx, userID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrAccountUserNotFound + } + if err != nil { + return nil, err + } + return u, nil +} + +// userByEmail returns the user with the given email, or nil when none exists +// (treating the store's gorm.ErrRecordNotFound as "available"). +func (s *AccountService) userByEmail(ctx context.Context, email string) (*model.User, error) { + u, err := s.users.GetByEmail(ctx, email) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return u, nil +} + +// Deactivate marks the user inactive and evicts all of their sessions. It is +// idempotent: deactivating an already-inactive user just clears sessions. +func (s *AccountService) Deactivate(ctx context.Context, userID uuid.UUID) error { + u, err := s.userByID(ctx, userID) + if err != nil { + return err + } + if u.IsActive { + u.IsActive = false + if err := s.users.Update(ctx, u); err != nil { + return err + } + } + return s.sessions.DeleteByUserID(ctx, userID) +} + +// RequestEmailChange validates the desired new email and stores a pending, +// hashed verification code, replacing any earlier request. It returns the +// plaintext code so the caller can email it to newEmail (the code is never +// stored or logged in plaintext). +func (s *AccountService) RequestEmailChange(ctx context.Context, userID uuid.UUID, newEmail string) (string, error) { + email := strings.ToLower(strings.TrimSpace(newEmail)) + u, err := s.userByID(ctx, userID) + if err != nil { + return "", err + } + if u.Email != nil && strings.EqualFold(strings.TrimSpace(*u.Email), email) { + return "", ErrSameEmail + } + existing, err := s.userByEmail(ctx, email) + if err != nil { + return "", err + } + if existing != nil { + return "", ErrEmailInUse + } + code, err := randomSixDigitCode() + if err != nil { + return "", err + } + req := &model.EmailChangeRequest{ + UserID: userID, + NewEmail: email, + CodeHash: s.hashCode(userID, email, code), + ExpiresAt: time.Now().Add(emailChangeCodeTTL), + } + if err := s.changes.Upsert(ctx, req); err != nil { + return "", err + } + return code, nil +} + +// ConfirmEmailChange verifies the code against the user's pending request and, +// on success, swaps the user's email and clears the request. The new email's +// availability is re-checked at confirm time to avoid a race with another +// signup. +func (s *AccountService) ConfirmEmailChange(ctx context.Context, userID uuid.UUID, code string) (string, error) { + req, err := s.changes.GetByUserID(ctx, userID) + if err != nil { + return "", err + } + if req == nil { + return "", ErrNoPendingEmailChange + } + if time.Now().After(req.ExpiresAt) || req.Attempts >= maxEmailChangeAttempts { + _ = s.changes.DeleteByUserID(ctx, userID) + return "", ErrInvalidEmailCode + } + want := s.hashCode(userID, req.NewEmail, code) + if subtle.ConstantTimeCompare([]byte(req.CodeHash), []byte(want)) != 1 { + if n, aerr := s.changes.IncrementAttempts(ctx, userID); aerr == nil && n >= maxEmailChangeAttempts { + _ = s.changes.DeleteByUserID(ctx, userID) + } + return "", ErrInvalidEmailCode + } + existing, err := s.userByEmail(ctx, req.NewEmail) + if err != nil { + return "", err + } + if existing != nil && existing.ID != userID { + _ = s.changes.DeleteByUserID(ctx, userID) + return "", ErrEmailInUse + } + u, err := s.userByID(ctx, userID) + if err != nil { + return "", err + } + newEmail := req.NewEmail + u.Email = &newEmail + if err := s.users.Update(ctx, u); err != nil { + return "", err + } + _ = s.changes.DeleteByUserID(ctx, userID) + return newEmail, nil +} + +// hashCode binds the code to the user and target email so a code for one change +// can never verify a different one. When no secret is configured it falls back +// to the same development-only key as magic-code login, rather than introducing +// a second hardcoded key. +func (s *AccountService) hashCode(userID uuid.UUID, email, code string) string { + key := strings.TrimSpace(s.secret) + if key == "" { + key = auth.DefaultMagicCodeHMACKey + } + mac := hmac.New(sha256.New, []byte(key)) + _, _ = mac.Write([]byte(userID.String())) + _, _ = mac.Write([]byte{0}) + _, _ = mac.Write([]byte(strings.ToLower(strings.TrimSpace(email)))) + _, _ = mac.Write([]byte{0}) + _, _ = mac.Write([]byte(strings.TrimSpace(code))) + return hex.EncodeToString(mac.Sum(nil)) +} + +// randomSixDigitCode returns a uniformly random zero-padded 6-digit code. +func randomSixDigitCode() (string, error) { + n, err := rand.Int(rand.Reader, big.NewInt(1_000_000)) + if err != nil { + return "", err + } + return fmt.Sprintf("%06d", n.Int64()), nil +} diff --git a/apps/api/internal/service/account_test.go b/apps/api/internal/service/account_test.go new file mode 100644 index 00000000..e5bdc6f0 --- /dev/null +++ b/apps/api/internal/service/account_test.go @@ -0,0 +1,116 @@ +package service_test + +import ( + "context" + "testing" + + "github.com/Devlaner/devlane/api/internal/service" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func newAccountSvc(db *gorm.DB) *service.AccountService { + return service.NewAccountService( + store.NewUserStore(db), + store.NewSessionStore(db), + store.NewEmailChangeRequestStore(db), + "test-email-change-secret", + ) +} + +// Deactivate flips is_active off and evicts the user's sessions, and is +// idempotent. Covers #209. +func TestAccount_Deactivate(t *testing.T) { + db := testutil.NewTestServer(t).DB + ctx := context.Background() + u := testutil.CreateUser(t, db) + key := testutil.LoginAs(t, db, u) + svc := newAccountSvc(db) + + require.NoError(t, svc.Deactivate(ctx, u.ID)) + + got, err := store.NewUserStore(db).GetByID(ctx, u.ID) + require.NoError(t, err) + require.False(t, got.IsActive, "user should be deactivated") + + sd, err := store.NewSessionStore(db).Get(ctx, key) + require.Nil(t, sd, "sessions should be evicted") + require.ErrorIs(t, err, gorm.ErrRecordNotFound) + + // Idempotent: deactivating again is a no-op that still succeeds. + require.NoError(t, svc.Deactivate(ctx, u.ID)) +} + +// The email-change flow issues a code, rejects a wrong code, and on the correct +// code swaps the user's email (normalized) and clears the pending request. +func TestAccount_EmailChange_HappyPath(t *testing.T) { + db := testutil.NewTestServer(t).DB + ctx := context.Background() + u := testutil.CreateUser(t, db) + svc := newAccountSvc(db) + + code, err := svc.RequestEmailChange(ctx, u.ID, "New.Email@Example.com") + require.NoError(t, err) + require.Len(t, code, 6) + + _, err = svc.ConfirmEmailChange(ctx, u.ID, "wrong0") + require.ErrorIs(t, err, service.ErrInvalidEmailCode) + + newEmail, err := svc.ConfirmEmailChange(ctx, u.ID, code) + require.NoError(t, err) + require.Equal(t, "new.email@example.com", newEmail) + + got, err := store.NewUserStore(db).GetByID(ctx, u.ID) + require.NoError(t, err) + require.NotNil(t, got.Email) + require.Equal(t, "new.email@example.com", *got.Email) + + // The pending request is gone, so a second confirm fails. + _, err = svc.ConfirmEmailChange(ctx, u.ID, code) + require.ErrorIs(t, err, service.ErrNoPendingEmailChange) +} + +// After too many wrong codes the pending request is invalidated, so even the +// correct code no longer works (defense against brute-forcing the 6-digit code). +func TestAccount_EmailChange_LocksOutAfterMaxAttempts(t *testing.T) { + db := testutil.NewTestServer(t).DB + ctx := context.Background() + u := testutil.CreateUser(t, db) + svc := newAccountSvc(db) + + code, err := svc.RequestEmailChange(ctx, u.ID, "later@example.com") + require.NoError(t, err) + + for i := 0; i < 5; i++ { + _, err = svc.ConfirmEmailChange(ctx, u.ID, "wrong0") + require.ErrorIs(t, err, service.ErrInvalidEmailCode) + } + // The request is now gone; the correct code no longer verifies. + _, err = svc.ConfirmEmailChange(ctx, u.ID, code) + require.ErrorIs(t, err, service.ErrNoPendingEmailChange) +} + +// Requesting a change to an address another account already uses is rejected. +func TestAccount_EmailChange_InUse(t *testing.T) { + db := testutil.NewTestServer(t).DB + ctx := context.Background() + testutil.CreateUser(t, db, testutil.WithUserEmail("taken@example.com")) + u := testutil.CreateUser(t, db) + svc := newAccountSvc(db) + + _, err := svc.RequestEmailChange(ctx, u.ID, "taken@example.com") + require.ErrorIs(t, err, service.ErrEmailInUse) +} + +// Requesting a change to the current email is rejected. +func TestAccount_EmailChange_SameEmail(t *testing.T) { + db := testutil.NewTestServer(t).DB + ctx := context.Background() + u := testutil.CreateUser(t, db, testutil.WithUserEmail("me@example.com")) + svc := newAccountSvc(db) + + _, err := svc.RequestEmailChange(ctx, u.ID, "ME@example.com") + require.ErrorIs(t, err, service.ErrSameEmail) +} diff --git a/apps/api/internal/store/email_change_request.go b/apps/api/internal/store/email_change_request.go new file mode 100644 index 00000000..4778083d --- /dev/null +++ b/apps/api/internal/store/email_change_request.go @@ -0,0 +1,68 @@ +package store + +import ( + "context" + "errors" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// EmailChangeRequestStore persists pending email-change verifications. +type EmailChangeRequestStore struct{ db *gorm.DB } + +func NewEmailChangeRequestStore(db *gorm.DB) *EmailChangeRequestStore { + return &EmailChangeRequestStore{db: db} +} + +// GetByUserID returns the user's pending email change, or nil when none exists. +func (s *EmailChangeRequestStore) GetByUserID(ctx context.Context, userID uuid.UUID) (*model.EmailChangeRequest, error) { + var r model.EmailChangeRequest + err := s.db.WithContext(ctx).Where("user_id = ?", userID).First(&r).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &r, nil +} + +// Upsert stores the user's pending email change, replacing any earlier one so +// only the most recent request is ever valid. It is a single atomic upsert on +// the user_id unique key, so concurrent requests for the same user can't race +// into a unique-constraint error. +func (s *EmailChangeRequestStore) Upsert(ctx context.Context, r *model.EmailChangeRequest) error { + r.Attempts = 0 + return s.db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"new_email", "code_hash", "attempts", "expires_at", "updated_at"}), + }). + Create(r).Error +} + +// IncrementAttempts atomically bumps the failed-attempt counter and returns the +// new value, so callers can invalidate a code after too many wrong guesses. +func (s *EmailChangeRequestStore) IncrementAttempts(ctx context.Context, userID uuid.UUID) (int, error) { + if err := s.db.WithContext(ctx). + Model(&model.EmailChangeRequest{}). + Where("user_id = ?", userID). + UpdateColumn("attempts", gorm.Expr("attempts + 1")).Error; err != nil { + return 0, err + } + r, err := s.GetByUserID(ctx, userID) + if err != nil || r == nil { + return 0, err + } + return r.Attempts, nil +} + +// DeleteByUserID removes the user's pending email change. +func (s *EmailChangeRequestStore) DeleteByUserID(ctx context.Context, userID uuid.UUID) error { + return s.db.WithContext(ctx). + Where("user_id = ?", userID). + Delete(&model.EmailChangeRequest{}).Error +} diff --git a/apps/api/migrations/000009_email_change_requests.down.sql b/apps/api/migrations/000009_email_change_requests.down.sql new file mode 100644 index 00000000..bbeb5582 --- /dev/null +++ b/apps/api/migrations/000009_email_change_requests.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS email_change_requests; diff --git a/apps/api/migrations/000009_email_change_requests.up.sql b/apps/api/migrations/000009_email_change_requests.up.sql new file mode 100644 index 00000000..f69faaa2 --- /dev/null +++ b/apps/api/migrations/000009_email_change_requests.up.sql @@ -0,0 +1,13 @@ +-- Pending email-change verifications. One row per user (the latest request +-- replaces any earlier one). The code is stored hashed; expiry and an attempt +-- counter bound how long and how many tries a code is valid for. +CREATE TABLE IF NOT EXISTS email_change_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL UNIQUE REFERENCES users (id) ON DELETE CASCADE, + new_email VARCHAR(255) NOT NULL, + code_hash VARCHAR(255) NOT NULL, + attempts INT NOT NULL DEFAULT 0, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index ffe0c1df..be573eb2 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -6,6 +6,7 @@ import { IntegrationsSection } from '../components/integrations/IntegrationsSect import { ProjectEstimatesSettings } from '../components/settings/ProjectEstimatesSettings'; import { NotificationPreferencesPanel } from '../components/settings/NotificationPreferencesPanel'; import { notificationPreferenceService } from '../services/notificationPreferenceService'; +import { accountService } from '../services/accountService'; import { UploadImageModal } from '../components/UploadImageModal'; import { ProjectIconModal, ProjectIconDisplay } from '../components/ProjectIconModal'; import { getImageUrl } from '../lib/utils'; @@ -76,7 +77,7 @@ export function SettingsPage() { }>(); const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); - const { user, setUserFromApi } = useAuth(); + const { user, setUserFromApi, logout, refreshUser } = useAuth(); const [workspace, setWorkspace] = useState(null); const [projects, setProjects] = useState([]); const [workspaceMembers, setWorkspaceMembers] = useState([]); @@ -354,6 +355,88 @@ export function SettingsPage() { const [displayName, setDisplayName] = useState(user?.name?.split(' ')[0]?.toLowerCase() ?? ''); const [profileEmail, setProfileEmail] = useState(user?.email ?? ''); const [deactivateOpen, setDeactivateOpen] = useState(false); + const [deactivateConfirmOpen, setDeactivateConfirmOpen] = useState(false); + const [deactivateBusy, setDeactivateBusy] = useState(false); + const [deactivateError, setDeactivateError] = useState(null); + // Email-change flow: 'idle' (show Change), 'request' (enter new email), + // 'verify' (enter the emailed code). + const [emailChangeStep, setEmailChangeStep] = useState<'idle' | 'request' | 'verify'>('idle'); + const [emailChangeNew, setEmailChangeNew] = useState(''); + const [emailChangeCode, setEmailChangeCode] = useState(''); + const [emailChangeError, setEmailChangeError] = useState(null); + const [emailChangeBusy, setEmailChangeBusy] = useState(false); + + const apiErrorMessage = (e: unknown, fallback: string): string => { + if ( + e && + typeof e === 'object' && + 'response' in e && + typeof (e as { response?: { data?: { error?: string } } }).response?.data?.error === 'string' + ) { + return (e as { response: { data: { error: string } } }).response.data.error; + } + return fallback; + }; + + // Bumped whenever the email-change flow is reset/cancelled; in-flight requests + // captured an earlier value and bail out instead of resurrecting the panel. + const emailFlowToken = useRef(0); + + const resetEmailChange = () => { + emailFlowToken.current += 1; + setEmailChangeStep('idle'); + setEmailChangeNew(''); + setEmailChangeCode(''); + setEmailChangeError(null); + setEmailChangeBusy(false); + }; + + const requestEmailChange = async () => { + const token = emailFlowToken.current; + setEmailChangeError(null); + setEmailChangeBusy(true); + try { + await accountService.requestEmailChange(emailChangeNew.trim()); + if (emailFlowToken.current !== token) return; + setEmailChangeStep('verify'); + } catch (e: unknown) { + if (emailFlowToken.current !== token) return; + setEmailChangeError(apiErrorMessage(e, 'Failed to send the confirmation code')); + } finally { + if (emailFlowToken.current === token) setEmailChangeBusy(false); + } + }; + + const verifyEmailChange = async () => { + const token = emailFlowToken.current; + setEmailChangeError(null); + setEmailChangeBusy(true); + try { + const updated = await accountService.verifyEmailChange(emailChangeCode.trim()); + if (emailFlowToken.current !== token) return; + setProfileEmail(updated); + await refreshUser(); + if (emailFlowToken.current !== token) return; + resetEmailChange(); + } catch (e: unknown) { + if (emailFlowToken.current !== token) return; + setEmailChangeError(apiErrorMessage(e, 'Failed to confirm the new email')); + } finally { + if (emailFlowToken.current === token) setEmailChangeBusy(false); + } + }; + + const deactivateAccount = async () => { + setDeactivateError(null); + setDeactivateBusy(true); + try { + await accountService.deactivate(); + await logout(); + } catch (e: unknown) { + setDeactivateError(apiErrorMessage(e, 'Failed to deactivate account. Please try again.')); + setDeactivateBusy(false); + } + }; const { theme, setTheme } = useTheme(); const [firstDayOfWeek, setFirstDayOfWeek] = useState('monday'); const [timezone, setTimezone] = useState('UTC'); @@ -876,13 +959,83 @@ export function SettingsPage() { - +
+ + {emailChangeStep === 'idle' && ( + + )} +
+ {emailChangeStep === 'request' && ( +
+

+ Enter your new email. We'll send a confirmation code to it. +

+ setEmailChangeNew(e.target.value)} + placeholder="new@email.com" + className="w-full rounded-(--radius-md) border border-(--border-subtle) bg-(--bg-surface-1) px-3 py-2 text-sm text-(--txt-primary) focus:outline-none focus:border-(--border-strong)" + /> +
+ + +
+
+ )} + {emailChangeStep === 'verify' && ( +
+

+ Enter the code we sent to{' '} + {emailChangeNew.trim()}. +

+ setEmailChangeCode(e.target.value)} + placeholder="6-digit code" + className="w-full rounded-(--radius-md) border border-(--border-subtle) bg-(--bg-surface-1) px-3 py-2 text-sm text-(--txt-primary) focus:outline-none focus:border-(--border-strong)" + /> +
+ + +
+
+ )} + {emailChangeError && ( +

{emailChangeError}

+ )} {profileError && ( @@ -935,14 +1088,58 @@ export function SettingsPage() { {deactivateOpen && (

- This action cannot be undone. Your account will be deactivated. + This deactivates your account and signs you out everywhere. Reactivating it + requires an administrator.

-
)} + { + if (deactivateBusy) return; + setDeactivateError(null); + setDeactivateConfirmOpen(false); + }} + title="Deactivate account?" + > +
+

+ You'll be signed out everywhere and won't be able to sign back in. + Reactivating your account requires an administrator. +

+ {deactivateError && ( +

{deactivateError}

+ )} +
+ + +
+
+
setAccountCoverModalOpen(false)} diff --git a/apps/web/src/services/accountService.ts b/apps/web/src/services/accountService.ts new file mode 100644 index 00000000..45110a79 --- /dev/null +++ b/apps/web/src/services/accountService.ts @@ -0,0 +1,24 @@ +import { apiClient } from '../api/client'; + +/** + * Self-service account actions: deactivation and the verified email-change flow. + */ +export const accountService = { + /** Deactivate the current account and sign out everywhere. */ + async deactivate(): Promise { + await apiClient.post('/api/users/me/deactivate/'); + }, + + /** Start an email change: emails a confirmation code to the new address. */ + async requestEmailChange(newEmail: string): Promise { + await apiClient.post('/api/users/me/change-email/', { new_email: newEmail }); + }, + + /** Confirm a pending email change with the emailed code; returns the new email. */ + async verifyEmailChange(code: string): Promise { + const { data } = await apiClient.post<{ email: string }>('/api/users/me/change-email/verify/', { + code, + }); + return data.email; + }, +};