Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions apps/api/internal/handler/account_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
117 changes: 117 additions & 0 deletions apps/api/internal/handler/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -27,6 +28,7 @@ import (

type AuthHandler struct {
Auth *auth.Service
Account *service.AccountService
Settings *store.InstanceSettingStore
Winv *store.WorkspaceInviteStore
Ws *store.WorkspaceStore
Expand Down Expand Up @@ -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) {
Expand Down
30 changes: 30 additions & 0 deletions apps/api/internal/model/email_change_request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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:"-"`
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
}
5 changes: 5 additions & 0 deletions apps/api/internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,15 @@ 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
}

authHandler := &handler.AuthHandler{
Auth: authSvc,
Account: accountSvc,
Settings: instanceSettingStore,
Winv: workspaceInviteStore,
Ws: workspaceStore,
Expand Down Expand Up @@ -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/", authHandler.RequestEmailChange)
api.POST("/users/me/change-email/verify/", authHandler.VerifyEmailChange)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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)
Expand Down
Loading
Loading