Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
57 changes: 55 additions & 2 deletions api/internal/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,26 @@ var (
ErrInvalidCredentials = errors.New("invalid email or password")
ErrEmailTaken = errors.New("email already registered")
ErrUsernameTaken = errors.New("username already taken")
ErrResetTokenInvalid = errors.New("reset token is invalid or expired")
)

const bcryptCost = 12

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}
}

// SetResetTokenStore attaches the password reset token store (optional dependency).
func (s *Service) SetResetTokenStore(ts *store.PasswordResetTokenStore) {
s.resetTokenStore = ts
}

type SignUpRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
Expand Down Expand Up @@ -145,6 +152,52 @@ func (s *Service) ChangePassword(ctx context.Context, userID uuid.UUID, currentP
return s.userStore.Update(ctx, u)
}

// ForgotPassword generates a reset token for the given email.
// Returns the plain token and the user. If the email is not found, returns ("", nil, nil)
// so callers can respond with a generic success (no user enumeration).
func (s *Service) ForgotPassword(ctx context.Context, email string) (token string, user *model.User, err error) {
if s.resetTokenStore == nil {
return "", nil, errors.New("password reset not configured")
}
email = strings.TrimSpace(strings.ToLower(email))
u, err := s.userStore.GetByEmail(ctx, email)
if err != nil || u == nil {
Comment thread
martian56 marked this conversation as resolved.
Outdated
return "", nil, nil
}
if !u.IsActive {
return "", nil, nil
}
token, err = s.resetTokenStore.Create(ctx, u.ID)
if err != nil {
return "", nil, err
}
return token, u, nil
}

// ResetPassword validates a reset token and sets the new password.
func (s *Service) ResetPassword(ctx context.Context, token, newPassword string) error {
if s.resetTokenStore == nil {
return errors.New("password reset not configured")
}
rec, err := s.resetTokenStore.GetValid(ctx, token)
if err != nil || rec == nil {
return ErrResetTokenInvalid
}
u, err := s.userStore.GetByID(ctx, rec.UserID)
if err != nil || u == 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.Password = string(hash)
if err := s.userStore.Update(ctx, u); err != nil {
return err
}
return s.resetTokenStore.MarkUsed(ctx, rec.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
79 changes: 79 additions & 0 deletions api/internal/handler/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ 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 +26,8 @@ type AuthHandler struct {
Ws *store.WorkspaceStore
NotifPrefs *store.UserNotificationPreferenceStore
ApiTokens *store.ApiTokenStore
Queue *queue.Publisher
AppBaseURL string
}

type SignInRequest struct {
Expand Down Expand Up @@ -164,6 +169,80 @@ func (h *AuthHandler) SignOut(c *gin.Context) {
c.Status(http.StatusNoContent)
}

type ForgotPasswordRequest struct {
Email string `json:"email" binding:"required,email"`
}

type ResetPasswordRequest struct {
Token string `json:"token" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8"`
}

// ForgotPassword generates a password-reset token and emails the user a reset link.
// POST /auth/forgot-password/
func (h *AuthHandler) ForgotPassword(c *gin.Context) {
var req ForgotPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
return
}

token, user, err := h.Auth.ForgotPassword(c.Request.Context(), req.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process request"})
return
}

// Always return success to prevent user enumeration.
if token == "" || user == nil {
c.JSON(http.StatusOK, gin.H{"message": "If that email is registered, a reset link has been sent."})
return
}

resetURL := fmt.Sprintf("%s/reset-password?token=%s", strings.TrimRight(h.AppBaseURL, "/"), token)

body := fmt.Sprintf(
"Hi %s,\n\nYou requested a password reset for your Devlane account.\n\nClick the link below to set a new password (valid for 30 minutes):\n%s\n\nIf you didn't request this, you can safely ignore this email.\n\n— Devlane",
strings.TrimSpace(user.FirstName+" "+user.LastName),
resetURL,
)

if h.Queue != nil {
if err := h.Queue.PublishSendEmail(c.Request.Context(), queue.SendEmailPayload{
To: *user.Email,
Subject: "Devlane – Reset your password",
Body: body,
Kind: "forgot_password",
}); err != nil {
slog.Error("failed to enqueue password reset email", "email", *user.Email, "error", err)
}
} else {
slog.Warn("password reset link (queue not configured — use this URL to reset)",
"email", *user.Email, "reset_url", resetURL)
Comment thread
martian56 marked this conversation as resolved.
Outdated
}

c.JSON(http.StatusOK, gin.H{"message": "If that email is registered, a reset link has been sent."})
}

// ResetPassword validates the token and sets a new password.
// POST /auth/reset-password/
func (h *AuthHandler) ResetPassword(c *gin.Context) {
var req ResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
return
}
if err := h.Auth.ResetPassword(c.Request.Context(), req.Token, req.NewPassword); err != nil {
if err == auth.ErrResetTokenInvalid {
c.JSON(http.StatusBadRequest, gin.H{"error": "Reset link is invalid or has expired. Please request a new one."})
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. You can now sign in."})
}

// Me returns the authenticated user.
// GET /api/users/me/
func (h *AuthHandler) Me(c *gin.Context) {
Expand Down
19 changes: 19 additions & 0 deletions api/internal/model/password_reset_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package model

import (
"time"

"github.com/google/uuid"
)

// PasswordResetToken stores a one-time token for "forgot password" flows.
type PasswordResetToken struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
UserID uuid.UUID `gorm:"type:uuid;not null;index"`
Token string `gorm:"type:varchar(64);uniqueIndex;not null"`
ExpiresAt time.Time `gorm:"not null"`
UsedAt *time.Time
CreatedAt time.Time
}

func (PasswordResetToken) TableName() string { return "password_reset_tokens" }
27 changes: 20 additions & 7 deletions api/internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,25 @@ func New(cfg Config) *gin.Engine {
userFavoriteStore := store.NewUserFavoriteStore(cfg.DB)

// Auth
passwordResetTokenStore := store.NewPasswordResetTokenStore(cfg.DB)
authSvc := auth.NewService(userStore, sessionStore)
authHandler := &handler.AuthHandler{Auth: authSvc, Settings: instanceSettingStore, Winv: workspaceInviteStore, Ws: workspaceStore, NotifPrefs: userNotifPrefStore, ApiTokens: apiTokenStore}
authSvc.SetResetTokenStore(passwordResetTokenStore)

appBaseURL := cfg.AppBaseURL
if appBaseURL == "" {
appBaseURL = cfg.CORSAllowOrigin
}

authHandler := &handler.AuthHandler{
Auth: authSvc,
Settings: instanceSettingStore,
Winv: workspaceInviteStore,
Ws: workspaceStore,
NotifPrefs: userNotifPrefStore,
ApiTokens: apiTokenStore,
Queue: cfg.Queue,
AppBaseURL: appBaseURL,
}
// Instance setup (no auth) — first-run flow; seeds general settings (instance_id, admin_email, instance_name)
instanceHandler := &handler.InstanceHandler{Auth: authSvc, Users: userStore, Settings: instanceSettingStore}
r.GET("/api/instance/setup-status/", instanceHandler.SetupStatus)
Expand Down Expand Up @@ -99,12 +116,6 @@ func New(cfg Config) *gin.Engine {
stickySvc := service.NewStickyService(stickyStore, workspaceStore)
recentVisitSvc := service.NewRecentVisitService(userRecentVisitStore, workspaceStore, issueStore, projectStore, pageStore)

// Base URL for invite links (e.g. email links to frontend)
appBaseURL := cfg.AppBaseURL
if appBaseURL == "" {
appBaseURL = cfg.CORSAllowOrigin
}

// Handlers
workspaceHandler := &handler.WorkspaceHandler{
Workspace: workspaceSvc,
Expand Down Expand Up @@ -277,6 +288,8 @@ func New(cfg Config) *gin.Engine {
authGroup.POST("/sign-in/", authHandler.SignIn)
authGroup.POST("/sign-up/", authHandler.SignUp)
authGroup.POST("/sign-out/", authHandler.SignOut)
authGroup.POST("/forgot-password/", authHandler.ForgotPassword)
authGroup.POST("/reset-password/", authHandler.ResetPassword)
}

// Legacy /api/v1
Expand Down
65 changes: 65 additions & 0 deletions api/internal/store/password_reset_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package store

import (
"context"
"crypto/rand"
"encoding/hex"
"time"

"github.com/Devlaner/devlane/api/internal/model"
"github.com/google/uuid"
"gorm.io/gorm"
)

const resetTokenExpireMinutes = 30

type PasswordResetTokenStore struct{ db *gorm.DB }

func NewPasswordResetTokenStore(db *gorm.DB) *PasswordResetTokenStore {
return &PasswordResetTokenStore{db: db}
}

// Create generates a cryptographically random token, stores it, and returns the plain token.
func (s *PasswordResetTokenStore) Create(ctx context.Context, userID uuid.UUID) (string, error) {
// Invalidate any existing unused tokens for this user.
s.db.WithContext(ctx).
Where("user_id = ? AND used_at IS NULL", userID).
Delete(&model.PasswordResetToken{})

Comment thread
martian56 marked this conversation as resolved.
Outdated
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
token := hex.EncodeToString(b)
rec := &model.PasswordResetToken{
ID: uuid.New(),
UserID: userID,
Token: token,
ExpiresAt: time.Now().UTC().Add(time.Duration(resetTokenExpireMinutes) * time.Minute),
}
if err := s.db.WithContext(ctx).Create(rec).Error; err != nil {
return "", err
}
return token, nil
}

// GetValid returns the token record if it exists, has not expired, and has not been used.
func (s *PasswordResetTokenStore) GetValid(ctx context.Context, token string) (*model.PasswordResetToken, error) {
var rec model.PasswordResetToken
err := s.db.WithContext(ctx).
Where("token = ? AND expires_at > ? AND used_at IS NULL", token, time.Now().UTC()).
First(&rec).Error
if err != nil {
return nil, err
}
return &rec, nil
}

// MarkUsed sets used_at so the token cannot be reused.
func (s *PasswordResetTokenStore) MarkUsed(ctx context.Context, id uuid.UUID) error {
now := time.Now().UTC()
return s.db.WithContext(ctx).
Model(&model.PasswordResetToken{}).
Where("id = ?", id).
Update("used_at", now).Error
}
1 change: 1 addition & 0 deletions api/migrations/000002_password_reset_tokens.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS password_reset_tokens;
10 changes: 10 additions & 0 deletions api/migrations/000002_password_reset_tokens.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
Comment thread
martian56 marked this conversation as resolved.
Outdated
user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens (user_id);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens (token);
Comment thread
martian56 marked this conversation as resolved.
Outdated
11 changes: 11 additions & 0 deletions ui/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,17 @@ export interface SignUpRequest {
invite_token?: string;
}

/** POST /auth/forgot-password/ request */
export interface ForgotPasswordRequest {
email: string;
}

/** POST /auth/reset-password/ request */
export interface ResetPasswordRequest {
token: string;
new_password: string;
}

/** Instance settings: section key -> value object (from GET /api/instance/settings/) */
export type InstanceSettingsResponse = Record<string, Record<string, unknown>>;

Expand Down
9 changes: 6 additions & 3 deletions ui/src/components/layout/ModuleDetailHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { DateRangeModal } from '../workspace-views/DateRangeModal';
import { ProjectIconDisplay } from '../ProjectIconModal';
import { ModuleWorkItemsFiltersPanel } from '../module-work-items/ModuleWorkItemsToolbarPanels';
import { ProjectIssuesDisplayPanel } from '../project-issues/ProjectIssuesDisplayPanel';
import { ProjectSectionNavChevron } from './ProjectSectionNavChevron';
import { workspaceService } from '../../services/workspaceService';
import { stateService } from '../../services/stateService';
import { cycleService } from '../../services/cycleService';
Expand Down Expand Up @@ -395,7 +394,9 @@ export function ModuleDetailHeader({
</span>
{projectName}
</Link>
<ProjectSectionNavChevron baseUrl={baseUrl} currentSection="modules" />
<span className="shrink-0 text-(--txt-placeholder)" aria-hidden>
/
</span>
<Link
to={`${baseUrl}/modules`}
className="flex shrink-0 items-center gap-1.5 truncate font-medium text-(--txt-secondary) no-underline hover:text-(--txt-primary) hover:underline"
Expand All @@ -405,7 +406,9 @@ export function ModuleDetailHeader({
</span>
Modules
</Link>
<ProjectSectionNavChevron baseUrl={baseUrl} currentSection="modules" />
<span className="shrink-0 text-(--txt-placeholder)" aria-hidden>
/
</span>
<div ref={dropdownRef} className="relative flex min-w-0 shrink-0 items-center">
<button
type="button"
Expand Down
Loading
Loading