Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
f68f002
fix: remove ProjectSectionNavChevron ('>' button) from all project pa…
nazarli-shabnam Apr 4, 2026
1002dfd
feat: implement auth ui pages with better ui(LoginPage,SignUpPage,For…
nazarli-shabnam Apr 4, 2026
9039940
refactor: Update auth, handler, model and 6 related areas for API and ui
nazarli-shabnam Apr 4, 2026
a909850
feat: add password_reset_tokens table with user_id, oken, and expire…
nazarli-shabnam Apr 4, 2026
89140d3
fix: linting
nazarli-shabnam Apr 4, 2026
cc3cc1a
refactor: update api, auth, handler, mail, middleware for API and ui
nazarli-shabnam Apr 5, 2026
f04b46a
feat(auth): add password_reset_tokens table for user password reset f…
nazarli-shabnam Apr 5, 2026
5558a5a
Merge branch '80-fix-create-views-buttons' of https://github.com/Devl…
nazarli-shabnam Apr 5, 2026
7310246
refactor(routing): remove forgot and reset password page routes
nazarli-shabnam Apr 5, 2026
adaef29
refactor: update api, auth, config, handler, model for API and ui
nazarli-shabnam Apr 5, 2026
b066032
feat(db): create accounts table to store user provider connections
nazarli-shabnam Apr 5, 2026
0ab34c3
feat(api): add auth for API
nazarli-shabnam Apr 9, 2026
109febb
feat(deps): add glebarez/sqlite dependency for sqlite database support
nazarli-shabnam Apr 9, 2026
ec83439
feat(auth): add env vars for google oauth and magic code login, updat…
nazarli-shabnam Apr 11, 2026
db325dc
feat(auth): add SignUpMagic and SessionForEmailUser to enable magic l…
nazarli-shabnam Apr 11, 2026
29a019a
refactor(ui): update api, instance-admin, pages, services for ui
nazarli-shabnam Apr 11, 2026
c2caeb8
feat(auth): introduce email magic code login/signup and API public UR…
nazarli-shabnam Apr 11, 2026
f4b111a
feat(auth): add email login magic code support and improve secret dec…
nazarli-shabnam Apr 11, 2026
b60122c
style(login): improve code formatting for readability
nazarli-shabnam Apr 11, 2026
37af3d7
chore(merge): bring sticky-notes updates into forgot-password branch
nazarli-shabnam Apr 14, 2026
51275b0
Merge remote-tracking branch 'origin/main' into forgot-password-in-login
nazarli-shabnam Apr 14, 2026
c36c5da
feat: all three edit pages (Google, GitHub, GitLab
nazarli-shabnam Apr 14, 2026
77d4939
feat: removed OAuthRedirectBase from AuthHandler
nazarli-shabnam Apr 14, 2026
a70ac2d
refactor: update auth, handler, router, contexts for API and ui
nazarli-shabnam Apr 14, 2026
488f8c9
refactor: replace the invite handling + ensureDefaultWorkspace with t…
nazarli-shabnam Apr 14, 2026
9dc0bc1
refactor: update RootRedirect to handle the 'no workspaces' case prop…
nazarli-shabnam Apr 14, 2026
53d50e2
chore: linting checks
nazarli-shabnam Apr 14, 2026
d3a16cf
chore: husky fixture
nazarli-shabnam Apr 14, 2026
361e34f
feat: set-password UI page
nazarli-shabnam Apr 14, 2026
07d2db6
feat: return distinct error for deactivated users
nazarli-shabnam Apr 14, 2026
dd703c9
feat: informational message when SMTP is off
nazarli-shabnam Apr 14, 2026
05716c8
fix: copilot warnings
nazarli-shabnam Apr 15, 2026
9f2ac4b
fix: persist organization size and harden auth migration
nazarli-shabnam Apr 15, 2026
6dd21d0
Merge branch 'main' of https://github.com/Devlaner/devlane into forgo…
nazarli-shabnam Apr 15, 2026
ee3e944
fix: catch block now uses getApiErrorMessage(err)
nazarli-shabnam Apr 15, 2026
e9cdf2e
fix: no longer duplicate the helpers
nazarli-shabnam Apr 15, 2026
496e86f
fix: validated check
nazarli-shabnam Apr 15, 2026
c4d79c5
fix: linting fixed
nazarli-shabnam Apr 15, 2026
aaeacba
fix: run prettier in pre-commit
nazarli-shabnam Apr 15, 2026
13be175
Merge branch 'main' of https://github.com/Devlaner/devlane into forgo…
nazarli-shabnam Apr 15, 2026
43bc47e
fix: set all three explicitly: API_PUBLIC_URL, FRONTEND_PUBLIC_URL, A…
nazarli-shabnam Apr 15, 2026
e284e65
feat: Built from API_PUBLIC_URL, fallback to request host if unset (w…
nazarli-shabnam Apr 15, 2026
9e6e8c5
fix: 000002 = password_reset_tokens, accounts uses access_token_expir…
nazarli-shabnam Apr 15, 2026
43932fe
fix: empty string keeps requests relative in prod
nazarli-shabnam Apr 15, 2026
9ab12ad
fix: magic code vs password login, parallel tests, clipboard
nazarli-shabnam Apr 15, 2026
0eebb99
fix: Lax, not Strict
nazarli-shabnam Apr 15, 2026
6599acc
feat: requires an SMTP host in the instance email settings and return…
nazarli-shabnam Apr 15, 2026
a8e71f3
fix: server signout uses same session source as auth middleware; redu…
nazarli-shabnam Apr 16, 2026
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
7 changes: 6 additions & 1 deletion api/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ func main() {
os.Exit(1)
}

sqlDB, _ := db.DB()
sqlDB, err := db.DB()
if err != nil {
log.Error("get underlying sql.DB", "error", err)
os.Exit(1)
}
defer sqlDB.Close()

// Redis
Expand Down Expand Up @@ -86,6 +90,7 @@ func main() {
Queue: queuePublisher,
Minio: mc,
CORSAllowOrigin: cfg.CORSAllowOrigin,
AppBaseURL: cfg.AppBaseURL,
})

// Start task consumer when RabbitMQ is available
Expand Down
88 changes: 82 additions & 6 deletions api/internal/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,27 @@ var (
ErrInvalidCredentials = errors.New("invalid email or password")
ErrEmailTaken = errors.New("email already registered")
ErrUsernameTaken = errors.New("username already taken")
ErrResetTokenInvalid = errors.New("invalid or expired reset token")
)

const bcryptCost = 12

// dummyHash is used for timing-safe responses when a user is not found.
var dummyHash []byte

func init() {
h, _ := bcrypt.GenerateFromPassword([]byte("timing-safe-dummy"), bcryptCost)
dummyHash = h
}

type Service struct {
userStore *store.UserStore
sessionStore *store.SessionStore
userStore *store.UserStore
sessionStore *store.SessionStore
resetTokenStore *store.PasswordResetTokenStore
}

func NewService(userStore *store.UserStore, sessionStore *store.SessionStore) *Service {
return &Service{userStore: userStore, sessionStore: sessionStore}
func NewService(userStore *store.UserStore, sessionStore *store.SessionStore, resetTokenStore *store.PasswordResetTokenStore) *Service {
return &Service{userStore: userStore, sessionStore: sessionStore, resetTokenStore: resetTokenStore}
}

type SignUpRequest struct {
Expand Down Expand Up @@ -80,11 +90,14 @@ func (s *Service) SignUp(ctx context.Context, req SignUpRequest) (sessionKey str
return sessionKey, u, nil
}

// SignIn authenticates a user with email+password. Uses a dummy bcrypt comparison
// when the user is not found to prevent timing-based user enumeration.
func (s *Service) SignIn(ctx context.Context, req SignInRequest) (sessionKey string, user *model.User, err error) {
email := strings.TrimSpace(strings.ToLower(req.Email))
u, err := s.userStore.GetByEmail(ctx, email)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", nil, ErrInvalidCredentials
}
return "", nil, err
Expand Down Expand Up @@ -117,12 +130,10 @@ func (s *Service) UserFromSession(ctx context.Context, sessionKey string) (*mode
return s.userStore.GetByID(ctx, data.UserID)
}

// UpdateProfile updates the user's profile (first name, last name, display name, timezone). Email is not updatable.
func (s *Service) UpdateProfile(ctx context.Context, u *model.User) error {
return s.userStore.Update(ctx, u)
}

// ChangePassword verifies current password and sets a new one. Returns ErrInvalidCredentials if current password is wrong or user not found.
func (s *Service) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error {
u, err := s.userStore.GetByID(ctx, userID)
if err != nil {
Expand All @@ -145,6 +156,71 @@ func (s *Service) ChangePassword(ctx context.Context, userID uuid.UUID, currentP
return s.userStore.Update(ctx, u)
}

// EmailCheck determines whether an email is already registered.
func (s *Service) EmailCheck(ctx context.Context, email string) (exists bool, err error) {
email = strings.TrimSpace(strings.ToLower(email))
u, err := s.userStore.GetByEmail(ctx, email)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
return u != nil, nil
}

// ForgotPassword generates a reset token for the given email.
// Returns ("", nil) when the email does not exist (to prevent user enumeration).
func (s *Service) ForgotPassword(ctx context.Context, email string) (token string, err error) {
if s.resetTokenStore == nil {
return "", errors.New("password reset not configured")
}
email = strings.TrimSpace(strings.ToLower(email))
u, err := s.userStore.GetByEmail(ctx, email)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
return "", err
}
if u == nil || !u.IsActive {
return "", nil
}
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return "", err
}
token = hex.EncodeToString(tokenBytes)
if err := s.resetTokenStore.Create(ctx, u.ID, token); err != nil {
return "", err
}
return token, nil
}

// ResetPassword validates the reset token and sets a new password.
func (s *Service) ResetPassword(ctx context.Context, token, newPassword string) error {
if s.resetTokenStore == nil {
return ErrResetTokenInvalid
}
rt, err := s.resetTokenStore.GetValid(ctx, token)
if err != nil || rt == nil {
return ErrResetTokenInvalid
}
Comment thread
nazarli-shabnam marked this conversation as resolved.
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost)
if err != nil {
return err
}
u, err := s.userStore.GetByID(ctx, rt.UserID)
if err != nil {
return ErrResetTokenInvalid
}
u.Password = string(hash)
if err := s.userStore.Update(ctx, u); err != nil {
return err
}
return s.resetTokenStore.MarkUsed(ctx, rt.ID)
}

func (s *Service) createSession(ctx context.Context, userID uuid.UUID) (string, error) {
key := make([]byte, 20)
if _, err := rand.Read(key); err != nil {
Expand Down
164 changes: 147 additions & 17 deletions api/internal/handler/auth.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
// Package handler implements HTTP handlers for the API.
package handler

import (
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"

"github.com/Devlaner/devlane/api/internal/auth"
"github.com/Devlaner/devlane/api/internal/middleware"
"github.com/Devlaner/devlane/api/internal/model"
"github.com/Devlaner/devlane/api/internal/queue"
"github.com/Devlaner/devlane/api/internal/store"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
Expand All @@ -23,6 +25,9 @@ type AuthHandler struct {
Ws *store.WorkspaceStore
NotifPrefs *store.UserNotificationPreferenceStore
ApiTokens *store.ApiTokenStore
Queue *queue.Publisher
AppBaseURL string
Log *slog.Logger
}

type SignInRequest struct {
Expand Down Expand Up @@ -52,6 +57,13 @@ func authBool(v model.JSONMap, key string, defaultVal bool) bool {
return defaultVal
}

func (h *AuthHandler) log() *slog.Logger {
if h.Log != nil {
return h.Log
}
return slog.Default()
}

// SignIn authenticates with email/password and sets a session cookie.
// POST /auth/sign-in/
func (h *AuthHandler) SignIn(c *gin.Context) {
Expand Down Expand Up @@ -132,11 +144,7 @@ func (h *AuthHandler) SignUp(c *gin.Context) {
})
if err != nil {
if err == auth.ErrEmailTaken {
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
return
}
if err == auth.ErrUsernameTaken {
c.JSON(http.StatusConflict, gin.H{"error": "Username already taken"})
c.JSON(http.StatusConflict, gin.H{"error": "An account with this email already exists"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Sign up failed"})
Expand All @@ -146,8 +154,12 @@ func (h *AuthHandler) SignUp(c *gin.Context) {
now := time.Now()
inv.Accepted = true
inv.RespondedAt = &now
_ = h.Winv.Update(ctx, inv)
_ = h.Ws.AddMember(ctx, &model.WorkspaceMember{WorkspaceID: inv.WorkspaceID, MemberID: user.ID, Role: inv.Role})
if err := h.Winv.Update(ctx, inv); err != nil {
h.log().Error("failed to mark invite accepted", "error", err, "invite_id", inv.ID)
}
if err := h.Ws.AddMember(ctx, &model.WorkspaceMember{WorkspaceID: inv.WorkspaceID, MemberID: user.ID, Role: inv.Role}); err != nil {
h.log().Error("failed to add member after signup", "error", err, "user_id", user.ID)
}
}
setSessionCookie(c, sessionKey)
c.JSON(http.StatusCreated, userResponse(user))
Expand Down Expand Up @@ -177,12 +189,12 @@ func (h *AuthHandler) Me(c *gin.Context) {

// UpdateMeRequest is the body for PATCH /api/users/me/
type UpdateMeRequest struct {
FirstName *string `json:"first_name"`
LastName *string `json:"last_name"`
DisplayName *string `json:"display_name"`
UserTimezone *string `json:"user_timezone"`
Avatar *string `json:"avatar"`
CoverImage *string `json:"cover_image"`
FirstName *string `json:"first_name" binding:"omitempty,max=255"`
LastName *string `json:"last_name" binding:"omitempty,max=255"`
DisplayName *string `json:"display_name" binding:"omitempty,max=255"`
UserTimezone *string `json:"user_timezone" binding:"omitempty,max=100"`
Avatar *string `json:"avatar" binding:"omitempty,max=2048"`
CoverImage *string `json:"cover_image" binding:"omitempty,max=2048"`
}

// UpdateMe updates the authenticated user's profile (email is not updatable).
Expand Down Expand Up @@ -398,8 +410,8 @@ func (h *AuthHandler) ListTokens(c *gin.Context) {
type CreateTokenRequest struct {
Label string `json:"label" binding:"required"`
Description string `json:"description"`
ExpiresIn *string `json:"expires_in"` // e.g. "7d", "30d", "90d", "365d", or empty for never
ExpiredAt *string `json:"expired_at"` // ISO date for custom expiry
ExpiresIn *string `json:"expires_in"`
ExpiredAt *string `json:"expired_at"`
}

// CreateToken creates a new API token and returns it once (including secret).
Expand Down Expand Up @@ -495,6 +507,123 @@ func (h *AuthHandler) RevokeToken(c *gin.Context) {
c.Status(http.StatusNoContent)
}

// InstanceAuthConfig returns public auth configuration (no auth required).
// GET /auth/config/
func (h *AuthHandler) InstanceAuthConfig(c *gin.Context) {
isPasswordEnabled := true
enableSignup := true
isSmtpConfigured := false
if h.Settings != nil {
ctx := c.Request.Context()
row, _ := h.Settings.Get(ctx, "auth")
if row != nil {
isPasswordEnabled = authBool(row.Value, "password", true)
enableSignup = authBool(row.Value, "allow_public_signup", true)
}
emailRow, _ := h.Settings.Get(ctx, "email")
if emailRow != nil && emailRow.Value != nil {
host, _ := emailRow.Value["host"].(string)
isSmtpConfigured = strings.TrimSpace(host) != ""
}
}
c.JSON(http.StatusOK, gin.H{
"is_email_password_enabled": isPasswordEnabled,
"enable_signup": enableSignup,
"is_smtp_configured": isSmtpConfigured,
})
}

// EmailCheck checks whether an email is already registered.
// POST /auth/email-check/
func (h *AuthHandler) EmailCheck(c *gin.Context) {
var body struct {
Email string `json:"email" binding:"required,email"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
exists, err := h.Auth.EmailCheck(c.Request.Context(), body.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Check failed"})
return
}
allowPublicSignup := true
if h.Settings != nil {
row, _ := h.Settings.Get(c.Request.Context(), "auth")
if row != nil {
allowPublicSignup = authBool(row.Value, "allow_public_signup", true)
}
}
c.JSON(http.StatusOK, gin.H{
"existing": exists,
"status": "CREDENTIAL",
"allow_public_signup": allowPublicSignup,
})
}

// ForgotPassword initiates a password reset flow by sending an email.
// POST /auth/forgot-password/
func (h *AuthHandler) ForgotPassword(c *gin.Context) {
var body struct {
Email string `json:"email" binding:"required,email"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
ctx := c.Request.Context()
token, err := h.Auth.ForgotPassword(ctx, body.Email)
if err != nil {
h.log().Error("forgot password error", "error", err)
}
if token != "" && h.Queue != nil && h.AppBaseURL != "" {
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ForgotPassword doesn’t honor the instance setting that disables password auth (auth.password), unlike SignIn/SignUp. If password auth is disabled (e.g., SSO-only), this endpoint should probably avoid generating tokens/sending emails and return the same generic success response.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nazarli-shabnam Check this

resetLink := strings.TrimSuffix(h.AppBaseURL, "/") + "/reset-password?token=" + token
subject := "Reset your Devlane password"
bodyText := fmt.Sprintf(
"You requested a password reset.\n\nClick the link below to reset your password:\n%s\n\nThis link expires in 30 minutes. If you did not request a reset, ignore this email.\n",
resetLink,
)
_ = h.Queue.PublishSendEmail(ctx, queue.SendEmailPayload{
To: body.Email,
Subject: subject,
Body: bodyText,
Kind: "forgot_password",
Extra: map[string]string{"reset_link": resetLink},
})
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The queue publish error is currently ignored (_ = h.Queue.PublishSendEmail(...)), which can silently drop password reset emails while still returning 200 to the user. Consider logging the publish error (at least at warn/error level) so failed enqueues are visible operationally.

Suggested change
_ = h.Queue.PublishSendEmail(ctx, queue.SendEmailPayload{
To: body.Email,
Subject: subject,
Body: bodyText,
Kind: "forgot_password",
Extra: map[string]string{"reset_link": resetLink},
})
if err := h.Queue.PublishSendEmail(ctx, queue.SendEmailPayload{
To: body.Email,
Subject: subject,
Body: bodyText,
Kind: "forgot_password",
Extra: map[string]string{"reset_link": resetLink},
}); err != nil {
h.log().Error("failed to enqueue forgot password email", "error", err, "email", body.Email)
}

Copilot uses AI. Check for mistakes.
Comment thread
nazarli-shabnam marked this conversation as resolved.
Outdated
}
c.JSON(http.StatusOK, gin.H{"message": "If an account exists for that email, a reset link has been sent."})
}

// ResetPassword validates a reset token and sets a new password.
// POST /auth/reset-password/
func (h *AuthHandler) ResetPassword(c *gin.Context) {
var body struct {
Token string `json:"token" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
Comment thread
nazarli-shabnam marked this conversation as resolved.
return
}
if err := h.Auth.ResetPassword(c.Request.Context(), body.Token, body.NewPassword); err != nil {
if err == auth.ErrResetTokenInvalid {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired reset token"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reset password"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Password has been reset successfully."})
}

func isSecureRequest(c *gin.Context) bool {
if c.Request.TLS != nil {
return true
}
return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
}

func setSessionCookie(c *gin.Context, sessionKey string) {
http.SetCookie(c.Writer, &http.Cookie{
Name: middleware.SessionCookieName,
Expand All @@ -503,7 +632,7 @@ func setSessionCookie(c *gin.Context, sessionKey string) {
MaxAge: 14 * 24 * 3600,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: false,
Secure: isSecureRequest(c),
})
}

Expand All @@ -515,6 +644,7 @@ func clearSessionCookie(c *gin.Context) {
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: isSecureRequest(c),
})
}

Expand Down
Loading
Loading