From f68f002ff6862ab7820fd28f773ca0be9850e55d Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Sat, 4 Apr 2026 10:34:15 +0400
Subject: [PATCH 01/43] fix: remove ProjectSectionNavChevron ('>' button) from
all project page headers
---
ui/src/components/layout/ModuleDetailHeader.tsx | 5 ++---
ui/src/components/layout/PageHeader.tsx | 3 +--
2 files changed, 3 insertions(+), 5 deletions(-)
diff --git a/ui/src/components/layout/ModuleDetailHeader.tsx b/ui/src/components/layout/ModuleDetailHeader.tsx
index c6999f7..93d284c 100644
--- a/ui/src/components/layout/ModuleDetailHeader.tsx
+++ b/ui/src/components/layout/ModuleDetailHeader.tsx
@@ -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';
@@ -395,7 +394,7 @@ export function ModuleDetailHeader({
{projectName}
-
+ /
Modules
-
+ /
)}
-
+ /
Date: Sat, 4 Apr 2026 10:36:12 +0400
Subject: [PATCH 02/43] feat: implement auth ui pages with better
ui(LoginPage,SignUpPage,ForgotPassword,ResetPassword)
---
ui/src/pages/ForgotPasswordPage.tsx | 156 ++++++++++++
ui/src/pages/LoginPage.tsx | 356 ++++++++++++++++++++++++----
ui/src/pages/ResetPasswordPage.tsx | 298 +++++++++++++++++++++++
3 files changed, 769 insertions(+), 41 deletions(-)
create mode 100644 ui/src/pages/ForgotPasswordPage.tsx
create mode 100644 ui/src/pages/ResetPasswordPage.tsx
diff --git a/ui/src/pages/ForgotPasswordPage.tsx b/ui/src/pages/ForgotPasswordPage.tsx
new file mode 100644
index 0000000..42ef00c
--- /dev/null
+++ b/ui/src/pages/ForgotPasswordPage.tsx
@@ -0,0 +1,156 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Link, useLocation } from 'react-router-dom';
+import { Button, Input } from '../components/ui';
+import { authService } from '../services/authService';
+
+const RESEND_COOLDOWN_SECONDS = 30;
+
+export function ForgotPasswordPage() {
+ const location = useLocation();
+ const prefilledEmail =
+ (location.state as { email?: string } | null)?.email ?? '';
+
+ const [email, setEmail] = useState(prefilledEmail);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [submitted, setSubmitted] = useState(false);
+ const [error, setError] = useState('');
+ const [countdown, setCountdown] = useState(0);
+ const timerRef = useRef | null>(null);
+
+ const startCountdown = useCallback(() => {
+ setCountdown(RESEND_COOLDOWN_SECONDS);
+ if (timerRef.current) clearInterval(timerRef.current);
+ timerRef.current = setInterval(() => {
+ setCountdown((prev) => {
+ if (prev <= 1) {
+ if (timerRef.current) clearInterval(timerRef.current);
+ return 0;
+ }
+ return prev - 1;
+ });
+ }, 1000);
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ if (timerRef.current) clearInterval(timerRef.current);
+ };
+ }, []);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError('');
+ setIsSubmitting(true);
+ try {
+ await authService.forgotPassword({ email });
+ setSubmitted(true);
+ startCountdown();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ async function handleResend() {
+ if (countdown > 0 || isSubmitting) return;
+ setError('');
+ setIsSubmitting(true);
+ try {
+ await authService.forgotPassword({ email });
+ startCountdown();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
Devlane
+
+
+
+
+
Reset your password
+
+ Enter the email address associated with your account and we'll send you a link to
+ reset your password.
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {submitted && (
+
+
+
+
+
+ If an account exists for {email} , we've sent a reset link.
+ Check your inbox (and spam folder).
+
+
+ )}
+
+
+
+
+
+ Back to sign in
+
+
+
+
+ );
+}
diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx
index 4cd909a..d1f82f4 100644
--- a/ui/src/pages/LoginPage.tsx
+++ b/ui/src/pages/LoginPage.tsx
@@ -1,7 +1,92 @@
-import { useState } from 'react';
-import { useNavigate, useLocation } from 'react-router-dom';
-import { Button, Input, Card, CardContent } from '../components/ui';
+import { useCallback, useMemo, useState } from 'react';
+import { useNavigate, useLocation, Link } from 'react-router-dom';
+import { Button, Input } from '../components/ui';
+import { IconEye, IconEyeOff } from '../components/ui/PasswordRevealIcons';
import { useAuth } from '../contexts/AuthContext';
+import { authService } from '../services/authService';
+
+type AuthMode = 'sign-in' | 'sign-up';
+
+function getPasswordStrength(password: string) {
+ const checks = {
+ minLength: password.length >= 8,
+ upper: /[A-Z]/.test(password),
+ lower: /[a-z]/.test(password),
+ number: /\d/.test(password),
+ special: /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password),
+ };
+ const passed = Object.values(checks).filter(Boolean).length;
+ return { checks, passed, valid: passed === 5 };
+}
+
+const IconCheck = () => (
+
+
+
+);
+
+function PasswordStrengthIndicator({ password }: { password: string }) {
+ const { checks, passed } = getPasswordStrength(password);
+ if (!password) return null;
+
+ const barColor =
+ passed <= 1
+ ? 'bg-red-500'
+ : passed <= 2
+ ? 'bg-orange-500'
+ : passed <= 3
+ ? 'bg-yellow-500'
+ : passed <= 4
+ ? 'bg-lime-500'
+ : 'bg-green-500';
+
+ const criteria = [
+ { met: checks.minLength, label: 'Min 8 characters' },
+ { met: checks.upper, label: 'Uppercase letter' },
+ { met: checks.lower, label: 'Lowercase letter' },
+ { met: checks.number, label: 'A number' },
+ { met: checks.special, label: 'A special character' },
+ ];
+
+ return (
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+
+ {criteria.map(({ met, label }) => (
+
+
+
+
+ {label}
+
+ ))}
+
+
+ );
+}
export function LoginPage() {
const navigate = useNavigate();
@@ -16,63 +101,252 @@ export function LoginPage() {
const returnPath = from ? (from.pathname ?? '/') + (from.search ?? '') : '/';
const prefilledEmail = state?.email ?? '';
+ const [mode, setMode] = useState('sign-in');
const [email, setEmail] = useState(prefilledEmail);
const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [firstName, setFirstName] = useState('');
+ const [lastName, setLastName] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+ const [showConfirm, setShowConfirm] = useState(false);
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
+ const strength = useMemo(() => getPasswordStrength(password), [password]);
+ const passwordsMatch = password === confirmPassword && confirmPassword.length > 0;
+
+ const clearForm = useCallback(() => {
+ setPassword('');
+ setConfirmPassword('');
+ setFirstName('');
+ setLastName('');
+ setShowPassword(false);
+ setShowConfirm(false);
+ setError('');
+ }, []);
+
+ const toggleMode = useCallback(() => {
+ clearForm();
+ setMode((m) => (m === 'sign-in' ? 'sign-up' : 'sign-in'));
+ }, [clearForm]);
+
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
+
+ if (mode === 'sign-up') {
+ if (!strength.valid) {
+ setError('Please meet all password requirements.');
+ return;
+ }
+ if (!passwordsMatch) {
+ setError('Passwords do not match.');
+ return;
+ }
+ }
+
setIsSubmitting(true);
try {
- const success = await login(email, password);
- if (success) {
- navigate(returnPath, { replace: true });
+ if (mode === 'sign-in') {
+ const success = await login(email, password);
+ if (success) {
+ navigate(returnPath, { replace: true });
+ } else {
+ setError('Invalid email or password.');
+ }
} else {
- setError('Invalid email or password.');
+ await authService.signUp({
+ email,
+ password,
+ first_name: firstName.trim(),
+ last_name: lastName.trim(),
+ });
+ const success = await login(email, password);
+ if (success) {
+ navigate(returnPath, { replace: true });
+ }
}
- } catch {
- setError('Something went wrong. Please try again.');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
} finally {
setIsSubmitting(false);
}
}
+ const isSignUp = mode === 'sign-up';
+
return (
-
-
-
- Sign in to Devlane
-
- Enter your email and password to continue.
+
+
+
Devlane
+
+
+
+
+
+ {isSignUp ? 'Create your account' : 'Sign in to Devlane'}
+
+
+ {isSignUp
+ ? 'Start managing your projects with Devlane.'
+ : 'Enter your credentials to continue.'}
-
-
-
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ {isSignUp ? 'Already have an account?' : "Don't have an account?"}{' '}
+
+ {isSignUp ? 'Sign in' : 'Sign up'}
+
+
+
+
+
+ By signing up, you agree to the Terms of Service and Privacy Policy.
+
);
}
diff --git a/ui/src/pages/ResetPasswordPage.tsx b/ui/src/pages/ResetPasswordPage.tsx
new file mode 100644
index 0000000..ed7ce8f
--- /dev/null
+++ b/ui/src/pages/ResetPasswordPage.tsx
@@ -0,0 +1,298 @@
+import { useMemo, useState } from 'react';
+import { Link, useSearchParams } from 'react-router-dom';
+import { Button } from '../components/ui';
+import { IconEye, IconEyeOff } from '../components/ui/PasswordRevealIcons';
+import { authService } from '../services/authService';
+
+function getPasswordStrength(password: string) {
+ const checks = {
+ minLength: password.length >= 8,
+ upper: /[A-Z]/.test(password),
+ lower: /[a-z]/.test(password),
+ number: /\d/.test(password),
+ special: /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password),
+ };
+ const passed = Object.values(checks).filter(Boolean).length;
+ return { checks, passed, valid: passed === 5 };
+}
+
+const IconCheckSmall = () => (
+
+
+
+);
+
+function PasswordStrengthIndicator({ password }: { password: string }) {
+ const { checks, passed } = getPasswordStrength(password);
+ if (!password) return null;
+
+ const barColor =
+ passed <= 1
+ ? 'bg-red-500'
+ : passed <= 2
+ ? 'bg-orange-500'
+ : passed <= 3
+ ? 'bg-yellow-500'
+ : passed <= 4
+ ? 'bg-lime-500'
+ : 'bg-green-500';
+
+ const criteria = [
+ { met: checks.minLength, label: 'Min 8 characters' },
+ { met: checks.upper, label: 'Uppercase letter' },
+ { met: checks.lower, label: 'Lowercase letter' },
+ { met: checks.number, label: 'A number' },
+ { met: checks.special, label: 'A special character' },
+ ];
+
+ return (
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+
+ {criteria.map(({ met, label }) => (
+
+
+
+
+ {label}
+
+ ))}
+
+
+ );
+}
+
+export function ResetPasswordPage() {
+ const [searchParams] = useSearchParams();
+ const token = searchParams.get('token') ?? '';
+
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+ const [showConfirm, setShowConfirm] = useState(false);
+ const [error, setError] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [success, setSuccess] = useState(false);
+
+ const strength = useMemo(() => getPasswordStrength(password), [password]);
+ const passwordsMatch = password === confirmPassword && confirmPassword.length > 0;
+
+ if (!token) {
+ return (
+
+
+
Devlane
+
+
+
Invalid reset link
+
+ This password reset link is missing or invalid.
+
+
+ Request a new reset link
+
+
+
+ );
+ }
+
+ if (success) {
+ return (
+
+
+
Devlane
+
+
+
+
Password reset
+
+ Your password has been successfully reset. You can now sign in with your new password.
+
+
+ Go to sign in
+
+
+
+ );
+ }
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError('');
+ if (!strength.valid) {
+ setError('Please meet all password requirements.');
+ return;
+ }
+ if (!passwordsMatch) {
+ setError('Passwords do not match.');
+ return;
+ }
+ setIsSubmitting(true);
+ try {
+ await authService.resetPassword({ token, new_password: password });
+ setSuccess(true);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
Devlane
+
+
+
+
+
Create a new password
+
+ Choose a strong password for your account.
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+ Back to sign in
+
+
+
+
+ );
+}
From 903994032d74de0c221f8b4ba6d8ec1d006e6633 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Sat, 4 Apr 2026 13:42:17 +0400
Subject: [PATCH 03/43] refactor: Update auth, handler, model and 6 related
areas for API and ui
---
api/internal/auth/service.go | 57 +++++++++++++++-
api/internal/handler/auth.go | 79 ++++++++++++++++++++++
api/internal/model/password_reset_token.go | 19 ++++++
api/internal/router/router.go | 27 ++++++--
api/internal/store/password_reset_token.go | 65 ++++++++++++++++++
ui/src/api/types.ts | 11 +++
ui/src/routes/index.tsx | 26 +++++++
ui/src/services/authService.ts | 22 +++++-
ui/vite.config.ts | 7 ++
9 files changed, 302 insertions(+), 11 deletions(-)
create mode 100644 api/internal/model/password_reset_token.go
create mode 100644 api/internal/store/password_reset_token.go
diff --git a/api/internal/auth/service.go b/api/internal/auth/service.go
index 390a66f..55e49a0 100644
--- a/api/internal/auth/service.go
+++ b/api/internal/auth/service.go
@@ -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"`
@@ -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 {
+ 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
+ }
+ 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 {
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index 08a5063..9a573c1 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -3,6 +3,8 @@ package handler
import (
"errors"
+ "fmt"
+ "log/slog"
"net/http"
"strings"
"time"
@@ -10,6 +12,7 @@ import (
"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"
@@ -23,6 +26,8 @@ type AuthHandler struct {
Ws *store.WorkspaceStore
NotifPrefs *store.UserNotificationPreferenceStore
ApiTokens *store.ApiTokenStore
+ Queue *queue.Publisher
+ AppBaseURL string
}
type SignInRequest struct {
@@ -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)
+ }
+
+ 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) {
diff --git a/api/internal/model/password_reset_token.go b/api/internal/model/password_reset_token.go
new file mode 100644
index 0000000..9a5706e
--- /dev/null
+++ b/api/internal/model/password_reset_token.go
@@ -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" }
diff --git a/api/internal/router/router.go b/api/internal/router/router.go
index df44598..abe9ded 100644
--- a/api/internal/router/router.go
+++ b/api/internal/router/router.go
@@ -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)
@@ -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,
@@ -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
diff --git a/api/internal/store/password_reset_token.go b/api/internal/store/password_reset_token.go
new file mode 100644
index 0000000..74c3a53
--- /dev/null
+++ b/api/internal/store/password_reset_token.go
@@ -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{})
+
+ 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
+}
diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts
index 29a9385..e90c237 100644
--- a/ui/src/api/types.ts
+++ b/ui/src/api/types.ts
@@ -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>;
diff --git a/ui/src/routes/index.tsx b/ui/src/routes/index.tsx
index 4ba075f..c8b11c9 100644
--- a/ui/src/routes/index.tsx
+++ b/ui/src/routes/index.tsx
@@ -140,6 +140,16 @@ const InstanceSetupCompletePage = lazy(() =>
page({ InstanceSetupCompletePage: m.InstanceSetupCompletePage }),
),
);
+const ForgotPasswordPage = lazy(() =>
+ import('../pages/ForgotPasswordPage').then((m) =>
+ page({ ForgotPasswordPage: m.ForgotPasswordPage }),
+ ),
+);
+const ResetPasswordPage = lazy(() =>
+ import('../pages/ResetPasswordPage').then((m) =>
+ page({ ResetPasswordPage: m.ResetPasswordPage }),
+ ),
+);
const InviteAcceptPage = lazy(() =>
import('../pages/InviteAcceptPage').then((m) => page({ InviteAcceptPage: m.InviteAcceptPage })),
);
@@ -293,6 +303,22 @@ const router = createBrowserRouter([
),
},
+ {
+ path: 'forgot-password',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'reset-password',
+ element: (
+ }>
+
+
+ ),
+ },
{
path: 'invite',
element: (
diff --git a/ui/src/services/authService.ts b/ui/src/services/authService.ts
index a93ca4e..5a0f87e 100644
--- a/ui/src/services/authService.ts
+++ b/ui/src/services/authService.ts
@@ -1,8 +1,14 @@
import { apiClient } from '../api/client';
-import type { UserApiResponse, SignInRequest, SignUpRequest } from '../api/types';
+import type {
+ UserApiResponse,
+ SignInRequest,
+ SignUpRequest,
+ ForgotPasswordRequest,
+ ResetPasswordRequest,
+} from '../api/types';
/**
- * Auth API: sign-in, sign-up, sign-out, current user.
+ * Auth API: sign-in, sign-up, sign-out, forgot/reset password, current user.
*/
export const authService = {
async signIn(payload: SignInRequest): Promise {
@@ -23,6 +29,18 @@ export const authService = {
await apiClient.post('/auth/sign-out/');
},
+ /** POST /auth/forgot-password/ */
+ async forgotPassword(payload: ForgotPasswordRequest): Promise<{ message: string }> {
+ const { data } = await apiClient.post<{ message: string }>('/auth/forgot-password/', payload);
+ return data;
+ },
+
+ /** POST /auth/reset-password/ */
+ async resetPassword(payload: ResetPasswordRequest): Promise<{ message: string }> {
+ const { data } = await apiClient.post<{ message: string }>('/auth/reset-password/', payload);
+ return data;
+ },
+
async getMe(): Promise {
try {
const { data } = await apiClient.get('/api/users/me/');
diff --git a/ui/vite.config.ts b/ui/vite.config.ts
index a0e822a..1105054 100644
--- a/ui/vite.config.ts
+++ b/ui/vite.config.ts
@@ -5,6 +5,13 @@ import tailwindcss from '@tailwindcss/vite';
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
+ // When VITE_API_BASE_URL is unset, the UI uses same-origin URLs; forward API + auth to the Go server.
+ server: {
+ proxy: {
+ '/api': { target: 'http://localhost:8080', changeOrigin: true },
+ '/auth': { target: 'http://localhost:8080', changeOrigin: true },
+ },
+ },
build: {
rollupOptions: {
output: {
From a90985051c5d45bf838426f8a525f98f6ebd902f Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Sat, 4 Apr 2026 13:43:36 +0400
Subject: [PATCH 04/43] feat: add password_reset_tokens table with user_id,
oken, and expires_at for password reset functionality
---
api/migrations/000002_password_reset_tokens.down.sql | 1 +
api/migrations/000002_password_reset_tokens.up.sql | 10 ++++++++++
2 files changed, 11 insertions(+)
create mode 100644 api/migrations/000002_password_reset_tokens.down.sql
create mode 100644 api/migrations/000002_password_reset_tokens.up.sql
diff --git a/api/migrations/000002_password_reset_tokens.down.sql b/api/migrations/000002_password_reset_tokens.down.sql
new file mode 100644
index 0000000..68371a2
--- /dev/null
+++ b/api/migrations/000002_password_reset_tokens.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS password_reset_tokens;
diff --git a/api/migrations/000002_password_reset_tokens.up.sql b/api/migrations/000002_password_reset_tokens.up.sql
new file mode 100644
index 0000000..c33dece
--- /dev/null
+++ b/api/migrations/000002_password_reset_tokens.up.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS password_reset_tokens (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ 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);
From 89140d367e883d1f8f90019e3d182684d35aef70 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Sat, 4 Apr 2026 13:59:13 +0400
Subject: [PATCH 05/43] fix: linting
---
ui/src/components/layout/ModuleDetailHeader.tsx | 8 ++++++--
ui/src/components/layout/PageHeader.tsx | 4 +++-
ui/src/pages/ForgotPasswordPage.tsx | 12 ++++--------
ui/src/pages/LoginPage.tsx | 8 ++------
ui/src/pages/ResetPasswordPage.tsx | 9 ++-------
5 files changed, 17 insertions(+), 24 deletions(-)
diff --git a/ui/src/components/layout/ModuleDetailHeader.tsx b/ui/src/components/layout/ModuleDetailHeader.tsx
index 93d284c..3dcc93c 100644
--- a/ui/src/components/layout/ModuleDetailHeader.tsx
+++ b/ui/src/components/layout/ModuleDetailHeader.tsx
@@ -394,7 +394,9 @@ export function ModuleDetailHeader({
{projectName}
- /
+
+ /
+
Modules
- /
+
+ /
+
)}
- /
+
+ /
+
- If an account exists for {email} , we've sent a reset link.
- Check your inbox (and spam folder).
+ If an account exists for {email} , we've sent a reset link. Check
+ your inbox (and spam folder).
)}
@@ -143,10 +142,7 @@ export function ForgotPasswordPage() {
-
+
Back to sign in
diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx
index d1f82f4..658f4c4 100644
--- a/ui/src/pages/LoginPage.tsx
+++ b/ui/src/pages/LoginPage.tsx
@@ -73,9 +73,7 @@ function PasswordStrengthIndicator({ password }: { password: string }) {
@@ -318,9 +316,7 @@ export function LoginPage() {
{isSubmitting
? isSignUp
diff --git a/ui/src/pages/ResetPasswordPage.tsx b/ui/src/pages/ResetPasswordPage.tsx
index ed7ce8f..2dee28b 100644
--- a/ui/src/pages/ResetPasswordPage.tsx
+++ b/ui/src/pages/ResetPasswordPage.tsx
@@ -70,9 +70,7 @@ function PasswordStrengthIndicator({ password }: { password: string }) {
@@ -285,10 +283,7 @@ export function ResetPasswordPage() {
-
+
Back to sign in
From cc3cc1ac1dcbd3e589b3c1d1b3c038eda593d5bf Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Sun, 5 Apr 2026 15:35:19 +0400
Subject: [PATCH 06/43] refactor: update api, auth, handler, mail, middleware
for API and ui
---
api/cmd/api/main.go | 7 +-
api/internal/auth/service.go | 88 ++++-
api/internal/handler/auth.go | 176 +++++++++-
api/internal/mail/mail.go | 4 +
api/internal/middleware/auth.go | 4 +-
api/internal/model/password_reset_token.go | 26 ++
api/internal/queue/consumer.go | 16 +
api/internal/router/router.go | 34 +-
api/internal/store/password_reset_token.go | 49 +++
ui/src/api/types.ts | 25 ++
ui/src/pages/ForgotPasswordPage.tsx | 123 +++++++
ui/src/pages/LoginPage.tsx | 389 ++++++++++++++++++---
ui/src/pages/ResetPasswordPage.tsx | 233 ++++++++++++
ui/src/routes/index.tsx | 26 ++
ui/src/services/authService.ts | 37 +-
15 files changed, 1147 insertions(+), 90 deletions(-)
create mode 100644 api/internal/model/password_reset_token.go
create mode 100644 api/internal/store/password_reset_token.go
create mode 100644 ui/src/pages/ForgotPasswordPage.tsx
create mode 100644 ui/src/pages/ResetPasswordPage.tsx
diff --git a/api/cmd/api/main.go b/api/cmd/api/main.go
index 4e85a9f..6bcf08d 100644
--- a/api/cmd/api/main.go
+++ b/api/cmd/api/main.go
@@ -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
@@ -86,6 +90,7 @@ func main() {
Queue: queuePublisher,
Minio: mc,
CORSAllowOrigin: cfg.CORSAllowOrigin,
+ AppBaseURL: cfg.AppBaseURL,
})
// Start task consumer when RabbitMQ is available
diff --git a/api/internal/auth/service.go b/api/internal/auth/service.go
index 390a66f..059254e 100644
--- a/api/internal/auth/service.go
+++ b/api/internal/auth/service.go
@@ -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 {
@@ -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
@@ -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 {
@@ -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
+ }
+ 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 {
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index 08a5063..2501ce6 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -1,8 +1,8 @@
-// Package handler implements HTTP handlers for the API.
package handler
import (
"errors"
+ "log/slog"
"net/http"
"strings"
"time"
@@ -10,6 +10,7 @@ import (
"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"
@@ -23,6 +24,9 @@ type AuthHandler struct {
Ws *store.WorkspaceStore
NotifPrefs *store.UserNotificationPreferenceStore
ApiTokens *store.ApiTokenStore
+ Queue *queue.Publisher
+ AppBaseURL string
+ Log *slog.Logger
}
type SignInRequest struct {
@@ -52,6 +56,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) {
@@ -132,11 +143,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"})
@@ -146,8 +153,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))
@@ -164,6 +175,129 @@ func (h *AuthHandler) SignOut(c *gin.Context) {
c.Status(http.StatusNoContent)
}
+// EmailCheck determines if an email already exists (Plane-style step 1 of auth flow).
+// POST /auth/email-check/
+func (h *AuthHandler) EmailCheck(c *gin.Context) {
+ var req struct {
+ Email string `json:"email" binding:"required,email"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid email"})
+ return
+ }
+ exists, err := h.Auth.EmailCheck(c.Request.Context(), req.Email)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Check failed"})
+ return
+ }
+
+ var 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 generates a password reset token and enqueues an email.
+// POST /auth/forgot-password/
+func (h *AuthHandler) ForgotPassword(c *gin.Context) {
+ var req struct {
+ Email string `json:"email" binding:"required,email"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid email"})
+ return
+ }
+
+ token, err := h.Auth.ForgotPassword(c.Request.Context(), req.Email)
+ if err != nil {
+ h.log().Error("forgot password", "error", err)
+ c.JSON(http.StatusOK, gin.H{"message": "If your email is registered, you will receive a password reset link."})
+ return
+ }
+
+ if token != "" {
+ baseURL := h.AppBaseURL
+ if baseURL == "" {
+ baseURL = "http://localhost:5173"
+ }
+ resetLink := baseURL + "/reset-password?token=" + token
+
+ if h.Queue != nil {
+ if err := h.Queue.PublishSendEmail(c.Request.Context(), queue.SendEmailPayload{
+ To: strings.TrimSpace(strings.ToLower(req.Email)),
+ Subject: "Reset your Devlane password",
+ Body: "You requested a password reset.\n\nClick the link below to set a new password:\n" + resetLink + "\n\nThis link expires in 30 minutes.\n\nIf you did not request this, you can safely ignore this email.",
+ Kind: "forgot_password",
+ }); err != nil {
+ h.log().Error("failed to enqueue reset email", "error", err, "to", req.Email)
+ }
+ } else {
+ h.log().Warn("queue not available, reset link not emailed", "to", req.Email, "link", resetLink)
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "If your email is registered, you will receive a password reset link."})
+}
+
+// ResetPassword validates a reset token and sets a new password.
+// POST /auth/reset-password/
+func (h *AuthHandler) ResetPassword(c *gin.Context) {
+ var req struct {
+ Token string `json:"token" binding:"required"`
+ NewPassword string `json:"new_password" binding:"required,min=8"`
+ }
+ 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": "Invalid or expired reset link. Please request a new one."})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Password reset failed"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "Password has been reset successfully."})
+}
+
+// InstanceAuthConfig returns public-facing auth configuration (which methods are enabled).
+// GET /auth/config/
+func (h *AuthHandler) InstanceAuthConfig(c *gin.Context) {
+ cfg := gin.H{
+ "is_email_password_enabled": true,
+ "enable_signup": true,
+ "is_smtp_configured": false,
+ }
+
+ if h.Settings != nil {
+ row, _ := h.Settings.Get(c.Request.Context(), "auth")
+ if row != nil {
+ cfg["is_email_password_enabled"] = authBool(row.Value, "password", true)
+ cfg["enable_signup"] = authBool(row.Value, "allow_public_signup", true)
+ }
+
+ emailRow, _ := h.Settings.Get(c.Request.Context(), "email")
+ if emailRow != nil && emailRow.Value != nil {
+ if host, ok := emailRow.Value["host"].(string); ok && strings.TrimSpace(host) != "" {
+ cfg["is_smtp_configured"] = true
+ }
+ }
+ }
+
+ c.JSON(http.StatusOK, cfg)
+}
+
// Me returns the authenticated user.
// GET /api/users/me/
func (h *AuthHandler) Me(c *gin.Context) {
@@ -177,12 +311,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).
@@ -398,8 +532,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).
@@ -495,6 +629,13 @@ func (h *AuthHandler) RevokeToken(c *gin.Context) {
c.Status(http.StatusNoContent)
}
+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,
@@ -503,7 +644,7 @@ func setSessionCookie(c *gin.Context, sessionKey string) {
MaxAge: 14 * 24 * 3600,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
- Secure: false,
+ Secure: isSecureRequest(c),
})
}
@@ -515,6 +656,7 @@ func clearSessionCookie(c *gin.Context) {
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
+ Secure: isSecureRequest(c),
})
}
diff --git a/api/internal/mail/mail.go b/api/internal/mail/mail.go
index 53a8cfd..f0f3069 100644
--- a/api/internal/mail/mail.go
+++ b/api/internal/mail/mail.go
@@ -64,6 +64,10 @@ func getEmailSettings(ctx context.Context, s *store.InstanceSettingStore) (*smtp
// settings and sends mail. If not configured or send fails, logs and returns error.
func NewSMTPEmailSender(instanceSettings *store.InstanceSettingStore, log *slog.Logger) func(ctx context.Context, to, subject, body string) error {
return func(ctx context.Context, to, subject, body string) error {
+ if instanceSettings == nil {
+ LogSkip(log, "instance settings store is nil", to, fmt.Errorf("no settings store"))
+ return fmt.Errorf("email not configured: no settings store")
+ }
cfg, err := getEmailSettings(ctx, instanceSettings)
if err != nil {
LogSkip(log, "instance email not configured", to, err)
diff --git a/api/internal/middleware/auth.go b/api/internal/middleware/auth.go
index 7ee2132..8a644f9 100644
--- a/api/internal/middleware/auth.go
+++ b/api/internal/middleware/auth.go
@@ -3,6 +3,7 @@ package middleware
import (
"log/slog"
"net/http"
+ "strings"
"github.com/Devlaner/devlane/api/internal/auth"
"github.com/Devlaner/devlane/api/internal/model"
@@ -19,8 +20,7 @@ func RequireAuth(authSvc *auth.Service, log *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
sessionKey, _ := c.Cookie(SessionCookieName)
if sessionKey == "" {
- // Also check Authorization header for Bearer (session key) for API clients
- if authHeader := c.GetHeader("Authorization"); len(authHeader) > 7 && authHeader[:7] == "Bearer " {
+ if authHeader := c.GetHeader("Authorization"); len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "bearer ") {
sessionKey = authHeader[7:]
}
}
diff --git a/api/internal/model/password_reset_token.go b/api/internal/model/password_reset_token.go
new file mode 100644
index 0000000..2a315f8
--- /dev/null
+++ b/api/internal/model/password_reset_token.go
@@ -0,0 +1,26 @@
+package model
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+type PasswordResetToken struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
+ Token string `gorm:"type:varchar(128);uniqueIndex;not null" json:"-"`
+ ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
+ UsedAt *time.Time `json:"used_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+func (PasswordResetToken) TableName() string { return "password_reset_tokens" }
+
+func (t *PasswordResetToken) BeforeCreate(tx *gorm.DB) error {
+ if t.ID == uuid.Nil {
+ t.ID = uuid.New()
+ }
+ return nil
+}
diff --git a/api/internal/queue/consumer.go b/api/internal/queue/consumer.go
index d288c65..76c6203 100644
--- a/api/internal/queue/consumer.go
+++ b/api/internal/queue/consumer.go
@@ -64,6 +64,22 @@ func (c *Consumer) handle(ctx context.Context, queue string, d amqp.Delivery, h
if c.log != nil {
c.log.Warn("task failed", "queue", queue, "error", err)
}
+ retryCount := int64(0)
+ if d.Headers != nil {
+ if v, ok := d.Headers["x-retry-count"]; ok {
+ if n, ok := v.(int64); ok {
+ retryCount = n
+ }
+ }
+ }
+ const maxRetries = 3
+ if retryCount >= maxRetries {
+ if c.log != nil {
+ c.log.Error("task permanently failed, discarding", "queue", queue, "retries", retryCount, "error", err)
+ }
+ _ = d.Ack(false)
+ return
+ }
_ = d.Nack(false, true)
return
}
diff --git a/api/internal/router/router.go b/api/internal/router/router.go
index df44598..8a62e94 100644
--- a/api/internal/router/router.go
+++ b/api/internal/router/router.go
@@ -69,9 +69,29 @@ func New(cfg Config) *gin.Engine {
apiTokenStore := store.NewApiTokenStore(cfg.DB)
userFavoriteStore := store.NewUserFavoriteStore(cfg.DB)
+ // Password reset tokens
+ passwordResetTokenStore := store.NewPasswordResetTokenStore(cfg.DB)
+
// Auth
- authSvc := auth.NewService(userStore, sessionStore)
- authHandler := &handler.AuthHandler{Auth: authSvc, Settings: instanceSettingStore, Winv: workspaceInviteStore, Ws: workspaceStore, NotifPrefs: userNotifPrefStore, ApiTokens: apiTokenStore}
+ authSvc := auth.NewService(userStore, sessionStore, passwordResetTokenStore)
+
+ // Base URL for invite links (e.g. email links to frontend)
+ 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,
+ Log: cfg.Log,
+ }
// 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)
@@ -99,12 +119,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,
@@ -274,9 +288,13 @@ func New(cfg Config) *gin.Engine {
// Auth routes (no auth required)
authGroup := r.Group("/auth")
{
+ authGroup.GET("/config/", authHandler.InstanceAuthConfig)
+ authGroup.POST("/email-check/", authHandler.EmailCheck)
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
diff --git a/api/internal/store/password_reset_token.go b/api/internal/store/password_reset_token.go
new file mode 100644
index 0000000..e2c2c4b
--- /dev/null
+++ b/api/internal/store/password_reset_token.go
@@ -0,0 +1,49 @@
+package store
+
+import (
+ "context"
+ "time"
+
+ "github.com/Devlaner/devlane/api/internal/model"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+const resetTokenExpiry = 30 * time.Minute
+
+type PasswordResetTokenStore struct{ db *gorm.DB }
+
+func NewPasswordResetTokenStore(db *gorm.DB) *PasswordResetTokenStore {
+ return &PasswordResetTokenStore{db: db}
+}
+
+func (s *PasswordResetTokenStore) Create(ctx context.Context, userID uuid.UUID, token string) error {
+ s.db.WithContext(ctx).
+ Where("user_id = ? AND used_at IS NULL", userID).
+ Delete(&model.PasswordResetToken{})
+
+ return s.db.WithContext(ctx).Create(&model.PasswordResetToken{
+ UserID: userID,
+ Token: token,
+ ExpiresAt: time.Now().UTC().Add(resetTokenExpiry),
+ }).Error
+}
+
+func (s *PasswordResetTokenStore) GetValid(ctx context.Context, token string) (*model.PasswordResetToken, error) {
+ var t model.PasswordResetToken
+ err := s.db.WithContext(ctx).
+ Where("token = ? AND used_at IS NULL AND expires_at > ?", token, time.Now().UTC()).
+ First(&t).Error
+ if err != nil {
+ return nil, err
+ }
+ return &t, nil
+}
+
+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
+}
diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts
index 29a9385..51d5bd2 100644
--- a/ui/src/api/types.ts
+++ b/ui/src/api/types.ts
@@ -301,6 +301,31 @@ export interface SignUpRequest {
invite_token?: string;
}
+/** POST /auth/email-check/ response */
+export interface EmailCheckResponse {
+ existing: boolean;
+ status: 'CREDENTIAL';
+ allow_public_signup: boolean;
+}
+
+/** POST /auth/forgot-password/ request */
+export interface ForgotPasswordRequest {
+ email: string;
+}
+
+/** POST /auth/reset-password/ request */
+export interface ResetPasswordRequest {
+ token: string;
+ new_password: string;
+}
+
+/** GET /auth/config/ response */
+export interface AuthConfigResponse {
+ is_email_password_enabled: boolean;
+ enable_signup: boolean;
+ is_smtp_configured: boolean;
+}
+
/** Instance settings: section key -> value object (from GET /api/instance/settings/) */
export type InstanceSettingsResponse = Record>;
diff --git a/ui/src/pages/ForgotPasswordPage.tsx b/ui/src/pages/ForgotPasswordPage.tsx
new file mode 100644
index 0000000..f384f14
--- /dev/null
+++ b/ui/src/pages/ForgotPasswordPage.tsx
@@ -0,0 +1,123 @@
+import { useState, useCallback, useEffect } from 'react';
+import { Link, useLocation } from 'react-router-dom';
+import { Button, Input, Card, CardContent } from '../components/ui';
+import { authService } from '../services/authService';
+import { CircleAlert, CircleCheck, ArrowLeft } from 'lucide-react';
+
+const RESEND_COOLDOWN_SECONDS = 30;
+
+export function ForgotPasswordPage() {
+ const location = useLocation();
+ const prefilledEmail = (location.state as { email?: string } | null)?.email ?? '';
+
+ const [email, setEmail] = useState(prefilledEmail);
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [cooldown, setCooldown] = useState(0);
+
+ useEffect(() => {
+ if (cooldown <= 0) return;
+ const timer = setInterval(() => setCooldown((c) => c - 1), 1000);
+ return () => clearInterval(timer);
+ }, [cooldown]);
+
+ const handleSubmit = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setIsSubmitting(true);
+ try {
+ await authService.forgotPassword({ email });
+ setSuccess(true);
+ setCooldown(RESEND_COOLDOWN_SECONDS);
+ } catch {
+ setError('Something went wrong. Please try again.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [email],
+ );
+
+ const handleResend = useCallback(async () => {
+ if (cooldown > 0) return;
+ setError('');
+ setIsSubmitting(true);
+ try {
+ await authService.forgotPassword({ email });
+ setCooldown(RESEND_COOLDOWN_SECONDS);
+ } catch {
+ setError('Something went wrong. Please try again.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ }, [email, cooldown]);
+
+ return (
+
+
+
+
+
+ Back to sign in
+
+
+ Reset your password
+
+ Enter your email and we'll send you a link to reset your password.
+
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+ {success && (
+
+
+
+ If {email} is registered, you'll receive a reset link shortly.
+ Check your inbox and spam folder.
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx
index 4cd909a..2ea18bf 100644
--- a/ui/src/pages/LoginPage.tsx
+++ b/ui/src/pages/LoginPage.tsx
@@ -1,12 +1,68 @@
-import { useState } from 'react';
-import { useNavigate, useLocation } from 'react-router-dom';
+import { useState, useCallback, useEffect, useMemo } from 'react';
+import { useNavigate, useLocation, Link } from 'react-router-dom';
import { Button, Input, Card, CardContent } from '../components/ui';
import { useAuth } from '../contexts/AuthContext';
+import { authService } from '../services/authService';
+import { Eye, EyeOff, CircleAlert, CircleCheck } from 'lucide-react';
+
+type AuthStep = 'email' | 'password';
+type AuthMode = 'sign-in' | 'sign-up';
+
+interface PasswordCriteria {
+ minLength: boolean;
+ hasUpper: boolean;
+ hasLower: boolean;
+ hasDigit: boolean;
+ hasSpecial: boolean;
+}
+
+function getPasswordCriteria(pw: string): PasswordCriteria {
+ return {
+ minLength: pw.length >= 8,
+ hasUpper: /[A-Z]/.test(pw),
+ hasLower: /[a-z]/.test(pw),
+ hasDigit: /\d/.test(pw),
+ hasSpecial: /[!@#$%^&*()\-_+=[\]{}|;:'",.<>?/]/.test(pw),
+ };
+}
+
+function isPasswordStrong(pw: string): boolean {
+ const c = getPasswordCriteria(pw);
+ return c.minLength && c.hasUpper && c.hasLower && c.hasDigit && c.hasSpecial;
+}
+
+function PasswordStrengthIndicator({ password }: { password: string }) {
+ const criteria = getPasswordCriteria(password);
+ if (!password) return null;
+
+ const items: [string, boolean][] = [
+ ['At least 8 characters', criteria.minLength],
+ ['Uppercase letter', criteria.hasUpper],
+ ['Lowercase letter', criteria.hasLower],
+ ['Number', criteria.hasDigit],
+ ['Special character', criteria.hasSpecial],
+ ];
+
+ return (
+
+ {items.map(([label, met]) => (
+
+ {met ? (
+
+ ) : (
+
+ )}
+ {label}
+
+ ))}
+
+ );
+}
export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
- const { login } = useAuth();
+ const { login, setUserFromApi } = useAuth();
const state = location.state as {
from?: { pathname?: string; search?: string };
@@ -16,61 +72,298 @@ export function LoginPage() {
const returnPath = from ? (from.pathname ?? '/') + (from.search ?? '') : '/';
const prefilledEmail = state?.email ?? '';
+ const [step, setStep] = useState('email');
+ const [mode, setMode] = useState('sign-in');
const [email, setEmail] = useState(prefilledEmail);
const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [firstName, setFirstName] = useState('');
+ const [lastName, setLastName] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+ const [showConfirm, setShowConfirm] = useState(false);
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
+ const [allowSignup, setAllowSignup] = useState(true);
+ const [isSmtpConfigured, setIsSmtpConfigured] = useState(false);
- async function handleSubmit(e: React.FormEvent) {
- e.preventDefault();
- setError('');
- setIsSubmitting(true);
- try {
- const success = await login(email, password);
- if (success) {
- navigate(returnPath, { replace: true });
- } else {
- setError('Invalid email or password.');
+ useEffect(() => {
+ authService
+ .getAuthConfig()
+ .then((cfg) => {
+ setAllowSignup(cfg.enable_signup);
+ setIsSmtpConfigured(cfg.is_smtp_configured);
+ })
+ .catch(() => {});
+ }, []);
+
+ const handleEmailSubmit = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setIsSubmitting(true);
+ try {
+ const resp = await authService.emailCheck(email);
+ if (resp.existing) {
+ setMode('sign-in');
+ } else {
+ if (!resp.allow_public_signup) {
+ setError('Sign-up is by invite only.');
+ setIsSubmitting(false);
+ return;
+ }
+ setMode('sign-up');
+ }
+ setStep('password');
+ } catch {
+ setStep('password');
+ setMode('sign-in');
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [email],
+ );
+
+ const handlePasswordSubmit = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+
+ if (mode === 'sign-up') {
+ if (!isPasswordStrong(password)) {
+ setError('Password does not meet strength requirements.');
+ return;
+ }
+ if (password !== confirmPassword) {
+ setError('Passwords do not match.');
+ return;
+ }
}
- } catch {
- setError('Something went wrong. Please try again.');
- } finally {
- setIsSubmitting(false);
- }
- }
+
+ setIsSubmitting(true);
+ try {
+ if (mode === 'sign-in') {
+ const success = await login(email, password);
+ if (success) {
+ navigate(returnPath, { replace: true });
+ } else {
+ setError('Invalid email or password.');
+ }
+ } else {
+ const user = await authService.signUp({
+ email,
+ password,
+ first_name: firstName,
+ last_name: lastName,
+ });
+ setUserFromApi(user);
+ navigate(returnPath, { replace: true });
+ }
+ } catch (err: unknown) {
+ if (err && typeof err === 'object' && 'response' in err) {
+ const axiosErr = err as { response?: { data?: { error?: string } } };
+ setError(axiosErr.response?.data?.error ?? 'Something went wrong.');
+ } else {
+ setError('Something went wrong. Please try again.');
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [
+ mode,
+ email,
+ password,
+ confirmPassword,
+ firstName,
+ lastName,
+ login,
+ setUserFromApi,
+ navigate,
+ returnPath,
+ ],
+ );
+
+ const goBackToEmail = useCallback(() => {
+ setStep('email');
+ setPassword('');
+ setConfirmPassword('');
+ setFirstName('');
+ setLastName('');
+ setError('');
+ }, []);
+
+ const toggleMode = useCallback(() => {
+ setMode((prev) => (prev === 'sign-in' ? 'sign-up' : 'sign-in'));
+ setPassword('');
+ setConfirmPassword('');
+ setError('');
+ }, []);
+
+ const title = useMemo(() => {
+ if (step === 'email') return 'Get started with Devlane';
+ return mode === 'sign-in' ? 'Welcome back!' : 'Create your account';
+ }, [step, mode]);
+
+ const subtitle = useMemo(() => {
+ if (step === 'email') return 'Enter your email to continue.';
+ return mode === 'sign-in'
+ ? 'Enter your password to sign in.'
+ : 'Set up your account to get started.';
+ }, [step, mode]);
return (
-
+
- Sign in to Devlane
-
- Enter your email and password to continue.
-
-
+ {title}
+ {subtitle}
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+ {step === 'email' && (
+
+ )}
+
+ {step === 'password' && (
+
+ )}
diff --git a/ui/src/pages/ResetPasswordPage.tsx b/ui/src/pages/ResetPasswordPage.tsx
new file mode 100644
index 0000000..73c5346
--- /dev/null
+++ b/ui/src/pages/ResetPasswordPage.tsx
@@ -0,0 +1,233 @@
+import { useState, useCallback, useMemo } from 'react';
+import { useSearchParams, Link } from 'react-router-dom';
+import { Button, Input, Card, CardContent } from '../components/ui';
+import { authService } from '../services/authService';
+import { Eye, EyeOff, CircleAlert, CircleCheck } from 'lucide-react';
+
+interface PasswordCriteria {
+ minLength: boolean;
+ hasUpper: boolean;
+ hasLower: boolean;
+ hasDigit: boolean;
+ hasSpecial: boolean;
+}
+
+function getPasswordCriteria(pw: string): PasswordCriteria {
+ return {
+ minLength: pw.length >= 8,
+ hasUpper: /[A-Z]/.test(pw),
+ hasLower: /[a-z]/.test(pw),
+ hasDigit: /\d/.test(pw),
+ hasSpecial: /[!@#$%^&*()\-_+=[\]{}|;:'",.<>?/]/.test(pw),
+ };
+}
+
+function isPasswordStrong(pw: string): boolean {
+ const c = getPasswordCriteria(pw);
+ return c.minLength && c.hasUpper && c.hasLower && c.hasDigit && c.hasSpecial;
+}
+
+function PasswordStrengthIndicator({ password }: { password: string }) {
+ const criteria = getPasswordCriteria(password);
+ if (!password) return null;
+
+ const items: [string, boolean][] = [
+ ['At least 8 characters', criteria.minLength],
+ ['Uppercase letter', criteria.hasUpper],
+ ['Lowercase letter', criteria.hasLower],
+ ['Number', criteria.hasDigit],
+ ['Special character', criteria.hasSpecial],
+ ];
+
+ return (
+
+ {items.map(([label, met]) => (
+
+ {met ? (
+
+ ) : (
+
+ )}
+ {label}
+
+ ))}
+
+ );
+}
+
+export function ResetPasswordPage() {
+ const [searchParams] = useSearchParams();
+ const token = searchParams.get('token') ?? '';
+
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+ const [showConfirm, setShowConfirm] = useState(false);
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const invalidToken = !token;
+
+ const passwordsMatch = useMemo(
+ () => confirmPassword.length > 0 && password === confirmPassword,
+ [password, confirmPassword],
+ );
+
+ const handleSubmit = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+
+ if (!isPasswordStrong(password)) {
+ setError('Password does not meet strength requirements.');
+ return;
+ }
+ if (!passwordsMatch) {
+ setError('Passwords do not match.');
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ await authService.resetPassword({ token, new_password: password });
+ setSuccess(true);
+ } catch (err: unknown) {
+ if (err && typeof err === 'object' && 'response' in err) {
+ const axiosErr = err as { response?: { data?: { error?: string } } };
+ setError(axiosErr.response?.data?.error ?? 'Something went wrong.');
+ } else {
+ setError('Something went wrong. Please try again.');
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [token, password, passwordsMatch],
+ );
+
+ if (invalidToken) {
+ return (
+
+
+
+
+ Invalid reset link
+
+ This password reset link is invalid or has expired. Please request a new one.
+
+
+ Request new reset link
+
+
+
+
+ );
+ }
+
+ if (success) {
+ return (
+
+
+
+
+ Password reset!
+
+ Your password has been reset successfully. You can now sign in with your new password.
+
+
+ Go to sign in
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Set a new password
+
+ Choose a strong password to secure your account.
+
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/ui/src/routes/index.tsx b/ui/src/routes/index.tsx
index 4ba075f..414c484 100644
--- a/ui/src/routes/index.tsx
+++ b/ui/src/routes/index.tsx
@@ -15,6 +15,16 @@ const page = (m: { [k: string]: React.ComponentType }) => ({
const LoginPage = lazy(() =>
import('../pages/LoginPage').then((m) => page({ LoginPage: m.LoginPage })),
);
+const ForgotPasswordPage = lazy(() =>
+ import('../pages/ForgotPasswordPage').then((m) =>
+ page({ ForgotPasswordPage: m.ForgotPasswordPage }),
+ ),
+);
+const ResetPasswordPage = lazy(() =>
+ import('../pages/ResetPasswordPage').then((m) =>
+ page({ ResetPasswordPage: m.ResetPasswordPage }),
+ ),
+);
const WorkspaceHomePage = lazy(() =>
import('../pages/WorkspaceHomePage').then((m) =>
page({ WorkspaceHomePage: m.WorkspaceHomePage }),
@@ -293,6 +303,22 @@ const router = createBrowserRouter([
),
},
+ {
+ path: 'forgot-password',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'reset-password',
+ element: (
+ }>
+
+
+ ),
+ },
{
path: 'invite',
element: (
diff --git a/ui/src/services/authService.ts b/ui/src/services/authService.ts
index a93ca4e..35e8b23 100644
--- a/ui/src/services/authService.ts
+++ b/ui/src/services/authService.ts
@@ -1,19 +1,20 @@
import { apiClient } from '../api/client';
-import type { UserApiResponse, SignInRequest, SignUpRequest } from '../api/types';
+import type {
+ UserApiResponse,
+ SignInRequest,
+ SignUpRequest,
+ EmailCheckResponse,
+ ForgotPasswordRequest,
+ ResetPasswordRequest,
+ AuthConfigResponse,
+} from '../api/types';
-/**
- * Auth API: sign-in, sign-up, sign-out, current user.
- */
export const authService = {
async signIn(payload: SignInRequest): Promise {
const { data } = await apiClient.post('/auth/sign-in/', payload);
return data;
},
- /**
- * Sign up a new user. When instance has allow_public_signup off, invite_token is required.
- * POST /auth/sign-up/
- */
async signUp(payload: SignUpRequest): Promise {
const { data } = await apiClient.post('/auth/sign-up/', payload);
return data;
@@ -31,4 +32,24 @@ export const authService = {
return null;
}
},
+
+ async emailCheck(email: string): Promise {
+ const { data } = await apiClient.post('/auth/email-check/', { email });
+ return data;
+ },
+
+ async forgotPassword(payload: ForgotPasswordRequest): Promise<{ message: string }> {
+ const { data } = await apiClient.post<{ message: string }>('/auth/forgot-password/', payload);
+ return data;
+ },
+
+ async resetPassword(payload: ResetPasswordRequest): Promise<{ message: string }> {
+ const { data } = await apiClient.post<{ message: string }>('/auth/reset-password/', payload);
+ return data;
+ },
+
+ async getAuthConfig(): Promise {
+ const { data } = await apiClient.get('/auth/config/');
+ return data;
+ },
};
From f04b46a10b7897a0741340ae071005d435a92385 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Sun, 5 Apr 2026 15:36:07 +0400
Subject: [PATCH 07/43] feat(auth): add password_reset_tokens table for user
password reset functionality
---
api/migrations/000002_password_reset_tokens.down.sql | 1 +
api/migrations/000002_password_reset_tokens.up.sql | 11 +++++++++++
2 files changed, 12 insertions(+)
create mode 100644 api/migrations/000002_password_reset_tokens.down.sql
create mode 100644 api/migrations/000002_password_reset_tokens.up.sql
diff --git a/api/migrations/000002_password_reset_tokens.down.sql b/api/migrations/000002_password_reset_tokens.down.sql
new file mode 100644
index 0000000..68371a2
--- /dev/null
+++ b/api/migrations/000002_password_reset_tokens.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS password_reset_tokens;
diff --git a/api/migrations/000002_password_reset_tokens.up.sql b/api/migrations/000002_password_reset_tokens.up.sql
new file mode 100644
index 0000000..eb52ece
--- /dev/null
+++ b/api/migrations/000002_password_reset_tokens.up.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS password_reset_tokens (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ token VARCHAR(128) 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_id ON password_reset_tokens(user_id);
+CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token) WHERE used_at IS NULL;
From 7310246e03b3c3c4f1d0a6f02c6c8e9031f0fbf7 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Sun, 5 Apr 2026 19:12:29 +0400
Subject: [PATCH 08/43] refactor(routing): remove forgot and reset password
page routes
---
ui/src/routes/index.tsx | 10 ----------
1 file changed, 10 deletions(-)
diff --git a/ui/src/routes/index.tsx b/ui/src/routes/index.tsx
index ae6c079..414c484 100644
--- a/ui/src/routes/index.tsx
+++ b/ui/src/routes/index.tsx
@@ -150,16 +150,6 @@ const InstanceSetupCompletePage = lazy(() =>
page({ InstanceSetupCompletePage: m.InstanceSetupCompletePage }),
),
);
-const ForgotPasswordPage = lazy(() =>
- import('../pages/ForgotPasswordPage').then((m) =>
- page({ ForgotPasswordPage: m.ForgotPasswordPage }),
- ),
-);
-const ResetPasswordPage = lazy(() =>
- import('../pages/ResetPasswordPage').then((m) =>
- page({ ResetPasswordPage: m.ResetPasswordPage }),
- ),
-);
const InviteAcceptPage = lazy(() =>
import('../pages/InviteAcceptPage').then((m) => page({ InviteAcceptPage: m.InviteAcceptPage })),
);
From adaef29263fc58f923a82390c69a33e841a001ec Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Mon, 6 Apr 2026 02:26:47 +0400
Subject: [PATCH 09/43] refactor: update api, auth, config, handler, model for
API and ui
---
api/cmd/api/main.go | 21 ++-
api/internal/auth/service.go | 71 ++++++++-
api/internal/config/config.go | 16 ++
api/internal/handler/auth.go | 37 +++--
api/internal/handler/oauth.go | 174 +++++++++++++++++++++
api/internal/model/account.go | 32 ++++
api/internal/oauth/github.go | 107 +++++++++++++
api/internal/oauth/gitlab.go | 71 +++++++++
api/internal/oauth/google.go | 83 ++++++++++
api/internal/oauth/oauth.go | 97 ++++++++++++
api/internal/queue/consumer.go | 25 ++-
api/internal/router/router.go | 76 +++++++--
api/internal/store/account.go | 56 +++++++
api/internal/store/password_reset_token.go | 14 +-
ui/src/api/types.ts | 3 +
ui/src/pages/LoginPage.tsx | 128 +++++++++++++--
16 files changed, 956 insertions(+), 55 deletions(-)
create mode 100644 api/internal/handler/oauth.go
create mode 100644 api/internal/model/account.go
create mode 100644 api/internal/oauth/github.go
create mode 100644 api/internal/oauth/gitlab.go
create mode 100644 api/internal/oauth/google.go
create mode 100644 api/internal/oauth/oauth.go
create mode 100644 api/internal/store/account.go
diff --git a/api/cmd/api/main.go b/api/cmd/api/main.go
index 6bcf08d..99ab1f9 100644
--- a/api/cmd/api/main.go
+++ b/api/cmd/api/main.go
@@ -84,13 +84,20 @@ func main() {
}
r := router.New(router.Config{
- Log: log,
- DB: db,
- Redis: rdb,
- Queue: queuePublisher,
- Minio: mc,
- CORSAllowOrigin: cfg.CORSAllowOrigin,
- AppBaseURL: cfg.AppBaseURL,
+ Log: log,
+ DB: db,
+ Redis: rdb,
+ Queue: queuePublisher,
+ Minio: mc,
+ CORSAllowOrigin: cfg.CORSAllowOrigin,
+ AppBaseURL: cfg.AppBaseURL,
+ GoogleClientID: cfg.GoogleClientID,
+ GoogleClientSecret: cfg.GoogleClientSecret,
+ GitHubClientID: cfg.GitHubClientID,
+ GitHubClientSecret: cfg.GitHubClientSecret,
+ GitLabClientID: cfg.GitLabClientID,
+ GitLabClientSecret: cfg.GitLabClientSecret,
+ GitLabHost: cfg.GitLabHost,
})
// Start task consumer when RabbitMQ is available
diff --git a/api/internal/auth/service.go b/api/internal/auth/service.go
index 059254e..823ea49 100644
--- a/api/internal/auth/service.go
+++ b/api/internal/auth/service.go
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"errors"
"strings"
+ "time"
"github.com/Devlaner/devlane/api/internal/model"
"github.com/Devlaner/devlane/api/internal/store"
@@ -35,12 +36,15 @@ type Service struct {
userStore *store.UserStore
sessionStore *store.SessionStore
resetTokenStore *store.PasswordResetTokenStore
+ accountStore *store.AccountStore
}
func NewService(userStore *store.UserStore, sessionStore *store.SessionStore, resetTokenStore *store.PasswordResetTokenStore) *Service {
return &Service{userStore: userStore, sessionStore: sessionStore, resetTokenStore: resetTokenStore}
}
+func (s *Service) SetAccountStore(as *store.AccountStore) { s.accountStore = as }
+
type SignUpRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
@@ -198,6 +202,7 @@ func (s *Service) ForgotPassword(ctx context.Context, email string) (token strin
}
// ResetPassword validates the reset token and sets a new password.
+// After a successful reset, ALL unused tokens for the user are invalidated.
func (s *Service) ResetPassword(ctx context.Context, token, newPassword string) error {
if s.resetTokenStore == nil {
return ErrResetTokenInvalid
@@ -218,7 +223,71 @@ func (s *Service) ResetPassword(ctx context.Context, token, newPassword string)
if err := s.userStore.Update(ctx, u); err != nil {
return err
}
- return s.resetTokenStore.MarkUsed(ctx, rt.ID)
+ _ = s.resetTokenStore.InvalidateForUser(ctx, rt.UserID)
+ return nil
+}
+
+// OAuthLogin finds or creates a user from OAuth provider data and creates a session.
+// If the email already exists, it links the account; if not, it creates a new user.
+func (s *Service) OAuthLogin(ctx context.Context, provider, providerAccountID, email, firstName, lastName, avatar, accessToken, refreshToken, idToken string) (sessionKey string, user *model.User, err error) {
+ email = strings.TrimSpace(strings.ToLower(email))
+ if email == "" {
+ return "", nil, errors.New("oauth: email is required")
+ }
+
+ u, err := s.userStore.GetByEmail(ctx, email)
+ if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+ return "", nil, err
+ }
+
+ if u != nil && !u.IsActive {
+ return "", nil, errors.New("account is deactivated")
+ }
+
+ if u == nil {
+ username := email
+ if at := strings.Index(email, "@"); at > 0 {
+ username = strings.ReplaceAll(email[:at], ".", "_")
+ }
+ if existing, _ := s.userStore.GetByUsername(ctx, username); existing != nil {
+ username = email
+ }
+ dummyPwd := make([]byte, 32)
+ _, _ = rand.Read(dummyPwd)
+ hash, _ := bcrypt.GenerateFromPassword(dummyPwd, bcryptCost)
+ u = &model.User{
+ Username: username,
+ Email: &email,
+ Password: string(hash),
+ FirstName: firstName,
+ LastName: lastName,
+ DisplayName: strings.TrimSpace(firstName + " " + lastName),
+ Avatar: avatar,
+ IsActive: true,
+ }
+ if err := s.userStore.Create(ctx, u); err != nil {
+ return "", nil, err
+ }
+ }
+
+ if s.accountStore != nil {
+ now := time.Now().UTC()
+ _ = s.accountStore.Upsert(ctx, &model.Account{
+ UserID: u.ID,
+ Provider: provider,
+ ProviderAccountID: providerAccountID,
+ AccessToken: accessToken,
+ RefreshToken: refreshToken,
+ IDToken: idToken,
+ LastConnectedAt: &now,
+ })
+ }
+
+ sessionKey, err = s.createSession(ctx, u.ID)
+ if err != nil {
+ return "", nil, err
+ }
+ return sessionKey, u, nil
}
func (s *Service) createSession(ctx context.Context, userID uuid.UUID) (string, error) {
diff --git a/api/internal/config/config.go b/api/internal/config/config.go
index b831ee2..3792a4c 100644
--- a/api/internal/config/config.go
+++ b/api/internal/config/config.go
@@ -42,6 +42,15 @@ type Config struct {
CORSAllowOrigin string
// AppBaseURL is the public URL of the frontend (e.g. https://app.example.com). Used for invite links in emails. If empty, CORSAllowOrigin is used.
AppBaseURL string
+
+ // OAuth providers
+ GoogleClientID string
+ GoogleClientSecret string
+ GitHubClientID string
+ GitHubClientSecret string
+ GitLabClientID string
+ GitLabClientSecret string
+ GitLabHost string // defaults to https://gitlab.com
}
func (c *Config) DSN() string {
@@ -87,6 +96,13 @@ func Load() (*Config, error) {
MigrationsPath: getEnv("MIGRATIONS_PATH", "migrations"),
CORSAllowOrigin: getEnv("CORS_ORIGIN", "http://localhost:5173"),
AppBaseURL: getEnv("APP_BASE_URL", ""),
+ GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
+ GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
+ GitHubClientID: getEnv("GITHUB_CLIENT_ID", ""),
+ GitHubClientSecret: getEnv("GITHUB_CLIENT_SECRET", ""),
+ GitLabClientID: getEnv("GITLAB_CLIENT_ID", ""),
+ GitLabClientSecret: getEnv("GITLAB_CLIENT_SECRET", ""),
+ GitLabHost: getEnv("GITLAB_HOST", "https://gitlab.com"),
}
return cfg, nil
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index 8408f39..bc8c7e1 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -19,15 +19,16 @@ import (
)
type AuthHandler struct {
- Auth *auth.Service
- Settings *store.InstanceSettingStore
- Winv *store.WorkspaceInviteStore
- Ws *store.WorkspaceStore
- NotifPrefs *store.UserNotificationPreferenceStore
- ApiTokens *store.ApiTokenStore
- Queue *queue.Publisher
- AppBaseURL string
- Log *slog.Logger
+ Auth *auth.Service
+ Settings *store.InstanceSettingStore
+ Winv *store.WorkspaceInviteStore
+ Ws *store.WorkspaceStore
+ NotifPrefs *store.UserNotificationPreferenceStore
+ ApiTokens *store.ApiTokenStore
+ Queue *queue.Publisher
+ AppBaseURL string
+ Log *slog.Logger
+ OAuthProviders map[string]any
}
type SignInRequest struct {
@@ -81,7 +82,7 @@ func (h *AuthHandler) SignIn(c *gin.Context) {
}
sessionKey, user, err := h.Auth.SignIn(c.Request.Context(), auth.SignInRequest{Email: req.Email, Password: req.Password})
if err != nil {
- if err == auth.ErrInvalidCredentials {
+ if errors.Is(err, auth.ErrInvalidCredentials) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
return
}
@@ -143,7 +144,7 @@ func (h *AuthHandler) SignUp(c *gin.Context) {
LastName: req.LastName,
})
if err != nil {
- if err == auth.ErrEmailTaken {
+ if errors.Is(err, auth.ErrEmailTaken) {
c.JSON(http.StatusConflict, gin.H{"error": "An account with this email already exists"})
return
}
@@ -255,7 +256,7 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
return
}
if err := h.Auth.ChangePassword(c.Request.Context(), user.ID, req.CurrentPassword, req.NewPassword); err != nil {
- if err == auth.ErrInvalidCredentials {
+ if errors.Is(err, auth.ErrInvalidCredentials) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Current password is incorrect"})
return
}
@@ -526,10 +527,20 @@ func (h *AuthHandler) InstanceAuthConfig(c *gin.Context) {
isSmtpConfigured = strings.TrimSpace(host) != ""
}
}
+ var isGoogleEnabled, isGitHubEnabled, isGitLabEnabled bool
+ if h.OAuthProviders != nil {
+ _, isGoogleEnabled = h.OAuthProviders["google"]
+ _, isGitHubEnabled = h.OAuthProviders["github"]
+ _, isGitLabEnabled = h.OAuthProviders["gitlab"]
+ }
+
c.JSON(http.StatusOK, gin.H{
"is_email_password_enabled": isPasswordEnabled,
"enable_signup": enableSignup,
"is_smtp_configured": isSmtpConfigured,
+ "is_google_enabled": isGoogleEnabled,
+ "is_github_enabled": isGitHubEnabled,
+ "is_gitlab_enabled": isGitLabEnabled,
})
}
@@ -607,7 +618,7 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
return
}
if err := h.Auth.ResetPassword(c.Request.Context(), body.Token, body.NewPassword); err != nil {
- if err == auth.ErrResetTokenInvalid {
+ if errors.Is(err, auth.ErrResetTokenInvalid) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired reset token"})
return
}
diff --git a/api/internal/handler/oauth.go b/api/internal/handler/oauth.go
new file mode 100644
index 0000000..2ac69eb
--- /dev/null
+++ b/api/internal/handler/oauth.go
@@ -0,0 +1,174 @@
+package handler
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/Devlaner/devlane/api/internal/auth"
+ "github.com/Devlaner/devlane/api/internal/middleware"
+ "github.com/Devlaner/devlane/api/internal/oauth"
+ "github.com/gin-gonic/gin"
+)
+
+type OAuthHandler struct {
+ Providers map[string]oauth.Provider
+ Auth *auth.Service
+ AppBaseURL string
+ Log *slog.Logger
+}
+
+func (h *OAuthHandler) log() *slog.Logger {
+ if h.Log != nil {
+ return h.Log
+ }
+ return slog.Default()
+}
+
+func (h *OAuthHandler) Initiate(c *gin.Context) {
+ providerName := c.Param("provider")
+ provider, ok := h.Providers[providerName]
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Unknown OAuth provider"})
+ return
+ }
+
+ stateBytes := make([]byte, 16)
+ if _, err := rand.Read(stateBytes); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate state"})
+ return
+ }
+ state := hex.EncodeToString(stateBytes)
+
+ nextPath := c.Query("next_path")
+ sessionVal := state
+ if nextPath != "" {
+ sessionVal = state + "|" + nextPath
+ }
+
+ c.SetCookie("oauth_state", sessionVal, 600, "/", "", isSecureRequest(c), true)
+ c.Redirect(http.StatusTemporaryRedirect, provider.AuthURL(state))
+}
+
+func (h *OAuthHandler) Callback(c *gin.Context) {
+ providerName := c.Param("provider")
+ provider, ok := h.Providers[providerName]
+ if !ok {
+ h.redirectError(c, "Unknown OAuth provider")
+ return
+ }
+
+ code := c.Query("code")
+ state := c.Query("state")
+ if code == "" {
+ errMsg := c.Query("error_description")
+ if errMsg == "" {
+ errMsg = c.Query("error")
+ }
+ if errMsg == "" {
+ errMsg = "Authorization code missing"
+ }
+ h.redirectError(c, errMsg)
+ return
+ }
+
+ cookieVal, err := c.Cookie("oauth_state")
+ if err != nil || cookieVal == "" {
+ h.redirectError(c, "OAuth state cookie missing")
+ return
+ }
+ parts := strings.SplitN(cookieVal, "|", 2)
+ savedState := parts[0]
+ nextPath := "/"
+ if len(parts) == 2 {
+ nextPath = parts[1]
+ }
+
+ if state != savedState {
+ h.redirectError(c, "OAuth state mismatch")
+ return
+ }
+
+ c.SetCookie("oauth_state", "", -1, "/", "", isSecureRequest(c), true)
+
+ ctx := c.Request.Context()
+ tokenData, err := provider.Exchange(ctx, code)
+ if err != nil {
+ h.log().Error("oauth token exchange failed", "provider", providerName, "error", err)
+ h.redirectError(c, "Authentication failed")
+ return
+ }
+
+ userInfo, err := provider.GetUserInfo(ctx, tokenData)
+ if err != nil {
+ h.log().Error("oauth user info failed", "provider", providerName, "error", err)
+ h.redirectError(c, "Failed to get user information")
+ return
+ }
+
+ if userInfo.Email == "" {
+ h.redirectError(c, "Email not available from provider")
+ return
+ }
+
+ sessionKey, _, err := h.Auth.OAuthLogin(
+ ctx,
+ providerName,
+ userInfo.ProviderID,
+ userInfo.Email,
+ userInfo.FirstName,
+ userInfo.LastName,
+ userInfo.Avatar,
+ tokenData.AccessToken,
+ tokenData.RefreshToken,
+ tokenData.IDToken,
+ )
+ if err != nil {
+ h.log().Error("oauth login failed", "provider", providerName, "error", err)
+ h.redirectError(c, "Authentication failed")
+ return
+ }
+
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: middleware.SessionCookieName,
+ Value: sessionKey,
+ Path: "/",
+ MaxAge: 14 * 24 * 3600,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ Secure: isSecureRequest(c),
+ })
+
+ redirectURL := h.AppBaseURL
+ if redirectURL == "" {
+ redirectURL = "/"
+ }
+ redirectURL = strings.TrimSuffix(redirectURL, "/") + sanitizeRedirectPath(nextPath)
+
+ c.Redirect(http.StatusTemporaryRedirect, redirectURL)
+}
+
+func (h *OAuthHandler) redirectError(c *gin.Context, message string) {
+ redirectURL := h.AppBaseURL
+ if redirectURL == "" {
+ redirectURL = "/"
+ }
+ redirectURL = strings.TrimSuffix(redirectURL, "/") + "/login?error=" + url.QueryEscape(message)
+ c.Redirect(http.StatusTemporaryRedirect, redirectURL)
+}
+
+func sanitizeRedirectPath(path string) string {
+ if path == "" {
+ return "/"
+ }
+ if !strings.HasPrefix(path, "/") {
+ path = "/" + path
+ }
+ if strings.HasPrefix(path, "//") {
+ return "/"
+ }
+ return path
+}
diff --git a/api/internal/model/account.go b/api/internal/model/account.go
new file mode 100644
index 0000000..6cd1a73
--- /dev/null
+++ b/api/internal/model/account.go
@@ -0,0 +1,32 @@
+package model
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+type Account struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
+ Provider string `gorm:"type:varchar(50);not null" json:"provider"`
+ ProviderAccountID string `gorm:"column:provider_account_id;type:varchar(255);not null" json:"provider_account_id"`
+ AccessToken string `gorm:"type:text" json:"-"`
+ RefreshToken string `gorm:"type:text" json:"-"`
+ IDToken string `gorm:"column:id_token;type:text" json:"-"`
+ TokenExpiresAt *time.Time `gorm:"column:token_expires_at" json:"-"`
+ LastConnectedAt *time.Time `gorm:"column:last_connected_at" json:"last_connected_at"`
+ Metadata JSONMap `gorm:"type:jsonb;default:'{}'" json:"metadata,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+func (Account) TableName() string { return "accounts" }
+
+func (a *Account) BeforeCreate(tx *gorm.DB) error {
+ if a.ID == uuid.Nil {
+ a.ID = uuid.New()
+ }
+ return nil
+}
diff --git a/api/internal/oauth/github.go b/api/internal/oauth/github.go
new file mode 100644
index 0000000..8291b78
--- /dev/null
+++ b/api/internal/oauth/github.go
@@ -0,0 +1,107 @@
+package oauth
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+)
+
+const (
+ githubAuthURL = "https://github.com/login/oauth/authorize"
+ githubTokenURL = "https://github.com/login/oauth/access_token"
+ githubUserURL = "https://api.github.com/user"
+ githubEmailURL = "https://api.github.com/user/emails"
+ githubScope = "read:user user:email"
+)
+
+type GitHubProvider struct {
+ cfg ProviderConfig
+}
+
+func NewGitHubProvider(cfg ProviderConfig) *GitHubProvider {
+ return &GitHubProvider{cfg: cfg}
+}
+
+func (g *GitHubProvider) Name() string { return "github" }
+
+func (g *GitHubProvider) AuthURL(state string) string {
+ params := url.Values{
+ "client_id": {g.cfg.ClientID},
+ "redirect_uri": {g.cfg.RedirectURI},
+ "scope": {githubScope},
+ "state": {state},
+ }
+ return githubAuthURL + "?" + params.Encode()
+}
+
+func (g *GitHubProvider) Exchange(_ context.Context, code string) (*TokenData, error) {
+ data := url.Values{
+ "client_id": {g.cfg.ClientID},
+ "client_secret": {g.cfg.ClientSecret},
+ "code": {code},
+ "redirect_uri": {g.cfg.RedirectURI},
+ }
+ resp, err := httpPostForm(githubTokenURL, data, map[string]string{"Accept": "application/json"})
+ if err != nil {
+ return nil, err
+ }
+ td := &TokenData{
+ AccessToken: strVal(resp, "access_token"),
+ RefreshToken: strVal(resp, "refresh_token"),
+ }
+ return td, nil
+}
+
+func (g *GitHubProvider) GetUserInfo(_ context.Context, token *TokenData) (*UserInfo, error) {
+ resp, err := httpGetJSON(githubUserURL, token.AccessToken)
+ if err != nil {
+ return nil, err
+ }
+ email := strVal(resp, "email")
+ if email == "" {
+ email, _ = g.fetchPrimaryEmail(token.AccessToken)
+ }
+ return &UserInfo{
+ Email: email,
+ FirstName: strVal(resp, "name"),
+ Avatar: strVal(resp, "avatar_url"),
+ ProviderID: fmt.Sprintf("%v", resp["id"]),
+ }, nil
+}
+
+func (g *GitHubProvider) fetchPrimaryEmail(accessToken string) (string, error) {
+ req, err := http.NewRequest("GET", githubEmailURL, nil)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ req.Header.Set("Accept", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ var emails []struct {
+ Email string `json:"email"`
+ Primary bool `json:"primary"`
+ Verified bool `json:"verified"`
+ }
+ if err := json.Unmarshal(body, &emails); err != nil {
+ return "", err
+ }
+ for _, e := range emails {
+ if e.Primary && e.Verified {
+ return e.Email, nil
+ }
+ }
+ for _, e := range emails {
+ if e.Primary {
+ return e.Email, nil
+ }
+ }
+ return "", fmt.Errorf("no primary email found")
+}
diff --git a/api/internal/oauth/gitlab.go b/api/internal/oauth/gitlab.go
new file mode 100644
index 0000000..e9c05bc
--- /dev/null
+++ b/api/internal/oauth/gitlab.go
@@ -0,0 +1,71 @@
+package oauth
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "strings"
+)
+
+const gitlabScope = "read_user"
+
+type GitLabProvider struct {
+ cfg ProviderConfig
+ host string
+}
+
+func NewGitLabProvider(cfg ProviderConfig, host string) *GitLabProvider {
+ host = strings.TrimSuffix(host, "/")
+ if host == "" {
+ host = "https://gitlab.com"
+ }
+ return &GitLabProvider{cfg: cfg, host: host}
+}
+
+func (g *GitLabProvider) Name() string { return "gitlab" }
+
+func (g *GitLabProvider) AuthURL(state string) string {
+ params := url.Values{
+ "client_id": {g.cfg.ClientID},
+ "redirect_uri": {g.cfg.RedirectURI},
+ "response_type": {"code"},
+ "scope": {gitlabScope},
+ "state": {state},
+ }
+ return g.host + "/oauth/authorize?" + params.Encode()
+}
+
+func (g *GitLabProvider) Exchange(_ context.Context, code string) (*TokenData, error) {
+ data := url.Values{
+ "client_id": {g.cfg.ClientID},
+ "client_secret": {g.cfg.ClientSecret},
+ "code": {code},
+ "redirect_uri": {g.cfg.RedirectURI},
+ "grant_type": {"authorization_code"},
+ }
+ tokenURL := g.host + "/oauth/token"
+ resp, err := httpPostForm(tokenURL, data, map[string]string{"Accept": "application/json"})
+ if err != nil {
+ return nil, err
+ }
+ td := &TokenData{
+ AccessToken: strVal(resp, "access_token"),
+ RefreshToken: strVal(resp, "refresh_token"),
+ IDToken: strVal(resp, "id_token"),
+ }
+ return td, nil
+}
+
+func (g *GitLabProvider) GetUserInfo(_ context.Context, token *TokenData) (*UserInfo, error) {
+ userURL := g.host + "/api/v4/user"
+ resp, err := httpGetJSON(userURL, token.AccessToken)
+ if err != nil {
+ return nil, err
+ }
+ return &UserInfo{
+ Email: strVal(resp, "email"),
+ FirstName: strVal(resp, "name"),
+ Avatar: strVal(resp, "avatar_url"),
+ ProviderID: fmt.Sprintf("%v", resp["id"]),
+ }, nil
+}
diff --git a/api/internal/oauth/google.go b/api/internal/oauth/google.go
new file mode 100644
index 0000000..6a67fec
--- /dev/null
+++ b/api/internal/oauth/google.go
@@ -0,0 +1,83 @@
+package oauth
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+)
+
+const (
+ googleAuthURL = "https://accounts.google.com/o/oauth2/v2/auth"
+ googleTokenURL = "https://oauth2.googleapis.com/token"
+ googleUserURL = "https://www.googleapis.com/oauth2/v2/userinfo"
+ googleScope = "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"
+)
+
+type GoogleProvider struct {
+ cfg ProviderConfig
+}
+
+func NewGoogleProvider(cfg ProviderConfig) *GoogleProvider {
+ return &GoogleProvider{cfg: cfg}
+}
+
+func (g *GoogleProvider) Name() string { return "google" }
+
+func (g *GoogleProvider) AuthURL(state string) string {
+ params := url.Values{
+ "client_id": {g.cfg.ClientID},
+ "redirect_uri": {g.cfg.RedirectURI},
+ "response_type": {"code"},
+ "scope": {googleScope},
+ "access_type": {"offline"},
+ "prompt": {"consent"},
+ "state": {state},
+ }
+ return googleAuthURL + "?" + params.Encode()
+}
+
+func (g *GoogleProvider) Exchange(_ context.Context, code string) (*TokenData, error) {
+ data := url.Values{
+ "code": {code},
+ "client_id": {g.cfg.ClientID},
+ "client_secret": {g.cfg.ClientSecret},
+ "redirect_uri": {g.cfg.RedirectURI},
+ "grant_type": {"authorization_code"},
+ }
+ resp, err := httpPostForm(googleTokenURL, data, nil)
+ if err != nil {
+ return nil, err
+ }
+ td := &TokenData{
+ AccessToken: strVal(resp, "access_token"),
+ RefreshToken: strVal(resp, "refresh_token"),
+ IDToken: strVal(resp, "id_token"),
+ }
+ return td, nil
+}
+
+func (g *GoogleProvider) GetUserInfo(_ context.Context, token *TokenData) (*UserInfo, error) {
+ resp, err := httpGetJSON(googleUserURL, token.AccessToken)
+ if err != nil {
+ return nil, err
+ }
+ return &UserInfo{
+ Email: strVal(resp, "email"),
+ FirstName: strVal(resp, "given_name"),
+ LastName: strVal(resp, "family_name"),
+ Avatar: strVal(resp, "picture"),
+ ProviderID: fmt.Sprintf("%v", resp["id"]),
+ }, nil
+}
+
+func strVal(m map[string]interface{}, key string) string {
+ v, ok := m[key]
+ if !ok || v == nil {
+ return ""
+ }
+ s, ok := v.(string)
+ if ok {
+ return s
+ }
+ return fmt.Sprintf("%v", v)
+}
diff --git a/api/internal/oauth/oauth.go b/api/internal/oauth/oauth.go
new file mode 100644
index 0000000..2775113
--- /dev/null
+++ b/api/internal/oauth/oauth.go
@@ -0,0 +1,97 @@
+package oauth
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+var (
+ ErrProviderNotConfigured = errors.New("oauth provider not configured")
+ ErrStateMismatch = errors.New("oauth state mismatch")
+ ErrCodeMissing = errors.New("oauth code missing")
+ ErrTokenExchange = errors.New("oauth token exchange failed")
+ ErrUserInfo = errors.New("oauth user info fetch failed")
+)
+
+type UserInfo struct {
+ Email string
+ FirstName string
+ LastName string
+ Avatar string
+ ProviderID string
+}
+
+type TokenData struct {
+ AccessToken string
+ RefreshToken string
+ IDToken string
+ ExpiresAt *time.Time
+}
+
+type ProviderConfig struct {
+ ClientID string
+ ClientSecret string
+ RedirectURI string
+}
+
+func httpPostForm(tokenURL string, data url.Values, extraHeaders map[string]string) (map[string]interface{}, error) {
+ req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ for k, v := range extraHeaders {
+ req.Header.Set(k, v)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode >= 400 {
+ return nil, fmt.Errorf("%w: %s", ErrTokenExchange, string(body))
+ }
+ var result map[string]interface{}
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parse token response: %w", err)
+ }
+ return result, nil
+}
+
+func httpGetJSON(url string, token string) (map[string]interface{}, error) {
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Accept", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode >= 400 {
+ return nil, fmt.Errorf("%w: %s", ErrUserInfo, string(body))
+ }
+ var result map[string]interface{}
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parse user info: %w", err)
+ }
+ return result, nil
+}
+
+type Provider interface {
+ Name() string
+ AuthURL(state string) string
+ Exchange(ctx context.Context, code string) (*TokenData, error)
+ GetUserInfo(ctx context.Context, token *TokenData) (*UserInfo, error)
+}
diff --git a/api/internal/queue/consumer.go b/api/internal/queue/consumer.go
index 76c6203..8dd1473 100644
--- a/api/internal/queue/consumer.go
+++ b/api/internal/queue/consumer.go
@@ -61,14 +61,16 @@ func (c *Consumer) Run(ctx context.Context, queues []string) error {
func (c *Consumer) handle(ctx context.Context, queue string, d amqp.Delivery, h TaskHandler) {
err := h(ctx, queue, d.Body)
if err != nil {
- if c.log != nil {
- c.log.Warn("task failed", "queue", queue, "error", err)
- }
retryCount := int64(0)
if d.Headers != nil {
if v, ok := d.Headers["x-retry-count"]; ok {
- if n, ok := v.(int64); ok {
+ switch n := v.(type) {
+ case int64:
retryCount = n
+ case int32:
+ retryCount = int64(n)
+ case int:
+ retryCount = int64(n)
}
}
}
@@ -80,7 +82,20 @@ func (c *Consumer) handle(ctx context.Context, queue string, d amqp.Delivery, h
_ = d.Ack(false)
return
}
- _ = d.Nack(false, true)
+ if c.log != nil {
+ c.log.Warn("task failed, retrying", "queue", queue, "retry", retryCount+1, "error", err)
+ }
+ headers := amqp.Table{"x-retry-count": retryCount + 1}
+ pubErr := c.ch.PublishWithContext(ctx, "", queue, false, false, amqp.Publishing{
+ DeliveryMode: amqp.Persistent,
+ ContentType: d.ContentType,
+ Body: d.Body,
+ Headers: headers,
+ })
+ if pubErr != nil && c.log != nil {
+ c.log.Error("failed to republish for retry", "queue", queue, "error", pubErr)
+ }
+ _ = d.Ack(false)
return
}
_ = d.Ack(false)
diff --git a/api/internal/router/router.go b/api/internal/router/router.go
index 5af24bb..820dda6 100644
--- a/api/internal/router/router.go
+++ b/api/internal/router/router.go
@@ -2,11 +2,13 @@ package router
import (
"log/slog"
+ "strings"
"github.com/Devlaner/devlane/api/internal/auth"
"github.com/Devlaner/devlane/api/internal/handler"
"github.com/Devlaner/devlane/api/internal/middleware"
"github.com/Devlaner/devlane/api/internal/minio"
+ "github.com/Devlaner/devlane/api/internal/oauth"
"github.com/Devlaner/devlane/api/internal/queue"
"github.com/Devlaner/devlane/api/internal/redis"
"github.com/Devlaner/devlane/api/internal/service"
@@ -24,6 +26,15 @@ type Config struct {
Minio *minio.Client // optional: file uploads (cover images, avatars, logos)
CORSAllowOrigin string // optional: e.g. "http://localhost:5173" for UI dev
AppBaseURL string // optional: base URL for invite links; if empty, CORSAllowOrigin is used
+
+ // OAuth providers (empty = disabled)
+ GoogleClientID string
+ GoogleClientSecret string
+ GitHubClientID string
+ GitHubClientSecret string
+ GitLabClientID string
+ GitLabClientSecret string
+ GitLabHost string
}
// New builds and returns the Gin engine with /api/ and /auth/ routes.
@@ -71,24 +82,59 @@ func New(cfg Config) *gin.Engine {
// Password reset tokens
passwordResetTokenStore := store.NewPasswordResetTokenStore(cfg.DB)
+ accountStore := store.NewAccountStore(cfg.DB)
// Auth
authSvc := auth.NewService(userStore, sessionStore, passwordResetTokenStore)
+ authSvc.SetAccountStore(accountStore)
appBaseURL := cfg.AppBaseURL
if appBaseURL == "" {
appBaseURL = cfg.CORSAllowOrigin
}
+ // Determine the API base URL (scheme+host of the backend) for OAuth redirect URIs
+ apiBaseURL := strings.TrimSuffix(appBaseURL, "/")
+
+ // OAuth providers
+ oauthProviders := make(map[string]oauth.Provider)
+ if cfg.GoogleClientID != "" && cfg.GoogleClientSecret != "" {
+ oauthProviders["google"] = oauth.NewGoogleProvider(oauth.ProviderConfig{
+ ClientID: cfg.GoogleClientID,
+ ClientSecret: cfg.GoogleClientSecret,
+ RedirectURI: apiBaseURL + "/auth/google/callback/",
+ })
+ }
+ if cfg.GitHubClientID != "" && cfg.GitHubClientSecret != "" {
+ oauthProviders["github"] = oauth.NewGitHubProvider(oauth.ProviderConfig{
+ ClientID: cfg.GitHubClientID,
+ ClientSecret: cfg.GitHubClientSecret,
+ RedirectURI: apiBaseURL + "/auth/github/callback/",
+ })
+ }
+ if cfg.GitLabClientID != "" && cfg.GitLabClientSecret != "" {
+ oauthProviders["gitlab"] = oauth.NewGitLabProvider(oauth.ProviderConfig{
+ ClientID: cfg.GitLabClientID,
+ ClientSecret: cfg.GitLabClientSecret,
+ RedirectURI: apiBaseURL + "/auth/gitlab/callback/",
+ }, cfg.GitLabHost)
+ }
+
+ oauthEnabled := make(map[string]any, len(oauthProviders))
+ for k, v := range oauthProviders {
+ oauthEnabled[k] = v
+ }
+
authHandler := &handler.AuthHandler{
- Auth: authSvc,
- Settings: instanceSettingStore,
- Winv: workspaceInviteStore,
- Ws: workspaceStore,
- NotifPrefs: userNotifPrefStore,
- ApiTokens: apiTokenStore,
- Queue: cfg.Queue,
- AppBaseURL: appBaseURL,
- Log: cfg.Log,
+ Auth: authSvc,
+ Settings: instanceSettingStore,
+ Winv: workspaceInviteStore,
+ Ws: workspaceStore,
+ NotifPrefs: userNotifPrefStore,
+ ApiTokens: apiTokenStore,
+ Queue: cfg.Queue,
+ AppBaseURL: appBaseURL,
+ Log: cfg.Log,
+ OAuthProviders: oauthEnabled,
}
// 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}
@@ -295,6 +341,18 @@ func New(cfg Config) *gin.Engine {
authGroup.POST("/reset-password/", authHandler.ResetPassword)
}
+ // OAuth routes (no auth required)
+ if len(oauthProviders) > 0 {
+ oauthHandler := &handler.OAuthHandler{
+ Providers: oauthProviders,
+ Auth: authSvc,
+ AppBaseURL: appBaseURL,
+ Log: cfg.Log,
+ }
+ authGroup.GET("/:provider/", oauthHandler.Initiate)
+ authGroup.GET("/:provider/callback/", oauthHandler.Callback)
+ }
+
// Legacy /api/v1
v1 := r.Group("/api/v1")
v1.Use(middleware.RequireAuth(authSvc, cfg.Log))
diff --git a/api/internal/store/account.go b/api/internal/store/account.go
new file mode 100644
index 0000000..3e7335b
--- /dev/null
+++ b/api/internal/store/account.go
@@ -0,0 +1,56 @@
+package store
+
+import (
+ "context"
+
+ "github.com/Devlaner/devlane/api/internal/model"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+type AccountStore struct{ db *gorm.DB }
+
+func NewAccountStore(db *gorm.DB) *AccountStore {
+ return &AccountStore{db: db}
+}
+
+func (s *AccountStore) Upsert(ctx context.Context, a *model.Account) error {
+ return s.db.WithContext(ctx).
+ Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "provider"}, {Name: "provider_account_id"}},
+ DoUpdates: clause.AssignmentColumns([]string{"access_token", "refresh_token", "id_token", "token_expires_at", "last_connected_at", "updated_at"}),
+ }).
+ Create(a).Error
+}
+
+func (s *AccountStore) GetByProvider(ctx context.Context, userID uuid.UUID, provider string) (*model.Account, error) {
+ var a model.Account
+ err := s.db.WithContext(ctx).
+ Where("user_id = ? AND provider = ?", userID, provider).
+ First(&a).Error
+ if err != nil {
+ return nil, err
+ }
+ return &a, nil
+}
+
+func (s *AccountStore) ListByUser(ctx context.Context, userID uuid.UUID) ([]model.Account, error) {
+ var accounts []model.Account
+ err := s.db.WithContext(ctx).
+ Where("user_id = ?", userID).
+ Order("created_at").
+ Find(&accounts).Error
+ return accounts, err
+}
+
+func (s *AccountStore) FindByProviderID(ctx context.Context, provider, providerAccountID string) (*model.Account, error) {
+ var a model.Account
+ err := s.db.WithContext(ctx).
+ Where("provider = ? AND provider_account_id = ?", provider, providerAccountID).
+ First(&a).Error
+ if err != nil {
+ return nil, err
+ }
+ return &a, nil
+}
diff --git a/api/internal/store/password_reset_token.go b/api/internal/store/password_reset_token.go
index e2c2c4b..925789e 100644
--- a/api/internal/store/password_reset_token.go
+++ b/api/internal/store/password_reset_token.go
@@ -18,10 +18,6 @@ func NewPasswordResetTokenStore(db *gorm.DB) *PasswordResetTokenStore {
}
func (s *PasswordResetTokenStore) Create(ctx context.Context, userID uuid.UUID, token string) error {
- s.db.WithContext(ctx).
- Where("user_id = ? AND used_at IS NULL", userID).
- Delete(&model.PasswordResetToken{})
-
return s.db.WithContext(ctx).Create(&model.PasswordResetToken{
UserID: userID,
Token: token,
@@ -29,6 +25,16 @@ func (s *PasswordResetTokenStore) Create(ctx context.Context, userID uuid.UUID,
}).Error
}
+// InvalidateForUser marks all unused tokens for the given user as used.
+// Called after a successful password reset to prevent replay of older tokens.
+func (s *PasswordResetTokenStore) InvalidateForUser(ctx context.Context, userID uuid.UUID) error {
+ now := time.Now().UTC()
+ return s.db.WithContext(ctx).
+ Model(&model.PasswordResetToken{}).
+ Where("user_id = ? AND used_at IS NULL", userID).
+ Update("used_at", now).Error
+}
+
func (s *PasswordResetTokenStore) GetValid(ctx context.Context, token string) (*model.PasswordResetToken, error) {
var t model.PasswordResetToken
err := s.db.WithContext(ctx).
diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts
index 51d5bd2..f708598 100644
--- a/ui/src/api/types.ts
+++ b/ui/src/api/types.ts
@@ -324,6 +324,9 @@ export interface AuthConfigResponse {
is_email_password_enabled: boolean;
enable_signup: boolean;
is_smtp_configured: boolean;
+ is_google_enabled: boolean;
+ is_github_enabled: boolean;
+ is_gitlab_enabled: boolean;
}
/** Instance settings: section key -> value object (from GET /api/instance/settings/) */
diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx
index 2ea18bf..429a30b 100644
--- a/ui/src/pages/LoginPage.tsx
+++ b/ui/src/pages/LoginPage.tsx
@@ -1,8 +1,9 @@
import { useState, useCallback, useEffect, useMemo } from 'react';
-import { useNavigate, useLocation, Link } from 'react-router-dom';
+import { useNavigate, useLocation, Link, useSearchParams } from 'react-router-dom';
import { Button, Input, Card, CardContent } from '../components/ui';
import { useAuth } from '../contexts/AuthContext';
import { authService } from '../services/authService';
+import { config } from '../config/env';
import { Eye, EyeOff, CircleAlert, CircleCheck } from 'lucide-react';
type AuthStep = 'email' | 'password';
@@ -85,6 +86,20 @@ export function LoginPage() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [allowSignup, setAllowSignup] = useState(true);
const [isSmtpConfigured, setIsSmtpConfigured] = useState(false);
+ const [oauthProviders, setOauthProviders] = useState({
+ google: false,
+ github: false,
+ gitlab: false,
+ });
+
+ const [searchParams] = useSearchParams();
+ const oauthError = searchParams.get('error');
+
+ useEffect(() => {
+ if (oauthError) {
+ setError(oauthError);
+ }
+ }, [oauthError]);
useEffect(() => {
authService
@@ -92,10 +107,26 @@ export function LoginPage() {
.then((cfg) => {
setAllowSignup(cfg.enable_signup);
setIsSmtpConfigured(cfg.is_smtp_configured);
+ setOauthProviders({
+ google: cfg.is_google_enabled,
+ github: cfg.is_github_enabled,
+ gitlab: cfg.is_gitlab_enabled,
+ });
})
.catch(() => {});
}, []);
+ const hasOAuth = oauthProviders.google || oauthProviders.github || oauthProviders.gitlab;
+
+ const handleOAuth = useCallback(
+ (provider: string) => {
+ const base = config.apiBaseUrl || '';
+ const nextPath = returnPath !== '/' ? `?next_path=${encodeURIComponent(returnPath)}` : '';
+ window.location.assign(`${base}/auth/${provider}/${nextPath}`);
+ },
+ [returnPath],
+ );
+
const handleEmailSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
@@ -227,21 +258,86 @@ export function LoginPage() {
)}
{step === 'email' && (
-
+ <>
+ {hasOAuth && (
+
+ {oauthProviders.google && (
+
handleOAuth('google')}
+ className="flex w-full items-center justify-center gap-2 rounded-md border border-(--border-primary) px-4 py-2 text-sm font-medium text-(--txt-primary) hover:bg-(--bg-subtle) transition-colors"
+ >
+
+
+
+
+
+
+ Continue with Google
+
+ )}
+ {oauthProviders.github && (
+
handleOAuth('github')}
+ className="flex w-full items-center justify-center gap-2 rounded-md border border-(--border-primary) px-4 py-2 text-sm font-medium text-(--txt-primary) hover:bg-(--bg-subtle) transition-colors"
+ >
+
+
+
+ Continue with GitHub
+
+ )}
+ {oauthProviders.gitlab && (
+
handleOAuth('gitlab')}
+ className="flex w-full items-center justify-center gap-2 rounded-md border border-(--border-primary) px-4 py-2 text-sm font-medium text-(--txt-primary) hover:bg-(--bg-subtle) transition-colors"
+ >
+
+
+
+ Continue with GitLab
+
+ )}
+
+
+ )}
+
+ >
)}
{step === 'password' && (
From b066032e501e5baa90bb3022d4e206fa4f441ae6 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Mon, 6 Apr 2026 02:31:31 +0400
Subject: [PATCH 10/43] feat(db): create accounts table to store user provider
connections
---
api/migrations/000003_accounts.down.sql | 1 +
api/migrations/000003_accounts.up.sql | 18 ++++++++++++++++++
2 files changed, 19 insertions(+)
create mode 100644 api/migrations/000003_accounts.down.sql
create mode 100644 api/migrations/000003_accounts.up.sql
diff --git a/api/migrations/000003_accounts.down.sql b/api/migrations/000003_accounts.down.sql
new file mode 100644
index 0000000..1616db4
--- /dev/null
+++ b/api/migrations/000003_accounts.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS accounts;
diff --git a/api/migrations/000003_accounts.up.sql b/api/migrations/000003_accounts.up.sql
new file mode 100644
index 0000000..ccf2edc
--- /dev/null
+++ b/api/migrations/000003_accounts.up.sql
@@ -0,0 +1,18 @@
+CREATE TABLE IF NOT EXISTS accounts (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ provider VARCHAR(50) NOT NULL,
+ provider_account_id VARCHAR(255) NOT NULL,
+ access_token TEXT DEFAULT '',
+ refresh_token TEXT DEFAULT '',
+ id_token TEXT DEFAULT '',
+ token_expires_at TIMESTAMPTZ,
+ last_connected_at TIMESTAMPTZ,
+ metadata JSONB DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE(provider, provider_account_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_accounts_user_id ON accounts(user_id);
+CREATE INDEX IF NOT EXISTS idx_accounts_provider ON accounts(provider, user_id);
From 0ab34c364ba4b8826d2e138b78e9f4675f5fbad0 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Thu, 9 Apr 2026 14:57:44 +0400
Subject: [PATCH 11/43] feat(api): add auth for API
---
api/internal/auth/service_test.go | 202 ++++++++++++++++++++++++++++++
1 file changed, 202 insertions(+)
create mode 100644 api/internal/auth/service_test.go
diff --git a/api/internal/auth/service_test.go b/api/internal/auth/service_test.go
new file mode 100644
index 0000000..1d82cb0
--- /dev/null
+++ b/api/internal/auth/service_test.go
@@ -0,0 +1,202 @@
+package auth
+
+import (
+ "context"
+ "testing"
+
+ "github.com/Devlaner/devlane/api/internal/store"
+ "github.com/glebarez/sqlite"
+ "gorm.io/gorm"
+)
+
+func newTestService(t *testing.T) (*Service, *gorm.DB) {
+ t.Helper()
+
+ db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("open sqlite: %v", err)
+ }
+
+ // Our production models include Postgres-specific column types/defaults (e.g. uuid + gen_random_uuid()).
+ // For unit tests, we create a SQLite-compatible schema that matches the columns used by stores.
+ stmts := []string{
+ `CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY,
+ password TEXT NOT NULL,
+ username TEXT NOT NULL,
+ email TEXT,
+ first_name TEXT DEFAULT '',
+ last_name TEXT DEFAULT '',
+ display_name TEXT,
+ avatar TEXT,
+ cover_image TEXT,
+ date_joined DATETIME NOT NULL,
+ created_at DATETIME,
+ updated_at DATETIME,
+ deleted_at DATETIME,
+ is_active INTEGER DEFAULT 1,
+ is_onboarded INTEGER DEFAULT 0,
+ user_timezone TEXT DEFAULT 'UTC'
+ );`,
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username);`,
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);`,
+ `CREATE TABLE IF NOT EXISTS sessions (
+ session_key TEXT PRIMARY KEY,
+ session_data TEXT NOT NULL,
+ expire_date DATETIME NOT NULL
+ );`,
+ `CREATE TABLE IF NOT EXISTS password_reset_tokens (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ token TEXT NOT NULL UNIQUE,
+ expires_at DATETIME NOT NULL,
+ used_at DATETIME,
+ created_at DATETIME
+ );`,
+ `CREATE INDEX IF NOT EXISTS idx_prt_user_id ON password_reset_tokens(user_id);`,
+ }
+ for _, s := range stmts {
+ if err := db.Exec(s).Error; err != nil {
+ t.Fatalf("create test schema: %v", err)
+ }
+ }
+
+ userStore := store.NewUserStore(db)
+ sessionStore := store.NewSessionStore(db)
+ resetStore := store.NewPasswordResetTokenStore(db)
+
+ svc := NewService(userStore, sessionStore, resetStore)
+ return svc, db
+}
+
+func TestPasswordSignupSigninMeFlow(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+
+ svc, _ := newTestService(t)
+
+ // Sign up
+ sessionKey, user, err := svc.SignUp(ctx, SignUpRequest{
+ Email: "Test.User@example.com",
+ Password: "S3cur3!Pass",
+ FirstName: "Test",
+ LastName: "User",
+ })
+ if err != nil {
+ t.Fatalf("SignUp: %v", err)
+ }
+ if user == nil || user.Email == nil || *user.Email != "test.user@example.com" {
+ t.Fatalf("unexpected user email: %#v", user)
+ }
+ if sessionKey == "" {
+ t.Fatalf("expected session key")
+ }
+
+ // Session -> user
+ got, err := svc.UserFromSession(ctx, sessionKey)
+ if err != nil {
+ t.Fatalf("UserFromSession: %v", err)
+ }
+ if got == nil || got.ID != user.ID {
+ t.Fatalf("unexpected user from session: %#v", got)
+ }
+
+ // Sign out invalidates session
+ if err := svc.SignOut(ctx, sessionKey); err != nil {
+ t.Fatalf("SignOut: %v", err)
+ }
+ got2, err := svc.UserFromSession(ctx, sessionKey)
+ if err == nil && got2 != nil {
+ t.Fatalf("expected no user after signout, got: %#v", got2)
+ }
+
+ // Sign in
+ sessionKey2, user2, err := svc.SignIn(ctx, SignInRequest{
+ Email: "test.user@example.com",
+ Password: "S3cur3!Pass",
+ })
+ if err != nil {
+ t.Fatalf("SignIn: %v", err)
+ }
+ if sessionKey2 == "" {
+ t.Fatalf("expected session key from SignIn")
+ }
+ if user2 == nil || user2.ID != user.ID {
+ t.Fatalf("unexpected user from SignIn: %#v", user2)
+ }
+}
+
+func TestEmailCheck(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+
+ svc, _ := newTestService(t)
+
+ exists, err := svc.EmailCheck(ctx, "nobody@example.com")
+ if err != nil {
+ t.Fatalf("EmailCheck: %v", err)
+ }
+ if exists {
+ t.Fatalf("expected email to not exist")
+ }
+
+ _, _, err = svc.SignUp(ctx, SignUpRequest{
+ Email: "someone@example.com",
+ Password: "S3cur3!Pass",
+ })
+ if err != nil {
+ t.Fatalf("SignUp: %v", err)
+ }
+
+ exists2, err := svc.EmailCheck(ctx, "SOMEONE@EXAMPLE.COM")
+ if err != nil {
+ t.Fatalf("EmailCheck: %v", err)
+ }
+ if !exists2 {
+ t.Fatalf("expected email to exist")
+ }
+}
+
+func TestForgotResetPassword(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+
+ svc, _ := newTestService(t)
+
+ _, _, err := svc.SignUp(ctx, SignUpRequest{
+ Email: "resetme@example.com",
+ Password: "OldP@ssw0rd!",
+ })
+ if err != nil {
+ t.Fatalf("SignUp: %v", err)
+ }
+
+ token, err := svc.ForgotPassword(ctx, "resetme@example.com")
+ if err != nil {
+ t.Fatalf("ForgotPassword: %v", err)
+ }
+ if token == "" {
+ t.Fatalf("expected non-empty reset token")
+ }
+
+ if err := svc.ResetPassword(ctx, token, "NewP@ssw0rd!"); err != nil {
+ t.Fatalf("ResetPassword: %v", err)
+ }
+
+ // Old password no longer works
+ _, _, err = svc.SignIn(ctx, SignInRequest{Email: "resetme@example.com", Password: "OldP@ssw0rd!"})
+ if err == nil {
+ t.Fatalf("expected old password to fail")
+ }
+
+ // New password works
+ _, _, err = svc.SignIn(ctx, SignInRequest{Email: "resetme@example.com", Password: "NewP@ssw0rd!"})
+ if err != nil {
+ t.Fatalf("expected new password to work, got: %v", err)
+ }
+
+ // Token is no longer valid (invalidate-for-user)
+ if err := svc.ResetPassword(ctx, token, "AnotherP@ssw0rd!"); err == nil {
+ t.Fatalf("expected reused token to fail")
+ }
+}
From 109febbf4e6bae03faf59b9dac8f26822623c2ce Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Thu, 9 Apr 2026 14:58:40 +0400
Subject: [PATCH 12/43] feat(deps): add glebarez/sqlite dependency for sqlite
database support
---
api/go.mod | 7 +++++++
api/go.sum | 17 +++++++++++++++++
2 files changed, 24 insertions(+)
diff --git a/api/go.mod b/api/go.mod
index 7c9e4b6..fc8cd39 100644
--- a/api/go.mod
+++ b/api/go.mod
@@ -4,6 +4,7 @@ go 1.25.5
require (
github.com/gin-gonic/gin v1.11.0
+ github.com/glebarez/sqlite v1.11.0
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
@@ -24,6 +25,7 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
+ github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
@@ -52,6 +54,7 @@ require (
github.com/philhofer/fwd v1.2.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
@@ -67,4 +70,8 @@ require (
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
+ modernc.org/libc v1.22.5 // indirect
+ modernc.org/mathutil v1.5.0 // indirect
+ modernc.org/memory v1.5.0 // indirect
+ modernc.org/sqlite v1.23.1 // indirect
)
diff --git a/api/go.sum b/api/go.sum
index 029016d..dbedf8b 100644
--- a/api/go.sum
+++ b/api/go.sum
@@ -45,6 +45,10 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
+github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
+github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
+github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
+github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
@@ -70,6 +74,8 @@ github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjY
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -142,6 +148,9 @@ github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzuk
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
+github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
@@ -207,3 +216,11 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
+modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
+modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
+modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
+modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
+modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
+modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
+modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
From ec834395f2d2a610d7c2d03b132b2a5729762adb Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Sat, 11 Apr 2026 14:53:07 +0400
Subject: [PATCH 13/43] feat(auth): add env vars for google oauth and magic
code login, update app base url description
---
api/.env.example | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/api/.env.example b/api/.env.example
index 2ec0799..b80636d 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -20,9 +20,18 @@ REDIS_DB=0
# RabbitMQ
RABBITMQ_URL=amqp://guest:guest@localhost:5672/
-# Frontend URL for invite links in emails (e.g. https://app.example.com). If unset, CORS_ORIGIN is used.
+# Frontend URL for invite links and post-login redirects (e.g. http://localhost:5173). If unset, CORS_ORIGIN is used.
APP_BASE_URL=https://app.example.com
+# Public URL of this API (OAuth callbacks must hit the API, not the SPA). Local dev when UI is on 5173 and API on 8080:
+# API_PUBLIC_URL=http://localhost:8080
+# Google Cloud Console → Authorized redirect URI: {API_PUBLIC_URL}/auth/google/callback/
+# GOOGLE_CLIENT_ID=
+# GOOGLE_CLIENT_SECRET=
+
+# HMAC key for email login codes (set in production).
+# MAGIC_CODE_SECRET=
+
# MinIO (S3-compatible)
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY_ID=minioadmin
From db325dcd3c7ea6b064e9c6f4e11422a778820464 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Sat, 11 Apr 2026 14:56:15 +0400
Subject: [PATCH 14/43] feat(auth): add SignUpMagic and SessionForEmailUser to
enable magic link authentication
---
api/internal/auth/magic_code.go | 34 ++++++++++++++++
api/internal/auth/magic_code_test.go | 28 +++++++++++++
api/internal/auth/service.go | 61 ++++++++++++++++++++++++++++
api/internal/auth/service_test.go | 28 +++++++++++++
4 files changed, 151 insertions(+)
create mode 100644 api/internal/auth/magic_code.go
create mode 100644 api/internal/auth/magic_code_test.go
diff --git a/api/internal/auth/magic_code.go b/api/internal/auth/magic_code.go
new file mode 100644
index 0000000..31dfe92
--- /dev/null
+++ b/api/internal/auth/magic_code.go
@@ -0,0 +1,34 @@
+package auth
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "strings"
+)
+
+// DefaultMagicCodeHMACKey is used when MAGIC_CODE_SECRET is unset (development only).
+const DefaultMagicCodeHMACKey = "devlane-insecure-magic-code-hmac-key-change-in-production"
+
+// NormalizeMagicCode strips spaces and hyphens so users can paste formatted codes.
+func NormalizeMagicCode(code string) string {
+ s := strings.TrimSpace(code)
+ s = strings.ReplaceAll(s, " ", "")
+ s = strings.ReplaceAll(s, "-", "")
+ return s
+}
+
+// MagicCodeHMAC returns a hex-encoded HMAC-SHA256 of the normalized email and code.
+func MagicCodeHMAC(secret, email, code string) string {
+ e := strings.ToLower(strings.TrimSpace(email))
+ c := NormalizeMagicCode(code)
+ key := strings.TrimSpace(secret)
+ if key == "" {
+ key = DefaultMagicCodeHMACKey
+ }
+ mac := hmac.New(sha256.New, []byte(key))
+ _, _ = mac.Write([]byte(e))
+ _, _ = mac.Write([]byte{0})
+ _, _ = mac.Write([]byte(c))
+ return hex.EncodeToString(mac.Sum(nil))
+}
diff --git a/api/internal/auth/magic_code_test.go b/api/internal/auth/magic_code_test.go
new file mode 100644
index 0000000..f4744c6
--- /dev/null
+++ b/api/internal/auth/magic_code_test.go
@@ -0,0 +1,28 @@
+package auth
+
+import "testing"
+
+func TestNormalizeMagicCode(t *testing.T) {
+ t.Parallel()
+ if got := NormalizeMagicCode(" 123 456 "); got != "123456" {
+ t.Fatalf("got %q", got)
+ }
+ if got := NormalizeMagicCode("12-34-56"); got != "123456" {
+ t.Fatalf("got %q", got)
+ }
+}
+
+func TestMagicCodeHMAC_Deterministic(t *testing.T) {
+ t.Parallel()
+ a := MagicCodeHMAC("secret", "A@B.com", "123456")
+ b := MagicCodeHMAC("secret", "a@b.com", "123456")
+ if a != b {
+ t.Fatalf("email case should not matter")
+ }
+ if MagicCodeHMAC("secret", "a@b.com", "123456") != MagicCodeHMAC("secret", "a@b.com", "1234 56") {
+ t.Fatalf("spacing should not matter")
+ }
+ if MagicCodeHMAC("s1", "a@b.com", "123456") == MagicCodeHMAC("s2", "a@b.com", "123456") {
+ t.Fatalf("secret should matter")
+ }
+}
diff --git a/api/internal/auth/service.go b/api/internal/auth/service.go
index 823ea49..2e021d3 100644
--- a/api/internal/auth/service.go
+++ b/api/internal/auth/service.go
@@ -94,6 +94,67 @@ func (s *Service) SignUp(ctx context.Context, req SignUpRequest) (sessionKey str
return sessionKey, u, nil
}
+// SignUpMagic creates a new user with a random password (same pattern as OAuth) and starts a session.
+func (s *Service) SignUpMagic(ctx context.Context, email, firstName, lastName string) (sessionKey string, user *model.User, err error) {
+ email = strings.TrimSpace(strings.ToLower(email))
+ existing, _ := s.userStore.GetByEmail(ctx, email)
+ if existing != nil {
+ return "", nil, ErrEmailTaken
+ }
+ username := email
+ if at := strings.Index(email, "@"); at > 0 {
+ username = strings.ReplaceAll(email[:at], ".", "_")
+ }
+ if existing, _ = s.userStore.GetByUsername(ctx, username); existing != nil {
+ username = email
+ }
+ dummyPwd := make([]byte, 32)
+ if _, err := rand.Read(dummyPwd); err != nil {
+ return "", nil, err
+ }
+ hash, err := bcrypt.GenerateFromPassword(dummyPwd, bcryptCost)
+ if err != nil {
+ return "", nil, err
+ }
+ u := &model.User{
+ Username: username,
+ Email: &email,
+ Password: string(hash),
+ FirstName: firstName,
+ LastName: lastName,
+ DisplayName: strings.TrimSpace(firstName + " " + lastName),
+ IsActive: true,
+ }
+ if err := s.userStore.Create(ctx, u); err != nil {
+ return "", nil, err
+ }
+ sessionKey, err = s.createSession(ctx, u.ID)
+ if err != nil {
+ return "", nil, err
+ }
+ return sessionKey, u, nil
+}
+
+// SessionForEmailUser creates a new session for an existing user by email (magic-code / trusted flows).
+func (s *Service) SessionForEmailUser(ctx context.Context, email string) (sessionKey string, user *model.User, 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 "", nil, ErrInvalidCredentials
+ }
+ return "", nil, err
+ }
+ if u == nil || !u.IsActive {
+ return "", nil, ErrInvalidCredentials
+ }
+ sessionKey, err = s.createSession(ctx, u.ID)
+ if err != nil {
+ return "", nil, err
+ }
+ 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) {
diff --git a/api/internal/auth/service_test.go b/api/internal/auth/service_test.go
index 1d82cb0..6966fa3 100644
--- a/api/internal/auth/service_test.go
+++ b/api/internal/auth/service_test.go
@@ -2,6 +2,7 @@ package auth
import (
"context"
+ "errors"
"testing"
"github.com/Devlaner/devlane/api/internal/store"
@@ -200,3 +201,30 @@ func TestForgotResetPassword(t *testing.T) {
t.Fatalf("expected reused token to fail")
}
}
+
+func TestSignUpMagicAndSessionForEmail(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+ svc, _ := newTestService(t)
+
+ sk1, u1, err := svc.SignUpMagic(ctx, "magic-new@example.com", "A", "B")
+ if err != nil {
+ t.Fatalf("SignUpMagic: %v", err)
+ }
+ if sk1 == "" || u1 == nil {
+ t.Fatalf("expected session and user")
+ }
+
+ sk2, u2, err := svc.SessionForEmailUser(ctx, "magic-new@example.com")
+ if err != nil {
+ t.Fatalf("SessionForEmailUser: %v", err)
+ }
+ if sk2 == "" || u2 == nil || u2.ID != u1.ID {
+ t.Fatalf("unexpected second session user: %#v", u2)
+ }
+
+ _, _, err = svc.SignUpMagic(ctx, "magic-new@example.com", "X", "Y")
+ if err == nil || !errors.Is(err, ErrEmailTaken) {
+ t.Fatalf("expected ErrEmailTaken, got %v", err)
+ }
+}
From 29a019a19cc85238755e95d72024fdf1be3e17d5 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Sat, 11 Apr 2026 14:57:10 +0400
Subject: [PATCH 15/43] refactor(ui): update api, instance-admin, pages,
services for ui
---
ui/src/api/types.ts | 18 ++
ui/src/pages/LoginPage.tsx | 216 ++++++++++++++++--
.../instance-admin/InstanceAdminEmailPage.tsx | 7 +-
ui/src/services/authService.ts | 12 +
4 files changed, 236 insertions(+), 17 deletions(-)
diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts
index f708598..6c12200 100644
--- a/ui/src/api/types.ts
+++ b/ui/src/api/types.ts
@@ -322,11 +322,29 @@ export interface ResetPasswordRequest {
/** GET /auth/config/ response */
export interface AuthConfigResponse {
is_email_password_enabled: boolean;
+ is_magic_code_enabled: boolean;
enable_signup: boolean;
is_smtp_configured: boolean;
is_google_enabled: boolean;
is_github_enabled: boolean;
is_gitlab_enabled: boolean;
+ /** Present when at least one OAuth provider is enabled; use for redirect URIs in provider consoles. */
+ oauth_redirect_base?: string;
+}
+
+/** POST /auth/magic-code/request/ */
+export interface MagicCodeRequestPayload {
+ email: string;
+ invite_token?: string;
+}
+
+/** POST /auth/magic-code/verify/ */
+export interface MagicCodeVerifyPayload {
+ email: string;
+ code: string;
+ first_name?: string;
+ last_name?: string;
+ invite_token?: string;
}
/** Instance settings: section key -> value object (from GET /api/instance/settings/) */
diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx
index 429a30b..7a1aaf4 100644
--- a/ui/src/pages/LoginPage.tsx
+++ b/ui/src/pages/LoginPage.tsx
@@ -3,10 +3,11 @@ import { useNavigate, useLocation, Link, useSearchParams } from 'react-router-do
import { Button, Input, Card, CardContent } from '../components/ui';
import { useAuth } from '../contexts/AuthContext';
import { authService } from '../services/authService';
+import { getApiErrorMessage } from '../api/client';
import { config } from '../config/env';
import { Eye, EyeOff, CircleAlert, CircleCheck } from 'lucide-react';
-type AuthStep = 'email' | 'password';
+type AuthStep = 'email' | 'password' | 'code';
type AuthMode = 'sign-in' | 'sign-up';
interface PasswordCriteria {
@@ -68,11 +69,20 @@ export function LoginPage() {
const state = location.state as {
from?: { pathname?: string; search?: string };
email?: string;
+ inviteToken?: string;
} | null;
const from = state?.from;
const returnPath = from ? (from.pathname ?? '/') + (from.search ?? '') : '/';
const prefilledEmail = state?.email ?? '';
+ const [searchParams] = useSearchParams();
+ const oauthError = searchParams.get('error');
+ const inviteToken = useMemo(() => {
+ const q = searchParams.get('invite')?.trim() ?? '';
+ const st = state?.inviteToken?.trim() ?? '';
+ return q || st;
+ }, [searchParams, state?.inviteToken]);
+
const [step, setStep] = useState('email');
const [mode, setMode] = useState('sign-in');
const [email, setEmail] = useState(prefilledEmail);
@@ -80,21 +90,21 @@ export function LoginPage() {
const [confirmPassword, setConfirmPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
+ const [magicCode, setMagicCode] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [allowSignup, setAllowSignup] = useState(true);
const [isSmtpConfigured, setIsSmtpConfigured] = useState(false);
+ const [isPasswordEnabled, setIsPasswordEnabled] = useState(true);
+ const [isMagicCodeEnabled, setIsMagicCodeEnabled] = useState(true);
const [oauthProviders, setOauthProviders] = useState({
google: false,
github: false,
gitlab: false,
});
- const [searchParams] = useSearchParams();
- const oauthError = searchParams.get('error');
-
useEffect(() => {
if (oauthError) {
setError(oauthError);
@@ -107,6 +117,8 @@ export function LoginPage() {
.then((cfg) => {
setAllowSignup(cfg.enable_signup);
setIsSmtpConfigured(cfg.is_smtp_configured);
+ setIsPasswordEnabled(cfg.is_email_password_enabled);
+ setIsMagicCodeEnabled(cfg.is_magic_code_enabled ?? true);
setOauthProviders({
google: cfg.is_google_enabled,
github: cfg.is_github_enabled,
@@ -118,6 +130,11 @@ export function LoginPage() {
const hasOAuth = oauthProviders.google || oauthProviders.github || oauthProviders.gitlab;
+ const canUseMagicCode =
+ isMagicCodeEnabled &&
+ isSmtpConfigured &&
+ (mode === 'sign-in' || (mode === 'sign-up' && (allowSignup || !!inviteToken)));
+
const handleOAuth = useCallback(
(provider: string) => {
const base = config.apiBaseUrl || '';
@@ -127,6 +144,13 @@ export function LoginPage() {
[returnPath],
);
+ const sendMagicCode = useCallback(async () => {
+ await authService.requestMagicCode({
+ email,
+ ...(inviteToken ? { invite_token: inviteToken } : {}),
+ });
+ }, [email, inviteToken]);
+
const handleEmailSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
@@ -138,12 +162,30 @@ export function LoginPage() {
setMode('sign-in');
} else {
if (!resp.allow_public_signup) {
- setError('Sign-up is by invite only.');
+ if (!inviteToken) {
+ setError('Sign-up is by invite only.');
+ setIsSubmitting(false);
+ return;
+ }
+ setMode('sign-up');
+ } else {
+ setMode('sign-up');
+ }
+ }
+
+ const magicOnly = !isPasswordEnabled && isMagicCodeEnabled && isSmtpConfigured;
+ if (magicOnly) {
+ try {
+ await sendMagicCode();
+ setStep('code');
+ } catch (err: unknown) {
+ setError(getApiErrorMessage(err) || 'Could not send sign-in code.');
+ } finally {
setIsSubmitting(false);
- return;
}
- setMode('sign-up');
+ return;
}
+
setStep('password');
} catch {
setStep('password');
@@ -152,9 +194,29 @@ export function LoginPage() {
setIsSubmitting(false);
}
},
- [email],
+ [
+ email,
+ inviteToken,
+ isPasswordEnabled,
+ isMagicCodeEnabled,
+ isSmtpConfigured,
+ sendMagicCode,
+ ],
);
+ const switchToMagicCode = useCallback(async () => {
+ setError('');
+ setIsSubmitting(true);
+ try {
+ await sendMagicCode();
+ setStep('code');
+ } catch (err: unknown) {
+ setError(getApiErrorMessage(err) || 'Could not send sign-in code.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ }, [sendMagicCode]);
+
const handlePasswordSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
@@ -186,17 +248,13 @@ export function LoginPage() {
password,
first_name: firstName,
last_name: lastName,
+ ...(inviteToken ? { invite_token: inviteToken } : {}),
});
setUserFromApi(user);
navigate(returnPath, { replace: true });
}
} catch (err: unknown) {
- if (err && typeof err === 'object' && 'response' in err) {
- const axiosErr = err as { response?: { data?: { error?: string } } };
- setError(axiosErr.response?.data?.error ?? 'Something went wrong.');
- } else {
- setError('Something went wrong. Please try again.');
- }
+ setError(getApiErrorMessage(err) || 'Something went wrong. Please try again.');
} finally {
setIsSubmitting(false);
}
@@ -208,6 +266,7 @@ export function LoginPage() {
confirmPassword,
firstName,
lastName,
+ inviteToken,
login,
setUserFromApi,
navigate,
@@ -215,12 +274,48 @@ export function LoginPage() {
],
);
+ const handleMagicCodeSubmit = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ const code = magicCode.replace(/\D/g, '');
+ if (code.length !== 6) {
+ setError('Enter the 6-digit code from your email.');
+ return;
+ }
+ setIsSubmitting(true);
+ try {
+ const user = await authService.verifyMagicCode({
+ email,
+ code,
+ first_name: firstName,
+ last_name: lastName,
+ ...(inviteToken ? { invite_token: inviteToken } : {}),
+ });
+ setUserFromApi(user);
+ navigate(returnPath, { replace: true });
+ } catch (err: unknown) {
+ setError(getApiErrorMessage(err) || 'Invalid or expired code.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [magicCode, email, firstName, lastName, inviteToken, setUserFromApi, navigate, returnPath],
+ );
+
const goBackToEmail = useCallback(() => {
setStep('email');
setPassword('');
setConfirmPassword('');
setFirstName('');
setLastName('');
+ setMagicCode('');
+ setError('');
+ }, []);
+
+ const goBackToPassword = useCallback(() => {
+ setStep('password');
+ setMagicCode('');
setError('');
}, []);
@@ -228,16 +323,20 @@ export function LoginPage() {
setMode((prev) => (prev === 'sign-in' ? 'sign-up' : 'sign-in'));
setPassword('');
setConfirmPassword('');
+ setMagicCode('');
setError('');
}, []);
const title = useMemo(() => {
if (step === 'email') return 'Get started with Devlane';
+ if (step === 'code') return mode === 'sign-in' ? 'Check your email' : 'Verify your email';
return mode === 'sign-in' ? 'Welcome back!' : 'Create your account';
}, [step, mode]);
const subtitle = useMemo(() => {
if (step === 'email') return 'Enter your email to continue.';
+ if (step === 'code')
+ return 'We sent a 6-digit code to your inbox. It expires in 10 minutes.';
return mode === 'sign-in'
? 'Enter your password to sign in.'
: 'Set up your account to get started.';
@@ -249,6 +348,11 @@ export function LoginPage() {
{title}
{subtitle}
+ {step === 'email' && isPasswordEnabled && canUseMagicCode && (
+
+ After you continue, you can use your password or choose a one-time email code instead.
+
+ )}
{error && (
@@ -424,7 +528,7 @@ export function LoginPage() {
)}
- {mode === 'sign-in' && isSmtpConfigured && (
+ {mode === 'sign-in' && isPasswordEnabled && isSmtpConfigured && (
)}
+ {canUseMagicCode && isPasswordEnabled && (
+
void switchToMagicCode()}
+ disabled={isSubmitting}
+ className="w-full text-center text-xs font-medium text-(--txt-accent) hover:underline disabled:opacity-50"
+ >
+ {mode === 'sign-in' ? 'Sign in with email code instead' : 'Sign up with email code instead'}
+
+ )}
+
{isSubmitting
? mode === 'sign-in'
@@ -446,7 +561,7 @@ export function LoginPage() {
: 'Create account'}
- {allowSignup && (
+ {(allowSignup || !!inviteToken) && (
{mode === 'sign-in' ? "Don't have an account?" : 'Already have an account?'}{' '}
)}
+
+ {step === 'code' && (
+
+ )}
diff --git a/ui/src/pages/instance-admin/InstanceAdminEmailPage.tsx b/ui/src/pages/instance-admin/InstanceAdminEmailPage.tsx
index b03814f..5453a0a 100644
--- a/ui/src/pages/instance-admin/InstanceAdminEmailPage.tsx
+++ b/ui/src/pages/instance-admin/InstanceAdminEmailPage.tsx
@@ -206,7 +206,12 @@ export function InstanceAdminEmailPage() {
value={smtpPasswordDisplay}
onChange={(e) => setSmtpPasswordLocal(e.target.value)}
onFocus={() => {
- if (smtpPasswordLocal === undefined) setSmtpPasswordLocal(smtpPasswordDisplay);
+ // Only copy the loaded password into local edit state when it is non-empty.
+ // Otherwise we would set local state to "" and the next save would send an empty
+ // password, which skips the merge and can mask decryption/key issues.
+ if (smtpPasswordLocal === undefined && smtpPasswordDisplay !== '') {
+ setSmtpPasswordLocal(smtpPasswordDisplay);
+ }
}}
placeholder={!smtpPasswordDisplay ? 'Set password' : ''}
className="block w-full rounded border border-(--border-subtle) bg-(--bg-surface-1) px-2.5 py-1.5 pr-9 text-xs text-(--txt-primary) focus:outline-none"
diff --git a/ui/src/services/authService.ts b/ui/src/services/authService.ts
index 1fbf152..f988061 100644
--- a/ui/src/services/authService.ts
+++ b/ui/src/services/authService.ts
@@ -7,6 +7,8 @@ import type {
ForgotPasswordRequest,
ResetPasswordRequest,
AuthConfigResponse,
+ MagicCodeRequestPayload,
+ MagicCodeVerifyPayload,
} from '../api/types';
export const authService = {
@@ -52,4 +54,14 @@ export const authService = {
const { data } = await apiClient.get('/auth/config/');
return data;
},
+
+ async requestMagicCode(payload: MagicCodeRequestPayload): Promise<{ message: string }> {
+ const { data } = await apiClient.post<{ message: string }>('/auth/magic-code/request/', payload);
+ return data;
+ },
+
+ async verifyMagicCode(payload: MagicCodeVerifyPayload): Promise {
+ const { data } = await apiClient.post('/auth/magic-code/verify/', payload);
+ return data;
+ },
};
From c2caeb8754cd43c48a19652283393d8b97664cf0 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Sat, 11 Apr 2026 15:00:51 +0400
Subject: [PATCH 16/43] feat(auth): introduce email magic code login/signup and
API public URL config
---
api/internal/config/config.go | 7 +
api/internal/handler/auth.go | 270 ++++++++++++++++++++++++++++++++--
api/internal/router/router.go | 41 ++++--
3 files changed, 291 insertions(+), 27 deletions(-)
diff --git a/api/internal/config/config.go b/api/internal/config/config.go
index 3792a4c..7ec015d 100644
--- a/api/internal/config/config.go
+++ b/api/internal/config/config.go
@@ -42,6 +42,8 @@ type Config struct {
CORSAllowOrigin string
// AppBaseURL is the public URL of the frontend (e.g. https://app.example.com). Used for invite links in emails. If empty, CORSAllowOrigin is used.
AppBaseURL string
+ // APIPublicURL is where this API is reachable from the browser (OAuth redirect URIs). If empty, AppBaseURL/CORSAllowOrigin is used (often wrong for local dev when the UI is on another port).
+ APIPublicURL string
// OAuth providers
GoogleClientID string
@@ -51,6 +53,9 @@ type Config struct {
GitLabClientID string
GitLabClientSecret string
GitLabHost string // defaults to https://gitlab.com
+
+ // MagicCodeSecret HMAC key for email login codes. If empty, a dev-only default is used (see auth package).
+ MagicCodeSecret string
}
func (c *Config) DSN() string {
@@ -96,6 +101,7 @@ func Load() (*Config, error) {
MigrationsPath: getEnv("MIGRATIONS_PATH", "migrations"),
CORSAllowOrigin: getEnv("CORS_ORIGIN", "http://localhost:5173"),
AppBaseURL: getEnv("APP_BASE_URL", ""),
+ APIPublicURL: getEnv("API_PUBLIC_URL", ""),
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
GitHubClientID: getEnv("GITHUB_CLIENT_ID", ""),
@@ -103,6 +109,7 @@ func Load() (*Config, error) {
GitLabClientID: getEnv("GITLAB_CLIENT_ID", ""),
GitLabClientSecret: getEnv("GITLAB_CLIENT_SECRET", ""),
GitLabHost: getEnv("GITLAB_HOST", "https://gitlab.com"),
+ MagicCodeSecret: getEnv("MAGIC_CODE_SECRET", ""),
}
return cfg, nil
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index bc8c7e1..73b74a0 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -1,9 +1,12 @@
package handler
import (
+ "crypto/rand"
+ "crypto/subtle"
"errors"
"fmt"
"log/slog"
+ "math/big"
"net/http"
"strings"
"time"
@@ -12,6 +15,7 @@ import (
"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/redis"
"github.com/Devlaner/devlane/api/internal/store"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -19,16 +23,19 @@ import (
)
type AuthHandler struct {
- Auth *auth.Service
- Settings *store.InstanceSettingStore
- Winv *store.WorkspaceInviteStore
- Ws *store.WorkspaceStore
- NotifPrefs *store.UserNotificationPreferenceStore
- ApiTokens *store.ApiTokenStore
- Queue *queue.Publisher
- AppBaseURL string
- Log *slog.Logger
- OAuthProviders map[string]any
+ Auth *auth.Service
+ Settings *store.InstanceSettingStore
+ Winv *store.WorkspaceInviteStore
+ Ws *store.WorkspaceStore
+ NotifPrefs *store.UserNotificationPreferenceStore
+ ApiTokens *store.ApiTokenStore
+ Queue *queue.Publisher
+ Redis *redis.Client
+ MagicCodeSecret string
+ AppBaseURL string
+ OAuthRedirectBase string // public API origin for OAuth callbacks (same as RedirectURI base)
+ Log *slog.Logger
+ OAuthProviders map[string]any
}
type SignInRequest struct {
@@ -512,6 +519,7 @@ func (h *AuthHandler) RevokeToken(c *gin.Context) {
// GET /auth/config/
func (h *AuthHandler) InstanceAuthConfig(c *gin.Context) {
isPasswordEnabled := true
+ isMagicCodeEnabled := true
enableSignup := true
isSmtpConfigured := false
if h.Settings != nil {
@@ -519,6 +527,7 @@ func (h *AuthHandler) InstanceAuthConfig(c *gin.Context) {
row, _ := h.Settings.Get(ctx, "auth")
if row != nil {
isPasswordEnabled = authBool(row.Value, "password", true)
+ isMagicCodeEnabled = authBool(row.Value, "magic_code", true)
enableSignup = authBool(row.Value, "allow_public_signup", true)
}
emailRow, _ := h.Settings.Get(ctx, "email")
@@ -534,14 +543,19 @@ func (h *AuthHandler) InstanceAuthConfig(c *gin.Context) {
_, isGitLabEnabled = h.OAuthProviders["gitlab"]
}
- c.JSON(http.StatusOK, gin.H{
+ out := gin.H{
"is_email_password_enabled": isPasswordEnabled,
+ "is_magic_code_enabled": isMagicCodeEnabled,
"enable_signup": enableSignup,
"is_smtp_configured": isSmtpConfigured,
"is_google_enabled": isGoogleEnabled,
"is_github_enabled": isGitHubEnabled,
"is_gitlab_enabled": isGitLabEnabled,
- })
+ }
+ if isGoogleEnabled || isGitHubEnabled || isGitLabEnabled {
+ out["oauth_redirect_base"] = strings.TrimSuffix(strings.TrimSpace(h.OAuthRedirectBase), "/")
+ }
+ c.JSON(http.StatusOK, out)
}
// EmailCheck checks whether an email is already registered.
@@ -628,6 +642,238 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Password has been reset successfully."})
}
+// MagicCodeRequest sends a one-time login code to the email when magic-code auth is enabled.
+// POST /auth/magic-code/request/
+func (h *AuthHandler) MagicCodeRequest(c *gin.Context) {
+ var body struct {
+ Email string `json:"email" binding:"required,email"`
+ InviteToken string `json:"invite_token"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
+ return
+ }
+ ctx := c.Request.Context()
+ magicEnabled := true
+ allowPublicSignup := true
+ isSmtpConfigured := false
+ if h.Settings != nil {
+ row, _ := h.Settings.Get(ctx, "auth")
+ if row != nil {
+ magicEnabled = authBool(row.Value, "magic_code", true)
+ allowPublicSignup = 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) != ""
+ }
+ }
+ if !magicEnabled {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Email code sign-in is disabled"})
+ return
+ }
+ if !isSmtpConfigured {
+ 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 (API logs show connection errors)."})
+ return
+ }
+ if h.Redis == nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Login codes unavailable. Redis is required; check REDIS_ADDR and API logs."})
+ return
+ }
+
+ exists, err := h.Auth.EmailCheck(ctx, body.Email)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Check failed"})
+ return
+ }
+
+ var inv *model.WorkspaceMemberInvite
+ if !exists {
+ if !allowPublicSignup {
+ if strings.TrimSpace(body.InviteToken) == "" {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Sign-up is by invite only. Use the link from your invitation email."})
+ return
+ }
+ if h.Winv == nil {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Sign-up is by invite only. Use the link from your invitation email."})
+ return
+ }
+ var ierr error
+ inv, ierr = h.Winv.GetByToken(ctx, strings.TrimSpace(body.InviteToken))
+ if ierr != nil || inv == nil {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Invalid or expired invite. Use the link from your invitation email."})
+ return
+ }
+ emailNorm := strings.TrimSpace(strings.ToLower(body.Email))
+ invEmailNorm := strings.TrimSpace(strings.ToLower(inv.Email))
+ if emailNorm != invEmailNorm {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Sign-up email must match the invited email address."})
+ return
+ }
+ }
+ }
+ _ = inv // invite validated when needed; stored in Redis for verify
+
+ code, err := randomSixDigitLoginCode()
+ if err != nil {
+ h.log().Error("magic code generate", "error", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send code"})
+ return
+ }
+ mac := auth.MagicCodeHMAC(h.MagicCodeSecret, body.Email, code)
+ store := &redis.MagicCodeLoginData{
+ CodeMAC: mac,
+ Attempts: 0,
+ InviteToken: strings.TrimSpace(body.InviteToken),
+ IsSignup: !exists,
+ }
+ if err := h.Redis.SetMagicCodeLogin(ctx, body.Email, store, redis.MagicCodeLoginTTL); err != nil {
+ h.log().Error("magic code redis set", "error", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send code"})
+ return
+ }
+
+ subject := "Your Devlane sign-in code"
+ bodyText := fmt.Sprintf(
+ "Your Devlane sign-in code is: %s\n\nThis code expires in 10 minutes. If you did not request it, you can ignore this email.\n",
+ code,
+ )
+ if err := h.Queue.PublishSendEmail(ctx, queue.SendEmailPayload{
+ To: body.Email,
+ Subject: subject,
+ Body: bodyText,
+ Kind: "magic_code_login",
+ Extra: map[string]string{"email": body.Email},
+ }); err != nil {
+ h.log().Error("magic code enqueue email", "error", err)
+ _ = h.Redis.DeleteMagicCodeLogin(ctx, body.Email)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send code"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "If that email can receive mail, a sign-in code has been sent."})
+}
+
+// MagicCodeVerify checks the code and creates a session (sign-in or sign-up).
+// POST /auth/magic-code/verify/
+func (h *AuthHandler) MagicCodeVerify(c *gin.Context) {
+ var body struct {
+ Email string `json:"email" binding:"required,email"`
+ Code string `json:"code" binding:"required"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ InviteToken string `json:"invite_token"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
+ return
+ }
+ ctx := c.Request.Context()
+ magicEnabled := true
+ if h.Settings != nil {
+ row, _ := h.Settings.Get(ctx, "auth")
+ if row != nil {
+ magicEnabled = authBool(row.Value, "magic_code", true)
+ }
+ }
+ if !magicEnabled {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Email code sign-in is disabled"})
+ return
+ }
+ if h.Redis == nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Login codes are temporarily unavailable"})
+ return
+ }
+
+ stored, err := h.Redis.GetMagicCodeLogin(ctx, body.Email)
+ if err != nil {
+ h.log().Error("magic code redis get", "error", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Verification failed"})
+ return
+ }
+ if stored == nil || stored.CodeMAC == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired code"})
+ return
+ }
+
+ tryMAC := auth.MagicCodeHMAC(h.MagicCodeSecret, body.Email, body.Code)
+ if subtle.ConstantTimeCompare([]byte(stored.CodeMAC), []byte(tryMAC)) != 1 {
+ _ = h.Redis.BumpMagicCodeLoginFailedAttempt(ctx, body.Email)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired code"})
+ return
+ }
+
+ if st := strings.TrimSpace(stored.InviteToken); st != "" && strings.TrimSpace(body.InviteToken) != "" &&
+ st != strings.TrimSpace(body.InviteToken) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired code"})
+ return
+ }
+
+ _ = h.Redis.DeleteMagicCodeLogin(ctx, body.Email)
+
+ var inv *model.WorkspaceMemberInvite
+ if stored.IsSignup && strings.TrimSpace(stored.InviteToken) != "" && h.Winv != nil {
+ inv, _ = h.Winv.GetByToken(ctx, strings.TrimSpace(stored.InviteToken))
+ }
+
+ if stored.IsSignup {
+ sessionKey, user, err := h.Auth.SignUpMagic(ctx, body.Email, body.FirstName, body.LastName)
+ if err != nil {
+ if errors.Is(err, auth.ErrEmailTaken) {
+ sessionKey2, user2, err2 := h.Auth.SessionForEmailUser(ctx, body.Email)
+ if err2 != nil {
+ c.JSON(http.StatusConflict, gin.H{"error": "An account with this email already exists"})
+ return
+ }
+ setSessionCookie(c, sessionKey2)
+ c.JSON(http.StatusOK, userResponse(user2))
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Sign up failed"})
+ return
+ }
+ if inv != nil && h.Winv != nil && h.Ws != nil {
+ now := time.Now()
+ inv.Accepted = true
+ inv.RespondedAt = &now
+ if err := h.Winv.Update(ctx, inv); err != nil {
+ h.log().Error("failed to mark invite accepted (magic)", "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 magic signup", "error", err, "user_id", user.ID)
+ }
+ }
+ setSessionCookie(c, sessionKey)
+ c.JSON(http.StatusCreated, userResponse(user))
+ return
+ }
+
+ sessionKey, user, err := h.Auth.SessionForEmailUser(ctx, body.Email)
+ if err != nil {
+ if errors.Is(err, auth.ErrInvalidCredentials) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired code"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Sign in failed"})
+ return
+ }
+ setSessionCookie(c, sessionKey)
+ c.JSON(http.StatusOK, userResponse(user))
+}
+
+func randomSixDigitLoginCode() (string, error) {
+ n, err := rand.Int(rand.Reader, big.NewInt(1000000))
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("%06d", n.Int64()), nil
+}
+
func isSecureRequest(c *gin.Context) bool {
if c.Request.TLS != nil {
return true
diff --git a/api/internal/router/router.go b/api/internal/router/router.go
index 820dda6..cc0b79f 100644
--- a/api/internal/router/router.go
+++ b/api/internal/router/router.go
@@ -26,6 +26,7 @@ type Config struct {
Minio *minio.Client // optional: file uploads (cover images, avatars, logos)
CORSAllowOrigin string // optional: e.g. "http://localhost:5173" for UI dev
AppBaseURL string // optional: base URL for invite links; if empty, CORSAllowOrigin is used
+ APIPublicURL string // optional: public API origin for OAuth callbacks (see API_PUBLIC_URL)
// OAuth providers (empty = disabled)
GoogleClientID string
@@ -35,6 +36,8 @@ type Config struct {
GitLabClientID string
GitLabClientSecret string
GitLabHost string
+ // MagicCodeSecret is the HMAC key for email login codes (see MAGIC_CODE_SECRET).
+ MagicCodeSecret string
}
// New builds and returns the Gin engine with /api/ and /auth/ routes.
@@ -92,8 +95,11 @@ func New(cfg Config) *gin.Engine {
appBaseURL = cfg.CORSAllowOrigin
}
- // Determine the API base URL (scheme+host of the backend) for OAuth redirect URIs
- apiBaseURL := strings.TrimSuffix(appBaseURL, "/")
+ // OAuth redirect URIs must hit this API, not the SPA dev server. Use API_PUBLIC_URL when UI and API differ.
+ oauthCallbackBase := strings.TrimSuffix(strings.TrimSpace(cfg.APIPublicURL), "/")
+ if oauthCallbackBase == "" {
+ oauthCallbackBase = strings.TrimSuffix(appBaseURL, "/")
+ }
// OAuth providers
oauthProviders := make(map[string]oauth.Provider)
@@ -101,21 +107,21 @@ func New(cfg Config) *gin.Engine {
oauthProviders["google"] = oauth.NewGoogleProvider(oauth.ProviderConfig{
ClientID: cfg.GoogleClientID,
ClientSecret: cfg.GoogleClientSecret,
- RedirectURI: apiBaseURL + "/auth/google/callback/",
+ RedirectURI: oauthCallbackBase + "/auth/google/callback/",
})
}
if cfg.GitHubClientID != "" && cfg.GitHubClientSecret != "" {
oauthProviders["github"] = oauth.NewGitHubProvider(oauth.ProviderConfig{
ClientID: cfg.GitHubClientID,
ClientSecret: cfg.GitHubClientSecret,
- RedirectURI: apiBaseURL + "/auth/github/callback/",
+ RedirectURI: oauthCallbackBase + "/auth/github/callback/",
})
}
if cfg.GitLabClientID != "" && cfg.GitLabClientSecret != "" {
oauthProviders["gitlab"] = oauth.NewGitLabProvider(oauth.ProviderConfig{
ClientID: cfg.GitLabClientID,
ClientSecret: cfg.GitLabClientSecret,
- RedirectURI: apiBaseURL + "/auth/gitlab/callback/",
+ RedirectURI: oauthCallbackBase + "/auth/gitlab/callback/",
}, cfg.GitLabHost)
}
@@ -125,16 +131,19 @@ func New(cfg Config) *gin.Engine {
}
authHandler := &handler.AuthHandler{
- Auth: authSvc,
- Settings: instanceSettingStore,
- Winv: workspaceInviteStore,
- Ws: workspaceStore,
- NotifPrefs: userNotifPrefStore,
- ApiTokens: apiTokenStore,
- Queue: cfg.Queue,
- AppBaseURL: appBaseURL,
- Log: cfg.Log,
- OAuthProviders: oauthEnabled,
+ Auth: authSvc,
+ Settings: instanceSettingStore,
+ Winv: workspaceInviteStore,
+ Ws: workspaceStore,
+ NotifPrefs: userNotifPrefStore,
+ ApiTokens: apiTokenStore,
+ Queue: cfg.Queue,
+ Redis: cfg.Redis,
+ MagicCodeSecret: cfg.MagicCodeSecret,
+ AppBaseURL: appBaseURL,
+ OAuthRedirectBase: oauthCallbackBase,
+ Log: cfg.Log,
+ OAuthProviders: oauthEnabled,
}
// 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}
@@ -339,6 +348,8 @@ func New(cfg Config) *gin.Engine {
authGroup.POST("/sign-out/", authHandler.SignOut)
authGroup.POST("/forgot-password/", authHandler.ForgotPassword)
authGroup.POST("/reset-password/", authHandler.ResetPassword)
+ authGroup.POST("/magic-code/request/", authHandler.MagicCodeRequest)
+ authGroup.POST("/magic-code/verify/", authHandler.MagicCodeVerify)
}
// OAuth routes (no auth required)
From f4b111a27ca7c6ef7e6888371d3d74e610fc7283 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Sat, 11 Apr 2026 15:01:54 +0400
Subject: [PATCH 17/43] feat(auth): add email login magic code support and
improve secret decryption
---
api/cmd/api/main.go | 2 +
api/internal/crypto/instance_secret.go | 8 ++-
api/internal/mail/mail.go | 5 ++
api/internal/redis/cache.go | 91 ++++++++++++++++++++++++--
4 files changed, 101 insertions(+), 5 deletions(-)
diff --git a/api/cmd/api/main.go b/api/cmd/api/main.go
index 99ab1f9..9c00249 100644
--- a/api/cmd/api/main.go
+++ b/api/cmd/api/main.go
@@ -91,6 +91,7 @@ func main() {
Minio: mc,
CORSAllowOrigin: cfg.CORSAllowOrigin,
AppBaseURL: cfg.AppBaseURL,
+ APIPublicURL: cfg.APIPublicURL,
GoogleClientID: cfg.GoogleClientID,
GoogleClientSecret: cfg.GoogleClientSecret,
GitHubClientID: cfg.GitHubClientID,
@@ -98,6 +99,7 @@ func main() {
GitLabClientID: cfg.GitLabClientID,
GitLabClientSecret: cfg.GitLabClientSecret,
GitLabHost: cfg.GitLabHost,
+ MagicCodeSecret: cfg.MagicCodeSecret,
})
// Start task consumer when RabbitMQ is available
diff --git a/api/internal/crypto/instance_secret.go b/api/internal/crypto/instance_secret.go
index 546d9ab..cbd9c96 100644
--- a/api/internal/crypto/instance_secret.go
+++ b/api/internal/crypto/instance_secret.go
@@ -9,10 +9,16 @@ import (
"errors"
"io"
"os"
+ "strings"
)
const encryptedPrefix = "enc:"
+// LooksEncrypted reports whether value appears to be stored with Encrypt (AES-GCM prefix).
+func LooksEncrypted(value string) bool {
+ return strings.HasPrefix(value, encryptedPrefix)
+}
+
func getKey() []byte {
s := os.Getenv("INSTANCE_ENCRYPTION_KEY")
if s == "" {
@@ -55,7 +61,7 @@ func Decrypt(value string) (string, error) {
}
key := getKey()
if key == nil {
- return "", nil
+ return "", errors.New("INSTANCE_ENCRYPTION_KEY is not set but this value is encrypted (enc:…); set the key or re-save the secret in instance settings")
}
raw, err := base64.StdEncoding.DecodeString(value[len(encryptedPrefix):])
if err != nil {
diff --git a/api/internal/mail/mail.go b/api/internal/mail/mail.go
index f0f3069..7d2bfbb 100644
--- a/api/internal/mail/mail.go
+++ b/api/internal/mail/mail.go
@@ -46,6 +46,11 @@ func getEmailSettings(ctx context.Context, s *store.InstanceSettingStore) (*smtp
username, _ := v["username"].(string)
passRaw, _ := v["password"].(string)
password := crypto.DecryptOrPlain(passRaw)
+ if crypto.LooksEncrypted(passRaw) && password == "" {
+ return nil, fmt.Errorf(
+ "SMTP password cannot be decrypted: ensure INSTANCE_ENCRYPTION_KEY matches the key used when the password was saved, or open instance email settings and save the SMTP password again",
+ )
+ }
host = strings.TrimSpace(host)
if host == "" {
return nil, fmt.Errorf("email host not configured")
diff --git a/api/internal/redis/cache.go b/api/internal/redis/cache.go
index 5d9cedf..db573a8 100644
--- a/api/internal/redis/cache.go
+++ b/api/internal/redis/cache.go
@@ -3,6 +3,7 @@ package redis
import (
"context"
"encoding/json"
+ "strings"
"time"
"github.com/redis/go-redis/v9"
@@ -11,14 +12,19 @@ import (
// Cache key prefixes.
const (
PrefixMagicLink = "magic_"
- PrefixLock = "lock_"
- PrefixCache = "cache_"
+ // PrefixMagicCodeLogin stores email login codes (numeric), separate from magic-link keys.
+ PrefixMagicCodeLogin = "logincode_"
+ PrefixLock = "lock_"
+ PrefixCache = "cache_"
)
// Default TTLs.
const (
- MagicLinkTTL = 600 * time.Second // 10 min
- LockTTL = 300 * time.Second // 5 min
+ MagicLinkTTL = 600 * time.Second // 10 min
+ MagicCodeLoginTTL = 600 * time.Second // 10 min
+ // MagicCodeMaxAttempts before the stored code is invalidated.
+ MagicCodeMaxAttempts = 10
+ LockTTL = 300 * time.Second // 5 min
)
// Get gets a string value. Returns redis.Nil when key does not exist.
@@ -128,6 +134,83 @@ func (c *Client) DeleteMagicLink(ctx context.Context, email string) error {
return c.Client.Del(ctx, PrefixMagicLink+email).Err()
}
+// --- Email login code (magic code) ---
+
+// MagicCodeLoginData is stored in Redis for one-time email codes.
+type MagicCodeLoginData struct {
+ CodeMAC string `json:"m"`
+ Attempts int `json:"a"`
+ InviteToken string `json:"it,omitempty"`
+ IsSignup bool `json:"su"`
+}
+
+// LoginCodeRedisKey returns the Redis key for magic-code login for an email address.
+func LoginCodeRedisKey(email string) string {
+ return PrefixMagicCodeLogin + strings.ToLower(strings.TrimSpace(email))
+}
+
+// SetMagicCodeLogin stores login code metadata for the email. Overwrites any prior code.
+func (c *Client) SetMagicCodeLogin(ctx context.Context, email string, data *MagicCodeLoginData, ttl time.Duration) error {
+ if ttl <= 0 {
+ ttl = MagicCodeLoginTTL
+ }
+ if data == nil {
+ data = &MagicCodeLoginData{}
+ }
+ b, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+ return c.Client.Set(ctx, LoginCodeRedisKey(email), b, ttl).Err()
+}
+
+// GetMagicCodeLogin returns stored login code metadata, or (nil, nil) if missing.
+func (c *Client) GetMagicCodeLogin(ctx context.Context, email string) (*MagicCodeLoginData, error) {
+ key := LoginCodeRedisKey(email)
+ s, err := c.Client.Get(ctx, key).Result()
+ if err != nil {
+ if err == redis.Nil {
+ return nil, nil
+ }
+ return nil, err
+ }
+ var out MagicCodeLoginData
+ if err := json.Unmarshal([]byte(s), &out); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// DeleteMagicCodeLogin removes the login code for an email (after success or lockout).
+func (c *Client) DeleteMagicCodeLogin(ctx context.Context, email string) error {
+ return c.Client.Del(ctx, LoginCodeRedisKey(email)).Err()
+}
+
+// BumpMagicCodeLoginFailedAttempt increments failed verification attempts and deletes the key after too many failures.
+func (c *Client) BumpMagicCodeLoginFailedAttempt(ctx context.Context, email string) error {
+ key := LoginCodeRedisKey(email)
+ data, err := c.GetMagicCodeLogin(ctx, email)
+ if err != nil || data == nil {
+ return err
+ }
+ data.Attempts++
+ if data.Attempts >= MagicCodeMaxAttempts {
+ return c.DeleteMagicCodeLogin(ctx, email)
+ }
+ b, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+ ttl, err := c.Client.TTL(ctx, key).Result()
+ if err != nil {
+ return err
+ }
+ if ttl < 0 {
+ ttl = MagicCodeLoginTTL
+ }
+ return c.Client.Set(ctx, key, b, ttl).Err()
+}
+
// --- Short-lived metadata (e.g. request origin per issue) ---
// SetRequestOrigin sets a short-lived value for an entity (e.g. issue_id -> origin).
From b60122ca62cabb7b38fd11b4d758023baeb27ad9 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Sat, 11 Apr 2026 15:09:01 +0400
Subject: [PATCH 18/43] style(login): improve code formatting for readability
---
ui/src/pages/LoginPage.tsx | 16 +++++-----------
ui/src/services/authService.ts | 5 ++++-
2 files changed, 9 insertions(+), 12 deletions(-)
diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx
index 7a1aaf4..59fe204 100644
--- a/ui/src/pages/LoginPage.tsx
+++ b/ui/src/pages/LoginPage.tsx
@@ -194,14 +194,7 @@ export function LoginPage() {
setIsSubmitting(false);
}
},
- [
- email,
- inviteToken,
- isPasswordEnabled,
- isMagicCodeEnabled,
- isSmtpConfigured,
- sendMagicCode,
- ],
+ [email, inviteToken, isPasswordEnabled, isMagicCodeEnabled, isSmtpConfigured, sendMagicCode],
);
const switchToMagicCode = useCallback(async () => {
@@ -335,8 +328,7 @@ export function LoginPage() {
const subtitle = useMemo(() => {
if (step === 'email') return 'Enter your email to continue.';
- if (step === 'code')
- return 'We sent a 6-digit code to your inbox. It expires in 10 minutes.';
+ if (step === 'code') return 'We sent a 6-digit code to your inbox. It expires in 10 minutes.';
return mode === 'sign-in'
? 'Enter your password to sign in.'
: 'Set up your account to get started.';
@@ -547,7 +539,9 @@ export function LoginPage() {
disabled={isSubmitting}
className="w-full text-center text-xs font-medium text-(--txt-accent) hover:underline disabled:opacity-50"
>
- {mode === 'sign-in' ? 'Sign in with email code instead' : 'Sign up with email code instead'}
+ {mode === 'sign-in'
+ ? 'Sign in with email code instead'
+ : 'Sign up with email code instead'}
)}
diff --git a/ui/src/services/authService.ts b/ui/src/services/authService.ts
index f988061..2bba41c 100644
--- a/ui/src/services/authService.ts
+++ b/ui/src/services/authService.ts
@@ -56,7 +56,10 @@ export const authService = {
},
async requestMagicCode(payload: MagicCodeRequestPayload): Promise<{ message: string }> {
- const { data } = await apiClient.post<{ message: string }>('/auth/magic-code/request/', payload);
+ const { data } = await apiClient.post<{ message: string }>(
+ '/auth/magic-code/request/',
+ payload,
+ );
return data;
},
From c36c5da8c6dee7714d4b6621c4a16b0a60033d75 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Tue, 14 Apr 2026 14:45:38 +0400
Subject: [PATCH 19/43] feat: all three edit pages (Google, GitHub, GitLab
---
api/.env.example | 7 +-
api/cmd/api/main.go | 24 +-
api/internal/config/config.go | 23 +-
api/internal/handler/auth.go | 73 +++--
api/internal/handler/instance.go | 41 ++-
api/internal/handler/oauth.go | 33 +-
api/internal/handler/oauth_config.go | 105 ++++++
api/internal/queue/consumer.go | 4 +-
api/internal/router/router.go | 92 ++----
api/migrations/000002_auth_schema.down.sql | 23 ++
api/migrations/000002_auth_schema.up.sql | 41 +++
.../000002_password_reset_tokens.down.sql | 1 -
.../000002_password_reset_tokens.up.sql | 11 -
api/migrations/000003_accounts.down.sql | 1 -
api/migrations/000003_accounts.up.sql | 18 --
ui/package-lock.json | 15 +
ui/package.json | 1 +
ui/src/api/client.ts | 12 +-
ui/src/api/types.ts | 17 +
.../components/layout/InstanceAdminLayout.tsx | 9 +-
ui/src/config/env.ts | 15 -
ui/src/lib/utils.ts | 4 +-
ui/src/pages/ForgotPasswordPage.tsx | 17 +-
ui/src/pages/InviteSignUpPage.tsx | 6 +-
ui/src/pages/LoginPage.tsx | 12 +-
ui/src/pages/ResetPasswordPage.tsx | 6 +-
.../InstanceAdminAuthGitHubPage.tsx | 286 +++++++++++++++++
.../InstanceAdminAuthGitLabPage.tsx | 299 +++++++++++++++++
.../InstanceAdminAuthGooglePage.tsx | 301 ++++++++++++++++++
.../InstanceAdminAuthenticationPage.tsx | 154 +++++++--
ui/src/pages/instance-admin/index.ts | 3 +
ui/src/routes/index.tsx | 39 +++
ui/vite.config.ts | 7 -
33 files changed, 1446 insertions(+), 254 deletions(-)
create mode 100644 api/internal/handler/oauth_config.go
create mode 100644 api/migrations/000002_auth_schema.down.sql
create mode 100644 api/migrations/000002_auth_schema.up.sql
delete mode 100644 api/migrations/000002_password_reset_tokens.down.sql
delete mode 100644 api/migrations/000002_password_reset_tokens.up.sql
delete mode 100644 api/migrations/000003_accounts.down.sql
delete mode 100644 api/migrations/000003_accounts.up.sql
delete mode 100644 ui/src/config/env.ts
create mode 100644 ui/src/pages/instance-admin/InstanceAdminAuthGitHubPage.tsx
create mode 100644 ui/src/pages/instance-admin/InstanceAdminAuthGitLabPage.tsx
create mode 100644 ui/src/pages/instance-admin/InstanceAdminAuthGooglePage.tsx
diff --git a/api/.env.example b/api/.env.example
index b80636d..0bb7c26 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -25,9 +25,8 @@ APP_BASE_URL=https://app.example.com
# Public URL of this API (OAuth callbacks must hit the API, not the SPA). Local dev when UI is on 5173 and API on 8080:
# API_PUBLIC_URL=http://localhost:8080
-# Google Cloud Console → Authorized redirect URI: {API_PUBLIC_URL}/auth/google/callback/
-# GOOGLE_CLIENT_ID=
-# GOOGLE_CLIENT_SECRET=
+
+# OAuth credentials (Google, GitHub, GitLab) are configured via Instance Admin UI, not env vars.
# HMAC key for email login codes (set in production).
# MAGIC_CODE_SECRET=
@@ -43,4 +42,4 @@ MINIO_USE_SSL=false
MIGRATIONS_PATH=migrations
-INSTANCE_ENCRYPTION_KEY=fhFGHFgrey576ytHFDRTy5755rhfhfghfhf
+INSTANCE_ENCRYPTION_KEY=change-me-generate-a-random-key
diff --git a/api/cmd/api/main.go b/api/cmd/api/main.go
index 9c00249..1eda2a0 100644
--- a/api/cmd/api/main.go
+++ b/api/cmd/api/main.go
@@ -84,22 +84,14 @@ func main() {
}
r := router.New(router.Config{
- Log: log,
- DB: db,
- Redis: rdb,
- Queue: queuePublisher,
- Minio: mc,
- CORSAllowOrigin: cfg.CORSAllowOrigin,
- AppBaseURL: cfg.AppBaseURL,
- APIPublicURL: cfg.APIPublicURL,
- GoogleClientID: cfg.GoogleClientID,
- GoogleClientSecret: cfg.GoogleClientSecret,
- GitHubClientID: cfg.GitHubClientID,
- GitHubClientSecret: cfg.GitHubClientSecret,
- GitLabClientID: cfg.GitLabClientID,
- GitLabClientSecret: cfg.GitLabClientSecret,
- GitLabHost: cfg.GitLabHost,
- MagicCodeSecret: cfg.MagicCodeSecret,
+ Log: log,
+ DB: db,
+ Redis: rdb,
+ Queue: queuePublisher,
+ Minio: mc,
+ CORSAllowOrigin: cfg.CORSAllowOrigin,
+ AppBaseURL: cfg.AppBaseURL,
+ MagicCodeSecret: cfg.MagicCodeSecret,
})
// Start task consumer when RabbitMQ is available
diff --git a/api/internal/config/config.go b/api/internal/config/config.go
index 7ec015d..54b046c 100644
--- a/api/internal/config/config.go
+++ b/api/internal/config/config.go
@@ -42,17 +42,6 @@ type Config struct {
CORSAllowOrigin string
// AppBaseURL is the public URL of the frontend (e.g. https://app.example.com). Used for invite links in emails. If empty, CORSAllowOrigin is used.
AppBaseURL string
- // APIPublicURL is where this API is reachable from the browser (OAuth redirect URIs). If empty, AppBaseURL/CORSAllowOrigin is used (often wrong for local dev when the UI is on another port).
- APIPublicURL string
-
- // OAuth providers
- GoogleClientID string
- GoogleClientSecret string
- GitHubClientID string
- GitHubClientSecret string
- GitLabClientID string
- GitLabClientSecret string
- GitLabHost string // defaults to https://gitlab.com
// MagicCodeSecret HMAC key for email login codes. If empty, a dev-only default is used (see auth package).
MagicCodeSecret string
@@ -100,16 +89,8 @@ func Load() (*Config, error) {
MinIOUseSSL: minioSSL,
MigrationsPath: getEnv("MIGRATIONS_PATH", "migrations"),
CORSAllowOrigin: getEnv("CORS_ORIGIN", "http://localhost:5173"),
- AppBaseURL: getEnv("APP_BASE_URL", ""),
- APIPublicURL: getEnv("API_PUBLIC_URL", ""),
- GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
- GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
- GitHubClientID: getEnv("GITHUB_CLIENT_ID", ""),
- GitHubClientSecret: getEnv("GITHUB_CLIENT_SECRET", ""),
- GitLabClientID: getEnv("GITLAB_CLIENT_ID", ""),
- GitLabClientSecret: getEnv("GITLAB_CLIENT_SECRET", ""),
- GitLabHost: getEnv("GITLAB_HOST", "https://gitlab.com"),
- MagicCodeSecret: getEnv("MAGIC_CODE_SECRET", ""),
+ AppBaseURL: getEnv("APP_BASE_URL", ""),
+ MagicCodeSecret: getEnv("MAGIC_CODE_SECRET", ""),
}
return cfg, nil
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index 73b74a0..d21b9ae 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -8,6 +8,7 @@ import (
"log/slog"
"math/big"
"net/http"
+ "net/mail"
"strings"
"time"
@@ -23,19 +24,17 @@ import (
)
type AuthHandler struct {
- Auth *auth.Service
- Settings *store.InstanceSettingStore
- Winv *store.WorkspaceInviteStore
- Ws *store.WorkspaceStore
- NotifPrefs *store.UserNotificationPreferenceStore
- ApiTokens *store.ApiTokenStore
- Queue *queue.Publisher
- Redis *redis.Client
- MagicCodeSecret string
- AppBaseURL string
- OAuthRedirectBase string // public API origin for OAuth callbacks (same as RedirectURI base)
- Log *slog.Logger
- OAuthProviders map[string]any
+ Auth *auth.Service
+ Settings *store.InstanceSettingStore
+ Winv *store.WorkspaceInviteStore
+ Ws *store.WorkspaceStore
+ NotifPrefs *store.UserNotificationPreferenceStore
+ ApiTokens *store.ApiTokenStore
+ Queue *queue.Publisher
+ Redis *redis.Client
+ MagicCodeSecret string
+ AppBaseURL string
+ Log *slog.Logger
}
type SignInRequest struct {
@@ -522,13 +521,19 @@ func (h *AuthHandler) InstanceAuthConfig(c *gin.Context) {
isMagicCodeEnabled := true
enableSignup := true
isSmtpConfigured := false
+ ctx := c.Request.Context()
+ googleAllowed := false
+ githubAllowed := false
+ gitlabAllowed := false
if h.Settings != nil {
- ctx := c.Request.Context()
row, _ := h.Settings.Get(ctx, "auth")
if row != nil {
isPasswordEnabled = authBool(row.Value, "password", true)
isMagicCodeEnabled = authBool(row.Value, "magic_code", true)
enableSignup = authBool(row.Value, "allow_public_signup", true)
+ googleAllowed = authBool(row.Value, "google", false)
+ githubAllowed = authBool(row.Value, "github", false)
+ gitlabAllowed = authBool(row.Value, "gitlab", false)
}
emailRow, _ := h.Settings.Get(ctx, "email")
if emailRow != nil && emailRow.Value != nil {
@@ -536,12 +541,9 @@ func (h *AuthHandler) InstanceAuthConfig(c *gin.Context) {
isSmtpConfigured = strings.TrimSpace(host) != ""
}
}
- var isGoogleEnabled, isGitHubEnabled, isGitLabEnabled bool
- if h.OAuthProviders != nil {
- _, isGoogleEnabled = h.OAuthProviders["google"]
- _, isGitHubEnabled = h.OAuthProviders["github"]
- _, isGitLabEnabled = h.OAuthProviders["gitlab"]
- }
+ isGoogleEnabled := googleAllowed && oauthGoogleCredentialsReady(ctx, h.Settings)
+ isGitHubEnabled := githubAllowed && oauthGitHubCredentialsReady(ctx, h.Settings)
+ isGitLabEnabled := gitlabAllowed && oauthGitLabCredentialsReady(ctx, h.Settings)
out := gin.H{
"is_email_password_enabled": isPasswordEnabled,
@@ -552,8 +554,9 @@ func (h *AuthHandler) InstanceAuthConfig(c *gin.Context) {
"is_github_enabled": isGitHubEnabled,
"is_gitlab_enabled": isGitLabEnabled,
}
- if isGoogleEnabled || isGitHubEnabled || isGitLabEnabled {
- out["oauth_redirect_base"] = strings.TrimSuffix(strings.TrimSpace(h.OAuthRedirectBase), "/")
+ out["oauth_redirect_base"] = requestCallbackBase(c)
+ if s := strings.TrimSpace(h.AppBaseURL); s != "" {
+ out["oauth_js_origin"] = strings.TrimSuffix(s, "/")
}
c.JSON(http.StatusOK, out)
}
@@ -591,13 +594,27 @@ func (h *AuthHandler) EmailCheck(c *gin.Context) {
// POST /auth/forgot-password/
func (h *AuthHandler) ForgotPassword(c *gin.Context) {
var body struct {
- Email string `json:"email" binding:"required,email"`
+ Email string `json:"email" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
ctx := c.Request.Context()
+ if h.Settings != nil {
+ row, _ := h.Settings.Get(ctx, "auth")
+ if row != nil && !authBool(row.Value, "password", true) {
+ c.JSON(http.StatusOK, gin.H{"message": "If an account exists for that email, a reset link has been sent."})
+ return
+ }
+ }
+ body.Email = strings.TrimSpace(body.Email)
+ addr, err := mail.ParseAddress(body.Email)
+ if err != nil || addr.Address == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
+ return
+ }
+ body.Email = strings.ToLower(addr.Address)
token, err := h.Auth.ForgotPassword(ctx, body.Email)
if err != nil {
h.log().Error("forgot password error", "error", err)
@@ -631,7 +648,15 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
return
}
- if err := h.Auth.ResetPassword(c.Request.Context(), body.Token, body.NewPassword); err != nil {
+ ctx := c.Request.Context()
+ if h.Settings != nil {
+ row, _ := h.Settings.Get(ctx, "auth")
+ if row != nil && !authBool(row.Value, "password", true) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Password sign-in is disabled; password reset is not available."})
+ return
+ }
+ }
+ if err := h.Auth.ResetPassword(ctx, body.Token, body.NewPassword); err != nil {
if errors.Is(err, auth.ErrResetTokenInvalid) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired reset token"})
return
diff --git a/api/internal/handler/instance.go b/api/internal/handler/instance.go
index 1cbce22..07d5d20 100644
--- a/api/internal/handler/instance.go
+++ b/api/internal/handler/instance.go
@@ -18,7 +18,7 @@ import (
// Allowed instance setting section keys (must match migration seed).
var allowedSettingKeys = map[string]bool{
- "general": true, "email": true, "auth": true, "ai": true, "image": true,
+ "general": true, "email": true, "auth": true, "oauth": true, "ai": true, "image": true,
}
// InstanceHandler serves instance setup (first-run); no auth required.
@@ -135,7 +135,7 @@ func (h *InstanceSettingsHandler) GetSettings(c *gin.Context) {
out[k] = decryptSectionSecrets(k, row.Value)
}
// Ensure all sections exist with defaults (migration seed may not have run if DB was created before seed)
- for _, key := range []string{"general", "email", "auth", "ai", "image"} {
+ for _, key := range []string{"general", "email", "auth", "oauth", "ai", "image"} {
if _, ok := out[key]; !ok {
out[key] = defaultSettingValue(key)
}
@@ -152,6 +152,8 @@ func decryptSectionSecrets(sectionKey string, m model.JSONMap) model.JSONMap {
switch sectionKey {
case "email":
secretKeys = []string{"password"}
+ case "oauth":
+ secretKeys = []string{"google_client_secret", "github_client_secret", "gitlab_client_secret"}
case "ai":
secretKeys = []string{"api_key"}
case "image":
@@ -179,6 +181,12 @@ func defaultSettingValue(key string) model.JSONMap {
return model.JSONMap{"host": "", "port": "587", "sender_email": "", "security": "TLS", "username": "", "password_set": false}
case "auth":
return model.JSONMap{"allow_public_signup": true, "magic_code": true, "password": true, "google": false, "github": false, "gitlab": false}
+ case "oauth":
+ return model.JSONMap{
+ "google_client_id": "", "google_client_secret_set": false,
+ "github_client_id": "", "github_client_secret_set": false,
+ "gitlab_client_id": "", "gitlab_client_secret_set": false, "gitlab_host": "",
+ }
case "ai":
return model.JSONMap{"model": "gpt-4o-mini", "api_key_set": false}
case "image":
@@ -287,6 +295,35 @@ func (h *InstanceSettingsHandler) UpdateSetting(c *gin.Context) {
}
value = merged
}
+ if key == "oauth" {
+ existing, _ := h.Settings.Get(c.Request.Context(), "oauth")
+ merged := model.JSONMap{}
+ if existing != nil {
+ for k, v := range existing.Value {
+ merged[k] = v
+ }
+ }
+ secretField := func(field string, setKey string) {
+ if v, ok := req.Value[field]; ok {
+ if s, ok := v.(string); ok && s != "" {
+ merged[field] = crypto.EncryptOrPlain(s)
+ merged[setKey] = true
+ }
+ }
+ }
+ for k, v := range req.Value {
+ switch k {
+ case "google_client_secret", "github_client_secret", "gitlab_client_secret":
+ continue
+ default:
+ merged[k] = v
+ }
+ }
+ secretField("google_client_secret", "google_client_secret_set")
+ secretField("github_client_secret", "github_client_secret_set")
+ secretField("gitlab_client_secret", "gitlab_client_secret_set")
+ value = merged
+ }
if err := h.Settings.Upsert(c.Request.Context(), key, value); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"})
return
diff --git a/api/internal/handler/oauth.go b/api/internal/handler/oauth.go
index 2ac69eb..ed846d4 100644
--- a/api/internal/handler/oauth.go
+++ b/api/internal/handler/oauth.go
@@ -11,11 +11,12 @@ import (
"github.com/Devlaner/devlane/api/internal/auth"
"github.com/Devlaner/devlane/api/internal/middleware"
"github.com/Devlaner/devlane/api/internal/oauth"
+ "github.com/Devlaner/devlane/api/internal/store"
"github.com/gin-gonic/gin"
)
type OAuthHandler struct {
- Providers map[string]oauth.Provider
+ Settings *store.InstanceSettingStore
Auth *auth.Service
AppBaseURL string
Log *slog.Logger
@@ -28,9 +29,35 @@ func (h *OAuthHandler) log() *slog.Logger {
return slog.Default()
}
+// requestCallbackBase derives the OAuth callback base URL from the incoming
+// request, matching Plane's approach: scheme://host. This ensures the redirect
+// URI always points to the API server that handles the callback.
+func requestCallbackBase(c *gin.Context) string {
+ scheme := "http"
+ if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
+ scheme = "https"
+ }
+ return scheme + "://" + c.Request.Host
+}
+
+func (h *OAuthHandler) resolveProvider(c *gin.Context, name string) (oauth.Provider, bool) {
+ ctx := c.Request.Context()
+ base := requestCallbackBase(c)
+ switch name {
+ case "google":
+ return BuildOAuthGoogleProvider(ctx, h.Settings, base)
+ case "github":
+ return BuildOAuthGitHubProvider(ctx, h.Settings, base)
+ case "gitlab":
+ return BuildOAuthGitLabProvider(ctx, h.Settings, base)
+ default:
+ return nil, false
+ }
+}
+
func (h *OAuthHandler) Initiate(c *gin.Context) {
providerName := c.Param("provider")
- provider, ok := h.Providers[providerName]
+ provider, ok := h.resolveProvider(c, providerName)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "Unknown OAuth provider"})
return
@@ -55,7 +82,7 @@ func (h *OAuthHandler) Initiate(c *gin.Context) {
func (h *OAuthHandler) Callback(c *gin.Context) {
providerName := c.Param("provider")
- provider, ok := h.Providers[providerName]
+ provider, ok := h.resolveProvider(c, providerName)
if !ok {
h.redirectError(c, "Unknown OAuth provider")
return
diff --git a/api/internal/handler/oauth_config.go b/api/internal/handler/oauth_config.go
new file mode 100644
index 0000000..4500c6e
--- /dev/null
+++ b/api/internal/handler/oauth_config.go
@@ -0,0 +1,105 @@
+package handler
+
+import (
+ "context"
+ "strings"
+
+ "github.com/Devlaner/devlane/api/internal/model"
+ "github.com/Devlaner/devlane/api/internal/oauth"
+ "github.com/Devlaner/devlane/api/internal/store"
+)
+
+func loadOAuthSettingsMap(ctx context.Context, st *store.InstanceSettingStore) model.JSONMap {
+ if st == nil {
+ return nil
+ }
+ row, err := st.Get(ctx, "oauth")
+ if err != nil || row == nil || row.Value == nil {
+ return nil
+ }
+ return decryptSectionSecrets("oauth", row.Value)
+}
+
+func jsonString(m model.JSONMap, key string) string {
+ if m == nil {
+ return ""
+ }
+ v, ok := m[key]
+ if !ok || v == nil {
+ return ""
+ }
+ s, _ := v.(string)
+ return strings.TrimSpace(s)
+}
+
+func oauthRedirectURI(callbackBase, provider string) string {
+ b := strings.TrimSuffix(strings.TrimSpace(callbackBase), "/")
+ return b + "/auth/" + provider + "/callback/"
+}
+
+// BuildOAuthGoogleProvider returns a configured Google provider from DB instance settings.
+func BuildOAuthGoogleProvider(ctx context.Context, st *store.InstanceSettingStore, callbackBase string) (oauth.Provider, bool) {
+ m := loadOAuthSettingsMap(ctx, st)
+ id := jsonString(m, "google_client_id")
+ sec := jsonString(m, "google_client_secret")
+ if id == "" || sec == "" {
+ return nil, false
+ }
+ return oauth.NewGoogleProvider(oauth.ProviderConfig{
+ ClientID: id,
+ ClientSecret: sec,
+ RedirectURI: oauthRedirectURI(callbackBase, "google"),
+ }), true
+}
+
+// BuildOAuthGitHubProvider returns a configured GitHub provider from DB instance settings.
+func BuildOAuthGitHubProvider(ctx context.Context, st *store.InstanceSettingStore, callbackBase string) (oauth.Provider, bool) {
+ m := loadOAuthSettingsMap(ctx, st)
+ id := jsonString(m, "github_client_id")
+ sec := jsonString(m, "github_client_secret")
+ if id == "" || sec == "" {
+ return nil, false
+ }
+ return oauth.NewGitHubProvider(oauth.ProviderConfig{
+ ClientID: id,
+ ClientSecret: sec,
+ RedirectURI: oauthRedirectURI(callbackBase, "github"),
+ }), true
+}
+
+// BuildOAuthGitLabProvider returns a configured GitLab provider from DB instance settings.
+func BuildOAuthGitLabProvider(ctx context.Context, st *store.InstanceSettingStore, callbackBase string) (oauth.Provider, bool) {
+ m := loadOAuthSettingsMap(ctx, st)
+ id := jsonString(m, "gitlab_client_id")
+ sec := jsonString(m, "gitlab_client_secret")
+ host := jsonString(m, "gitlab_host")
+ if id == "" || sec == "" {
+ return nil, false
+ }
+ return oauth.NewGitLabProvider(oauth.ProviderConfig{
+ ClientID: id,
+ ClientSecret: sec,
+ RedirectURI: oauthRedirectURI(callbackBase, "gitlab"),
+ }, host), true
+}
+
+func oauthGoogleCredentialsReady(ctx context.Context, st *store.InstanceSettingStore) bool {
+ m := loadOAuthSettingsMap(ctx, st)
+ id := jsonString(m, "google_client_id")
+ sec := jsonString(m, "google_client_secret")
+ return id != "" && sec != ""
+}
+
+func oauthGitHubCredentialsReady(ctx context.Context, st *store.InstanceSettingStore) bool {
+ m := loadOAuthSettingsMap(ctx, st)
+ id := jsonString(m, "github_client_id")
+ sec := jsonString(m, "github_client_secret")
+ return id != "" && sec != ""
+}
+
+func oauthGitLabCredentialsReady(ctx context.Context, st *store.InstanceSettingStore) bool {
+ m := loadOAuthSettingsMap(ctx, st)
+ id := jsonString(m, "gitlab_client_id")
+ sec := jsonString(m, "gitlab_client_secret")
+ return id != "" && sec != ""
+}
diff --git a/api/internal/queue/consumer.go b/api/internal/queue/consumer.go
index 8dd1473..4a69a7c 100644
--- a/api/internal/queue/consumer.go
+++ b/api/internal/queue/consumer.go
@@ -9,7 +9,7 @@ import (
amqp "github.com/rabbitmq/amqp091-go"
)
-// TaskHandler is called for each consumed message. Return nil to ack; non-nil to nack/requeue.
+// TaskHandler is called for each consumed message. Return nil to ack; non-nil triggers republish+ack with incremented x-retry-count (see handle).
type TaskHandler func(ctx context.Context, queue string, body []byte) error
// Consumer consumes from RabbitMQ queues and dispatches to handlers.
@@ -61,6 +61,8 @@ func (c *Consumer) Run(ctx context.Context, queues []string) error {
func (c *Consumer) handle(ctx context.Context, queue string, d amqp.Delivery, h TaskHandler) {
err := h(ctx, queue, d.Body)
if err != nil {
+ // Retries: republish with incremented x-retry-count and Ack the original delivery.
+ // (Nack(requeue=true) would redeliver the same headers, so the count would never advance.)
retryCount := int64(0)
if d.Headers != nil {
if v, ok := d.Headers["x-retry-count"]; ok {
diff --git a/api/internal/router/router.go b/api/internal/router/router.go
index cc0b79f..1691bff 100644
--- a/api/internal/router/router.go
+++ b/api/internal/router/router.go
@@ -2,13 +2,11 @@ package router
import (
"log/slog"
- "strings"
"github.com/Devlaner/devlane/api/internal/auth"
"github.com/Devlaner/devlane/api/internal/handler"
"github.com/Devlaner/devlane/api/internal/middleware"
"github.com/Devlaner/devlane/api/internal/minio"
- "github.com/Devlaner/devlane/api/internal/oauth"
"github.com/Devlaner/devlane/api/internal/queue"
"github.com/Devlaner/devlane/api/internal/redis"
"github.com/Devlaner/devlane/api/internal/service"
@@ -25,17 +23,8 @@ type Config struct {
Queue *queue.Publisher // optional: enqueue emails, webhooks
Minio *minio.Client // optional: file uploads (cover images, avatars, logos)
CORSAllowOrigin string // optional: e.g. "http://localhost:5173" for UI dev
- AppBaseURL string // optional: base URL for invite links; if empty, CORSAllowOrigin is used
- APIPublicURL string // optional: public API origin for OAuth callbacks (see API_PUBLIC_URL)
-
- // OAuth providers (empty = disabled)
- GoogleClientID string
- GoogleClientSecret string
- GitHubClientID string
- GitHubClientSecret string
- GitLabClientID string
- GitLabClientSecret string
- GitLabHost string
+ AppBaseURL string // optional: base URL for invite links; if empty, CORSAllowOrigin is used
+
// MagicCodeSecret is the HMAC key for email login codes (see MAGIC_CODE_SECRET).
MagicCodeSecret string
}
@@ -95,55 +84,18 @@ func New(cfg Config) *gin.Engine {
appBaseURL = cfg.CORSAllowOrigin
}
- // OAuth redirect URIs must hit this API, not the SPA dev server. Use API_PUBLIC_URL when UI and API differ.
- oauthCallbackBase := strings.TrimSuffix(strings.TrimSpace(cfg.APIPublicURL), "/")
- if oauthCallbackBase == "" {
- oauthCallbackBase = strings.TrimSuffix(appBaseURL, "/")
- }
-
- // OAuth providers
- oauthProviders := make(map[string]oauth.Provider)
- if cfg.GoogleClientID != "" && cfg.GoogleClientSecret != "" {
- oauthProviders["google"] = oauth.NewGoogleProvider(oauth.ProviderConfig{
- ClientID: cfg.GoogleClientID,
- ClientSecret: cfg.GoogleClientSecret,
- RedirectURI: oauthCallbackBase + "/auth/google/callback/",
- })
- }
- if cfg.GitHubClientID != "" && cfg.GitHubClientSecret != "" {
- oauthProviders["github"] = oauth.NewGitHubProvider(oauth.ProviderConfig{
- ClientID: cfg.GitHubClientID,
- ClientSecret: cfg.GitHubClientSecret,
- RedirectURI: oauthCallbackBase + "/auth/github/callback/",
- })
- }
- if cfg.GitLabClientID != "" && cfg.GitLabClientSecret != "" {
- oauthProviders["gitlab"] = oauth.NewGitLabProvider(oauth.ProviderConfig{
- ClientID: cfg.GitLabClientID,
- ClientSecret: cfg.GitLabClientSecret,
- RedirectURI: oauthCallbackBase + "/auth/gitlab/callback/",
- }, cfg.GitLabHost)
- }
-
- oauthEnabled := make(map[string]any, len(oauthProviders))
- for k, v := range oauthProviders {
- oauthEnabled[k] = v
- }
-
authHandler := &handler.AuthHandler{
- Auth: authSvc,
- Settings: instanceSettingStore,
- Winv: workspaceInviteStore,
- Ws: workspaceStore,
- NotifPrefs: userNotifPrefStore,
- ApiTokens: apiTokenStore,
- Queue: cfg.Queue,
- Redis: cfg.Redis,
- MagicCodeSecret: cfg.MagicCodeSecret,
- AppBaseURL: appBaseURL,
- OAuthRedirectBase: oauthCallbackBase,
- Log: cfg.Log,
- OAuthProviders: oauthEnabled,
+ Auth: authSvc,
+ Settings: instanceSettingStore,
+ Winv: workspaceInviteStore,
+ Ws: workspaceStore,
+ NotifPrefs: userNotifPrefStore,
+ ApiTokens: apiTokenStore,
+ Queue: cfg.Queue,
+ Redis: cfg.Redis,
+ MagicCodeSecret: cfg.MagicCodeSecret,
+ AppBaseURL: appBaseURL,
+ Log: cfg.Log,
}
// 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}
@@ -352,17 +304,15 @@ func New(cfg Config) *gin.Engine {
authGroup.POST("/magic-code/verify/", authHandler.MagicCodeVerify)
}
- // OAuth routes (no auth required)
- if len(oauthProviders) > 0 {
- oauthHandler := &handler.OAuthHandler{
- Providers: oauthProviders,
- Auth: authSvc,
- AppBaseURL: appBaseURL,
- Log: cfg.Log,
- }
- authGroup.GET("/:provider/", oauthHandler.Initiate)
- authGroup.GET("/:provider/callback/", oauthHandler.Callback)
+ // OAuth routes (no auth required); provider resolved from instance settings at request time.
+ oauthHandler := &handler.OAuthHandler{
+ Settings: instanceSettingStore,
+ Auth: authSvc,
+ AppBaseURL: appBaseURL,
+ Log: cfg.Log,
}
+ authGroup.GET("/:provider/", oauthHandler.Initiate)
+ authGroup.GET("/:provider/callback/", oauthHandler.Callback)
// Legacy /api/v1
v1 := r.Group("/api/v1")
diff --git a/api/migrations/000002_auth_schema.down.sql b/api/migrations/000002_auth_schema.down.sql
new file mode 100644
index 0000000..af86784
--- /dev/null
+++ b/api/migrations/000002_auth_schema.down.sql
@@ -0,0 +1,23 @@
+-- Reverse order: accounts legacy shape first, then drop password_reset_tokens.
+
+DROP INDEX IF EXISTS idx_accounts_provider;
+
+ALTER TABLE accounts ALTER COLUMN id DROP DEFAULT;
+
+ALTER TABLE accounts ADD COLUMN IF NOT EXISTS access_token_expired_at TIMESTAMPTZ;
+ALTER TABLE accounts ADD COLUMN IF NOT EXISTS refresh_token_expired_at TIMESTAMPTZ;
+
+UPDATE accounts
+SET access_token_expired_at = token_expires_at
+WHERE access_token_expired_at IS NULL AND token_expires_at IS NOT NULL;
+
+ALTER TABLE accounts DROP COLUMN IF EXISTS token_expires_at;
+
+UPDATE accounts SET access_token = COALESCE(access_token, '') WHERE access_token IS NULL;
+ALTER TABLE accounts ALTER COLUMN access_token SET NOT NULL;
+
+UPDATE accounts SET last_connected_at = COALESCE(last_connected_at, NOW()) WHERE last_connected_at IS NULL;
+ALTER TABLE accounts ALTER COLUMN last_connected_at SET DEFAULT NOW();
+ALTER TABLE accounts ALTER COLUMN last_connected_at SET NOT NULL;
+
+DROP TABLE IF EXISTS password_reset_tokens;
diff --git a/api/migrations/000002_auth_schema.up.sql b/api/migrations/000002_auth_schema.up.sql
new file mode 100644
index 0000000..f85a3c4
--- /dev/null
+++ b/api/migrations/000002_auth_schema.up.sql
@@ -0,0 +1,41 @@
+-- Password reset tokens + OAuth accounts columns (single migration after init).
+-- If schema_migrations is already 2 from an older password-only 000002, this file will not run again;
+-- apply the accounts section manually or coordinate a follow-up migration.
+
+CREATE TABLE IF NOT EXISTS password_reset_tokens (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ token VARCHAR(128) 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_id ON password_reset_tokens(user_id);
+CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token) WHERE used_at IS NULL;
+
+-- Align public.accounts with model.Account (legacy init columns -> token_expires_at, nullable tokens / last_connected_at).
+
+ALTER TABLE accounts ADD COLUMN IF NOT EXISTS token_expires_at TIMESTAMPTZ;
+
+UPDATE accounts
+SET token_expires_at = access_token_expired_at
+WHERE token_expires_at IS NULL AND access_token_expired_at IS NOT NULL;
+
+UPDATE accounts
+SET token_expires_at = refresh_token_expired_at
+WHERE token_expires_at IS NULL AND refresh_token_expired_at IS NOT NULL;
+
+ALTER TABLE accounts DROP COLUMN access_token_expired_at;
+ALTER TABLE accounts DROP COLUMN refresh_token_expired_at;
+
+ALTER TABLE accounts ALTER COLUMN access_token SET DEFAULT '';
+ALTER TABLE accounts ALTER COLUMN access_token DROP NOT NULL;
+UPDATE accounts SET access_token = '' WHERE access_token IS NULL;
+
+ALTER TABLE accounts ALTER COLUMN last_connected_at DROP DEFAULT;
+ALTER TABLE accounts ALTER COLUMN last_connected_at DROP NOT NULL;
+
+CREATE INDEX IF NOT EXISTS idx_accounts_provider ON accounts (provider, user_id);
+
+ALTER TABLE accounts ALTER COLUMN id SET DEFAULT gen_random_uuid();
diff --git a/api/migrations/000002_password_reset_tokens.down.sql b/api/migrations/000002_password_reset_tokens.down.sql
deleted file mode 100644
index 68371a2..0000000
--- a/api/migrations/000002_password_reset_tokens.down.sql
+++ /dev/null
@@ -1 +0,0 @@
-DROP TABLE IF EXISTS password_reset_tokens;
diff --git a/api/migrations/000002_password_reset_tokens.up.sql b/api/migrations/000002_password_reset_tokens.up.sql
deleted file mode 100644
index eb52ece..0000000
--- a/api/migrations/000002_password_reset_tokens.up.sql
+++ /dev/null
@@ -1,11 +0,0 @@
-CREATE TABLE IF NOT EXISTS password_reset_tokens (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- token VARCHAR(128) 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_id ON password_reset_tokens(user_id);
-CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token) WHERE used_at IS NULL;
diff --git a/api/migrations/000003_accounts.down.sql b/api/migrations/000003_accounts.down.sql
deleted file mode 100644
index 1616db4..0000000
--- a/api/migrations/000003_accounts.down.sql
+++ /dev/null
@@ -1 +0,0 @@
-DROP TABLE IF EXISTS accounts;
diff --git a/api/migrations/000003_accounts.up.sql b/api/migrations/000003_accounts.up.sql
deleted file mode 100644
index ccf2edc..0000000
--- a/api/migrations/000003_accounts.up.sql
+++ /dev/null
@@ -1,18 +0,0 @@
-CREATE TABLE IF NOT EXISTS accounts (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- provider VARCHAR(50) NOT NULL,
- provider_account_id VARCHAR(255) NOT NULL,
- access_token TEXT DEFAULT '',
- refresh_token TEXT DEFAULT '',
- id_token TEXT DEFAULT '',
- token_expires_at TIMESTAMPTZ,
- last_connected_at TIMESTAMPTZ,
- metadata JSONB DEFAULT '{}',
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- UNIQUE(provider, provider_account_id)
-);
-
-CREATE INDEX IF NOT EXISTS idx_accounts_user_id ON accounts(user_id);
-CREATE INDEX IF NOT EXISTS idx_accounts_provider ON accounts(provider, user_id);
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 2cb15d4..c401c65 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -22,6 +22,7 @@
"@tiptap/starter-kit": "3.22.3",
"axios": "^1.13.5",
"clsx": "^2.1.1",
+ "devlane": "file:..",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -46,6 +47,16 @@
"vite": "^7.3.1"
}
},
+ "..": {
+ "version": "1.0.0",
+ "license": "ISC",
+ "devDependencies": {
+ "@commitlint/cli": "^19.8.1",
+ "@commitlint/config-conventional": "^19.8.1",
+ "husky": "^9.1.7",
+ "lint-staged": "^16.4.0"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -3443,6 +3454,10 @@
"node": ">=8"
}
},
+ "node_modules/devlane": {
+ "resolved": "..",
+ "link": true
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
diff --git a/ui/package.json b/ui/package.json
index 202cea2..5f3eb86 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -28,6 +28,7 @@
"@tiptap/starter-kit": "3.22.3",
"axios": "^1.13.5",
"clsx": "^2.1.1",
+ "devlane": "file:..",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts
index a29506d..a1214c1 100644
--- a/ui/src/api/client.ts
+++ b/ui/src/api/client.ts
@@ -1,14 +1,14 @@
import axios, { type AxiosError } from 'axios';
-import { config } from '../config/env';
/**
- * Shared Axios instance for all API requests.
- * - baseURL from config
- * - credentials included for cookie-based auth
- * - consistent error handling
+ * In dev the UI runs on :5173 (Vite) while the Go API runs on :8080.
+ * In production the UI is served by the same origin as the API,
+ * so an empty string keeps requests relative.
*/
+export const API_BASE = import.meta.env.DEV ? 'http://localhost:8080' : '';
+
export const apiClient = axios.create({
- baseURL: config.apiBaseUrl,
+ baseURL: API_BASE,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts
index 6c12200..9b6dac2 100644
--- a/ui/src/api/types.ts
+++ b/ui/src/api/types.ts
@@ -330,6 +330,8 @@ export interface AuthConfigResponse {
is_gitlab_enabled: boolean;
/** Present when at least one OAuth provider is enabled; use for redirect URIs in provider consoles. */
oauth_redirect_base?: string;
+ /** SPA origin for provider “JavaScript origin” fields (from APP_BASE_URL / CORS). */
+ oauth_js_origin?: string;
}
/** POST /auth/magic-code/request/ */
@@ -382,6 +384,21 @@ export interface InstanceAuthSection {
gitlab?: boolean;
}
+/** OAuth app credentials (instance admin); secrets encrypted at rest */
+export interface InstanceOAuthSection {
+ google_client_id?: string;
+ google_client_secret?: string;
+ google_client_secret_set?: boolean;
+ github_client_id?: string;
+ github_client_secret?: string;
+ github_client_secret_set?: boolean;
+ gitlab_client_id?: string;
+ gitlab_client_secret?: string;
+ gitlab_client_secret_set?: boolean;
+ /** Self-managed GitLab base URL; empty defaults to https://gitlab.com */
+ gitlab_host?: string;
+}
+
/** AI section shape (api_key is decrypted when returned from API) */
export interface InstanceAISection {
model?: string;
diff --git a/ui/src/components/layout/InstanceAdminLayout.tsx b/ui/src/components/layout/InstanceAdminLayout.tsx
index 4b95ad5..936e797 100644
--- a/ui/src/components/layout/InstanceAdminLayout.tsx
+++ b/ui/src/components/layout/InstanceAdminLayout.tsx
@@ -221,6 +221,12 @@ const BREADCRUMB_LABEL: Record = {
image: 'Image',
};
+const AUTH_SUB_LABEL: Record = {
+ google: 'Google',
+ github: 'GitHub',
+ gitlab: 'GitLab',
+};
+
export function InstanceAdminLayout() {
const location = useLocation();
const pathname = location.pathname;
@@ -228,7 +234,8 @@ export function InstanceAdminLayout() {
const segments = pathname.replace(basePath, '').replace(/^\//, '').split('/').filter(Boolean);
const segment = segments[0] || 'general';
const breadcrumbLabel = BREADCRUMB_LABEL[segment] ?? 'General';
- const breadcrumbTail = segments[1] === 'create' ? 'Create' : null;
+ const breadcrumbTail =
+ segments[1] === 'create' ? 'Create' : (AUTH_SUB_LABEL[segments[1] ?? ''] ?? null);
return (
diff --git a/ui/src/config/env.ts b/ui/src/config/env.ts
deleted file mode 100644
index c9fbb61..0000000
--- a/ui/src/config/env.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-/**
- * Environment and app config.
- * Centralizes env access so components don't depend on import.meta.env directly.
- */
-
-const getEnv = (key: string): string => {
- if (typeof import.meta === 'undefined' || !import.meta.env) return '';
- const v = import.meta.env[key];
- return typeof v === 'string' ? v : '';
-};
-
-export const config = {
- /** Base URL for the API (e.g. '' for same origin or 'http://localhost:8080') */
- apiBaseUrl: getEnv('VITE_API_BASE_URL') ?? '',
-} as const;
diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts
index 55a1bf8..2a8dae2 100644
--- a/ui/src/lib/utils.ts
+++ b/ui/src/lib/utils.ts
@@ -1,7 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import type { WorkspaceMemberApiResponse } from '../api/types';
-import { config } from '../config/env';
/**
* Merges Tailwind classes with clsx, resolving conflicts via tailwind-merge.
@@ -23,9 +22,8 @@ export function getImageUrl(url: string | null | undefined): string | null {
) {
return t;
}
- const base = (config.apiBaseUrl ?? '').replace(/\/+$/, '');
const path = t.startsWith('/') ? t : '/' + t;
- return base + path;
+ return path;
}
/** Normalize UUID strings for comparison (case + hyphen insensitive). */
diff --git a/ui/src/pages/ForgotPasswordPage.tsx b/ui/src/pages/ForgotPasswordPage.tsx
index f384f14..37eed5e 100644
--- a/ui/src/pages/ForgotPasswordPage.tsx
+++ b/ui/src/pages/ForgotPasswordPage.tsx
@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Button, Input, Card, CardContent } from '../components/ui';
import { authService } from '../services/authService';
+import { getApiErrorMessage } from '../api/client';
import { CircleAlert, CircleCheck, ArrowLeft } from 'lucide-react';
const RESEND_COOLDOWN_SECONDS = 30;
@@ -28,11 +29,13 @@ export function ForgotPasswordPage() {
setError('');
setIsSubmitting(true);
try {
- await authService.forgotPassword({ email });
+ const normalized = email.trim().toLowerCase();
+ await authService.forgotPassword({ email: normalized });
+ setEmail(normalized);
setSuccess(true);
setCooldown(RESEND_COOLDOWN_SECONDS);
- } catch {
- setError('Something went wrong. Please try again.');
+ } catch (err: unknown) {
+ setError(getApiErrorMessage(err) || 'Something went wrong. Please try again.');
} finally {
setIsSubmitting(false);
}
@@ -45,10 +48,12 @@ export function ForgotPasswordPage() {
setError('');
setIsSubmitting(true);
try {
- await authService.forgotPassword({ email });
+ const normalized = email.trim().toLowerCase();
+ await authService.forgotPassword({ email: normalized });
+ setEmail(normalized);
setCooldown(RESEND_COOLDOWN_SECONDS);
- } catch {
- setError('Something went wrong. Please try again.');
+ } catch (err: unknown) {
+ setError(getApiErrorMessage(err) || 'Something went wrong. Please try again.');
} finally {
setIsSubmitting(false);
}
diff --git a/ui/src/pages/InviteSignUpPage.tsx b/ui/src/pages/InviteSignUpPage.tsx
index c6affd5..171ab7b 100644
--- a/ui/src/pages/InviteSignUpPage.tsx
+++ b/ui/src/pages/InviteSignUpPage.tsx
@@ -234,6 +234,7 @@ export function InviteSignUpPage() {
onClick={() => setShowPassword((p) => !p)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-(--txt-icon-tertiary) hover:text-(--txt-secondary)"
aria-label={showPassword ? 'Hide password' : 'Show password'}
+ aria-pressed={showPassword}
>
{showPassword ?
:
}
@@ -268,7 +269,10 @@ export function InviteSignUpPage() {
type="button"
onClick={() => setShowConfirmPassword((p) => !p)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-(--txt-icon-tertiary) hover:text-(--txt-secondary)"
- aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
+ aria-label={
+ showConfirmPassword ? 'Hide confirm password' : 'Show confirm password'
+ }
+ aria-pressed={showConfirmPassword}
>
{showConfirmPassword ?
:
}
diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx
index 59fe204..5dc90e3 100644
--- a/ui/src/pages/LoginPage.tsx
+++ b/ui/src/pages/LoginPage.tsx
@@ -3,8 +3,7 @@ import { useNavigate, useLocation, Link, useSearchParams } from 'react-router-do
import { Button, Input, Card, CardContent } from '../components/ui';
import { useAuth } from '../contexts/AuthContext';
import { authService } from '../services/authService';
-import { getApiErrorMessage } from '../api/client';
-import { config } from '../config/env';
+import { API_BASE, getApiErrorMessage } from '../api/client';
import { Eye, EyeOff, CircleAlert, CircleCheck } from 'lucide-react';
type AuthStep = 'email' | 'password' | 'code';
@@ -137,9 +136,8 @@ export function LoginPage() {
const handleOAuth = useCallback(
(provider: string) => {
- const base = config.apiBaseUrl || '';
const nextPath = returnPath !== '/' ? `?next_path=${encodeURIComponent(returnPath)}` : '';
- window.location.assign(`${base}/auth/${provider}/${nextPath}`);
+ window.location.assign(`${API_BASE}/auth/${provider}/${nextPath}`);
},
[returnPath],
);
@@ -482,7 +480,8 @@ export function LoginPage() {
type="button"
className="absolute top-[2.1rem] right-3 text-(--txt-tertiary) hover:text-(--txt-primary)"
onClick={() => setShowPassword((v) => !v)}
- tabIndex={-1}
+ aria-label={showPassword ? 'Hide password' : 'Show password'}
+ aria-pressed={showPassword}
>
{showPassword ?
:
}
@@ -505,7 +504,8 @@ export function LoginPage() {
type="button"
className="absolute top-[2.1rem] right-3 text-(--txt-tertiary) hover:text-(--txt-primary)"
onClick={() => setShowConfirm((v) => !v)}
- tabIndex={-1}
+ aria-label={showConfirm ? 'Hide confirm password' : 'Show confirm password'}
+ aria-pressed={showConfirm}
>
{showConfirm ?
:
}
diff --git a/ui/src/pages/ResetPasswordPage.tsx b/ui/src/pages/ResetPasswordPage.tsx
index 73c5346..3610c60 100644
--- a/ui/src/pages/ResetPasswordPage.tsx
+++ b/ui/src/pages/ResetPasswordPage.tsx
@@ -179,7 +179,8 @@ export function ResetPasswordPage() {
type="button"
className="absolute top-[2.1rem] right-3 text-(--txt-tertiary) hover:text-(--txt-primary)"
onClick={() => setShowPassword((v) => !v)}
- tabIndex={-1}
+ aria-label={showPassword ? 'Hide new password' : 'Show new password'}
+ aria-pressed={showPassword}
>
{showPassword ?
:
}
@@ -201,7 +202,8 @@ export function ResetPasswordPage() {
type="button"
className="absolute top-[2.1rem] right-3 text-(--txt-tertiary) hover:text-(--txt-primary)"
onClick={() => setShowConfirm((v) => !v)}
- tabIndex={-1}
+ aria-label={showConfirm ? 'Hide confirm new password' : 'Show confirm new password'}
+ aria-pressed={showConfirm}
>
{showConfirm ?
:
}
diff --git a/ui/src/pages/instance-admin/InstanceAdminAuthGitHubPage.tsx b/ui/src/pages/instance-admin/InstanceAdminAuthGitHubPage.tsx
new file mode 100644
index 0000000..179c79e
--- /dev/null
+++ b/ui/src/pages/instance-admin/InstanceAdminAuthGitHubPage.tsx
@@ -0,0 +1,286 @@
+import React, { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Button, Input } from '../../components/ui';
+import { instanceSettingsService } from '../../services/instanceService';
+import { authService } from '../../services/authService';
+import { getApiErrorMessage } from '../../api/client';
+import type { InstanceAuthSection, InstanceOAuthSection } from '../../api/types';
+import { Copy, Eye, EyeOff } from 'lucide-react';
+
+function CopyRow({ label, hint, value }: { label: string; hint: string; value: string }) {
+ const [copied, setCopied] = useState(false);
+ const onCopy = () => {
+ if (!value) return;
+ void navigator.clipboard.writeText(value).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ };
+ return (
+
+
+ {label}
+
+
+ {copied ? 'Copied' : 'Copy'}
+
+
+
+
{hint}
+
+ );
+}
+
+function ToggleSwitch({
+ checked,
+ onChange,
+ disabled,
+}: {
+ checked: boolean;
+ onChange: (v: boolean) => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ onChange(e.target.checked)}
+ disabled={disabled}
+ />
+
+
+ );
+}
+
+const IconGitHub = () => (
+
+
+
+);
+
+export function InstanceAdminAuthGitHubPage() {
+ const navigate = useNavigate();
+ const [clientId, setClientId] = useState('');
+ const [clientSecret, setClientSecret] = useState('');
+ const [secretSet, setSecretSet] = useState(false);
+ const [showSecret, setShowSecret] = useState(false);
+ const [enabled, setEnabled] = useState(false);
+
+ const [initialClientId, setInitialClientId] = useState('');
+ const [initialSecret, setInitialSecret] = useState('');
+ const [initialEnabled, setInitialEnabled] = useState(false);
+
+ const [oauthRedirectBase, setOauthRedirectBase] = useState('');
+ const [oauthJsOrigin, setOauthJsOrigin] = useState('');
+
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState('');
+
+ useEffect(() => {
+ let cancelled = false;
+ Promise.all([instanceSettingsService.getSettings(), authService.getAuthConfig()])
+ .then(([settings, cfg]) => {
+ if (cancelled) return;
+ const o = (settings.oauth || {}) as InstanceOAuthSection;
+ const a = (settings.auth || {}) as InstanceAuthSection;
+ setClientId(o.github_client_id ?? '');
+ setInitialClientId(o.github_client_id ?? '');
+ setSecretSet(o.github_client_secret_set ?? false);
+ if (o.github_client_secret) {
+ setClientSecret(o.github_client_secret);
+ setInitialSecret(o.github_client_secret);
+ }
+ setEnabled(a.github ?? false);
+ setInitialEnabled(a.github ?? false);
+ if (cfg.oauth_redirect_base) setOauthRedirectBase(cfg.oauth_redirect_base);
+ if (cfg.oauth_js_origin) setOauthJsOrigin(cfg.oauth_js_origin);
+ else if (typeof window !== 'undefined') setOauthJsOrigin(window.location.origin);
+ })
+ .catch((err) => {
+ if (!cancelled) setError(getApiErrorMessage(err));
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const callbackUrl = oauthRedirectBase ? `${oauthRedirectBase}/auth/github/callback/` : '';
+
+ const isDirty =
+ clientId !== initialClientId || clientSecret !== initialSecret || enabled !== initialEnabled;
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setSuccess('');
+ setSaving(true);
+
+ const oauthPayload: Record
= {
+ github_client_id: clientId.trim(),
+ };
+ if (clientSecret.trim()) {
+ oauthPayload.github_client_secret = clientSecret.trim();
+ }
+
+ const authPayload = { github: enabled };
+
+ Promise.all([
+ instanceSettingsService.updateSection('oauth', oauthPayload),
+ instanceSettingsService.updateSection('auth', authPayload),
+ ])
+ .then(([oauthRes]) => {
+ setSuccess('Your GitHub authentication is configured. You should test it now.');
+ if (oauthRes.value) {
+ const v = oauthRes.value as InstanceOAuthSection;
+ setClientId(v.github_client_id ?? '');
+ setInitialClientId(v.github_client_id ?? '');
+ setSecretSet(v.github_client_secret_set ?? false);
+ if (v.github_client_secret) {
+ setClientSecret(v.github_client_secret);
+ setInitialSecret(v.github_client_secret);
+ }
+ }
+ setInitialEnabled(enabled);
+ })
+ .catch((err) => setError(getApiErrorMessage(err)))
+ .finally(() => setSaving(false));
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
GitHub
+
+ Allow members to login or sign up for Devlane with their GitHub accounts.
+
+
+
+
setEnabled(v)} disabled={saving} />
+
+
+ {error &&
{error}
}
+ {success &&
{success}
}
+
+
+
+ );
+}
diff --git a/ui/src/pages/instance-admin/InstanceAdminAuthGitLabPage.tsx b/ui/src/pages/instance-admin/InstanceAdminAuthGitLabPage.tsx
new file mode 100644
index 0000000..4acd975
--- /dev/null
+++ b/ui/src/pages/instance-admin/InstanceAdminAuthGitLabPage.tsx
@@ -0,0 +1,299 @@
+import React, { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Button, Input } from '../../components/ui';
+import { instanceSettingsService } from '../../services/instanceService';
+import { authService } from '../../services/authService';
+import { getApiErrorMessage } from '../../api/client';
+import type { InstanceAuthSection, InstanceOAuthSection } from '../../api/types';
+import { Copy, Eye, EyeOff } from 'lucide-react';
+
+function CopyRow({ label, hint, value }: { label: string; hint: string; value: string }) {
+ const [copied, setCopied] = useState(false);
+ const onCopy = () => {
+ if (!value) return;
+ void navigator.clipboard.writeText(value).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ };
+ return (
+
+
+ {label}
+
+
+ {copied ? 'Copied' : 'Copy'}
+
+
+
+
{hint}
+
+ );
+}
+
+function ToggleSwitch({
+ checked,
+ onChange,
+ disabled,
+}: {
+ checked: boolean;
+ onChange: (v: boolean) => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ onChange(e.target.checked)}
+ disabled={disabled}
+ />
+
+
+ );
+}
+
+const IconGitLab = () => (
+
+
+
+);
+
+export function InstanceAdminAuthGitLabPage() {
+ const navigate = useNavigate();
+ const [clientId, setClientId] = useState('');
+ const [clientSecret, setClientSecret] = useState('');
+ const [gitlabHost, setGitlabHost] = useState('');
+ const [secretSet, setSecretSet] = useState(false);
+ const [showSecret, setShowSecret] = useState(false);
+ const [enabled, setEnabled] = useState(false);
+
+ const [initialClientId, setInitialClientId] = useState('');
+ const [initialSecret, setInitialSecret] = useState('');
+ const [initialHost, setInitialHost] = useState('');
+ const [initialEnabled, setInitialEnabled] = useState(false);
+
+ const [oauthRedirectBase, setOauthRedirectBase] = useState('');
+
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState('');
+
+ useEffect(() => {
+ let cancelled = false;
+ Promise.all([instanceSettingsService.getSettings(), authService.getAuthConfig()])
+ .then(([settings, cfg]) => {
+ if (cancelled) return;
+ const o = (settings.oauth || {}) as InstanceOAuthSection;
+ const a = (settings.auth || {}) as InstanceAuthSection;
+ setClientId(o.gitlab_client_id ?? '');
+ setInitialClientId(o.gitlab_client_id ?? '');
+ setGitlabHost(o.gitlab_host ?? '');
+ setInitialHost(o.gitlab_host ?? '');
+ setSecretSet(o.gitlab_client_secret_set ?? false);
+ if (o.gitlab_client_secret) {
+ setClientSecret(o.gitlab_client_secret);
+ setInitialSecret(o.gitlab_client_secret);
+ }
+ setEnabled(a.gitlab ?? false);
+ setInitialEnabled(a.gitlab ?? false);
+ if (cfg.oauth_redirect_base) setOauthRedirectBase(cfg.oauth_redirect_base);
+ })
+ .catch((err) => {
+ if (!cancelled) setError(getApiErrorMessage(err));
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const callbackUrl = oauthRedirectBase ? `${oauthRedirectBase}/auth/gitlab/callback/` : '';
+
+ const isDirty =
+ clientId !== initialClientId ||
+ clientSecret !== initialSecret ||
+ gitlabHost !== initialHost ||
+ enabled !== initialEnabled;
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setSuccess('');
+ setSaving(true);
+
+ const oauthPayload: Record = {
+ gitlab_client_id: clientId.trim(),
+ gitlab_host: gitlabHost.trim(),
+ };
+ if (clientSecret.trim()) {
+ oauthPayload.gitlab_client_secret = clientSecret.trim();
+ }
+
+ const authPayload = { gitlab: enabled };
+
+ Promise.all([
+ instanceSettingsService.updateSection('oauth', oauthPayload),
+ instanceSettingsService.updateSection('auth', authPayload),
+ ])
+ .then(([oauthRes]) => {
+ setSuccess('Your GitLab authentication is configured. You should test it now.');
+ if (oauthRes.value) {
+ const v = oauthRes.value as InstanceOAuthSection;
+ setClientId(v.gitlab_client_id ?? '');
+ setInitialClientId(v.gitlab_client_id ?? '');
+ setGitlabHost(v.gitlab_host ?? '');
+ setInitialHost(v.gitlab_host ?? '');
+ setSecretSet(v.gitlab_client_secret_set ?? false);
+ if (v.gitlab_client_secret) {
+ setClientSecret(v.gitlab_client_secret);
+ setInitialSecret(v.gitlab_client_secret);
+ }
+ }
+ setInitialEnabled(enabled);
+ })
+ .catch((err) => setError(getApiErrorMessage(err)))
+ .finally(() => setSaving(false));
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
GitLab
+
+ Allow members to log in or sign up for Devlane with their GitLab accounts.
+
+
+
+
setEnabled(v)} disabled={saving} />
+
+
+ {error &&
{error}
}
+ {success &&
{success}
}
+
+
+
+ );
+}
diff --git a/ui/src/pages/instance-admin/InstanceAdminAuthGooglePage.tsx b/ui/src/pages/instance-admin/InstanceAdminAuthGooglePage.tsx
new file mode 100644
index 0000000..40c51b6
--- /dev/null
+++ b/ui/src/pages/instance-admin/InstanceAdminAuthGooglePage.tsx
@@ -0,0 +1,301 @@
+import React, { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Button, Input } from '../../components/ui';
+import { instanceSettingsService } from '../../services/instanceService';
+import { authService } from '../../services/authService';
+import { getApiErrorMessage } from '../../api/client';
+import type { InstanceAuthSection, InstanceOAuthSection } from '../../api/types';
+import { Copy, Eye, EyeOff } from 'lucide-react';
+
+function CopyRow({ label, hint, value }: { label: string; hint: string; value: string }) {
+ const [copied, setCopied] = useState(false);
+ const onCopy = () => {
+ if (!value) return;
+ void navigator.clipboard.writeText(value).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ };
+ return (
+
+
+ {label}
+
+
+ {copied ? 'Copied' : 'Copy'}
+
+
+
+
{hint}
+
+ );
+}
+
+function ToggleSwitch({
+ checked,
+ onChange,
+ disabled,
+}: {
+ checked: boolean;
+ onChange: (v: boolean) => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ onChange(e.target.checked)}
+ disabled={disabled}
+ />
+
+
+ );
+}
+
+const IconGoogle = () => (
+
+
+
+
+
+
+);
+
+export function InstanceAdminAuthGooglePage() {
+ const navigate = useNavigate();
+ const [clientId, setClientId] = useState('');
+ const [clientSecret, setClientSecret] = useState('');
+ const [secretSet, setSecretSet] = useState(false);
+ const [showSecret, setShowSecret] = useState(false);
+ const [enabled, setEnabled] = useState(false);
+
+ const [initialClientId, setInitialClientId] = useState('');
+ const [initialSecret, setInitialSecret] = useState('');
+ const [initialEnabled, setInitialEnabled] = useState(false);
+
+ const [oauthRedirectBase, setOauthRedirectBase] = useState('');
+ const [oauthJsOrigin, setOauthJsOrigin] = useState('');
+
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState('');
+
+ useEffect(() => {
+ let cancelled = false;
+ Promise.all([instanceSettingsService.getSettings(), authService.getAuthConfig()])
+ .then(([settings, cfg]) => {
+ if (cancelled) return;
+ const o = (settings.oauth || {}) as InstanceOAuthSection;
+ const a = (settings.auth || {}) as InstanceAuthSection;
+ setClientId(o.google_client_id ?? '');
+ setInitialClientId(o.google_client_id ?? '');
+ setSecretSet(o.google_client_secret_set ?? false);
+ if (o.google_client_secret) {
+ setClientSecret(o.google_client_secret);
+ setInitialSecret(o.google_client_secret);
+ }
+ setEnabled(a.google ?? false);
+ setInitialEnabled(a.google ?? false);
+ if (cfg.oauth_redirect_base) setOauthRedirectBase(cfg.oauth_redirect_base);
+ if (cfg.oauth_js_origin) setOauthJsOrigin(cfg.oauth_js_origin);
+ else if (typeof window !== 'undefined') setOauthJsOrigin(window.location.origin);
+ })
+ .catch((err) => {
+ if (!cancelled) setError(getApiErrorMessage(err));
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const callbackUrl = oauthRedirectBase ? `${oauthRedirectBase}/auth/google/callback/` : '';
+
+ const isDirty =
+ clientId !== initialClientId || clientSecret !== initialSecret || enabled !== initialEnabled;
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setSuccess('');
+ setSaving(true);
+
+ const oauthPayload: Record = {
+ google_client_id: clientId.trim(),
+ };
+ if (clientSecret.trim()) {
+ oauthPayload.google_client_secret = clientSecret.trim();
+ }
+
+ const authPayload = { google: enabled };
+
+ Promise.all([
+ instanceSettingsService.updateSection('oauth', oauthPayload),
+ instanceSettingsService.updateSection('auth', authPayload),
+ ])
+ .then(([oauthRes]) => {
+ setSuccess('Your Google authentication is configured. You should test it now.');
+ if (oauthRes.value) {
+ const v = oauthRes.value as InstanceOAuthSection;
+ setClientId(v.google_client_id ?? '');
+ setInitialClientId(v.google_client_id ?? '');
+ setSecretSet(v.google_client_secret_set ?? false);
+ if (v.google_client_secret) {
+ setClientSecret(v.google_client_secret);
+ setInitialSecret(v.google_client_secret);
+ }
+ }
+ setInitialEnabled(enabled);
+ })
+ .catch((err) => setError(getApiErrorMessage(err)))
+ .finally(() => setSaving(false));
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
Google
+
+ Allow members to login or sign up for Devlane with their Google accounts.
+
+
+
+
setEnabled(v)} disabled={saving} />
+
+
+ {error &&
{error}
}
+ {success &&
{success}
}
+
+
+
+ );
+}
diff --git a/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx b/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx
index 07e739b..52c233c 100644
--- a/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx
+++ b/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx
@@ -1,8 +1,11 @@
import React, { useEffect, useState } from 'react';
-import { Button, Skeleton } from '../../components/ui';
+import { Link } from 'react-router-dom';
+import { Settings2 } from 'lucide-react';
+import { Skeleton } from '../../components/ui';
import { instanceSettingsService } from '../../services/instanceService';
+import { authService } from '../../services/authService';
import { getApiErrorMessage } from '../../api/client';
-import type { InstanceAuthSection } from '../../api/types';
+import type { InstanceAuthSection, InstanceOAuthSection } from '../../api/types';
const IconEnvelope = () => (
(
);
-const AUTH_MODES: Array<{
+type OAuthProviderKey = 'google' | 'github' | 'gitlab';
+
+interface AuthMode {
key: keyof InstanceAuthSection;
Icon: () => React.ReactElement;
name: string;
desc: string;
- action?: string;
-}> = [
+ isOAuth?: boolean;
+ oauthKey?: OAuthProviderKey;
+ editPath?: string;
+}
+
+const AUTH_MODES: AuthMode[] = [
{
key: 'magic_code',
Icon: IconEnvelope,
@@ -88,24 +97,76 @@ const AUTH_MODES: Array<{
Icon: IconGoogle,
name: 'Google',
desc: 'Allow members to log in or sign up for Devlane with their Google accounts.',
- action: 'Edit',
+ isOAuth: true,
+ oauthKey: 'google',
+ editPath: '/instance-admin/authentication/google',
},
{
key: 'github',
Icon: IconGitHub,
name: 'GitHub',
desc: 'Allow members to log in or sign up for Devlane with their GitHub accounts.',
- action: 'Edit',
+ isOAuth: true,
+ oauthKey: 'github',
+ editPath: '/instance-admin/authentication/github',
},
{
key: 'gitlab',
Icon: IconGitLab,
name: 'GitLab',
desc: 'Allow members to log in or sign up for Devlane with their GitLab accounts.',
- action: 'Configure',
+ isOAuth: true,
+ oauthKey: 'gitlab',
+ editPath: '/instance-admin/authentication/gitlab',
},
];
+function ToggleSwitch({
+ checked,
+ onChange,
+ disabled,
+}: {
+ checked: boolean;
+ onChange: (v: boolean) => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ onChange(e.target.checked)}
+ disabled={disabled}
+ />
+
+
+ );
+}
+
+function isOAuthConfigured(oauthKey: OAuthProviderKey, oauth: InstanceOAuthSection): boolean {
+ switch (oauthKey) {
+ case 'google':
+ return !!(oauth.google_client_id && oauth.google_client_secret_set);
+ case 'github':
+ return !!(oauth.github_client_id && oauth.github_client_secret_set);
+ case 'gitlab':
+ return !!(oauth.gitlab_client_id && oauth.gitlab_client_secret_set);
+ default:
+ return false;
+ }
+}
+
+function countEnabledAuthMethods(auth: InstanceAuthSection): number {
+ let n = 0;
+ if (auth.magic_code) n++;
+ if (auth.password) n++;
+ if (auth.google) n++;
+ if (auth.github) n++;
+ if (auth.gitlab) n++;
+ return n;
+}
+
export function InstanceAdminAuthenticationPage() {
const [auth, setAuth] = useState({
allow_public_signup: true,
@@ -115,16 +176,15 @@ export function InstanceAdminAuthenticationPage() {
github: false,
gitlab: false,
});
+ const [oauth, setOauth] = useState({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
- void saving; // reserved for future use (e.g. disable submit while saving)
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
- instanceSettingsService
- .getSettings()
- .then((settings) => {
+ Promise.all([instanceSettingsService.getSettings(), authService.getAuthConfig()])
+ .then(([settings]) => {
if (cancelled) return;
const a = (settings.auth || {}) as InstanceAuthSection;
setAuth({
@@ -135,6 +195,8 @@ export function InstanceAdminAuthenticationPage() {
github: a.github ?? false,
gitlab: a.gitlab ?? false,
});
+ const o = (settings.oauth || {}) as InstanceOAuthSection;
+ setOauth(o);
})
.catch((err) => {
if (!cancelled) setError(getApiErrorMessage(err));
@@ -148,6 +210,14 @@ export function InstanceAdminAuthenticationPage() {
}, []);
const handleToggle = (key: keyof InstanceAuthSection, value: boolean) => {
+ if (!value && key !== 'allow_public_signup') {
+ if (countEnabledAuthMethods(auth) <= 1) {
+ setError(
+ 'At least one authentication method must remain enabled. Please enable another method before disabling this one.',
+ );
+ return;
+ }
+ }
const prev = auth;
const next = { ...auth, [key]: value };
setAuth(next);
@@ -202,7 +272,7 @@ export function InstanceAdminAuthenticationPage() {
}
return (
-
+
Manage authentication modes for your instance
@@ -223,15 +293,11 @@ export function InstanceAdminAuthenticationPage() {
Toggling this off will only let users sign up when they are invited.
-
- handleToggle('allow_public_signup', e.target.checked)}
- />
-
-
+
handleToggle('allow_public_signup', v)}
+ disabled={saving}
+ />
@@ -242,6 +308,9 @@ export function InstanceAdminAuthenticationPage() {
{AUTH_MODES.map((item) => {
const Icon = item.Icon;
const on = auth[item.key] ?? false;
+ const configured =
+ item.isOAuth && item.oauthKey ? isOAuthConfigured(item.oauthKey, oauth) : false;
+
return (
{item.desc}
-
- {item.action && (
-
- {item.action}
-
+
+ {item.isOAuth && item.editPath && configured && (
+ <>
+
+ Edit
+
+ handleToggle(item.key, v)}
+ disabled={saving}
+ />
+ >
)}
-
-
+
+ Configure
+
+ )}
+ {!item.isOAuth && (
+ handleToggle(item.key, e.target.checked)}
+ onChange={(v) => handleToggle(item.key, v)}
+ disabled={saving}
/>
-
-
+ )}
);
diff --git a/ui/src/pages/instance-admin/index.ts b/ui/src/pages/instance-admin/index.ts
index 3c93d20..0117cad 100644
--- a/ui/src/pages/instance-admin/index.ts
+++ b/ui/src/pages/instance-admin/index.ts
@@ -3,6 +3,9 @@ export { InstanceAdminWorkspacePage } from './InstanceAdminWorkspacePage';
export { InstanceAdminCreateWorkspacePage } from './InstanceAdminCreateWorkspacePage';
export { InstanceAdminEmailPage } from './InstanceAdminEmailPage';
export { InstanceAdminAuthenticationPage } from './InstanceAdminAuthenticationPage';
+export { InstanceAdminAuthGooglePage } from './InstanceAdminAuthGooglePage';
+export { InstanceAdminAuthGitHubPage } from './InstanceAdminAuthGitHubPage';
+export { InstanceAdminAuthGitLabPage } from './InstanceAdminAuthGitLabPage';
export { InstanceAdminAIPage } from './InstanceAdminAIPage';
export { InstanceAdminImagePage } from './InstanceAdminImagePage';
export { InstanceAdminLoginPage } from './InstanceAdminLoginPage';
diff --git a/ui/src/routes/index.tsx b/ui/src/routes/index.tsx
index 414c484..3e2f8a7 100644
--- a/ui/src/routes/index.tsx
+++ b/ui/src/routes/index.tsx
@@ -115,6 +115,21 @@ const InstanceAdminAuthenticationPage = lazy(() =>
}),
),
);
+const InstanceAdminAuthGooglePage = lazy(() =>
+ import('../pages/instance-admin').then((m) =>
+ page({ InstanceAdminAuthGooglePage: m.InstanceAdminAuthGooglePage }),
+ ),
+);
+const InstanceAdminAuthGitHubPage = lazy(() =>
+ import('../pages/instance-admin').then((m) =>
+ page({ InstanceAdminAuthGitHubPage: m.InstanceAdminAuthGitHubPage }),
+ ),
+);
+const InstanceAdminAuthGitLabPage = lazy(() =>
+ import('../pages/instance-admin').then((m) =>
+ page({ InstanceAdminAuthGitLabPage: m.InstanceAdminAuthGitLabPage }),
+ ),
+);
const InstanceAdminAIPage = lazy(() =>
import('../pages/instance-admin').then((m) =>
page({ InstanceAdminAIPage: m.InstanceAdminAIPage }),
@@ -277,6 +292,30 @@ const router = createBrowserRouter([
),
},
+ {
+ path: 'authentication/google',
+ element: (
+
}>
+
+
+ ),
+ },
+ {
+ path: 'authentication/github',
+ element: (
+
}>
+
+
+ ),
+ },
+ {
+ path: 'authentication/gitlab',
+ element: (
+
}>
+
+
+ ),
+ },
{
path: 'ai',
element: (
diff --git a/ui/vite.config.ts b/ui/vite.config.ts
index 1105054..a0e822a 100644
--- a/ui/vite.config.ts
+++ b/ui/vite.config.ts
@@ -5,13 +5,6 @@ import tailwindcss from '@tailwindcss/vite';
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
- // When VITE_API_BASE_URL is unset, the UI uses same-origin URLs; forward API + auth to the Go server.
- server: {
- proxy: {
- '/api': { target: 'http://localhost:8080', changeOrigin: true },
- '/auth': { target: 'http://localhost:8080', changeOrigin: true },
- },
- },
build: {
rollupOptions: {
output: {
From 77d493957b38e9d4d2a7f7501ea3c43487be0493 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Tue, 14 Apr 2026 14:56:29 +0400
Subject: [PATCH 20/43] feat: removed OAuthRedirectBase from AuthHandler
---
api/internal/config/config.go | 4 ++--
api/internal/router/router.go | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/api/internal/config/config.go b/api/internal/config/config.go
index 54b046c..0fd339c 100644
--- a/api/internal/config/config.go
+++ b/api/internal/config/config.go
@@ -89,8 +89,8 @@ func Load() (*Config, error) {
MinIOUseSSL: minioSSL,
MigrationsPath: getEnv("MIGRATIONS_PATH", "migrations"),
CORSAllowOrigin: getEnv("CORS_ORIGIN", "http://localhost:5173"),
- AppBaseURL: getEnv("APP_BASE_URL", ""),
- MagicCodeSecret: getEnv("MAGIC_CODE_SECRET", ""),
+ AppBaseURL: getEnv("APP_BASE_URL", ""),
+ MagicCodeSecret: getEnv("MAGIC_CODE_SECRET", ""),
}
return cfg, nil
diff --git a/api/internal/router/router.go b/api/internal/router/router.go
index 1691bff..6ce1717 100644
--- a/api/internal/router/router.go
+++ b/api/internal/router/router.go
@@ -23,7 +23,7 @@ type Config struct {
Queue *queue.Publisher // optional: enqueue emails, webhooks
Minio *minio.Client // optional: file uploads (cover images, avatars, logos)
CORSAllowOrigin string // optional: e.g. "http://localhost:5173" for UI dev
- AppBaseURL string // optional: base URL for invite links; if empty, CORSAllowOrigin is used
+ AppBaseURL string // optional: base URL for invite links; if empty, CORSAllowOrigin is used
// MagicCodeSecret is the HMAC key for email login codes (see MAGIC_CODE_SECRET).
MagicCodeSecret string
From a70ac2d228794fe3932d5ca90d2615cbd965da02 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Tue, 14 Apr 2026 16:02:19 +0400
Subject: [PATCH 21/43] refactor: update auth, handler, router, contexts for
API and ui
---
api/internal/auth/service.go | 18 +++++----
api/internal/handler/auth.go | 55 ++++++++++++++++++++++++++
api/internal/handler/oauth.go | 70 ++++++++++++++++++++++++++++++++-
api/internal/router/router.go | 1 +
ui/src/contexts/AuthContext.tsx | 13 ++++++
5 files changed, 148 insertions(+), 9 deletions(-)
diff --git a/api/internal/auth/service.go b/api/internal/auth/service.go
index 2e021d3..cce58b4 100644
--- a/api/internal/auth/service.go
+++ b/api/internal/auth/service.go
@@ -290,22 +290,24 @@ func (s *Service) ResetPassword(ctx context.Context, token, newPassword string)
// OAuthLogin finds or creates a user from OAuth provider data and creates a session.
// If the email already exists, it links the account; if not, it creates a new user.
-func (s *Service) OAuthLogin(ctx context.Context, provider, providerAccountID, email, firstName, lastName, avatar, accessToken, refreshToken, idToken string) (sessionKey string, user *model.User, err error) {
+// isNewUser is true when a brand-new user row was created (first-time sign-up).
+func (s *Service) OAuthLogin(ctx context.Context, provider, providerAccountID, email, firstName, lastName, avatar, accessToken, refreshToken, idToken string) (sessionKey string, user *model.User, isNewUser bool, err error) {
email = strings.TrimSpace(strings.ToLower(email))
if email == "" {
- return "", nil, errors.New("oauth: email is required")
+ return "", nil, false, errors.New("oauth: email is required")
}
u, err := s.userStore.GetByEmail(ctx, email)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
- return "", nil, err
+ return "", nil, false, err
}
if u != nil && !u.IsActive {
- return "", nil, errors.New("account is deactivated")
+ return "", nil, false, errors.New("account is deactivated")
}
- if u == nil {
+ newUser := u == nil
+ if newUser {
username := email
if at := strings.Index(email, "@"); at > 0 {
username = strings.ReplaceAll(email[:at], ".", "_")
@@ -327,7 +329,7 @@ func (s *Service) OAuthLogin(ctx context.Context, provider, providerAccountID, e
IsActive: true,
}
if err := s.userStore.Create(ctx, u); err != nil {
- return "", nil, err
+ return "", nil, false, err
}
}
@@ -346,9 +348,9 @@ func (s *Service) OAuthLogin(ctx context.Context, provider, providerAccountID, e
sessionKey, err = s.createSession(ctx, u.ID)
if err != nil {
- return "", nil, err
+ return "", nil, false, err
}
- return sessionKey, u, nil
+ return sessionKey, u, newUser, nil
}
func (s *Service) createSession(ctx context.Context, userID uuid.UUID) (string, error) {
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index d21b9ae..615cde7 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -1,6 +1,7 @@
package handler
import (
+ "context"
"crypto/rand"
"crypto/subtle"
"errors"
@@ -9,6 +10,7 @@ import (
"math/big"
"net/http"
"net/mail"
+ "regexp"
"strings"
"time"
@@ -167,6 +169,8 @@ func (h *AuthHandler) SignUp(c *gin.Context) {
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)
}
+ } else {
+ h.ensureDefaultWorkspace(ctx, user)
}
setSessionCookie(c, sessionKey)
c.JSON(http.StatusCreated, userResponse(user))
@@ -872,6 +876,8 @@ func (h *AuthHandler) MagicCodeVerify(c *gin.Context) {
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 magic signup", "error", err, "user_id", user.ID)
}
+ } else {
+ h.ensureDefaultWorkspace(ctx, user)
}
setSessionCookie(c, sessionKey)
c.JSON(http.StatusCreated, userResponse(user))
@@ -930,6 +936,55 @@ func clearSessionCookie(c *gin.Context) {
})
}
+var authSlugRe = regexp.MustCompile(`[^a-z0-9-]+`)
+
+// ensureDefaultWorkspace creates a personal workspace for a newly signed-up
+// user when they have no workspaces, so they land inside a workspace instead
+// of an empty "no workspaces" screen. Failures are logged but never block.
+func (h *AuthHandler) ensureDefaultWorkspace(ctx context.Context, u *model.User) {
+ if h.Ws == nil || u == nil {
+ return
+ }
+ list, _ := h.Ws.ListByMemberID(ctx, u.ID)
+ if len(list) > 0 {
+ return
+ }
+
+ displayName := strings.TrimSpace(u.DisplayName)
+ if displayName == "" {
+ displayName = strings.TrimSpace(u.FirstName)
+ }
+ if displayName == "" && u.Email != nil {
+ displayName = strings.Split(*u.Email, "@")[0]
+ }
+
+ wsName := displayName + "'s Workspace"
+ slug := strings.Trim(authSlugRe.ReplaceAllString(strings.ToLower(displayName), "-"), "-")
+ if slug == "" {
+ slug = "workspace"
+ }
+
+ exists, _ := h.Ws.SlugExists(ctx, slug, uuid.Nil)
+ if exists {
+ slug = slug + "-" + fmt.Sprintf("%x%x", u.ID[0], u.ID[1])
+ }
+
+ w := &model.Workspace{
+ Name: wsName,
+ Slug: slug,
+ OwnerID: u.ID,
+ CreatedByID: &u.ID,
+ }
+ if err := h.Ws.Create(ctx, w); err != nil {
+ h.log().Warn("auto-create workspace failed", "user_id", u.ID, "error", err)
+ return
+ }
+ m := &model.WorkspaceMember{WorkspaceID: w.ID, MemberID: u.ID, Role: 20}
+ if err := h.Ws.AddMember(ctx, m); err != nil {
+ h.log().Warn("auto-add workspace member failed", "user_id", u.ID, "error", err)
+ }
+}
+
func userResponse(u *model.User) gin.H {
if u == nil {
return gin.H{}
diff --git a/api/internal/handler/oauth.go b/api/internal/handler/oauth.go
index ed846d4..54a45f0 100644
--- a/api/internal/handler/oauth.go
+++ b/api/internal/handler/oauth.go
@@ -1,22 +1,27 @@
package handler
import (
+ "context"
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"net/url"
+ "regexp"
"strings"
"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/oauth"
"github.com/Devlaner/devlane/api/internal/store"
"github.com/gin-gonic/gin"
+ "github.com/google/uuid"
)
type OAuthHandler struct {
Settings *store.InstanceSettingStore
+ Workspaces *store.WorkspaceStore
Auth *auth.Service
AppBaseURL string
Log *slog.Logger
@@ -141,7 +146,7 @@ func (h *OAuthHandler) Callback(c *gin.Context) {
return
}
- sessionKey, _, err := h.Auth.OAuthLogin(
+ sessionKey, user, isNewUser, err := h.Auth.OAuthLogin(
ctx,
providerName,
userInfo.ProviderID,
@@ -159,6 +164,10 @@ func (h *OAuthHandler) Callback(c *gin.Context) {
return
}
+ if isNewUser {
+ h.ensureDefaultWorkspace(ctx, user)
+ }
+
http.SetCookie(c.Writer, &http.Cookie{
Name: middleware.SessionCookieName,
Value: sessionKey,
@@ -175,6 +184,16 @@ func (h *OAuthHandler) Callback(c *gin.Context) {
}
redirectURL = strings.TrimSuffix(redirectURL, "/") + sanitizeRedirectPath(nextPath)
+ // When the SPA runs on a different origin (dev mode), cross-origin cookies
+ // may not be sent back on the first XHR. Pass the session key in the URL
+ // fragment so the frontend can use it as a Bearer token. Fragments are never
+ // sent to servers, so this is safe for browser history / logs.
+ callbackOrigin := requestCallbackBase(c)
+ spaOrigin := strings.TrimSuffix(strings.TrimSpace(h.AppBaseURL), "/")
+ if spaOrigin != "" && !strings.EqualFold(spaOrigin, callbackOrigin) {
+ redirectURL += "#session_token=" + url.QueryEscape(sessionKey)
+ }
+
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
}
@@ -199,3 +218,52 @@ func sanitizeRedirectPath(path string) string {
}
return path
}
+
+var defaultSlugRe = regexp.MustCompile(`[^a-z0-9-]+`)
+
+// ensureDefaultWorkspace creates a personal workspace for a newly signed-up
+// user so they land inside a workspace instead of an empty "no workspaces"
+// screen. Failures are logged but never block the sign-up.
+func (h *OAuthHandler) ensureDefaultWorkspace(ctx context.Context, u *model.User) {
+ if h.Workspaces == nil || u == nil {
+ return
+ }
+ list, _ := h.Workspaces.ListByMemberID(ctx, u.ID)
+ if len(list) > 0 {
+ return
+ }
+
+ displayName := strings.TrimSpace(u.DisplayName)
+ if displayName == "" {
+ displayName = strings.TrimSpace(u.FirstName)
+ }
+ if displayName == "" && u.Email != nil {
+ displayName = strings.Split(*u.Email, "@")[0]
+ }
+
+ wsName := displayName + "'s Workspace"
+ slug := strings.Trim(defaultSlugRe.ReplaceAllString(strings.ToLower(displayName), "-"), "-")
+ if slug == "" {
+ slug = "workspace"
+ }
+
+ exists, _ := h.Workspaces.SlugExists(ctx, slug, uuid.Nil)
+ if exists {
+ slug = slug + "-" + hex.EncodeToString([]byte{byte(u.ID[0]), byte(u.ID[1])})
+ }
+
+ w := &model.Workspace{
+ Name: wsName,
+ Slug: slug,
+ OwnerID: u.ID,
+ CreatedByID: &u.ID,
+ }
+ if err := h.Workspaces.Create(ctx, w); err != nil {
+ h.log().Warn("auto-create workspace failed", "user_id", u.ID, "error", err)
+ return
+ }
+ m := &model.WorkspaceMember{WorkspaceID: w.ID, MemberID: u.ID, Role: 20}
+ if err := h.Workspaces.AddMember(ctx, m); err != nil {
+ h.log().Warn("auto-add workspace member failed", "user_id", u.ID, "error", err)
+ }
+}
diff --git a/api/internal/router/router.go b/api/internal/router/router.go
index 6ce1717..d8e51e9 100644
--- a/api/internal/router/router.go
+++ b/api/internal/router/router.go
@@ -307,6 +307,7 @@ func New(cfg Config) *gin.Engine {
// OAuth routes (no auth required); provider resolved from instance settings at request time.
oauthHandler := &handler.OAuthHandler{
Settings: instanceSettingStore,
+ Workspaces: workspaceStore,
Auth: authSvc,
AppBaseURL: appBaseURL,
Log: cfg.Log,
diff --git a/ui/src/contexts/AuthContext.tsx b/ui/src/contexts/AuthContext.tsx
index 78fff4e..a9bdae7 100644
--- a/ui/src/contexts/AuthContext.tsx
+++ b/ui/src/contexts/AuthContext.tsx
@@ -10,6 +10,7 @@ import {
} from 'react';
import type { User } from '../types';
import type { UserApiResponse } from '../api/types';
+import { apiClient } from '../api/client';
import { authService } from '../services/authService';
function mapApiUserToUser(api: UserApiResponse): User {
@@ -42,6 +43,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
+ // After OAuth, the API may pass the session token in the URL fragment
+ // (cross-origin dev mode). Read it, set as Bearer header, then clear.
+ const hash = window.location.hash;
+ if (hash.includes('session_token=')) {
+ const params = new URLSearchParams(hash.slice(1));
+ const token = params.get('session_token');
+ if (token) {
+ apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`;
+ window.history.replaceState(null, '', window.location.pathname + window.location.search);
+ }
+ }
+
let cancelled = false;
authService.getMe().then((api) => {
if (!cancelled && api) setUser(mapApiUserToUser(api));
From 488f8c95cd207b32a50e70417500498397e686f5 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Tue, 14 Apr 2026 16:03:06 +0400
Subject: [PATCH 22/43] refactor: replace the invite handling +
ensureDefaultWorkspace with the unified postSignUpWorkflow
---
api/internal/auth/service.go | 12 +++
api/internal/handler/auth.go | 133 +++++++++++++++++++++++-----------
api/internal/handler/oauth.go | 1 +
3 files changed, 105 insertions(+), 41 deletions(-)
diff --git a/api/internal/auth/service.go b/api/internal/auth/service.go
index cce58b4..491a23b 100644
--- a/api/internal/auth/service.go
+++ b/api/internal/auth/service.go
@@ -94,6 +94,18 @@ func (s *Service) SignUp(ctx context.Context, req SignUpRequest) (sessionKey str
return sessionKey, u, nil
}
+// EmailExists returns true if a user with the given email is registered.
+func (s *Service) EmailExists(ctx context.Context, email string) bool {
+ email = strings.TrimSpace(strings.ToLower(email))
+ u, err := s.userStore.GetByEmail(ctx, email)
+ return err == nil && u != nil
+}
+
+// UpdateUser persists changes to a user row (e.g. setting is_onboarded).
+func (s *Service) UpdateUser(ctx context.Context, u *model.User) error {
+ return s.userStore.Update(ctx, u)
+}
+
// SignUpMagic creates a new user with a random password (same pattern as OAuth) and starts a session.
func (s *Service) SignUpMagic(ctx context.Context, email, firstName, lastName string) (sessionKey string, user *model.User, err error) {
email = strings.TrimSpace(strings.ToLower(email))
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index 615cde7..e8c075e 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -159,19 +159,7 @@ func (h *AuthHandler) SignUp(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Sign up failed"})
return
}
- if inv != nil && h.Winv != nil && h.Ws != nil {
- now := time.Now()
- inv.Accepted = true
- inv.RespondedAt = &now
- 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)
- }
- } else {
- h.ensureDefaultWorkspace(ctx, user)
- }
+ postSignUpWorkflow(ctx, h.postSignUpDeps(), user)
setSessionCookie(c, sessionKey)
c.JSON(http.StatusCreated, userResponse(user))
}
@@ -866,19 +854,7 @@ func (h *AuthHandler) MagicCodeVerify(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Sign up failed"})
return
}
- if inv != nil && h.Winv != nil && h.Ws != nil {
- now := time.Now()
- inv.Accepted = true
- inv.RespondedAt = &now
- if err := h.Winv.Update(ctx, inv); err != nil {
- h.log().Error("failed to mark invite accepted (magic)", "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 magic signup", "error", err, "user_id", user.ID)
- }
- } else {
- h.ensureDefaultWorkspace(ctx, user)
- }
+ postSignUpWorkflow(ctx, h.postSignUpDeps(), user)
setSessionCookie(c, sessionKey)
c.JSON(http.StatusCreated, userResponse(user))
return
@@ -936,20 +912,54 @@ func clearSessionCookie(c *gin.Context) {
})
}
-var authSlugRe = regexp.MustCompile(`[^a-z0-9-]+`)
+var autoSlugRe = regexp.MustCompile(`[^a-z0-9-]+`)
-// ensureDefaultWorkspace creates a personal workspace for a newly signed-up
-// user when they have no workspaces, so they land inside a workspace instead
-// of an empty "no workspaces" screen. Failures are logged but never block.
-func (h *AuthHandler) ensureDefaultWorkspace(ctx context.Context, u *model.User) {
- if h.Ws == nil || u == nil {
+// postSignUpWorkflow mirrors Plane's post_user_auth_workflow: auto-accepts all
+// pending workspace invites for the user's email, creates a default workspace
+// when the user ends up with none and workspace creation is allowed, and marks
+// is_onboarded. All failures are logged but never block the sign-up.
+func postSignUpWorkflow(ctx context.Context, deps postSignUpDeps, u *model.User) {
+ if u == nil {
return
}
- list, _ := h.Ws.ListByMemberID(ctx, u.ID)
- if len(list) > 0 {
- return
+
+ // 1. Auto-accept every pending workspace invite for this email.
+ if deps.Invites != nil && u.Email != nil {
+ invites, _ := deps.Invites.ListPendingByEmail(ctx, strings.TrimSpace(strings.ToLower(*u.Email)))
+ now := time.Now()
+ for i := range invites {
+ invites[i].Accepted = true
+ invites[i].RespondedAt = &now
+ _ = deps.Invites.Update(ctx, &invites[i])
+ if deps.Workspaces != nil {
+ _ = deps.Workspaces.AddMember(ctx, &model.WorkspaceMember{
+ WorkspaceID: invites[i].WorkspaceID,
+ MemberID: u.ID,
+ Role: invites[i].Role,
+ })
+ }
+ }
}
+ // 2. If user still has no workspaces and workspace creation is allowed,
+ // create a personal default workspace.
+ if deps.Workspaces != nil {
+ list, _ := deps.Workspaces.ListByMemberID(ctx, u.ID)
+ if len(list) == 0 && !isWorkspaceCreationRestricted(ctx, deps.Settings) {
+ createDefaultWorkspace(ctx, deps, u)
+ }
+ }
+
+ // 3. Mark user as onboarded.
+ if deps.Auth != nil && !u.IsOnboarded {
+ u.IsOnboarded = true
+ if err := deps.Auth.UpdateUser(ctx, u); err != nil {
+ deps.log().Warn("failed to set is_onboarded", "user_id", u.ID, "error", err)
+ }
+ }
+}
+
+func createDefaultWorkspace(ctx context.Context, deps postSignUpDeps, u *model.User) {
displayName := strings.TrimSpace(u.DisplayName)
if displayName == "" {
displayName = strings.TrimSpace(u.FirstName)
@@ -959,12 +969,12 @@ func (h *AuthHandler) ensureDefaultWorkspace(ctx context.Context, u *model.User)
}
wsName := displayName + "'s Workspace"
- slug := strings.Trim(authSlugRe.ReplaceAllString(strings.ToLower(displayName), "-"), "-")
+ slug := strings.Trim(autoSlugRe.ReplaceAllString(strings.ToLower(displayName), "-"), "-")
if slug == "" {
slug = "workspace"
}
- exists, _ := h.Ws.SlugExists(ctx, slug, uuid.Nil)
+ exists, _ := deps.Workspaces.SlugExists(ctx, slug, uuid.Nil)
if exists {
slug = slug + "-" + fmt.Sprintf("%x%x", u.ID[0], u.ID[1])
}
@@ -975,13 +985,54 @@ func (h *AuthHandler) ensureDefaultWorkspace(ctx context.Context, u *model.User)
OwnerID: u.ID,
CreatedByID: &u.ID,
}
- if err := h.Ws.Create(ctx, w); err != nil {
- h.log().Warn("auto-create workspace failed", "user_id", u.ID, "error", err)
+ if err := deps.Workspaces.Create(ctx, w); err != nil {
+ deps.log().Warn("auto-create workspace failed", "user_id", u.ID, "error", err)
return
}
m := &model.WorkspaceMember{WorkspaceID: w.ID, MemberID: u.ID, Role: 20}
- if err := h.Ws.AddMember(ctx, m); err != nil {
- h.log().Warn("auto-add workspace member failed", "user_id", u.ID, "error", err)
+ if err := deps.Workspaces.AddMember(ctx, m); err != nil {
+ deps.log().Warn("auto-add workspace member failed", "user_id", u.ID, "error", err)
+ }
+}
+
+func isWorkspaceCreationRestricted(ctx context.Context, settings *store.InstanceSettingStore) bool {
+ if settings == nil {
+ return false
+ }
+ row, _ := settings.Get(ctx, "general")
+ if row == nil {
+ return false
+ }
+ if v, ok := row.Value["only_admin_can_create_workspace"]; ok {
+ if b, ok := v.(bool); ok {
+ return b
+ }
+ }
+ return false
+}
+
+type postSignUpDeps struct {
+ Auth *auth.Service
+ Invites *store.WorkspaceInviteStore
+ Workspaces *store.WorkspaceStore
+ Settings *store.InstanceSettingStore
+ Logger *slog.Logger
+}
+
+func (d postSignUpDeps) log() *slog.Logger {
+ if d.Logger != nil {
+ return d.Logger
+ }
+ return slog.Default()
+}
+
+func (h *AuthHandler) postSignUpDeps() postSignUpDeps {
+ return postSignUpDeps{
+ Auth: h.Auth,
+ Invites: h.Winv,
+ Workspaces: h.Ws,
+ Settings: h.Settings,
+ Logger: h.Log,
}
}
diff --git a/api/internal/handler/oauth.go b/api/internal/handler/oauth.go
index 54a45f0..dad107c 100644
--- a/api/internal/handler/oauth.go
+++ b/api/internal/handler/oauth.go
@@ -22,6 +22,7 @@ import (
type OAuthHandler struct {
Settings *store.InstanceSettingStore
Workspaces *store.WorkspaceStore
+ Invites *store.WorkspaceInviteStore
Auth *auth.Service
AppBaseURL string
Log *slog.Logger
From 9dc0bc166caee08b53925d170bc70c0d28e93553 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Tue, 14 Apr 2026 16:05:52 +0400
Subject: [PATCH 23/43] refactor: update RootRedirect to handle the 'no
workspaces' case properly
---
api/internal/handler/auth.go | 15 +++---
api/internal/handler/oauth.go | 83 ++++++++++++------------------
api/internal/router/router.go | 1 +
ui/src/api/types.ts | 1 +
ui/src/components/RootRedirect.tsx | 29 ++++++++---
5 files changed, 63 insertions(+), 66 deletions(-)
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index e8c075e..c352cb6 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -538,13 +538,14 @@ func (h *AuthHandler) InstanceAuthConfig(c *gin.Context) {
isGitLabEnabled := gitlabAllowed && oauthGitLabCredentialsReady(ctx, h.Settings)
out := gin.H{
- "is_email_password_enabled": isPasswordEnabled,
- "is_magic_code_enabled": isMagicCodeEnabled,
- "enable_signup": enableSignup,
- "is_smtp_configured": isSmtpConfigured,
- "is_google_enabled": isGoogleEnabled,
- "is_github_enabled": isGitHubEnabled,
- "is_gitlab_enabled": isGitLabEnabled,
+ "is_email_password_enabled": isPasswordEnabled,
+ "is_magic_code_enabled": isMagicCodeEnabled,
+ "enable_signup": enableSignup,
+ "is_smtp_configured": isSmtpConfigured,
+ "is_google_enabled": isGoogleEnabled,
+ "is_github_enabled": isGitHubEnabled,
+ "is_gitlab_enabled": isGitLabEnabled,
+ "is_workspace_creation_disabled": isWorkspaceCreationRestricted(ctx, h.Settings),
}
out["oauth_redirect_base"] = requestCallbackBase(c)
if s := strings.TrimSpace(h.AppBaseURL); s != "" {
diff --git a/api/internal/handler/oauth.go b/api/internal/handler/oauth.go
index dad107c..bf2d29e 100644
--- a/api/internal/handler/oauth.go
+++ b/api/internal/handler/oauth.go
@@ -1,22 +1,18 @@
package handler
import (
- "context"
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"net/url"
- "regexp"
"strings"
"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/oauth"
"github.com/Devlaner/devlane/api/internal/store"
"github.com/gin-gonic/gin"
- "github.com/google/uuid"
)
type OAuthHandler struct {
@@ -147,6 +143,30 @@ func (h *OAuthHandler) Callback(c *gin.Context) {
return
}
+ // Enforce allow_public_signup for new users — matches Plane's
+ // Adapter.__check_signup: if signup is disabled, only users with a
+ // pending workspace invite may register.
+ if !h.Auth.EmailExists(ctx, userInfo.Email) {
+ var allowPublicSignup = true
+ if h.Settings != nil {
+ row, _ := h.Settings.Get(ctx, "auth")
+ if row != nil {
+ allowPublicSignup = authBool(row.Value, "allow_public_signup", true)
+ }
+ }
+ if !allowPublicSignup {
+ hasInvite := false
+ if h.Invites != nil {
+ invites, _ := h.Invites.ListPendingByEmail(ctx, strings.TrimSpace(strings.ToLower(userInfo.Email)))
+ hasInvite = len(invites) > 0
+ }
+ if !hasInvite {
+ h.redirectError(c, "Sign-up is by invite only")
+ return
+ }
+ }
+ }
+
sessionKey, user, isNewUser, err := h.Auth.OAuthLogin(
ctx,
providerName,
@@ -166,7 +186,7 @@ func (h *OAuthHandler) Callback(c *gin.Context) {
}
if isNewUser {
- h.ensureDefaultWorkspace(ctx, user)
+ postSignUpWorkflow(ctx, h.postSignUpDeps(), user)
}
http.SetCookie(c.Writer, &http.Cookie{
@@ -220,51 +240,12 @@ func sanitizeRedirectPath(path string) string {
return path
}
-var defaultSlugRe = regexp.MustCompile(`[^a-z0-9-]+`)
-
-// ensureDefaultWorkspace creates a personal workspace for a newly signed-up
-// user so they land inside a workspace instead of an empty "no workspaces"
-// screen. Failures are logged but never block the sign-up.
-func (h *OAuthHandler) ensureDefaultWorkspace(ctx context.Context, u *model.User) {
- if h.Workspaces == nil || u == nil {
- return
- }
- list, _ := h.Workspaces.ListByMemberID(ctx, u.ID)
- if len(list) > 0 {
- return
- }
-
- displayName := strings.TrimSpace(u.DisplayName)
- if displayName == "" {
- displayName = strings.TrimSpace(u.FirstName)
- }
- if displayName == "" && u.Email != nil {
- displayName = strings.Split(*u.Email, "@")[0]
- }
-
- wsName := displayName + "'s Workspace"
- slug := strings.Trim(defaultSlugRe.ReplaceAllString(strings.ToLower(displayName), "-"), "-")
- if slug == "" {
- slug = "workspace"
- }
-
- exists, _ := h.Workspaces.SlugExists(ctx, slug, uuid.Nil)
- if exists {
- slug = slug + "-" + hex.EncodeToString([]byte{byte(u.ID[0]), byte(u.ID[1])})
- }
-
- w := &model.Workspace{
- Name: wsName,
- Slug: slug,
- OwnerID: u.ID,
- CreatedByID: &u.ID,
- }
- if err := h.Workspaces.Create(ctx, w); err != nil {
- h.log().Warn("auto-create workspace failed", "user_id", u.ID, "error", err)
- return
- }
- m := &model.WorkspaceMember{WorkspaceID: w.ID, MemberID: u.ID, Role: 20}
- if err := h.Workspaces.AddMember(ctx, m); err != nil {
- h.log().Warn("auto-add workspace member failed", "user_id", u.ID, "error", err)
+func (h *OAuthHandler) postSignUpDeps() postSignUpDeps {
+ return postSignUpDeps{
+ Auth: h.Auth,
+ Invites: h.Invites,
+ Workspaces: h.Workspaces,
+ Settings: h.Settings,
+ Logger: h.Log,
}
}
diff --git a/api/internal/router/router.go b/api/internal/router/router.go
index d8e51e9..fab5650 100644
--- a/api/internal/router/router.go
+++ b/api/internal/router/router.go
@@ -308,6 +308,7 @@ func New(cfg Config) *gin.Engine {
oauthHandler := &handler.OAuthHandler{
Settings: instanceSettingStore,
Workspaces: workspaceStore,
+ Invites: workspaceInviteStore,
Auth: authSvc,
AppBaseURL: appBaseURL,
Log: cfg.Log,
diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts
index 9b6dac2..ad25969 100644
--- a/ui/src/api/types.ts
+++ b/ui/src/api/types.ts
@@ -328,6 +328,7 @@ export interface AuthConfigResponse {
is_google_enabled: boolean;
is_github_enabled: boolean;
is_gitlab_enabled: boolean;
+ is_workspace_creation_disabled: boolean;
/** Present when at least one OAuth provider is enabled; use for redirect URIs in provider consoles. */
oauth_redirect_base?: string;
/** SPA origin for provider “JavaScript origin” fields (from APP_BASE_URL / CORS). */
diff --git a/ui/src/components/RootRedirect.tsx b/ui/src/components/RootRedirect.tsx
index 3f65746..8b175dd 100644
--- a/ui/src/components/RootRedirect.tsx
+++ b/ui/src/components/RootRedirect.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom';
+import { authService } from '../services/authService';
import { instanceService } from '../services/instanceService';
import { workspaceService } from '../services/workspaceService';
@@ -12,12 +13,15 @@ const PageFallback = () => (
/**
* At root "/", checks setup status then redirects:
* - setup required → /setup
- * - authenticated → first workspace /:slug or "no workspaces" message
+ * - authenticated + has workspaces → first workspace /:slug
+ * - authenticated + no workspaces + creation allowed → /create-workspace
+ * - authenticated + no workspaces + creation restricted → info message
*/
export function RootRedirect() {
const [setupRequired, setSetupRequired] = useState(null);
const [firstSlug, setFirstSlug] = useState(null);
const [noWorkspaces, setNoWorkspaces] = useState(false);
+ const [wsCreationDisabled, setWsCreationDisabled] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -30,11 +34,17 @@ export function RootRedirect() {
return;
}
setSetupRequired(false);
- return workspaceService.list().then((list) => {
- if (cancelled) return;
- if (list.length > 0) setFirstSlug(list[0].slug);
- else setNoWorkspaces(true);
- });
+ return Promise.all([workspaceService.list(), authService.getAuthConfig()]).then(
+ ([list, config]) => {
+ if (cancelled) return;
+ if (list.length > 0) {
+ setFirstSlug(list[0].slug);
+ } else {
+ setWsCreationDisabled(config?.is_workspace_creation_disabled ?? false);
+ setNoWorkspaces(true);
+ }
+ },
+ );
})
.catch(() => {
if (!cancelled) setSetupRequired(false);
@@ -54,11 +64,14 @@ export function RootRedirect() {
return ;
}
if (noWorkspaces) {
+ if (!wsCreationDisabled) {
+ return ;
+ }
return (
-
You don’t have any workspaces yet.
+
You don't have any workspaces yet.
- Create one from instance admin or use the API to get started.
+ Workspace creation is restricted. Ask your instance admin to invite you to a workspace.
);
From 53d50e2911626da5e7ec0c94a772eab8448fa00c Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Tue, 14 Apr 2026 16:09:49 +0400
Subject: [PATCH 24/43] chore: linting checks
---
api/internal/handler/auth.go | 5 -
ui/src/pages/CreateWorkspacePage.tsx | 148 +++++++++++++++++++++++++++
ui/src/routes/index.tsx | 15 +++
3 files changed, 163 insertions(+), 5 deletions(-)
create mode 100644 ui/src/pages/CreateWorkspacePage.tsx
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index c352cb6..49570d1 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -834,11 +834,6 @@ func (h *AuthHandler) MagicCodeVerify(c *gin.Context) {
_ = h.Redis.DeleteMagicCodeLogin(ctx, body.Email)
- var inv *model.WorkspaceMemberInvite
- if stored.IsSignup && strings.TrimSpace(stored.InviteToken) != "" && h.Winv != nil {
- inv, _ = h.Winv.GetByToken(ctx, strings.TrimSpace(stored.InviteToken))
- }
-
if stored.IsSignup {
sessionKey, user, err := h.Auth.SignUpMagic(ctx, body.Email, body.FirstName, body.LastName)
if err != nil {
diff --git a/ui/src/pages/CreateWorkspacePage.tsx b/ui/src/pages/CreateWorkspacePage.tsx
new file mode 100644
index 0000000..12a623d
--- /dev/null
+++ b/ui/src/pages/CreateWorkspacePage.tsx
@@ -0,0 +1,148 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Button, Input } from '../components/ui';
+import { useAuth } from '../contexts/AuthContext';
+import { workspaceService } from '../services/workspaceService';
+import { getApiErrorMessage } from '../api/client';
+import { slugFromName, validateWorkspaceSlug } from '../utils/workspace';
+import { ORGANIZATION_SIZE_OPTIONS } from '../constants/workspace';
+
+const IconChevronDown = () => (
+
+
+
+);
+
+export function CreateWorkspacePage() {
+ const navigate = useNavigate();
+ const { isAuthenticated, user } = useAuth();
+ const [name, setName] = useState('');
+ const [slug, setSlug] = useState('');
+ const [organizationSize, setOrganizationSize] = useState('');
+ const [error, setError] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const baseUrl = typeof window !== 'undefined' ? `${window.location.origin}/` : '';
+
+ const handleNameChange = (e: React.ChangeEvent) => {
+ const next = e.target.value;
+ setName(next);
+ if (!slug || slug === slugFromName(name)) {
+ setSlug(slugFromName(next));
+ }
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ const trimmedName = name.trim();
+ const trimmedSlug = slug.trim().toLowerCase() || slugFromName(trimmedName);
+
+ if (!trimmedName) {
+ setError('Please enter a workspace name.');
+ return;
+ }
+ if (!validateWorkspaceSlug(trimmedSlug)) {
+ setError('Workspace URL must be lowercase letters, numbers, and hyphens only.');
+ return;
+ }
+ if (!isAuthenticated || !user) {
+ setError('You need to be signed in to create a workspace.');
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const ws = await workspaceService.create({ name: trimmedName, slug: trimmedSlug });
+ navigate(`/${ws.slug}`, { replace: true });
+ } catch (err) {
+ setError(getApiErrorMessage(err));
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
Create your workspace
+
+ Workspaces are shared environments where teams manage their projects.
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/routes/index.tsx b/ui/src/routes/index.tsx
index 3e2f8a7..30808fa 100644
--- a/ui/src/routes/index.tsx
+++ b/ui/src/routes/index.tsx
@@ -165,6 +165,11 @@ const InstanceSetupCompletePage = lazy(() =>
page({ InstanceSetupCompletePage: m.InstanceSetupCompletePage }),
),
);
+const CreateWorkspacePage = lazy(() =>
+ import('../pages/CreateWorkspacePage').then((m) =>
+ page({ CreateWorkspacePage: m.CreateWorkspacePage }),
+ ),
+);
const InviteAcceptPage = lazy(() =>
import('../pages/InviteAcceptPage').then((m) => page({ InviteAcceptPage: m.InviteAcceptPage })),
);
@@ -373,6 +378,16 @@ const router = createBrowserRouter([
},
],
},
+ {
+ path: 'create-workspace',
+ element: (
+
+ }>
+
+
+
+ ),
+ },
{
element: ,
children: [
From d3a16cfff14a3b45bb51cee60616d5b50209a87e Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Tue, 14 Apr 2026 19:27:56 +0400
Subject: [PATCH 25/43] chore: husky fixture
---
.github/workflows/ui-ci.yml | 14 +++++++++++++-
ui/package-lock.json | 6 +-----
ui/package.json | 1 -
3 files changed, 14 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/ui-ci.yml b/.github/workflows/ui-ci.yml
index 8d342c8..3623c8e 100644
--- a/.github/workflows/ui-ci.yml
+++ b/.github/workflows/ui-ci.yml
@@ -23,7 +23,19 @@ jobs:
with:
node-version: "22"
cache: npm
- cache-dependency-path: ui/package-lock.json
+ cache-dependency-path: |
+ package-lock.json
+ ui/package-lock.json
+
+ - name: Install root dependencies (Husky)
+ working-directory: .
+ run: npm ci
+
+ - name: Verify Husky setup
+ working-directory: .
+ run: |
+ npm run prepare
+ npx husky --version
- name: Install dependencies
run: npm ci
diff --git a/ui/package-lock.json b/ui/package-lock.json
index c401c65..5f4db2c 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -22,7 +22,6 @@
"@tiptap/starter-kit": "3.22.3",
"axios": "^1.13.5",
"clsx": "^2.1.1",
- "devlane": "file:..",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -49,6 +48,7 @@
},
"..": {
"version": "1.0.0",
+ "extraneous": true,
"license": "ISC",
"devDependencies": {
"@commitlint/cli": "^19.8.1",
@@ -3454,10 +3454,6 @@
"node": ">=8"
}
},
- "node_modules/devlane": {
- "resolved": "..",
- "link": true
- },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
diff --git a/ui/package.json b/ui/package.json
index 5f3eb86..202cea2 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -28,7 +28,6 @@
"@tiptap/starter-kit": "3.22.3",
"axios": "^1.13.5",
"clsx": "^2.1.1",
- "devlane": "file:..",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
From 361e34f144ba24706ce9b0a4504ff461b842e0fa Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Tue, 14 Apr 2026 20:58:13 +0400
Subject: [PATCH 26/43] feat: set-password UI page
---
api/internal/auth/service.go | 55 +-
api/internal/handler/auth.go | 56 +-
api/internal/handler/oauth.go | 6 +
api/internal/model/user.go | 5 +-
api/internal/router/router.go | 2 +
.../000003_user_password_autoset.down.sql | 1 +
.../000003_user_password_autoset.up.sql | 1 +
ui/src/api/types.ts | 1 +
ui/src/components/auth/AuthPageShell.tsx | 35 ++
ui/src/pages/ForgotPasswordPage.tsx | 13 +-
ui/src/pages/LoginPage.tsx | 13 +-
ui/src/pages/ResetPasswordPage.tsx | 33 +-
ui/src/pages/SetPasswordPage.tsx | 189 ++++++
ui/src/pages/SignUpPage.tsx | 554 ++++++++++++++++++
ui/src/routes/index.tsx | 24 +
ui/src/services/authService.ts | 5 +
16 files changed, 929 insertions(+), 64 deletions(-)
create mode 100644 api/migrations/000003_user_password_autoset.down.sql
create mode 100644 api/migrations/000003_user_password_autoset.up.sql
create mode 100644 ui/src/components/auth/AuthPageShell.tsx
create mode 100644 ui/src/pages/SetPasswordPage.tsx
create mode 100644 ui/src/pages/SignUpPage.tsx
diff --git a/api/internal/auth/service.go b/api/internal/auth/service.go
index 491a23b..a195777 100644
--- a/api/internal/auth/service.go
+++ b/api/internal/auth/service.go
@@ -129,13 +129,14 @@ func (s *Service) SignUpMagic(ctx context.Context, email, firstName, lastName st
return "", nil, err
}
u := &model.User{
- Username: username,
- Email: &email,
- Password: string(hash),
- FirstName: firstName,
- LastName: lastName,
- DisplayName: strings.TrimSpace(firstName + " " + lastName),
- IsActive: true,
+ Username: username,
+ Email: &email,
+ Password: string(hash),
+ FirstName: firstName,
+ LastName: lastName,
+ DisplayName: strings.TrimSpace(firstName + " " + lastName),
+ IsActive: true,
+ IsPasswordAutoset: true,
}
if err := s.userStore.Create(ctx, u); err != nil {
return "", nil, err
@@ -300,6 +301,29 @@ func (s *Service) ResetPassword(ctx context.Context, token, newPassword string)
return nil
}
+var ErrPasswordAlreadySet = errors.New("password is already set")
+
+// SetPassword lets a user who signed up via OAuth/magic set their first password.
+func (s *Service) SetPassword(ctx context.Context, userID uuid.UUID, newPassword string) error {
+ u, err := s.userStore.GetByID(ctx, userID)
+ if err != nil {
+ return err
+ }
+ if u == nil {
+ return ErrInvalidCredentials
+ }
+ if !u.IsPasswordAutoset {
+ return ErrPasswordAlreadySet
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost)
+ if err != nil {
+ return err
+ }
+ u.Password = string(hash)
+ u.IsPasswordAutoset = false
+ return s.userStore.Update(ctx, u)
+}
+
// OAuthLogin finds or creates a user from OAuth provider data and creates a session.
// If the email already exists, it links the account; if not, it creates a new user.
// isNewUser is true when a brand-new user row was created (first-time sign-up).
@@ -331,14 +355,15 @@ func (s *Service) OAuthLogin(ctx context.Context, provider, providerAccountID, e
_, _ = rand.Read(dummyPwd)
hash, _ := bcrypt.GenerateFromPassword(dummyPwd, bcryptCost)
u = &model.User{
- Username: username,
- Email: &email,
- Password: string(hash),
- FirstName: firstName,
- LastName: lastName,
- DisplayName: strings.TrimSpace(firstName + " " + lastName),
- Avatar: avatar,
- IsActive: true,
+ Username: username,
+ Email: &email,
+ Password: string(hash),
+ FirstName: firstName,
+ LastName: lastName,
+ DisplayName: strings.TrimSpace(firstName + " " + lastName),
+ Avatar: avatar,
+ IsActive: true,
+ IsPasswordAutoset: true,
}
if err := s.userStore.Create(ctx, u); err != nil {
return "", nil, false, err
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index 49570d1..4421d92 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -1032,24 +1032,52 @@ func (h *AuthHandler) postSignUpDeps() postSignUpDeps {
}
}
+// SetPassword lets OAuth/magic-code users set their first password.
+// POST /auth/set-password/
+func (h *AuthHandler) SetPassword(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ var body struct {
+ Password string `json:"password" binding:"required,min=8"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
+ return
+ }
+ if err := h.Auth.SetPassword(c.Request.Context(), user.ID, body.Password); err != nil {
+ if errors.Is(err, auth.ErrPasswordAlreadySet) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Password is already set. Use change-password instead."})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to set password"})
+ return
+ }
+ user.IsPasswordAutoset = false
+ c.JSON(http.StatusOK, userResponse(user))
+}
+
func userResponse(u *model.User) gin.H {
if u == nil {
return gin.H{}
}
return gin.H{
- "id": u.ID.String(),
- "email": u.Email,
- "username": u.Username,
- "first_name": u.FirstName,
- "last_name": u.LastName,
- "display_name": u.DisplayName,
- "avatar": u.Avatar,
- "cover_image": u.CoverImage,
- "is_active": u.IsActive,
- "is_onboarded": u.IsOnboarded,
- "date_joined": u.DateJoined,
- "created_at": u.CreatedAt,
- "updated_at": u.UpdatedAt,
- "user_timezone": u.UserTimezone,
+ "id": u.ID.String(),
+ "email": u.Email,
+ "username": u.Username,
+ "first_name": u.FirstName,
+ "last_name": u.LastName,
+ "display_name": u.DisplayName,
+ "avatar": u.Avatar,
+ "cover_image": u.CoverImage,
+ "is_active": u.IsActive,
+ "is_onboarded": u.IsOnboarded,
+ "is_password_autoset": u.IsPasswordAutoset,
+ "date_joined": u.DateJoined,
+ "created_at": u.CreatedAt,
+ "updated_at": u.UpdatedAt,
+ "user_timezone": u.UserTimezone,
}
}
diff --git a/api/internal/handler/oauth.go b/api/internal/handler/oauth.go
index bf2d29e..26b3375 100644
--- a/api/internal/handler/oauth.go
+++ b/api/internal/handler/oauth.go
@@ -187,6 +187,12 @@ func (h *OAuthHandler) Callback(c *gin.Context) {
if isNewUser {
postSignUpWorkflow(ctx, h.postSignUpDeps(), user)
+ nextPath = "/"
+ }
+
+ if !user.IsActive {
+ h.redirectError(c, "Your account has been deactivated. Please contact the administrator.")
+ return
}
http.SetCookie(c.Writer, &http.Cookie{
diff --git a/api/internal/model/user.go b/api/internal/model/user.go
index ef25a06..e5b7cbc 100644
--- a/api/internal/model/user.go
+++ b/api/internal/model/user.go
@@ -23,8 +23,9 @@ type User struct {
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
IsActive bool `gorm:"column:is_active;default:true" json:"is_active"`
- IsOnboarded bool `gorm:"column:is_onboarded;default:false" json:"is_onboarded"`
- UserTimezone string `gorm:"column:user_timezone;default:UTC" json:"user_timezone"`
+ IsOnboarded bool `gorm:"column:is_onboarded;default:false" json:"is_onboarded"`
+ IsPasswordAutoset bool `gorm:"column:is_password_autoset;default:false" json:"is_password_autoset"`
+ UserTimezone string `gorm:"column:user_timezone;default:UTC" json:"user_timezone"`
}
func (User) TableName() string { return "users" }
diff --git a/api/internal/router/router.go b/api/internal/router/router.go
index fab5650..d756827 100644
--- a/api/internal/router/router.go
+++ b/api/internal/router/router.go
@@ -154,6 +154,7 @@ func New(cfg Config) *gin.Engine {
api.GET("/users/me/", authHandler.Me)
api.PATCH("/users/me/", authHandler.UpdateMe)
api.POST("/users/me/change-password/", authHandler.ChangePassword)
+ 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.GET("/users/me/activity/", userHandler.GetActivity)
@@ -302,6 +303,7 @@ func New(cfg Config) *gin.Engine {
authGroup.POST("/reset-password/", authHandler.ResetPassword)
authGroup.POST("/magic-code/request/", authHandler.MagicCodeRequest)
authGroup.POST("/magic-code/verify/", authHandler.MagicCodeVerify)
+ authGroup.POST("/set-password/", middleware.RequireAuth(authSvc, cfg.Log), authHandler.SetPassword)
}
// OAuth routes (no auth required); provider resolved from instance settings at request time.
diff --git a/api/migrations/000003_user_password_autoset.down.sql b/api/migrations/000003_user_password_autoset.down.sql
new file mode 100644
index 0000000..234c8f3
--- /dev/null
+++ b/api/migrations/000003_user_password_autoset.down.sql
@@ -0,0 +1 @@
+ALTER TABLE users DROP COLUMN IF EXISTS is_password_autoset;
diff --git a/api/migrations/000003_user_password_autoset.up.sql b/api/migrations/000003_user_password_autoset.up.sql
new file mode 100644
index 0000000..0213a18
--- /dev/null
+++ b/api/migrations/000003_user_password_autoset.up.sql
@@ -0,0 +1 @@
+ALTER TABLE users ADD COLUMN IF NOT EXISTS is_password_autoset BOOLEAN NOT NULL DEFAULT false;
diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts
index ad25969..486c5f5 100644
--- a/ui/src/api/types.ts
+++ b/ui/src/api/types.ts
@@ -224,6 +224,7 @@ export interface UserApiResponse {
cover_image?: string;
is_active: boolean;
is_onboarded: boolean;
+ is_password_autoset?: boolean;
date_joined: string;
created_at: string;
updated_at: string;
diff --git a/ui/src/components/auth/AuthPageShell.tsx b/ui/src/components/auth/AuthPageShell.tsx
new file mode 100644
index 0000000..09c1297
--- /dev/null
+++ b/ui/src/components/auth/AuthPageShell.tsx
@@ -0,0 +1,35 @@
+import { Link } from 'react-router-dom';
+
+interface AuthPageShellProps {
+ mode: 'sign-in' | 'sign-up';
+ enableSignup?: boolean;
+ children: React.ReactNode;
+}
+
+export function AuthPageShell({ mode, enableSignup = true, children }: AuthPageShellProps) {
+ return (
+
+
+
+ Devlane
+
+ {enableSignup && (
+
+
+ {mode === 'sign-in' ? 'New to Devlane?' : 'Already have an account?'}
+
+
+ {mode === 'sign-in' ? 'Sign up' : 'Sign in'}
+
+
+ )}
+
+
+ {children}
+
+
+ );
+}
diff --git a/ui/src/pages/ForgotPasswordPage.tsx b/ui/src/pages/ForgotPasswordPage.tsx
index 37eed5e..aacca28 100644
--- a/ui/src/pages/ForgotPasswordPage.tsx
+++ b/ui/src/pages/ForgotPasswordPage.tsx
@@ -1,9 +1,10 @@
import { useState, useCallback, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
-import { Button, Input, Card, CardContent } from '../components/ui';
+import { Button, Input } from '../components/ui';
import { authService } from '../services/authService';
import { getApiErrorMessage } from '../api/client';
import { CircleAlert, CircleCheck, ArrowLeft } from 'lucide-react';
+import { AuthPageShell } from '../components/auth/AuthPageShell';
const RESEND_COOLDOWN_SECONDS = 30;
@@ -60,9 +61,8 @@ export function ForgotPasswordPage() {
}, [email, cooldown]);
return (
-
+
);
}
diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx
index 5dc90e3..2ca3c2f 100644
--- a/ui/src/pages/LoginPage.tsx
+++ b/ui/src/pages/LoginPage.tsx
@@ -1,10 +1,11 @@
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useNavigate, useLocation, Link, useSearchParams } from 'react-router-dom';
-import { Button, Input, Card, CardContent } from '../components/ui';
+import { Button, Input } from '../components/ui';
import { useAuth } from '../contexts/AuthContext';
import { authService } from '../services/authService';
import { API_BASE, getApiErrorMessage } from '../api/client';
import { Eye, EyeOff, CircleAlert, CircleCheck } from 'lucide-react';
+import { AuthPageShell } from '../components/auth/AuthPageShell';
type AuthStep = 'email' | 'password' | 'code';
type AuthMode = 'sign-in' | 'sign-up';
@@ -333,9 +334,8 @@ export function LoginPage() {
}, [step, mode]);
return (
-
-
-
+
+
{title}
{subtitle}
{step === 'email' && isPasswordEnabled && canUseMagicCode && (
@@ -638,8 +638,7 @@ export function LoginPage() {
)}
)}
-
-
-
+
+
);
}
diff --git a/ui/src/pages/ResetPasswordPage.tsx b/ui/src/pages/ResetPasswordPage.tsx
index 3610c60..2f1a924 100644
--- a/ui/src/pages/ResetPasswordPage.tsx
+++ b/ui/src/pages/ResetPasswordPage.tsx
@@ -1,8 +1,9 @@
import { useState, useCallback, useMemo } from 'react';
import { useSearchParams, Link } from 'react-router-dom';
-import { Button, Input, Card, CardContent } from '../components/ui';
+import { Button, Input } from '../components/ui';
import { authService } from '../services/authService';
import { Eye, EyeOff, CircleAlert, CircleCheck } from 'lucide-react';
+import { AuthPageShell } from '../components/auth/AuthPageShell';
interface PasswordCriteria {
minLength: boolean;
@@ -108,9 +109,8 @@ export function ResetPasswordPage() {
if (invalidToken) {
return (
-
-
-
+
+
Invalid reset link
@@ -122,17 +122,15 @@ export function ResetPasswordPage() {
>
Request new reset link
-
-
-
+
+
);
}
if (success) {
return (
-
-
-
+
+
Password reset!
@@ -141,16 +139,14 @@ export function ResetPasswordPage() {
Go to sign in
-
-
-
+
+
);
}
return (
-
-
-
+
+
Set a new password
Choose a strong password to secure your account.
@@ -228,8 +224,7 @@ export function ResetPasswordPage() {
-
-
-
+
+
);
}
diff --git a/ui/src/pages/SetPasswordPage.tsx b/ui/src/pages/SetPasswordPage.tsx
new file mode 100644
index 0000000..6cc84de
--- /dev/null
+++ b/ui/src/pages/SetPasswordPage.tsx
@@ -0,0 +1,189 @@
+import { useState, useCallback, useMemo } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Button, Input } from '../components/ui';
+import { useAuth } from '../contexts/AuthContext';
+import { authService } from '../services/authService';
+import { getApiErrorMessage } from '../api/client';
+import { Eye, EyeOff, CircleAlert, CircleCheck } from 'lucide-react';
+import { AuthPageShell } from '../components/auth/AuthPageShell';
+
+interface PasswordCriteria {
+ minLength: boolean;
+ hasUpper: boolean;
+ hasLower: boolean;
+ hasDigit: boolean;
+ hasSpecial: boolean;
+}
+
+function getPasswordCriteria(pw: string): PasswordCriteria {
+ return {
+ minLength: pw.length >= 8,
+ hasUpper: /[A-Z]/.test(pw),
+ hasLower: /[a-z]/.test(pw),
+ hasDigit: /\d/.test(pw),
+ hasSpecial: /[!@#$%^&*()\-_+=[\]{}|;:'",.<>?/]/.test(pw),
+ };
+}
+
+function isPasswordStrong(pw: string): boolean {
+ const c = getPasswordCriteria(pw);
+ return c.minLength && c.hasUpper && c.hasLower && c.hasDigit && c.hasSpecial;
+}
+
+function PasswordStrengthIndicator({ password }: { password: string }) {
+ const criteria = getPasswordCriteria(password);
+ if (!password) return null;
+
+ const items: [string, boolean][] = [
+ ['At least 8 characters', criteria.minLength],
+ ['Uppercase letter', criteria.hasUpper],
+ ['Lowercase letter', criteria.hasLower],
+ ['Number', criteria.hasDigit],
+ ['Special character', criteria.hasSpecial],
+ ];
+
+ return (
+
+ {items.map(([label, met]) => (
+
+ {met ? (
+
+ ) : (
+
+ )}
+ {label}
+
+ ))}
+
+ );
+}
+
+export function SetPasswordPage() {
+ const navigate = useNavigate();
+ const { user, setUserFromApi } = useAuth();
+
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+ const [showConfirm, setShowConfirm] = useState(false);
+ const [error, setError] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const passwordsMatch = useMemo(
+ () => confirmPassword.length > 0 && password === confirmPassword,
+ [password, confirmPassword],
+ );
+
+ const isDisabled = !isPasswordStrong(password) || !passwordsMatch || isSubmitting;
+
+ const handleSubmit = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+
+ if (!isPasswordStrong(password)) {
+ setError('Password does not meet strength requirements.');
+ return;
+ }
+ if (!passwordsMatch) {
+ setError('Passwords do not match.');
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const updated = await authService.setPassword({ password });
+ setUserFromApi(updated);
+ navigate('/', { replace: true });
+ } catch (err: unknown) {
+ setError(getApiErrorMessage(err) || 'Something went wrong. Please try again.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [password, passwordsMatch, setUserFromApi, navigate],
+ );
+
+ return (
+
+
+
Set password
+
Create a new password.
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/ui/src/pages/SignUpPage.tsx b/ui/src/pages/SignUpPage.tsx
new file mode 100644
index 0000000..ed51781
--- /dev/null
+++ b/ui/src/pages/SignUpPage.tsx
@@ -0,0 +1,554 @@
+import { useState, useCallback, useEffect, useMemo } from 'react';
+import { useNavigate, useLocation, Link, useSearchParams } from 'react-router-dom';
+import { Button, Input } from '../components/ui';
+import { useAuth } from '../contexts/AuthContext';
+import { authService } from '../services/authService';
+import { API_BASE, getApiErrorMessage } from '../api/client';
+import { Eye, EyeOff, CircleAlert, CircleCheck } from 'lucide-react';
+import { AuthPageShell } from '../components/auth/AuthPageShell';
+
+type AuthStep = 'email' | 'password' | 'code';
+
+interface PasswordCriteria {
+ minLength: boolean;
+ hasUpper: boolean;
+ hasLower: boolean;
+ hasDigit: boolean;
+ hasSpecial: boolean;
+}
+
+function getPasswordCriteria(pw: string): PasswordCriteria {
+ return {
+ minLength: pw.length >= 8,
+ hasUpper: /[A-Z]/.test(pw),
+ hasLower: /[a-z]/.test(pw),
+ hasDigit: /\d/.test(pw),
+ hasSpecial: /[!@#$%^&*()\-_+=[\]{}|;:'",.<>?/]/.test(pw),
+ };
+}
+
+function isPasswordStrong(pw: string): boolean {
+ const c = getPasswordCriteria(pw);
+ return c.minLength && c.hasUpper && c.hasLower && c.hasDigit && c.hasSpecial;
+}
+
+function PasswordStrengthIndicator({ password }: { password: string }) {
+ const criteria = getPasswordCriteria(password);
+ if (!password) return null;
+
+ const items: [string, boolean][] = [
+ ['At least 8 characters', criteria.minLength],
+ ['Uppercase letter', criteria.hasUpper],
+ ['Lowercase letter', criteria.hasLower],
+ ['Number', criteria.hasDigit],
+ ['Special character', criteria.hasSpecial],
+ ];
+
+ return (
+
+ {items.map(([label, met]) => (
+
+ {met ? (
+
+ ) : (
+
+ )}
+ {label}
+
+ ))}
+
+ );
+}
+
+export function SignUpPage() {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const { setUserFromApi } = useAuth();
+
+ const state = location.state as {
+ from?: { pathname?: string; search?: string };
+ email?: string;
+ inviteToken?: string;
+ } | null;
+ const from = state?.from;
+ const returnPath = from ? (from.pathname ?? '/') + (from.search ?? '') : '/';
+ const prefilledEmail = state?.email ?? '';
+
+ const [searchParams] = useSearchParams();
+ const oauthError = searchParams.get('error');
+ const inviteToken = useMemo(() => {
+ const q = searchParams.get('invite')?.trim() ?? '';
+ const st = state?.inviteToken?.trim() ?? '';
+ return q || st;
+ }, [searchParams, state?.inviteToken]);
+
+ const [step, setStep] = useState('email');
+ const [email, setEmail] = useState(prefilledEmail);
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [firstName, setFirstName] = useState('');
+ const [lastName, setLastName] = useState('');
+ const [magicCode, setMagicCode] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+ const [showConfirm, setShowConfirm] = useState(false);
+ const [error, setError] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [allowSignup, setAllowSignup] = useState(true);
+ const [isSmtpConfigured, setIsSmtpConfigured] = useState(false);
+ const [isPasswordEnabled, setIsPasswordEnabled] = useState(true);
+ const [isMagicCodeEnabled, setIsMagicCodeEnabled] = useState(true);
+ const [oauthProviders, setOauthProviders] = useState({
+ google: false,
+ github: false,
+ gitlab: false,
+ });
+
+ useEffect(() => {
+ if (oauthError) {
+ setError(oauthError);
+ }
+ }, [oauthError]);
+
+ useEffect(() => {
+ authService
+ .getAuthConfig()
+ .then((cfg) => {
+ setAllowSignup(cfg.enable_signup);
+ setIsSmtpConfigured(cfg.is_smtp_configured);
+ setIsPasswordEnabled(cfg.is_email_password_enabled);
+ setIsMagicCodeEnabled(cfg.is_magic_code_enabled ?? true);
+ setOauthProviders({
+ google: cfg.is_google_enabled,
+ github: cfg.is_github_enabled,
+ gitlab: cfg.is_gitlab_enabled,
+ });
+ })
+ .catch(() => {});
+ }, []);
+
+ const hasOAuth = oauthProviders.google || oauthProviders.github || oauthProviders.gitlab;
+
+ const canUseMagicCode = isMagicCodeEnabled && isSmtpConfigured && (allowSignup || !!inviteToken);
+
+ const handleOAuth = useCallback(
+ (provider: string) => {
+ const nextPath = returnPath !== '/' ? `?next_path=${encodeURIComponent(returnPath)}` : '';
+ window.location.assign(`${API_BASE}/auth/${provider}/${nextPath}`);
+ },
+ [returnPath],
+ );
+
+ const sendMagicCode = useCallback(async () => {
+ await authService.requestMagicCode({
+ email,
+ ...(inviteToken ? { invite_token: inviteToken } : {}),
+ });
+ }, [email, inviteToken]);
+
+ const handleEmailSubmit = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setIsSubmitting(true);
+ try {
+ const resp = await authService.emailCheck(email);
+ if (resp.existing) {
+ navigate('/login', { state: { email }, replace: true });
+ return;
+ }
+ if (!resp.allow_public_signup && !inviteToken) {
+ setError('Sign-up is by invite only.');
+ setIsSubmitting(false);
+ return;
+ }
+
+ const magicOnly = !isPasswordEnabled && isMagicCodeEnabled && isSmtpConfigured;
+ if (magicOnly) {
+ try {
+ await sendMagicCode();
+ setStep('code');
+ } catch (err: unknown) {
+ setError(getApiErrorMessage(err) || 'Could not send sign-up code.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ return;
+ }
+
+ setStep('password');
+ } catch {
+ setStep('password');
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [email, inviteToken, isPasswordEnabled, isMagicCodeEnabled, isSmtpConfigured, sendMagicCode, navigate],
+ );
+
+ const switchToMagicCode = useCallback(async () => {
+ setError('');
+ setIsSubmitting(true);
+ try {
+ await sendMagicCode();
+ setStep('code');
+ } catch (err: unknown) {
+ setError(getApiErrorMessage(err) || 'Could not send sign-up code.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ }, [sendMagicCode]);
+
+ const handlePasswordSubmit = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+
+ if (!isPasswordStrong(password)) {
+ setError('Password does not meet strength requirements.');
+ return;
+ }
+ if (password !== confirmPassword) {
+ setError('Passwords do not match.');
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const user = await authService.signUp({
+ email,
+ password,
+ first_name: firstName,
+ last_name: lastName,
+ ...(inviteToken ? { invite_token: inviteToken } : {}),
+ });
+ setUserFromApi(user);
+ navigate(returnPath, { replace: true });
+ } catch (err: unknown) {
+ setError(getApiErrorMessage(err) || 'Something went wrong. Please try again.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [email, password, confirmPassword, firstName, lastName, inviteToken, setUserFromApi, navigate, returnPath],
+ );
+
+ const handleMagicCodeSubmit = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ const code = magicCode.replace(/\D/g, '');
+ if (code.length !== 6) {
+ setError('Enter the 6-digit code from your email.');
+ return;
+ }
+ setIsSubmitting(true);
+ try {
+ const user = await authService.verifyMagicCode({
+ email,
+ code,
+ first_name: firstName,
+ last_name: lastName,
+ ...(inviteToken ? { invite_token: inviteToken } : {}),
+ });
+ setUserFromApi(user);
+ navigate(returnPath, { replace: true });
+ } catch (err: unknown) {
+ setError(getApiErrorMessage(err) || 'Invalid or expired code.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [magicCode, email, firstName, lastName, inviteToken, setUserFromApi, navigate, returnPath],
+ );
+
+ const goBackToEmail = useCallback(() => {
+ setStep('email');
+ setPassword('');
+ setConfirmPassword('');
+ setFirstName('');
+ setLastName('');
+ setMagicCode('');
+ setError('');
+ }, []);
+
+ const goBackToPassword = useCallback(() => {
+ setStep('password');
+ setMagicCode('');
+ setError('');
+ }, []);
+
+ if (!allowSignup && !inviteToken) {
+ return (
+
+
+
Sign up is disabled
+
+ Public sign-up is currently disabled. Please contact your administrator.
+
+
+ Go to sign in
+
+
+
+ );
+ }
+
+ const title = step === 'email' ? 'Create your account' : step === 'code' ? 'Verify your email' : 'Create your account';
+ const subtitle =
+ step === 'email'
+ ? 'Enter your email to get started.'
+ : step === 'code'
+ ? 'We sent a 6-digit code to your inbox. It expires in 10 minutes.'
+ : 'Set up your account to get started.';
+
+ return (
+
+
+
{title}
+
{subtitle}
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+ {step === 'email' && (
+ <>
+ {hasOAuth && (
+
+ {oauthProviders.google && (
+
handleOAuth('google')}
+ className="flex w-full items-center justify-center gap-2 rounded-md border border-(--border-primary) px-4 py-2 text-sm font-medium text-(--txt-primary) hover:bg-(--bg-subtle) transition-colors"
+ >
+
+
+
+
+
+
+ Sign up with Google
+
+ )}
+ {oauthProviders.github && (
+
handleOAuth('github')}
+ className="flex w-full items-center justify-center gap-2 rounded-md border border-(--border-primary) px-4 py-2 text-sm font-medium text-(--txt-primary) hover:bg-(--bg-subtle) transition-colors"
+ >
+
+
+
+ Sign up with GitHub
+
+ )}
+ {oauthProviders.gitlab && (
+
handleOAuth('gitlab')}
+ className="flex w-full items-center justify-center gap-2 rounded-md border border-(--border-primary) px-4 py-2 text-sm font-medium text-(--txt-primary) hover:bg-(--bg-subtle) transition-colors"
+ >
+
+
+
+ Sign up with GitLab
+
+ )}
+
+
+ )}
+
+ >
+ )}
+
+ {step === 'password' && (
+
+ )}
+
+ {step === 'code' && (
+
+ )}
+
+
+ );
+}
diff --git a/ui/src/routes/index.tsx b/ui/src/routes/index.tsx
index 30808fa..1eaa8e9 100644
--- a/ui/src/routes/index.tsx
+++ b/ui/src/routes/index.tsx
@@ -25,6 +25,12 @@ const ResetPasswordPage = lazy(() =>
page({ ResetPasswordPage: m.ResetPasswordPage }),
),
);
+const SignUpPage = lazy(() =>
+ import('../pages/SignUpPage').then((m) => page({ SignUpPage: m.SignUpPage })),
+);
+const SetPasswordPage = lazy(() =>
+ import('../pages/SetPasswordPage').then((m) => page({ SetPasswordPage: m.SetPasswordPage })),
+);
const WorkspaceHomePage = lazy(() =>
import('../pages/WorkspaceHomePage').then((m) =>
page({ WorkspaceHomePage: m.WorkspaceHomePage }),
@@ -363,6 +369,24 @@ const router = createBrowserRouter([
),
},
+ {
+ path: 'sign-up',
+ element: (
+ }>
+
+
+ ),
+ },
+ {
+ path: 'accounts/set-password',
+ element: (
+
+ }>
+
+
+
+ ),
+ },
{
path: 'invite',
element: (
diff --git a/ui/src/services/authService.ts b/ui/src/services/authService.ts
index 2bba41c..60b581c 100644
--- a/ui/src/services/authService.ts
+++ b/ui/src/services/authService.ts
@@ -67,4 +67,9 @@ export const authService = {
const { data } = await apiClient.post('/auth/magic-code/verify/', payload);
return data;
},
+
+ async setPassword(payload: { password: string }): Promise {
+ const { data } = await apiClient.post('/auth/set-password/', payload);
+ return data;
+ },
};
From 07d2db6fb5b478a10237a08574028392c041e960 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Tue, 14 Apr 2026 21:03:35 +0400
Subject: [PATCH 27/43] feat: return distinct error for deactivated users
---
api/internal/auth/service.go | 8 ++++++--
api/internal/handler/auth.go | 8 ++++++++
ui/src/contexts/AuthContext.tsx | 10 +++-------
ui/src/pages/LoginPage.tsx | 8 ++------
4 files changed, 19 insertions(+), 15 deletions(-)
diff --git a/api/internal/auth/service.go b/api/internal/auth/service.go
index a195777..9571cc7 100644
--- a/api/internal/auth/service.go
+++ b/api/internal/auth/service.go
@@ -20,6 +20,7 @@ var (
ErrEmailTaken = errors.New("email already registered")
ErrUsernameTaken = errors.New("username already taken")
ErrResetTokenInvalid = errors.New("invalid or expired reset token")
+ ErrUserDeactivated = errors.New("user account deactivated")
)
const bcryptCost = 12
@@ -158,9 +159,12 @@ func (s *Service) SessionForEmailUser(ctx context.Context, email string) (sessio
}
return "", nil, err
}
- if u == nil || !u.IsActive {
+ if u == nil {
return "", nil, ErrInvalidCredentials
}
+ if !u.IsActive {
+ return "", nil, ErrUserDeactivated
+ }
sessionKey, err = s.createSession(ctx, u.ID)
if err != nil {
return "", nil, err
@@ -181,7 +185,7 @@ func (s *Service) SignIn(ctx context.Context, req SignInRequest) (sessionKey str
return "", nil, err
}
if !u.IsActive {
- return "", nil, ErrInvalidCredentials
+ return "", nil, ErrUserDeactivated
}
if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(req.Password)); err != nil {
return "", nil, ErrInvalidCredentials
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index 4421d92..34c1e44 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -90,6 +90,10 @@ func (h *AuthHandler) SignIn(c *gin.Context) {
}
sessionKey, user, err := h.Auth.SignIn(c.Request.Context(), auth.SignInRequest{Email: req.Email, Password: req.Password})
if err != nil {
+ if errors.Is(err, auth.ErrUserDeactivated) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Your account has been deactivated. Please contact the administrator.", "error_code": "USER_ACCOUNT_DEACTIVATED"})
+ return
+ }
if errors.Is(err, auth.ErrInvalidCredentials) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
return
@@ -858,6 +862,10 @@ func (h *AuthHandler) MagicCodeVerify(c *gin.Context) {
sessionKey, user, err := h.Auth.SessionForEmailUser(ctx, body.Email)
if err != nil {
+ if errors.Is(err, auth.ErrUserDeactivated) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Your account has been deactivated. Please contact the administrator.", "error_code": "USER_ACCOUNT_DEACTIVATED"})
+ return
+ }
if errors.Is(err, auth.ErrInvalidCredentials) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired code"})
return
diff --git a/ui/src/contexts/AuthContext.tsx b/ui/src/contexts/AuthContext.tsx
index a9bdae7..a7f753c 100644
--- a/ui/src/contexts/AuthContext.tsx
+++ b/ui/src/contexts/AuthContext.tsx
@@ -70,13 +70,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
const login = useCallback(async (email: string, password: string): Promise => {
- try {
- const api = await authService.signIn({ email, password });
- setUser(mapApiUserToUser(api));
- return true;
- } catch {
- return false;
- }
+ const api = await authService.signIn({ email, password });
+ setUser(mapApiUserToUser(api));
+ return true;
}, []);
const logout = useCallback(async () => {
diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx
index 2ca3c2f..4ec3591 100644
--- a/ui/src/pages/LoginPage.tsx
+++ b/ui/src/pages/LoginPage.tsx
@@ -228,12 +228,8 @@ export function LoginPage() {
setIsSubmitting(true);
try {
if (mode === 'sign-in') {
- const success = await login(email, password);
- if (success) {
- navigate(returnPath, { replace: true });
- } else {
- setError('Invalid email or password.');
- }
+ await login(email, password);
+ navigate(returnPath, { replace: true });
} else {
const user = await authService.signUp({
email,
From dd703c98b43324012836c88c73b0a3cea1da1e43 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Tue, 14 Apr 2026 21:05:40 +0400
Subject: [PATCH 28/43] feat: informational message when SMTP is off
---
api/internal/auth/service.go | 39 ++++++++++++++++++++++++
api/internal/handler/auth.go | 16 ++++++++++
ui/src/components/auth/AuthPageShell.tsx | 4 +++
ui/src/pages/LoginPage.tsx | 22 ++++++++-----
4 files changed, 73 insertions(+), 8 deletions(-)
diff --git a/api/internal/auth/service.go b/api/internal/auth/service.go
index 9571cc7..a06f8dd 100644
--- a/api/internal/auth/service.go
+++ b/api/internal/auth/service.go
@@ -25,6 +25,33 @@ var (
const bcryptCost = 12
+var ErrPasswordTooWeak = errors.New("password does not meet complexity requirements")
+
+// ValidatePasswordStrength checks that a password meets complexity requirements:
+// min 8 chars, at least one uppercase, one lowercase, one digit, one special character.
+func ValidatePasswordStrength(pw string) error {
+ if len(pw) < 8 {
+ return ErrPasswordTooWeak
+ }
+ var hasUpper, hasLower, hasDigit, hasSpecial bool
+ for _, c := range pw {
+ switch {
+ case c >= 'A' && c <= 'Z':
+ hasUpper = true
+ case c >= 'a' && c <= 'z':
+ hasLower = true
+ case c >= '0' && c <= '9':
+ hasDigit = true
+ default:
+ hasSpecial = true
+ }
+ }
+ if !hasUpper || !hasLower || !hasDigit || !hasSpecial {
+ return ErrPasswordTooWeak
+ }
+ return nil
+}
+
// dummyHash is used for timing-safe responses when a user is not found.
var dummyHash []byte
@@ -59,6 +86,9 @@ type SignInRequest struct {
}
func (s *Service) SignUp(ctx context.Context, req SignUpRequest) (sessionKey string, user *model.User, err error) {
+ if err := ValidatePasswordStrength(req.Password); err != nil {
+ return "", nil, err
+ }
email := strings.TrimSpace(strings.ToLower(req.Email))
existing, _ := s.userStore.GetByEmail(ctx, email)
if existing != nil {
@@ -217,6 +247,9 @@ func (s *Service) UpdateProfile(ctx context.Context, u *model.User) error {
}
func (s *Service) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error {
+ if err := ValidatePasswordStrength(newPassword); err != nil {
+ return err
+ }
u, err := s.userStore.GetByID(ctx, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -282,6 +315,9 @@ func (s *Service) ForgotPassword(ctx context.Context, email string) (token strin
// ResetPassword validates the reset token and sets a new password.
// After a successful reset, ALL unused tokens for the user are invalidated.
func (s *Service) ResetPassword(ctx context.Context, token, newPassword string) error {
+ if err := ValidatePasswordStrength(newPassword); err != nil {
+ return err
+ }
if s.resetTokenStore == nil {
return ErrResetTokenInvalid
}
@@ -309,6 +345,9 @@ var ErrPasswordAlreadySet = errors.New("password is already set")
// SetPassword lets a user who signed up via OAuth/magic set their first password.
func (s *Service) SetPassword(ctx context.Context, userID uuid.UUID, newPassword string) error {
+ if err := ValidatePasswordStrength(newPassword); err != nil {
+ return err
+ }
u, err := s.userStore.GetByID(ctx, userID)
if err != nil {
return err
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index 34c1e44..53786aa 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -156,6 +156,10 @@ func (h *AuthHandler) SignUp(c *gin.Context) {
LastName: req.LastName,
})
if err != nil {
+ if errors.Is(err, auth.ErrPasswordTooWeak) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Password must contain at least 8 characters, one uppercase, one lowercase, one digit, and one special character."})
+ return
+ }
if errors.Is(err, auth.ErrEmailTaken) {
c.JSON(http.StatusConflict, gin.H{"error": "An account with this email already exists"})
return
@@ -258,6 +262,10 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
return
}
if err := h.Auth.ChangePassword(c.Request.Context(), user.ID, req.CurrentPassword, req.NewPassword); err != nil {
+ if errors.Is(err, auth.ErrPasswordTooWeak) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Password must contain at least 8 characters, one uppercase, one lowercase, one digit, and one special character."})
+ return
+ }
if errors.Is(err, auth.ErrInvalidCredentials) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Current password is incorrect"})
return
@@ -654,6 +662,10 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
}
}
if err := h.Auth.ResetPassword(ctx, body.Token, body.NewPassword); err != nil {
+ if errors.Is(err, auth.ErrPasswordTooWeak) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Password must contain at least 8 characters, one uppercase, one lowercase, one digit, and one special character."})
+ return
+ }
if errors.Is(err, auth.ErrResetTokenInvalid) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired reset token"})
return
@@ -1056,6 +1068,10 @@ func (h *AuthHandler) SetPassword(c *gin.Context) {
return
}
if err := h.Auth.SetPassword(c.Request.Context(), user.ID, body.Password); err != nil {
+ if errors.Is(err, auth.ErrPasswordTooWeak) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Password must contain at least 8 characters, one uppercase, one lowercase, one digit, and one special character."})
+ return
+ }
if errors.Is(err, auth.ErrPasswordAlreadySet) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Password is already set. Use change-password instead."})
return
diff --git a/ui/src/components/auth/AuthPageShell.tsx b/ui/src/components/auth/AuthPageShell.tsx
index 09c1297..29bc8be 100644
--- a/ui/src/components/auth/AuthPageShell.tsx
+++ b/ui/src/components/auth/AuthPageShell.tsx
@@ -30,6 +30,10 @@ export function AuthPageShell({ mode, enableSignup = true, children }: AuthPageS
{children}
+
+ By {mode === 'sign-in' ? 'signing in' : 'signing up'}, you agree to our terms of service and
+ privacy policy.
+
);
}
diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx
index 4ec3591..6d51972 100644
--- a/ui/src/pages/LoginPage.tsx
+++ b/ui/src/pages/LoginPage.tsx
@@ -516,15 +516,21 @@ export function LoginPage() {
)}
- {mode === 'sign-in' && isPasswordEnabled && isSmtpConfigured && (
+ {mode === 'sign-in' && isPasswordEnabled && (
-
- Forgot your password?
-
+ {isSmtpConfigured ? (
+
+ Forgot your password?
+
+ ) : (
+
+ To reset your password, ask your administrator to configure SMTP.
+
+ )}
)}
From 05716c8614b189bf33a9c968aefef63d5be82821 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Wed, 15 Apr 2026 09:14:27 +0400
Subject: [PATCH 29/43] fix: copilot warnings
---
api/internal/auth/service.go | 3 +
api/internal/auth/service_test.go | 33 ++
api/internal/handler/auth.go | 3 +
api/internal/handler/instance.go | 18 +
api/internal/model/instance_setting.go | 2 +-
api/internal/model/project.go | 21 +-
api/internal/model/user.go | 28 +-
api/internal/queue/consumer.go | 14 +-
api/migrations/000002_auth_schema.up.sql | 33 +-
ui/src/components/auth/AuthPageShell.tsx | 4 +-
ui/src/pages/ForgotPasswordPage.tsx | 106 ++--
ui/src/pages/LoginPage.tsx | 649 ++++++++---------------
ui/src/pages/ResetPasswordPage.tsx | 190 +++----
ui/src/pages/SetPasswordPage.tsx | 8 +-
ui/src/pages/SignUpPage.tsx | 49 +-
15 files changed, 544 insertions(+), 617 deletions(-)
diff --git a/api/internal/auth/service.go b/api/internal/auth/service.go
index a06f8dd..dc598d2 100644
--- a/api/internal/auth/service.go
+++ b/api/internal/auth/service.go
@@ -333,6 +333,9 @@ func (s *Service) ResetPassword(ctx context.Context, token, newPassword string)
if err != nil {
return ErrResetTokenInvalid
}
+ if !u.IsActive {
+ return ErrResetTokenInvalid
+ }
u.Password = string(hash)
if err := s.userStore.Update(ctx, u); err != nil {
return err
diff --git a/api/internal/auth/service_test.go b/api/internal/auth/service_test.go
index 6966fa3..576a01d 100644
--- a/api/internal/auth/service_test.go
+++ b/api/internal/auth/service_test.go
@@ -37,6 +37,7 @@ func newTestService(t *testing.T) (*Service, *gorm.DB) {
deleted_at DATETIME,
is_active INTEGER DEFAULT 1,
is_onboarded INTEGER DEFAULT 0,
+ is_password_autoset INTEGER DEFAULT 0,
user_timezone TEXT DEFAULT 'UTC'
);`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username);`,
@@ -202,6 +203,38 @@ func TestForgotResetPassword(t *testing.T) {
}
}
+func TestResetPasswordInactiveUser(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+
+ svc, db := newTestService(t)
+
+ _, user, err := svc.SignUp(ctx, SignUpRequest{
+ Email: "inactive-reset@example.com",
+ Password: "OldP@ssw0rd!",
+ })
+ if err != nil {
+ t.Fatalf("SignUp: %v", err)
+ }
+
+ token, err := svc.ForgotPassword(ctx, "inactive-reset@example.com")
+ if err != nil {
+ t.Fatalf("ForgotPassword: %v", err)
+ }
+ if token == "" {
+ t.Fatalf("expected non-empty reset token")
+ }
+
+ if err := db.Exec("UPDATE users SET is_active = 0 WHERE id = ?", user.ID.String()).Error; err != nil {
+ t.Fatalf("deactivate user: %v", err)
+ }
+
+ err = svc.ResetPassword(ctx, token, "NewP@ssw0rd!")
+ if !errors.Is(err, ErrResetTokenInvalid) {
+ t.Fatalf("expected ErrResetTokenInvalid, got %v", err)
+ }
+}
+
func TestSignUpMagicAndSessionForEmail(t *testing.T) {
t.Parallel()
ctx := context.Background()
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index 53786aa..19b9ccd 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -63,6 +63,9 @@ func authBool(v model.JSONMap, key string, defaultVal bool) bool {
if b, ok := x.(bool); ok {
return b
}
+ if f, ok := x.(float64); ok {
+ return f != 0
+ }
return defaultVal
}
diff --git a/api/internal/handler/instance.go b/api/internal/handler/instance.go
index 07d5d20..5cf298d 100644
--- a/api/internal/handler/instance.go
+++ b/api/internal/handler/instance.go
@@ -295,6 +295,24 @@ func (h *InstanceSettingsHandler) UpdateSetting(c *gin.Context) {
}
value = merged
}
+ if key == "auth" {
+ // Merge with existing so per-provider pages (Google/GitHub/GitLab) do not wipe other flags.
+ existing, _ := h.Settings.Get(c.Request.Context(), "auth")
+ merged := model.JSONMap{}
+ if existing != nil {
+ for k, v := range existing.Value {
+ merged[k] = v
+ }
+ } else {
+ for k, v := range defaultSettingValue("auth") {
+ merged[k] = v
+ }
+ }
+ for k, v := range req.Value {
+ merged[k] = v
+ }
+ value = merged
+ }
if key == "oauth" {
existing, _ := h.Settings.Get(c.Request.Context(), "oauth")
merged := model.JSONMap{}
diff --git a/api/internal/model/instance_setting.go b/api/internal/model/instance_setting.go
index 0bc421a..0ab8ac5 100644
--- a/api/internal/model/instance_setting.go
+++ b/api/internal/model/instance_setting.go
@@ -7,7 +7,7 @@ import (
// InstanceSetting holds one section of instance admin settings (key-value, value is JSONB).
type InstanceSetting struct {
Key string `gorm:"type:varchar(64);primaryKey" json:"key"`
- Value JSONMap `gorm:"type:jsonb;not null;default:{}" json:"value"`
+ Value JSONMap `gorm:"type:jsonb;serializer:json;not null;default:'{}'" json:"value"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
diff --git a/api/internal/model/project.go b/api/internal/model/project.go
index 3c6fe19..07494be 100644
--- a/api/internal/model/project.go
+++ b/api/internal/model/project.go
@@ -3,6 +3,7 @@ package model
import (
"database/sql/driver"
"encoding/json"
+ "fmt"
"time"
"github.com/google/uuid"
@@ -18,11 +19,25 @@ func (m *JSONMap) Scan(v interface{}) error {
*m = nil
return nil
}
- b, ok := v.([]byte)
- if !ok {
+ var raw []byte
+ switch x := v.(type) {
+ case []byte:
+ raw = x
+ case string:
+ raw = []byte(x)
+ default:
+ return fmt.Errorf("JSONMap: unsupported scan type %T", v)
+ }
+ if len(raw) == 0 {
+ *m = JSONMap{}
return nil
}
- return json.Unmarshal(b, m)
+ mm := make(map[string]interface{})
+ if err := json.Unmarshal(raw, &mm); err != nil {
+ return err
+ }
+ *m = mm
+ return nil
}
// Project matches migration table "projects".
diff --git a/api/internal/model/user.go b/api/internal/model/user.go
index e5b7cbc..d45a53c 100644
--- a/api/internal/model/user.go
+++ b/api/internal/model/user.go
@@ -9,20 +9,20 @@ import (
// User matches migration table "users".
type User struct {
- ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
- Password string `gorm:"type:varchar(128);not null" json:"-"`
- Username string `gorm:"type:varchar(128);uniqueIndex;not null" json:"username"`
- Email *string `gorm:"type:varchar(255);uniqueIndex" json:"email"`
- FirstName string `gorm:"column:first_name;type:varchar(255);default:''" json:"first_name"`
- LastName string `gorm:"column:last_name;type:varchar(255);default:''" json:"last_name"`
- DisplayName string `gorm:"column:display_name;type:varchar(255)" json:"display_name"`
- Avatar string `gorm:"type:text" json:"avatar,omitempty"`
- CoverImage string `gorm:"column:cover_image;type:text" json:"cover_image,omitempty"`
- DateJoined time.Time `gorm:"column:date_joined;not null" json:"date_joined"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
- IsActive bool `gorm:"column:is_active;default:true" json:"is_active"`
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ Password string `gorm:"type:varchar(128);not null" json:"-"`
+ Username string `gorm:"type:varchar(128);uniqueIndex;not null" json:"username"`
+ Email *string `gorm:"type:varchar(255);uniqueIndex" json:"email"`
+ FirstName string `gorm:"column:first_name;type:varchar(255);default:''" json:"first_name"`
+ LastName string `gorm:"column:last_name;type:varchar(255);default:''" json:"last_name"`
+ DisplayName string `gorm:"column:display_name;type:varchar(255)" json:"display_name"`
+ Avatar string `gorm:"type:text" json:"avatar,omitempty"`
+ CoverImage string `gorm:"column:cover_image;type:text" json:"cover_image,omitempty"`
+ DateJoined time.Time `gorm:"column:date_joined;not null" json:"date_joined"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ IsActive bool `gorm:"column:is_active;default:true" json:"is_active"`
IsOnboarded bool `gorm:"column:is_onboarded;default:false" json:"is_onboarded"`
IsPasswordAutoset bool `gorm:"column:is_password_autoset;default:false" json:"is_password_autoset"`
UserTimezone string `gorm:"column:user_timezone;default:UTC" json:"user_timezone"`
diff --git a/api/internal/queue/consumer.go b/api/internal/queue/consumer.go
index 4a69a7c..4def304 100644
--- a/api/internal/queue/consumer.go
+++ b/api/internal/queue/consumer.go
@@ -61,8 +61,8 @@ func (c *Consumer) Run(ctx context.Context, queues []string) error {
func (c *Consumer) handle(ctx context.Context, queue string, d amqp.Delivery, h TaskHandler) {
err := h(ctx, queue, d.Body)
if err != nil {
- // Retries: republish with incremented x-retry-count and Ack the original delivery.
- // (Nack(requeue=true) would redeliver the same headers, so the count would never advance.)
+ // Retries: republish with incremented x-retry-count, then Ack the original delivery.
+ // (Nack(requeue=true) on handler failure would redeliver the same headers, so the count would never advance.)
retryCount := int64(0)
if d.Headers != nil {
if v, ok := d.Headers["x-retry-count"]; ok {
@@ -94,8 +94,14 @@ func (c *Consumer) handle(ctx context.Context, queue string, d amqp.Delivery, h
Body: d.Body,
Headers: headers,
})
- if pubErr != nil && c.log != nil {
- c.log.Error("failed to republish for retry", "queue", queue, "error", pubErr)
+ if pubErr != nil {
+ if c.log != nil {
+ c.log.Error("failed to republish for retry", "queue", queue, "error", pubErr)
+ }
+ if nackErr := d.Nack(false, true); nackErr != nil && c.log != nil {
+ c.log.Error("nack after republish failure", "queue", queue, "error", nackErr)
+ }
+ return
}
_ = d.Ack(false)
return
diff --git a/api/migrations/000002_auth_schema.up.sql b/api/migrations/000002_auth_schema.up.sql
index f85a3c4..bb03760 100644
--- a/api/migrations/000002_auth_schema.up.sql
+++ b/api/migrations/000002_auth_schema.up.sql
@@ -18,16 +18,29 @@ CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tok
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS token_expires_at TIMESTAMPTZ;
-UPDATE accounts
-SET token_expires_at = access_token_expired_at
-WHERE token_expires_at IS NULL AND access_token_expired_at IS NOT NULL;
-
-UPDATE accounts
-SET token_expires_at = refresh_token_expired_at
-WHERE token_expires_at IS NULL AND refresh_token_expired_at IS NOT NULL;
-
-ALTER TABLE accounts DROP COLUMN access_token_expired_at;
-ALTER TABLE accounts DROP COLUMN refresh_token_expired_at;
+DO $$
+BEGIN
+ IF EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'accounts' AND column_name = 'access_token_expired_at'
+ ) THEN
+ UPDATE accounts
+ SET token_expires_at = access_token_expired_at
+ WHERE token_expires_at IS NULL AND access_token_expired_at IS NOT NULL;
+ END IF;
+
+ IF EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'accounts' AND column_name = 'refresh_token_expired_at'
+ ) THEN
+ UPDATE accounts
+ SET token_expires_at = refresh_token_expired_at
+ WHERE token_expires_at IS NULL AND refresh_token_expired_at IS NOT NULL;
+ END IF;
+END $$;
+
+ALTER TABLE accounts DROP COLUMN IF EXISTS access_token_expired_at;
+ALTER TABLE accounts DROP COLUMN IF EXISTS refresh_token_expired_at;
ALTER TABLE accounts ALTER COLUMN access_token SET DEFAULT '';
ALTER TABLE accounts ALTER COLUMN access_token DROP NOT NULL;
diff --git a/ui/src/components/auth/AuthPageShell.tsx b/ui/src/components/auth/AuthPageShell.tsx
index 29bc8be..a16b4f7 100644
--- a/ui/src/components/auth/AuthPageShell.tsx
+++ b/ui/src/components/auth/AuthPageShell.tsx
@@ -15,9 +15,7 @@ export function AuthPageShell({ mode, enableSignup = true, children }: AuthPageS
{enableSignup && (
-
- {mode === 'sign-in' ? 'New to Devlane?' : 'Already have an account?'}
-
+
{mode === 'sign-in' ? 'New to Devlane?' : 'Already have an account?'}
-
-
- Back to sign in
-
+
+
+ Back to sign in
+
-
Reset your password
-
- Enter your email and we'll send you a link to reset your password.
-
+
Reset your password
+
+ Enter your email and we'll send you a link to reset your password.
+
- {error && (
-
-
- {error}
-
- )}
+ {error && (
+
+
+ {error}
+
+ )}
- {success && (
-
-
-
- If {email} is registered, you'll receive a reset link shortly.
- Check your inbox and spam folder.
-
-
- )}
+ {success && (
+
+
+
+ If {email} is registered, you'll receive a reset link shortly.
+ Check your inbox and spam folder.
+
+
+ )}
-
+ {!success ? (
+
+ {isSubmitting ? 'Sending…' : 'Send reset link'}
+
+ ) : (
+
0 || isSubmitting}
+ onClick={handleResend}
+ >
+ {cooldown > 0 ? `Resend in ${cooldown}s` : 'Resend reset link'}
+
+ )}
+
);
diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx
index 6d51972..7d0dea6 100644
--- a/ui/src/pages/LoginPage.tsx
+++ b/ui/src/pages/LoginPage.tsx
@@ -4,67 +4,16 @@ import { Button, Input } from '../components/ui';
import { useAuth } from '../contexts/AuthContext';
import { authService } from '../services/authService';
import { API_BASE, getApiErrorMessage } from '../api/client';
-import { Eye, EyeOff, CircleAlert, CircleCheck } from 'lucide-react';
+import { Eye, EyeOff, CircleAlert } from 'lucide-react';
import { AuthPageShell } from '../components/auth/AuthPageShell';
type AuthStep = 'email' | 'password' | 'code';
type AuthMode = 'sign-in' | 'sign-up';
-interface PasswordCriteria {
- minLength: boolean;
- hasUpper: boolean;
- hasLower: boolean;
- hasDigit: boolean;
- hasSpecial: boolean;
-}
-
-function getPasswordCriteria(pw: string): PasswordCriteria {
- return {
- minLength: pw.length >= 8,
- hasUpper: /[A-Z]/.test(pw),
- hasLower: /[a-z]/.test(pw),
- hasDigit: /\d/.test(pw),
- hasSpecial: /[!@#$%^&*()\-_+=[\]{}|;:'",.<>?/]/.test(pw),
- };
-}
-
-function isPasswordStrong(pw: string): boolean {
- const c = getPasswordCriteria(pw);
- return c.minLength && c.hasUpper && c.hasLower && c.hasDigit && c.hasSpecial;
-}
-
-function PasswordStrengthIndicator({ password }: { password: string }) {
- const criteria = getPasswordCriteria(password);
- if (!password) return null;
-
- const items: [string, boolean][] = [
- ['At least 8 characters', criteria.minLength],
- ['Uppercase letter', criteria.hasUpper],
- ['Lowercase letter', criteria.hasLower],
- ['Number', criteria.hasDigit],
- ['Special character', criteria.hasSpecial],
- ];
-
- return (
-
- {items.map(([label, met]) => (
-
- {met ? (
-
- ) : (
-
- )}
- {label}
-
- ))}
-
- );
-}
-
export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
- const { login, setUserFromApi } = useAuth();
+ const { login } = useAuth();
const state = location.state as {
from?: { pathname?: string; search?: string };
@@ -87,12 +36,8 @@ export function LoginPage() {
const [mode, setMode] = useState
('sign-in');
const [email, setEmail] = useState(prefilledEmail);
const [password, setPassword] = useState('');
- const [confirmPassword, setConfirmPassword] = useState('');
- const [firstName, setFirstName] = useState('');
- const [lastName, setLastName] = useState('');
const [magicCode, setMagicCode] = useState('');
const [showPassword, setShowPassword] = useState(false);
- const [showConfirm, setShowConfirm] = useState(false);
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [allowSignup, setAllowSignup] = useState(true);
@@ -130,10 +75,7 @@ export function LoginPage() {
const hasOAuth = oauthProviders.google || oauthProviders.github || oauthProviders.gitlab;
- const canUseMagicCode =
- isMagicCodeEnabled &&
- isSmtpConfigured &&
- (mode === 'sign-in' || (mode === 'sign-up' && (allowSignup || !!inviteToken)));
+ const canUseMagicCode = isMagicCodeEnabled && isSmtpConfigured;
const handleOAuth = useCallback(
(provider: string) => {
@@ -160,16 +102,8 @@ export function LoginPage() {
if (resp.existing) {
setMode('sign-in');
} else {
- if (!resp.allow_public_signup) {
- if (!inviteToken) {
- setError('Sign-up is by invite only.');
- setIsSubmitting(false);
- return;
- }
- setMode('sign-up');
- } else {
- setMode('sign-up');
- }
+ navigate('/sign-up', { state: { email, inviteToken }, replace: true });
+ return;
}
const magicOnly = !isPasswordEnabled && isMagicCodeEnabled && isSmtpConfigured;
@@ -193,7 +127,15 @@ export function LoginPage() {
setIsSubmitting(false);
}
},
- [email, inviteToken, isPasswordEnabled, isMagicCodeEnabled, isSmtpConfigured, sendMagicCode],
+ [
+ email,
+ inviteToken,
+ isPasswordEnabled,
+ isMagicCodeEnabled,
+ isSmtpConfigured,
+ sendMagicCode,
+ navigate,
+ ],
);
const switchToMagicCode = useCallback(async () => {
@@ -213,53 +155,17 @@ export function LoginPage() {
async (e: React.FormEvent) => {
e.preventDefault();
setError('');
-
- if (mode === 'sign-up') {
- if (!isPasswordStrong(password)) {
- setError('Password does not meet strength requirements.');
- return;
- }
- if (password !== confirmPassword) {
- setError('Passwords do not match.');
- return;
- }
- }
-
setIsSubmitting(true);
try {
- if (mode === 'sign-in') {
- await login(email, password);
- navigate(returnPath, { replace: true });
- } else {
- const user = await authService.signUp({
- email,
- password,
- first_name: firstName,
- last_name: lastName,
- ...(inviteToken ? { invite_token: inviteToken } : {}),
- });
- setUserFromApi(user);
- navigate(returnPath, { replace: true });
- }
+ await login(email, password);
+ navigate(returnPath, { replace: true });
} catch (err: unknown) {
setError(getApiErrorMessage(err) || 'Something went wrong. Please try again.');
} finally {
setIsSubmitting(false);
}
},
- [
- mode,
- email,
- password,
- confirmPassword,
- firstName,
- lastName,
- inviteToken,
- login,
- setUserFromApi,
- navigate,
- returnPath,
- ],
+ [email, password, login, navigate, returnPath],
);
const handleMagicCodeSubmit = useCallback(
@@ -273,14 +179,11 @@ export function LoginPage() {
}
setIsSubmitting(true);
try {
- const user = await authService.verifyMagicCode({
+ await authService.verifyMagicCode({
email,
code,
- first_name: firstName,
- last_name: lastName,
...(inviteToken ? { invite_token: inviteToken } : {}),
});
- setUserFromApi(user);
navigate(returnPath, { replace: true });
} catch (err: unknown) {
setError(getApiErrorMessage(err) || 'Invalid or expired code.');
@@ -288,15 +191,12 @@ export function LoginPage() {
setIsSubmitting(false);
}
},
- [magicCode, email, firstName, lastName, inviteToken, setUserFromApi, navigate, returnPath],
+ [magicCode, email, inviteToken, navigate, returnPath],
);
const goBackToEmail = useCallback(() => {
setStep('email');
setPassword('');
- setConfirmPassword('');
- setFirstName('');
- setLastName('');
setMagicCode('');
setError('');
}, []);
@@ -307,339 +207,248 @@ export function LoginPage() {
setError('');
}, []);
- const toggleMode = useCallback(() => {
- setMode((prev) => (prev === 'sign-in' ? 'sign-up' : 'sign-in'));
- setPassword('');
- setConfirmPassword('');
- setMagicCode('');
- setError('');
- }, []);
-
const title = useMemo(() => {
if (step === 'email') return 'Get started with Devlane';
- if (step === 'code') return mode === 'sign-in' ? 'Check your email' : 'Verify your email';
- return mode === 'sign-in' ? 'Welcome back!' : 'Create your account';
- }, [step, mode]);
+ if (step === 'code') return 'Check your email';
+ return 'Welcome back!';
+ }, [step]);
const subtitle = useMemo(() => {
if (step === 'email') return 'Enter your email to continue.';
if (step === 'code') return 'We sent a 6-digit code to your inbox. It expires in 10 minutes.';
- return mode === 'sign-in'
- ? 'Enter your password to sign in.'
- : 'Set up your account to get started.';
- }, [step, mode]);
+ return 'Enter your password to sign in.';
+ }, [step]);
return (
-
+
-
{title}
-
{subtitle}
- {step === 'email' && isPasswordEnabled && canUseMagicCode && (
-
- After you continue, you can use your password or choose a one-time email code instead.
-
- )}
-
- {error && (
-
-
- {error}
-
- )}
-
- {step === 'email' && (
- <>
- {hasOAuth && (
-
- {oauthProviders.google && (
-
handleOAuth('google')}
- className="flex w-full items-center justify-center gap-2 rounded-md border border-(--border-primary) px-4 py-2 text-sm font-medium text-(--txt-primary) hover:bg-(--bg-subtle) transition-colors"
- >
-
-
-
-
-
-
- Continue with Google
-
- )}
- {oauthProviders.github && (
-
handleOAuth('github')}
- className="flex w-full items-center justify-center gap-2 rounded-md border border-(--border-primary) px-4 py-2 text-sm font-medium text-(--txt-primary) hover:bg-(--bg-subtle) transition-colors"
- >
-
-
-
- Continue with GitHub
-
- )}
- {oauthProviders.gitlab && (
-
handleOAuth('gitlab')}
- className="flex w-full items-center justify-center gap-2 rounded-md border border-(--border-primary) px-4 py-2 text-sm font-medium text-(--txt-primary) hover:bg-(--bg-subtle) transition-colors"
- >
-
-
-
- Continue with GitLab
-
- )}
-
-
- )}
-
- >
- )}
-
- {step === 'password' && (
-
-
+
{
@@ -229,7 +237,17 @@ export function SignUpPage() {
setIsSubmitting(false);
}
},
- [email, password, confirmPassword, firstName, lastName, inviteToken, setUserFromApi, navigate, returnPath],
+ [
+ email,
+ password,
+ confirmPassword,
+ firstName,
+ lastName,
+ inviteToken,
+ setUserFromApi,
+ navigate,
+ returnPath,
+ ],
);
const handleMagicCodeSubmit = useCallback(
@@ -293,7 +311,12 @@ export function SignUpPage() {
);
}
- const title = step === 'email' ? 'Create your account' : step === 'code' ? 'Verify your email' : 'Create your account';
+ const title =
+ step === 'email'
+ ? 'Create your account'
+ : step === 'code'
+ ? 'Verify your email'
+ : 'Create your account';
const subtitle =
step === 'email'
? 'Enter your email to get started.'
@@ -325,10 +348,22 @@ export function SignUpPage() {
className="flex w-full items-center justify-center gap-2 rounded-md border border-(--border-primary) px-4 py-2 text-sm font-medium text-(--txt-primary) hover:bg-(--bg-subtle) transition-colors"
>
-
-
-
-
+
+
+
+
Sign up with Google
From 9f2ac4b3975260dd2df8a5d9b6681d1af1c28717 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Wed, 15 Apr 2026 09:43:34 +0400
Subject: [PATCH 30/43] fix: persist organization size and harden auth
migration
- Add optional organization_size to POST /api/workspaces/ and create flows
- Remove gen_random_uuid defaults from 000002 and PasswordResetToken model
- Load instance admin authentication settings without getAuthConfig
- Prefix /api/ image paths with API_BASE in dev via getImageUrl
---
api/internal/handler/workspace.go | 7 ++++---
api/internal/model/password_reset_token.go | 2 +-
api/internal/service/workspace.go | 15 ++++++++++-----
api/migrations/000002_auth_schema.up.sql | 4 +---
ui/src/api/types.ts | 2 ++
ui/src/lib/utils.ts | 4 ++++
ui/src/pages/CreateWorkspacePage.tsx | 6 +++++-
.../InstanceAdminAuthenticationPage.tsx | 8 ++++----
.../InstanceAdminCreateWorkspacePage.tsx | 6 +++++-
9 files changed, 36 insertions(+), 18 deletions(-)
diff --git a/api/internal/handler/workspace.go b/api/internal/handler/workspace.go
index 44ec820..c43903a 100644
--- a/api/internal/handler/workspace.go
+++ b/api/internal/handler/workspace.go
@@ -63,14 +63,15 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
}
}
var body struct {
- Name string `json:"name" binding:"required"`
- Slug string `json:"slug"`
+ Name string `json:"name" binding:"required"`
+ Slug string `json:"slug"`
+ OrganizationSize string `json:"organization_size"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
return
}
- w, err := h.Workspace.Create(c.Request.Context(), body.Name, body.Slug, user.ID)
+ w, err := h.Workspace.Create(c.Request.Context(), body.Name, body.Slug, body.OrganizationSize, user.ID)
if err != nil {
if err == service.ErrSlugInvalid {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid slug"})
diff --git a/api/internal/model/password_reset_token.go b/api/internal/model/password_reset_token.go
index 2a315f8..663c57c 100644
--- a/api/internal/model/password_reset_token.go
+++ b/api/internal/model/password_reset_token.go
@@ -8,7 +8,7 @@ import (
)
type PasswordResetToken struct {
- ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
Token string `gorm:"type:varchar(128);uniqueIndex;not null" json:"-"`
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
diff --git a/api/internal/service/workspace.go b/api/internal/service/workspace.go
index bf58785..063d93d 100644
--- a/api/internal/service/workspace.go
+++ b/api/internal/service/workspace.go
@@ -54,7 +54,7 @@ func (s *WorkspaceService) GetBySlug(ctx context.Context, slug string, userID uu
return w, nil
}
-func (s *WorkspaceService) Create(ctx context.Context, name, slug string, ownerID uuid.UUID) (*model.Workspace, error) {
+func (s *WorkspaceService) Create(ctx context.Context, name, slug, organizationSize string, ownerID uuid.UUID) (*model.Workspace, error) {
slug = strings.TrimSpace(strings.ToLower(slug))
if slug == "" {
slug = strings.Trim(slugifyName.ReplaceAllString(strings.ToLower(name), "-"), "-")
@@ -69,11 +69,16 @@ func (s *WorkspaceService) Create(ctx context.Context, name, slug string, ownerI
if exists {
return nil, ErrSlugTaken
}
+ orgSize := strings.TrimSpace(organizationSize)
+ if len(orgSize) > 50 {
+ orgSize = orgSize[:50]
+ }
w := &model.Workspace{
- Name: name,
- Slug: slug,
- OwnerID: ownerID,
- CreatedByID: &ownerID,
+ Name: name,
+ Slug: slug,
+ OwnerID: ownerID,
+ CreatedByID: &ownerID,
+ OrganizationSize: orgSize,
}
if err := s.ws.Create(ctx, w); err != nil {
return nil, err
diff --git a/api/migrations/000002_auth_schema.up.sql b/api/migrations/000002_auth_schema.up.sql
index bb03760..00bc24f 100644
--- a/api/migrations/000002_auth_schema.up.sql
+++ b/api/migrations/000002_auth_schema.up.sql
@@ -3,7 +3,7 @@
-- apply the accounts section manually or coordinate a follow-up migration.
CREATE TABLE IF NOT EXISTS password_reset_tokens (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(128) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
@@ -50,5 +50,3 @@ ALTER TABLE accounts ALTER COLUMN last_connected_at DROP DEFAULT;
ALTER TABLE accounts ALTER COLUMN last_connected_at DROP NOT NULL;
CREATE INDEX IF NOT EXISTS idx_accounts_provider ON accounts (provider, user_id);
-
-ALTER TABLE accounts ALTER COLUMN id SET DEFAULT gen_random_uuid();
diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts
index 486c5f5..f5626ae 100644
--- a/ui/src/api/types.ts
+++ b/ui/src/api/types.ts
@@ -7,6 +7,8 @@
export interface CreateWorkspaceRequest {
name: string;
slug: string;
+ /** Optional team size range (e.g. from create-workspace form). */
+ organization_size?: string;
}
/** Workspace as returned by the API (list + get) */
diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts
index 2a8dae2..f9efa25 100644
--- a/ui/src/lib/utils.ts
+++ b/ui/src/lib/utils.ts
@@ -1,5 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
+import { API_BASE } from '../api/client';
import type { WorkspaceMemberApiResponse } from '../api/types';
/**
@@ -23,6 +24,9 @@ export function getImageUrl(url: string | null | undefined): string | null {
return t;
}
const path = t.startsWith('/') ? t : '/' + t;
+ if (API_BASE && path.startsWith('/api/')) {
+ return `${API_BASE.replace(/\/$/, '')}${path}`;
+ }
return path;
}
diff --git a/ui/src/pages/CreateWorkspacePage.tsx b/ui/src/pages/CreateWorkspacePage.tsx
index 12a623d..cb6e93d 100644
--- a/ui/src/pages/CreateWorkspacePage.tsx
+++ b/ui/src/pages/CreateWorkspacePage.tsx
@@ -63,7 +63,11 @@ export function CreateWorkspacePage() {
setIsSubmitting(true);
try {
- const ws = await workspaceService.create({ name: trimmedName, slug: trimmedSlug });
+ const ws = await workspaceService.create({
+ name: trimmedName,
+ slug: trimmedSlug,
+ ...(organizationSize.trim() ? { organization_size: organizationSize.trim() } : {}),
+ });
navigate(`/${ws.slug}`, { replace: true });
} catch (err) {
setError(getApiErrorMessage(err));
diff --git a/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx b/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx
index 52c233c..69d0c29 100644
--- a/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx
+++ b/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx
@@ -3,7 +3,6 @@ import { Link } from 'react-router-dom';
import { Settings2 } from 'lucide-react';
import { Skeleton } from '../../components/ui';
import { instanceSettingsService } from '../../services/instanceService';
-import { authService } from '../../services/authService';
import { getApiErrorMessage } from '../../api/client';
import type { InstanceAuthSection, InstanceOAuthSection } from '../../api/types';
@@ -183,8 +182,9 @@ export function InstanceAdminAuthenticationPage() {
useEffect(() => {
let cancelled = false;
- Promise.all([instanceSettingsService.getSettings(), authService.getAuthConfig()])
- .then(([settings]) => {
+ instanceSettingsService
+ .getSettings()
+ .then((settings) => {
if (cancelled) return;
const a = (settings.auth || {}) as InstanceAuthSection;
setAuth({
@@ -198,7 +198,7 @@ export function InstanceAdminAuthenticationPage() {
const o = (settings.oauth || {}) as InstanceOAuthSection;
setOauth(o);
})
- .catch((err) => {
+ .catch((err: unknown) => {
if (!cancelled) setError(getApiErrorMessage(err));
})
.finally(() => {
diff --git a/ui/src/pages/instance-admin/InstanceAdminCreateWorkspacePage.tsx b/ui/src/pages/instance-admin/InstanceAdminCreateWorkspacePage.tsx
index 5445fa7..0cafcfe 100644
--- a/ui/src/pages/instance-admin/InstanceAdminCreateWorkspacePage.tsx
+++ b/ui/src/pages/instance-admin/InstanceAdminCreateWorkspacePage.tsx
@@ -73,7 +73,11 @@ export function InstanceAdminCreateWorkspacePage() {
setIsSubmitting(true);
try {
- await workspaceService.create({ name: trimmedName, slug: trimmedSlug });
+ await workspaceService.create({
+ name: trimmedName,
+ slug: trimmedSlug,
+ ...(organizationSize.trim() ? { organization_size: organizationSize.trim() } : {}),
+ });
setShowSetupHint(false);
navigate('/instance-admin/workspace', { replace: true });
} catch (err) {
From ee3e9446eac414b5e6758b339e364f5a3ea3e747 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Wed, 15 Apr 2026 10:36:50 +0400
Subject: [PATCH 31/43] fix: catch block now uses getApiErrorMessage(err)
---
ui/src/pages/ResetPasswordPage.tsx | 8 ++------
1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/ui/src/pages/ResetPasswordPage.tsx b/ui/src/pages/ResetPasswordPage.tsx
index b94d4fd..6d7ab46 100644
--- a/ui/src/pages/ResetPasswordPage.tsx
+++ b/ui/src/pages/ResetPasswordPage.tsx
@@ -4,6 +4,7 @@ import { Button, Input } from '../components/ui';
import { authService } from '../services/authService';
import { Eye, EyeOff, CircleAlert, CircleCheck } from 'lucide-react';
import { AuthPageShell } from '../components/auth/AuthPageShell';
+import { getApiErrorMessage } from '../api/client';
interface PasswordCriteria {
minLength: boolean;
@@ -94,12 +95,7 @@ export function ResetPasswordPage() {
await authService.resetPassword({ token, new_password: password });
setSuccess(true);
} catch (err: unknown) {
- if (err && typeof err === 'object' && 'response' in err) {
- const axiosErr = err as { response?: { data?: { error?: string } } };
- setError(axiosErr.response?.data?.error ?? 'Something went wrong.');
- } else {
- setError('Something went wrong. Please try again.');
- }
+ setError(getApiErrorMessage(err));
} finally {
setIsSubmitting(false);
}
From e9cdf2ec73d218489e7f4ad8f48ba1b70f01953c Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Wed, 15 Apr 2026 10:58:51 +0400
Subject: [PATCH 32/43] fix: no longer duplicate the helpers
---
ui/src/components/instance-admin/index.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/ui/src/components/instance-admin/index.ts b/ui/src/components/instance-admin/index.ts
index 9203186..b35d46a 100644
--- a/ui/src/components/instance-admin/index.ts
+++ b/ui/src/components/instance-admin/index.ts
@@ -1 +1,2 @@
export { CreateWorkspaceSetupHint } from './CreateWorkspaceSetupHint';
+export { InstanceAdminCopyRow, InstanceAdminToggleSwitch } from './InstanceAdminAuthControls';
From 496e86f2af4dea9ad2761324fb855394dd5732b8 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Wed, 15 Apr 2026 11:09:43 +0400
Subject: [PATCH 33/43] fix: validated check
- Introduced oauthHTTPClient with a 30s timeout
- httpPostForm/httpGetJSON now take ctx and use http.NewRequestWithContext+that client
- Providers pass ctx through Exchange/GetUserInfo
- GitHub fetchPrimaryEmail uses NewRequestWithContext and oauthHTTPClient
---
api/internal/oauth/github.go | 16 ++---
api/internal/oauth/gitlab.go | 8 +--
api/internal/oauth/google.go | 8 +--
api/internal/oauth/oauth.go | 15 +++--
ui/package-lock.json | 11 ---
.../InstanceAdminAuthControls.tsx | 67 +++++++++++++++++++
.../InstanceAdminAuthGitHubPage.tsx | 66 ++----------------
.../InstanceAdminAuthGitLabPage.tsx | 64 ++----------------
.../InstanceAdminAuthGooglePage.tsx | 66 ++----------------
.../InstanceAdminAuthenticationPage.tsx | 30 ++-------
10 files changed, 110 insertions(+), 241 deletions(-)
create mode 100644 ui/src/components/instance-admin/InstanceAdminAuthControls.tsx
diff --git a/api/internal/oauth/github.go b/api/internal/oauth/github.go
index 8291b78..45c4f55 100644
--- a/api/internal/oauth/github.go
+++ b/api/internal/oauth/github.go
@@ -37,14 +37,14 @@ func (g *GitHubProvider) AuthURL(state string) string {
return githubAuthURL + "?" + params.Encode()
}
-func (g *GitHubProvider) Exchange(_ context.Context, code string) (*TokenData, error) {
+func (g *GitHubProvider) Exchange(ctx context.Context, code string) (*TokenData, error) {
data := url.Values{
"client_id": {g.cfg.ClientID},
"client_secret": {g.cfg.ClientSecret},
"code": {code},
"redirect_uri": {g.cfg.RedirectURI},
}
- resp, err := httpPostForm(githubTokenURL, data, map[string]string{"Accept": "application/json"})
+ resp, err := httpPostForm(ctx, githubTokenURL, data, map[string]string{"Accept": "application/json"})
if err != nil {
return nil, err
}
@@ -55,14 +55,14 @@ func (g *GitHubProvider) Exchange(_ context.Context, code string) (*TokenData, e
return td, nil
}
-func (g *GitHubProvider) GetUserInfo(_ context.Context, token *TokenData) (*UserInfo, error) {
- resp, err := httpGetJSON(githubUserURL, token.AccessToken)
+func (g *GitHubProvider) GetUserInfo(ctx context.Context, token *TokenData) (*UserInfo, error) {
+ resp, err := httpGetJSON(ctx, githubUserURL, token.AccessToken)
if err != nil {
return nil, err
}
email := strVal(resp, "email")
if email == "" {
- email, _ = g.fetchPrimaryEmail(token.AccessToken)
+ email, _ = g.fetchPrimaryEmail(ctx, token.AccessToken)
}
return &UserInfo{
Email: email,
@@ -72,14 +72,14 @@ func (g *GitHubProvider) GetUserInfo(_ context.Context, token *TokenData) (*User
}, nil
}
-func (g *GitHubProvider) fetchPrimaryEmail(accessToken string) (string, error) {
- req, err := http.NewRequest("GET", githubEmailURL, nil)
+func (g *GitHubProvider) fetchPrimaryEmail(ctx context.Context, accessToken string) (string, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubEmailURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
- resp, err := http.DefaultClient.Do(req)
+ resp, err := oauthHTTPClient.Do(req)
if err != nil {
return "", err
}
diff --git a/api/internal/oauth/gitlab.go b/api/internal/oauth/gitlab.go
index e9c05bc..63cd2b5 100644
--- a/api/internal/oauth/gitlab.go
+++ b/api/internal/oauth/gitlab.go
@@ -35,7 +35,7 @@ func (g *GitLabProvider) AuthURL(state string) string {
return g.host + "/oauth/authorize?" + params.Encode()
}
-func (g *GitLabProvider) Exchange(_ context.Context, code string) (*TokenData, error) {
+func (g *GitLabProvider) Exchange(ctx context.Context, code string) (*TokenData, error) {
data := url.Values{
"client_id": {g.cfg.ClientID},
"client_secret": {g.cfg.ClientSecret},
@@ -44,7 +44,7 @@ func (g *GitLabProvider) Exchange(_ context.Context, code string) (*TokenData, e
"grant_type": {"authorization_code"},
}
tokenURL := g.host + "/oauth/token"
- resp, err := httpPostForm(tokenURL, data, map[string]string{"Accept": "application/json"})
+ resp, err := httpPostForm(ctx, tokenURL, data, map[string]string{"Accept": "application/json"})
if err != nil {
return nil, err
}
@@ -56,9 +56,9 @@ func (g *GitLabProvider) Exchange(_ context.Context, code string) (*TokenData, e
return td, nil
}
-func (g *GitLabProvider) GetUserInfo(_ context.Context, token *TokenData) (*UserInfo, error) {
+func (g *GitLabProvider) GetUserInfo(ctx context.Context, token *TokenData) (*UserInfo, error) {
userURL := g.host + "/api/v4/user"
- resp, err := httpGetJSON(userURL, token.AccessToken)
+ resp, err := httpGetJSON(ctx, userURL, token.AccessToken)
if err != nil {
return nil, err
}
diff --git a/api/internal/oauth/google.go b/api/internal/oauth/google.go
index 6a67fec..0f74f47 100644
--- a/api/internal/oauth/google.go
+++ b/api/internal/oauth/google.go
@@ -36,7 +36,7 @@ func (g *GoogleProvider) AuthURL(state string) string {
return googleAuthURL + "?" + params.Encode()
}
-func (g *GoogleProvider) Exchange(_ context.Context, code string) (*TokenData, error) {
+func (g *GoogleProvider) Exchange(ctx context.Context, code string) (*TokenData, error) {
data := url.Values{
"code": {code},
"client_id": {g.cfg.ClientID},
@@ -44,7 +44,7 @@ func (g *GoogleProvider) Exchange(_ context.Context, code string) (*TokenData, e
"redirect_uri": {g.cfg.RedirectURI},
"grant_type": {"authorization_code"},
}
- resp, err := httpPostForm(googleTokenURL, data, nil)
+ resp, err := httpPostForm(ctx, googleTokenURL, data, nil)
if err != nil {
return nil, err
}
@@ -56,8 +56,8 @@ func (g *GoogleProvider) Exchange(_ context.Context, code string) (*TokenData, e
return td, nil
}
-func (g *GoogleProvider) GetUserInfo(_ context.Context, token *TokenData) (*UserInfo, error) {
- resp, err := httpGetJSON(googleUserURL, token.AccessToken)
+func (g *GoogleProvider) GetUserInfo(ctx context.Context, token *TokenData) (*UserInfo, error) {
+ resp, err := httpGetJSON(ctx, googleUserURL, token.AccessToken)
if err != nil {
return nil, err
}
diff --git a/api/internal/oauth/oauth.go b/api/internal/oauth/oauth.go
index 2775113..73529a0 100644
--- a/api/internal/oauth/oauth.go
+++ b/api/internal/oauth/oauth.go
@@ -20,6 +20,9 @@ var (
ErrUserInfo = errors.New("oauth user info fetch failed")
)
+// oauthHTTPClient bounds OAuth HTTP latency; requests also respect ctx cancellation.
+var oauthHTTPClient = &http.Client{Timeout: 30 * time.Second}
+
type UserInfo struct {
Email string
FirstName string
@@ -41,8 +44,8 @@ type ProviderConfig struct {
RedirectURI string
}
-func httpPostForm(tokenURL string, data url.Values, extraHeaders map[string]string) (map[string]interface{}, error) {
- req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
+func httpPostForm(ctx context.Context, tokenURL string, data url.Values, extraHeaders map[string]string) (map[string]interface{}, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
@@ -50,7 +53,7 @@ func httpPostForm(tokenURL string, data url.Values, extraHeaders map[string]stri
for k, v := range extraHeaders {
req.Header.Set(k, v)
}
- resp, err := http.DefaultClient.Do(req)
+ resp, err := oauthHTTPClient.Do(req)
if err != nil {
return nil, err
}
@@ -66,14 +69,14 @@ func httpPostForm(tokenURL string, data url.Values, extraHeaders map[string]stri
return result, nil
}
-func httpGetJSON(url string, token string) (map[string]interface{}, error) {
- req, err := http.NewRequest("GET", url, nil)
+func httpGetJSON(ctx context.Context, urlStr string, token string) (map[string]interface{}, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
- resp, err := http.DefaultClient.Do(req)
+ resp, err := oauthHTTPClient.Do(req)
if err != nil {
return nil, err
}
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 5f4db2c..2cb15d4 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -46,17 +46,6 @@
"vite": "^7.3.1"
}
},
- "..": {
- "version": "1.0.0",
- "extraneous": true,
- "license": "ISC",
- "devDependencies": {
- "@commitlint/cli": "^19.8.1",
- "@commitlint/config-conventional": "^19.8.1",
- "husky": "^9.1.7",
- "lint-staged": "^16.4.0"
- }
- },
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
diff --git a/ui/src/components/instance-admin/InstanceAdminAuthControls.tsx b/ui/src/components/instance-admin/InstanceAdminAuthControls.tsx
new file mode 100644
index 0000000..8cc1e58
--- /dev/null
+++ b/ui/src/components/instance-admin/InstanceAdminAuthControls.tsx
@@ -0,0 +1,67 @@
+import { useState } from 'react';
+import { Copy } from 'lucide-react';
+
+export function InstanceAdminCopyRow({
+ label,
+ hint,
+ value,
+}: {
+ label: string;
+ hint: string;
+ value: string;
+}) {
+ const [copied, setCopied] = useState(false);
+ const onCopy = () => {
+ if (!value) return;
+ void navigator.clipboard.writeText(value).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ };
+ return (
+
+
+ {label}
+
+
+ {copied ? 'Copied' : 'Copy'}
+
+
+
+
{hint}
+
+ );
+}
+
+export function InstanceAdminToggleSwitch({
+ checked,
+ onChange,
+ disabled,
+}: {
+ checked: boolean;
+ onChange: (v: boolean) => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ onChange(e.target.checked)}
+ disabled={disabled}
+ />
+
+
+ );
+}
diff --git a/ui/src/pages/instance-admin/InstanceAdminAuthGitHubPage.tsx b/ui/src/pages/instance-admin/InstanceAdminAuthGitHubPage.tsx
index 179c79e..c9abeba 100644
--- a/ui/src/pages/instance-admin/InstanceAdminAuthGitHubPage.tsx
+++ b/ui/src/pages/instance-admin/InstanceAdminAuthGitHubPage.tsx
@@ -1,68 +1,12 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, Input } from '../../components/ui';
+import { InstanceAdminCopyRow, InstanceAdminToggleSwitch } from '../../components/instance-admin';
import { instanceSettingsService } from '../../services/instanceService';
import { authService } from '../../services/authService';
import { getApiErrorMessage } from '../../api/client';
import type { InstanceAuthSection, InstanceOAuthSection } from '../../api/types';
-import { Copy, Eye, EyeOff } from 'lucide-react';
-
-function CopyRow({ label, hint, value }: { label: string; hint: string; value: string }) {
- const [copied, setCopied] = useState(false);
- const onCopy = () => {
- if (!value) return;
- void navigator.clipboard.writeText(value).then(() => {
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- });
- };
- return (
-
-
- {label}
-
-
- {copied ? 'Copied' : 'Copy'}
-
-
-
-
{hint}
-
- );
-}
-
-function ToggleSwitch({
- checked,
- onChange,
- disabled,
-}: {
- checked: boolean;
- onChange: (v: boolean) => void;
- disabled?: boolean;
-}) {
- return (
-
- onChange(e.target.checked)}
- disabled={disabled}
- />
-
-
- );
-}
+import { Eye, EyeOff } from 'lucide-react';
const IconGitHub = () => (
@@ -188,7 +132,7 @@ export function InstanceAdminAuthGitHubPage() {
- setEnabled(v)} disabled={saving} />
+ setEnabled(v)} disabled={saving} />
{error && {error}
}
@@ -255,12 +199,12 @@ export function InstanceAdminAuthGitHubPage() {
Devlane-provided details for GitHub
-
-
{
- if (!value) return;
- void navigator.clipboard.writeText(value).then(() => {
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- });
- };
- return (
-
-
- {label}
-
-
- {copied ? 'Copied' : 'Copy'}
-
-
-
-
{hint}
-
- );
-}
-
-function ToggleSwitch({
- checked,
- onChange,
- disabled,
-}: {
- checked: boolean;
- onChange: (v: boolean) => void;
- disabled?: boolean;
-}) {
- return (
-
- onChange(e.target.checked)}
- disabled={disabled}
- />
-
-
- );
-}
+import { Eye, EyeOff } from 'lucide-react';
const IconGitLab = () => (
@@ -195,7 +139,7 @@ export function InstanceAdminAuthGitLabPage() {
- setEnabled(v)} disabled={saving} />
+ setEnabled(v)} disabled={saving} />
{error && {error}
}
@@ -273,7 +217,7 @@ export function InstanceAdminAuthGitLabPage() {
Devlane-provided details for GitLab
-
{
- if (!value) return;
- void navigator.clipboard.writeText(value).then(() => {
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- });
- };
- return (
-
-
- {label}
-
-
- {copied ? 'Copied' : 'Copy'}
-
-
-
-
{hint}
-
- );
-}
-
-function ToggleSwitch({
- checked,
- onChange,
- disabled,
-}: {
- checked: boolean;
- onChange: (v: boolean) => void;
- disabled?: boolean;
-}) {
- return (
-
- onChange(e.target.checked)}
- disabled={disabled}
- />
-
-
- );
-}
+import { Eye, EyeOff } from 'lucide-react';
const IconGoogle = () => (
@@ -203,7 +147,7 @@ export function InstanceAdminAuthGooglePage() {
- setEnabled(v)} disabled={saving} />
+ setEnabled(v)} disabled={saving} />
{error && {error}
}
@@ -270,12 +214,12 @@ export function InstanceAdminAuthGooglePage() {
Devlane-provided details for Google
-
- void;
- disabled?: boolean;
-}) {
- return (
-
- onChange(e.target.checked)}
- disabled={disabled}
- />
-
-
- );
-}
-
function isOAuthConfigured(oauthKey: OAuthProviderKey, oauth: InstanceOAuthSection): boolean {
switch (oauthKey) {
case 'google':
@@ -293,7 +271,7 @@ export function InstanceAdminAuthenticationPage() {
Toggling this off will only let users sign up when they are invited.
- handleToggle('allow_public_signup', v)}
disabled={saving}
@@ -334,7 +312,7 @@ export function InstanceAdminAuthenticationPage() {
>
Edit
- handleToggle(item.key, v)}
disabled={saving}
@@ -351,7 +329,7 @@ export function InstanceAdminAuthenticationPage() {
)}
{!item.isOAuth && (
- handleToggle(item.key, v)}
disabled={saving}
From c4d79c5d2882046b3bc9420ea2d430b76b2d0794 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Wed, 15 Apr 2026 11:28:26 +0400
Subject: [PATCH 34/43] fix: linting fixed
---
ui/src/pages/instance-admin/InstanceAdminAuthGitHubPage.tsx | 6 +++++-
ui/src/pages/instance-admin/InstanceAdminAuthGitLabPage.tsx | 6 +++++-
ui/src/pages/instance-admin/InstanceAdminAuthGooglePage.tsx | 6 +++++-
3 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/ui/src/pages/instance-admin/InstanceAdminAuthGitHubPage.tsx b/ui/src/pages/instance-admin/InstanceAdminAuthGitHubPage.tsx
index c9abeba..d673f09 100644
--- a/ui/src/pages/instance-admin/InstanceAdminAuthGitHubPage.tsx
+++ b/ui/src/pages/instance-admin/InstanceAdminAuthGitHubPage.tsx
@@ -132,7 +132,11 @@ export function InstanceAdminAuthGitHubPage() {
- setEnabled(v)} disabled={saving} />
+ setEnabled(v)}
+ disabled={saving}
+ />
{error && {error}
}
diff --git a/ui/src/pages/instance-admin/InstanceAdminAuthGitLabPage.tsx b/ui/src/pages/instance-admin/InstanceAdminAuthGitLabPage.tsx
index 8bffabd..a9a1bc1 100644
--- a/ui/src/pages/instance-admin/InstanceAdminAuthGitLabPage.tsx
+++ b/ui/src/pages/instance-admin/InstanceAdminAuthGitLabPage.tsx
@@ -139,7 +139,11 @@ export function InstanceAdminAuthGitLabPage() {
- setEnabled(v)} disabled={saving} />
+ setEnabled(v)}
+ disabled={saving}
+ />
{error && {error}
}
diff --git a/ui/src/pages/instance-admin/InstanceAdminAuthGooglePage.tsx b/ui/src/pages/instance-admin/InstanceAdminAuthGooglePage.tsx
index 4f7989f..7d94bee 100644
--- a/ui/src/pages/instance-admin/InstanceAdminAuthGooglePage.tsx
+++ b/ui/src/pages/instance-admin/InstanceAdminAuthGooglePage.tsx
@@ -147,7 +147,11 @@ export function InstanceAdminAuthGooglePage() {
- setEnabled(v)} disabled={saving} />
+ setEnabled(v)}
+ disabled={saving}
+ />
{error && {error}
}
From aaeacba88e23b7d661fb933d075541975cbe10c6 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Wed, 15 Apr 2026 11:36:58 +0400
Subject: [PATCH 35/43] fix: run prettier in pre-commit
---
lint-staged.config.mjs | 1 +
1 file changed, 1 insertion(+)
diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs
index 12a2cd6..8cc194f 100644
--- a/lint-staged.config.mjs
+++ b/lint-staged.config.mjs
@@ -6,6 +6,7 @@ export default {
const args = files.map(q).join(' ');
return [
`npm --prefix ui exec -- eslint --max-warnings=0 --fix --config ui/eslint.config.js ${args}`,
+ `npx prettier --write ${args}`,
];
},
'ui/**/*.{css,json,md}': (files) => (files.length ? [`npx prettier --write ${files.join(' ')}`] : []),
From 43bc47e04fc0ae492b0f0eaf0cca2bffac5aec09 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Thu, 16 Apr 2026 02:56:31 +0400
Subject: [PATCH 36/43] fix: set all three explicitly: API_PUBLIC_URL,
FRONTEND_PUBLIC_URL, APP_BASE_URL
---
api/.env.example | 11 +++++---
api/cmd/api/main.go | 18 ++++++------
api/internal/config/config.go | 7 +++++
api/internal/handler/auth.go | 42 ++++++++++++++++++---------
api/internal/router/router.go | 53 +++++++++++++++++++----------------
ui/.env.example | 2 ++
6 files changed, 83 insertions(+), 50 deletions(-)
create mode 100644 ui/.env.example
diff --git a/api/.env.example b/api/.env.example
index 0bb7c26..959de80 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -20,11 +20,14 @@ REDIS_DB=0
# RabbitMQ
RABBITMQ_URL=amqp://guest:guest@localhost:5672/
-# Frontend URL for invite links and post-login redirects (e.g. http://localhost:5173). If unset, CORS_ORIGIN is used.
-APP_BASE_URL=https://app.example.com
+# Frontend URL for invite links and post-login redirects. If unset, CORS_ORIGIN is used.
+APP_BASE_URL=http://localhost:5173
-# Public URL of this API (OAuth callbacks must hit the API, not the SPA). Local dev when UI is on 5173 and API on 8080:
-# API_PUBLIC_URL=http://localhost:8080
+# Browser-visible SPA origin for OAuth admin hints (Google Authorized JavaScript origins, GitHub Homepage URL). If unset, APP_BASE_URL then CORS_ORIGIN.
+FRONTEND_PUBLIC_URL=http://localhost:5173
+
+# Public URL of this API (OAuth redirect URIs must hit the API, not the SPA).
+API_PUBLIC_URL=http://localhost:8080
# OAuth credentials (Google, GitHub, GitLab) are configured via Instance Admin UI, not env vars.
diff --git a/api/cmd/api/main.go b/api/cmd/api/main.go
index 1eda2a0..fe077cf 100644
--- a/api/cmd/api/main.go
+++ b/api/cmd/api/main.go
@@ -84,14 +84,16 @@ func main() {
}
r := router.New(router.Config{
- Log: log,
- DB: db,
- Redis: rdb,
- Queue: queuePublisher,
- Minio: mc,
- CORSAllowOrigin: cfg.CORSAllowOrigin,
- AppBaseURL: cfg.AppBaseURL,
- MagicCodeSecret: cfg.MagicCodeSecret,
+ Log: log,
+ DB: db,
+ Redis: rdb,
+ Queue: queuePublisher,
+ Minio: mc,
+ CORSAllowOrigin: cfg.CORSAllowOrigin,
+ AppBaseURL: cfg.AppBaseURL,
+ FrontendPublicURL: cfg.FrontendPublicURL,
+ APIPublicURL: cfg.APIPublicURL,
+ MagicCodeSecret: cfg.MagicCodeSecret,
})
// Start task consumer when RabbitMQ is available
diff --git a/api/internal/config/config.go b/api/internal/config/config.go
index 0fd339c..2fa2808 100644
--- a/api/internal/config/config.go
+++ b/api/internal/config/config.go
@@ -42,6 +42,11 @@ type Config struct {
CORSAllowOrigin string
// AppBaseURL is the public URL of the frontend (e.g. https://app.example.com). Used for invite links in emails. If empty, CORSAllowOrigin is used.
AppBaseURL string
+ // FrontendPublicURL is the browser-visible SPA origin (e.g. https://app.example.com). Used for OAuth "Authorized JavaScript origins" / homepage hints in instance-admin. If empty, AppBaseURL then CORSAllowOrigin apply (see router).
+ FrontendPublicURL string
+ // APIPublicURL is the public URL of the API (e.g. https://api.example.com or http://localhost:8080).
+ // Used to generate OAuth callback URLs shown in instance-admin and sent to providers.
+ APIPublicURL string
// MagicCodeSecret HMAC key for email login codes. If empty, a dev-only default is used (see auth package).
MagicCodeSecret string
@@ -90,6 +95,8 @@ func Load() (*Config, error) {
MigrationsPath: getEnv("MIGRATIONS_PATH", "migrations"),
CORSAllowOrigin: getEnv("CORS_ORIGIN", "http://localhost:5173"),
AppBaseURL: getEnv("APP_BASE_URL", ""),
+ FrontendPublicURL: getEnv("FRONTEND_PUBLIC_URL", ""),
+ APIPublicURL: getEnv("API_PUBLIC_URL", ""),
MagicCodeSecret: getEnv("MAGIC_CODE_SECRET", ""),
}
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index 19b9ccd..36db198 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -26,17 +26,19 @@ import (
)
type AuthHandler struct {
- Auth *auth.Service
- Settings *store.InstanceSettingStore
- Winv *store.WorkspaceInviteStore
- Ws *store.WorkspaceStore
- NotifPrefs *store.UserNotificationPreferenceStore
- ApiTokens *store.ApiTokenStore
- Queue *queue.Publisher
- Redis *redis.Client
- MagicCodeSecret string
- AppBaseURL string
- Log *slog.Logger
+ Auth *auth.Service
+ Settings *store.InstanceSettingStore
+ Winv *store.WorkspaceInviteStore
+ Ws *store.WorkspaceStore
+ NotifPrefs *store.UserNotificationPreferenceStore
+ ApiTokens *store.ApiTokenStore
+ Queue *queue.Publisher
+ Redis *redis.Client
+ MagicCodeSecret string
+ AppBaseURL string
+ FrontendPublicURL string
+ APIPublicURL string
+ Log *slog.Logger
}
type SignInRequest struct {
@@ -562,13 +564,25 @@ func (h *AuthHandler) InstanceAuthConfig(c *gin.Context) {
"is_gitlab_enabled": isGitLabEnabled,
"is_workspace_creation_disabled": isWorkspaceCreationRestricted(ctx, h.Settings),
}
- out["oauth_redirect_base"] = requestCallbackBase(c)
- if s := strings.TrimSpace(h.AppBaseURL); s != "" {
- out["oauth_js_origin"] = strings.TrimSuffix(s, "/")
+ out["oauth_redirect_base"] = oauthCallbackBase(c, h.APIPublicURL)
+ if js := h.oauthJSOriginForProviders(); js != "" {
+ out["oauth_js_origin"] = js
}
c.JSON(http.StatusOK, out)
}
+// oauthJSOriginForProviders is the SPA origin admins paste into Google "Authorized JavaScript origins",
+// GitHub "Homepage URL", etc. Prefer FRONTEND_PUBLIC_URL so CORS_ORIGIN can differ from the public app URL when needed.
+func (h *AuthHandler) oauthJSOriginForProviders() string {
+ if s := strings.TrimSpace(h.FrontendPublicURL); s != "" {
+ return strings.TrimSuffix(s, "/")
+ }
+ if s := strings.TrimSpace(h.AppBaseURL); s != "" {
+ return strings.TrimSuffix(s, "/")
+ }
+ return ""
+}
+
// EmailCheck checks whether an email is already registered.
// POST /auth/email-check/
func (h *AuthHandler) EmailCheck(c *gin.Context) {
diff --git a/api/internal/router/router.go b/api/internal/router/router.go
index d756827..46fc725 100644
--- a/api/internal/router/router.go
+++ b/api/internal/router/router.go
@@ -17,13 +17,15 @@ import (
// Config holds dependencies for the router.
type Config struct {
- Log *slog.Logger
- DB *gorm.DB
- Redis *redis.Client // optional: cache, locks, magic-link
- Queue *queue.Publisher // optional: enqueue emails, webhooks
- Minio *minio.Client // optional: file uploads (cover images, avatars, logos)
- CORSAllowOrigin string // optional: e.g. "http://localhost:5173" for UI dev
- AppBaseURL string // optional: base URL for invite links; if empty, CORSAllowOrigin is used
+ Log *slog.Logger
+ DB *gorm.DB
+ Redis *redis.Client // optional: cache, locks, magic-link
+ Queue *queue.Publisher // optional: enqueue emails, webhooks
+ Minio *minio.Client // optional: file uploads (cover images, avatars, logos)
+ CORSAllowOrigin string // optional: e.g. "http://localhost:5173" for UI dev
+ AppBaseURL string // optional: base URL for invite links; if empty, CORSAllowOrigin is used
+ FrontendPublicURL string // optional: SPA origin for OAuth JS-origin hints; if empty, falls back to AppBaseURL chain
+ APIPublicURL string // optional: public API URL for OAuth callback generation
// MagicCodeSecret is the HMAC key for email login codes (see MAGIC_CODE_SECRET).
MagicCodeSecret string
@@ -85,17 +87,19 @@ func New(cfg Config) *gin.Engine {
}
authHandler := &handler.AuthHandler{
- Auth: authSvc,
- Settings: instanceSettingStore,
- Winv: workspaceInviteStore,
- Ws: workspaceStore,
- NotifPrefs: userNotifPrefStore,
- ApiTokens: apiTokenStore,
- Queue: cfg.Queue,
- Redis: cfg.Redis,
- MagicCodeSecret: cfg.MagicCodeSecret,
- AppBaseURL: appBaseURL,
- Log: cfg.Log,
+ Auth: authSvc,
+ Settings: instanceSettingStore,
+ Winv: workspaceInviteStore,
+ Ws: workspaceStore,
+ NotifPrefs: userNotifPrefStore,
+ ApiTokens: apiTokenStore,
+ Queue: cfg.Queue,
+ Redis: cfg.Redis,
+ MagicCodeSecret: cfg.MagicCodeSecret,
+ AppBaseURL: appBaseURL,
+ FrontendPublicURL: cfg.FrontendPublicURL,
+ APIPublicURL: cfg.APIPublicURL,
+ Log: cfg.Log,
}
// 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}
@@ -308,12 +312,13 @@ func New(cfg Config) *gin.Engine {
// OAuth routes (no auth required); provider resolved from instance settings at request time.
oauthHandler := &handler.OAuthHandler{
- Settings: instanceSettingStore,
- Workspaces: workspaceStore,
- Invites: workspaceInviteStore,
- Auth: authSvc,
- AppBaseURL: appBaseURL,
- Log: cfg.Log,
+ Settings: instanceSettingStore,
+ Workspaces: workspaceStore,
+ Invites: workspaceInviteStore,
+ Auth: authSvc,
+ AppBaseURL: appBaseURL,
+ APIPublicURL: cfg.APIPublicURL,
+ Log: cfg.Log,
}
authGroup.GET("/:provider/", oauthHandler.Initiate)
authGroup.GET("/:provider/callback/", oauthHandler.Callback)
diff --git a/ui/.env.example b/ui/.env.example
new file mode 100644
index 0000000..dc70aff
--- /dev/null
+++ b/ui/.env.example
@@ -0,0 +1,2 @@
+# Base URL the browser uses to call the API. Dev: API on 8080. Production: often leave unset (same origin as the UI).
+VITE_API_BASE_URL=http://localhost:8080
From e284e65d5184b532f298a4c7e6905c3bc68e6eb2 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Thu, 16 Apr 2026 03:03:40 +0400
Subject: [PATCH 37/43] feat: Built from API_PUBLIC_URL, fallback to request
host if unset (works behind proxies if API_PUBLIC_URL is set in prod)
---
api/internal/handler/oauth.go | 29 ++++++++++++++++++-----------
api/internal/model/account.go | 25 +++++++++++++------------
api/internal/store/account.go | 2 +-
3 files changed, 32 insertions(+), 24 deletions(-)
diff --git a/api/internal/handler/oauth.go b/api/internal/handler/oauth.go
index 26b3375..d06a6ad 100644
--- a/api/internal/handler/oauth.go
+++ b/api/internal/handler/oauth.go
@@ -16,12 +16,13 @@ import (
)
type OAuthHandler struct {
- Settings *store.InstanceSettingStore
- Workspaces *store.WorkspaceStore
- Invites *store.WorkspaceInviteStore
- Auth *auth.Service
- AppBaseURL string
- Log *slog.Logger
+ Settings *store.InstanceSettingStore
+ Workspaces *store.WorkspaceStore
+ Invites *store.WorkspaceInviteStore
+ Auth *auth.Service
+ AppBaseURL string
+ APIPublicURL string
+ Log *slog.Logger
}
func (h *OAuthHandler) log() *slog.Logger {
@@ -31,9 +32,8 @@ func (h *OAuthHandler) log() *slog.Logger {
return slog.Default()
}
-// requestCallbackBase derives the OAuth callback base URL from the incoming
-// request, matching Plane's approach: scheme://host. This ensures the redirect
-// URI always points to the API server that handles the callback.
+// requestCallbackBase derives the OAuth callback base URL from the incoming request.
+// This is used as a fallback when APIPublicURL is not configured.
func requestCallbackBase(c *gin.Context) string {
scheme := "http"
if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
@@ -42,9 +42,16 @@ func requestCallbackBase(c *gin.Context) string {
return scheme + "://" + c.Request.Host
}
+func oauthCallbackBase(c *gin.Context, configuredBase string) string {
+ if b := strings.TrimSuffix(strings.TrimSpace(configuredBase), "/"); b != "" {
+ return b
+ }
+ return requestCallbackBase(c)
+}
+
func (h *OAuthHandler) resolveProvider(c *gin.Context, name string) (oauth.Provider, bool) {
ctx := c.Request.Context()
- base := requestCallbackBase(c)
+ base := oauthCallbackBase(c, h.APIPublicURL)
switch name {
case "google":
return BuildOAuthGoogleProvider(ctx, h.Settings, base)
@@ -215,7 +222,7 @@ func (h *OAuthHandler) Callback(c *gin.Context) {
// may not be sent back on the first XHR. Pass the session key in the URL
// fragment so the frontend can use it as a Bearer token. Fragments are never
// sent to servers, so this is safe for browser history / logs.
- callbackOrigin := requestCallbackBase(c)
+ callbackOrigin := oauthCallbackBase(c, h.APIPublicURL)
spaOrigin := strings.TrimSuffix(strings.TrimSpace(h.AppBaseURL), "/")
if spaOrigin != "" && !strings.EqualFold(spaOrigin, callbackOrigin) {
redirectURL += "#session_token=" + url.QueryEscape(sessionKey)
diff --git a/api/internal/model/account.go b/api/internal/model/account.go
index 6cd1a73..58c2209 100644
--- a/api/internal/model/account.go
+++ b/api/internal/model/account.go
@@ -8,18 +8,19 @@ import (
)
type Account struct {
- ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
- UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
- Provider string `gorm:"type:varchar(50);not null" json:"provider"`
- ProviderAccountID string `gorm:"column:provider_account_id;type:varchar(255);not null" json:"provider_account_id"`
- AccessToken string `gorm:"type:text" json:"-"`
- RefreshToken string `gorm:"type:text" json:"-"`
- IDToken string `gorm:"column:id_token;type:text" json:"-"`
- TokenExpiresAt *time.Time `gorm:"column:token_expires_at" json:"-"`
- LastConnectedAt *time.Time `gorm:"column:last_connected_at" json:"last_connected_at"`
- Metadata JSONMap `gorm:"type:jsonb;default:'{}'" json:"metadata,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
+ Provider string `gorm:"type:varchar(50);not null" json:"provider"`
+ ProviderAccountID string `gorm:"column:provider_account_id;type:varchar(255);not null" json:"provider_account_id"`
+ AccessToken string `gorm:"type:text;not null" json:"-"`
+ AccessTokenExpiredAt *time.Time `gorm:"column:access_token_expired_at" json:"-"`
+ RefreshToken string `gorm:"type:text" json:"-"`
+ RefreshTokenExpiredAt *time.Time `gorm:"column:refresh_token_expired_at" json:"-"`
+ IDToken string `gorm:"column:id_token;type:text" json:"-"`
+ LastConnectedAt *time.Time `gorm:"column:last_connected_at" json:"last_connected_at"`
+ Metadata JSONMap `gorm:"type:jsonb;default:'{}'" json:"metadata,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
func (Account) TableName() string { return "accounts" }
diff --git a/api/internal/store/account.go b/api/internal/store/account.go
index 3e7335b..366abd4 100644
--- a/api/internal/store/account.go
+++ b/api/internal/store/account.go
@@ -19,7 +19,7 @@ func (s *AccountStore) Upsert(ctx context.Context, a *model.Account) error {
return s.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "provider"}, {Name: "provider_account_id"}},
- DoUpdates: clause.AssignmentColumns([]string{"access_token", "refresh_token", "id_token", "token_expires_at", "last_connected_at", "updated_at"}),
+ DoUpdates: clause.AssignmentColumns([]string{"access_token", "access_token_expired_at", "refresh_token", "refresh_token_expired_at", "id_token", "last_connected_at", "updated_at"}),
}).
Create(a).Error
}
From 9e6e8c53362d33984f3004ae70e257456630e83d Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Thu, 16 Apr 2026 03:05:03 +0400
Subject: [PATCH 38/43] fix: 000002 = password_reset_tokens, accounts uses
access_token_expired_at/refresh_token_expired_at
---
api/migrations/000002_auth_schema.down.sql | 22 ----------
api/migrations/000002_auth_schema.up.sql | 41 -------------------
.../000003_user_password_autoset.down.sql | 1 -
.../000003_user_password_autoset.up.sql | 1 -
4 files changed, 65 deletions(-)
delete mode 100644 api/migrations/000003_user_password_autoset.down.sql
delete mode 100644 api/migrations/000003_user_password_autoset.up.sql
diff --git a/api/migrations/000002_auth_schema.down.sql b/api/migrations/000002_auth_schema.down.sql
index af86784..68371a2 100644
--- a/api/migrations/000002_auth_schema.down.sql
+++ b/api/migrations/000002_auth_schema.down.sql
@@ -1,23 +1 @@
--- Reverse order: accounts legacy shape first, then drop password_reset_tokens.
-
-DROP INDEX IF EXISTS idx_accounts_provider;
-
-ALTER TABLE accounts ALTER COLUMN id DROP DEFAULT;
-
-ALTER TABLE accounts ADD COLUMN IF NOT EXISTS access_token_expired_at TIMESTAMPTZ;
-ALTER TABLE accounts ADD COLUMN IF NOT EXISTS refresh_token_expired_at TIMESTAMPTZ;
-
-UPDATE accounts
-SET access_token_expired_at = token_expires_at
-WHERE access_token_expired_at IS NULL AND token_expires_at IS NOT NULL;
-
-ALTER TABLE accounts DROP COLUMN IF EXISTS token_expires_at;
-
-UPDATE accounts SET access_token = COALESCE(access_token, '') WHERE access_token IS NULL;
-ALTER TABLE accounts ALTER COLUMN access_token SET NOT NULL;
-
-UPDATE accounts SET last_connected_at = COALESCE(last_connected_at, NOW()) WHERE last_connected_at IS NULL;
-ALTER TABLE accounts ALTER COLUMN last_connected_at SET DEFAULT NOW();
-ALTER TABLE accounts ALTER COLUMN last_connected_at SET NOT NULL;
-
DROP TABLE IF EXISTS password_reset_tokens;
diff --git a/api/migrations/000002_auth_schema.up.sql b/api/migrations/000002_auth_schema.up.sql
index 00bc24f..6465457 100644
--- a/api/migrations/000002_auth_schema.up.sql
+++ b/api/migrations/000002_auth_schema.up.sql
@@ -1,7 +1,3 @@
--- Password reset tokens + OAuth accounts columns (single migration after init).
--- If schema_migrations is already 2 from an older password-only 000002, this file will not run again;
--- apply the accounts section manually or coordinate a follow-up migration.
-
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -13,40 +9,3 @@ CREATE TABLE IF NOT EXISTS password_reset_tokens (
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token) WHERE used_at IS NULL;
-
--- Align public.accounts with model.Account (legacy init columns -> token_expires_at, nullable tokens / last_connected_at).
-
-ALTER TABLE accounts ADD COLUMN IF NOT EXISTS token_expires_at TIMESTAMPTZ;
-
-DO $$
-BEGIN
- IF EXISTS (
- SELECT 1 FROM information_schema.columns
- WHERE table_schema = 'public' AND table_name = 'accounts' AND column_name = 'access_token_expired_at'
- ) THEN
- UPDATE accounts
- SET token_expires_at = access_token_expired_at
- WHERE token_expires_at IS NULL AND access_token_expired_at IS NOT NULL;
- END IF;
-
- IF EXISTS (
- SELECT 1 FROM information_schema.columns
- WHERE table_schema = 'public' AND table_name = 'accounts' AND column_name = 'refresh_token_expired_at'
- ) THEN
- UPDATE accounts
- SET token_expires_at = refresh_token_expired_at
- WHERE token_expires_at IS NULL AND refresh_token_expired_at IS NOT NULL;
- END IF;
-END $$;
-
-ALTER TABLE accounts DROP COLUMN IF EXISTS access_token_expired_at;
-ALTER TABLE accounts DROP COLUMN IF EXISTS refresh_token_expired_at;
-
-ALTER TABLE accounts ALTER COLUMN access_token SET DEFAULT '';
-ALTER TABLE accounts ALTER COLUMN access_token DROP NOT NULL;
-UPDATE accounts SET access_token = '' WHERE access_token IS NULL;
-
-ALTER TABLE accounts ALTER COLUMN last_connected_at DROP DEFAULT;
-ALTER TABLE accounts ALTER COLUMN last_connected_at DROP NOT NULL;
-
-CREATE INDEX IF NOT EXISTS idx_accounts_provider ON accounts (provider, user_id);
diff --git a/api/migrations/000003_user_password_autoset.down.sql b/api/migrations/000003_user_password_autoset.down.sql
deleted file mode 100644
index 234c8f3..0000000
--- a/api/migrations/000003_user_password_autoset.down.sql
+++ /dev/null
@@ -1 +0,0 @@
-ALTER TABLE users DROP COLUMN IF EXISTS is_password_autoset;
diff --git a/api/migrations/000003_user_password_autoset.up.sql b/api/migrations/000003_user_password_autoset.up.sql
deleted file mode 100644
index 0213a18..0000000
--- a/api/migrations/000003_user_password_autoset.up.sql
+++ /dev/null
@@ -1 +0,0 @@
-ALTER TABLE users ADD COLUMN IF NOT EXISTS is_password_autoset BOOLEAN NOT NULL DEFAULT false;
From 43932fefc802cbfd2c863c69a8293a956fb1fa64 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Thu, 16 Apr 2026 03:09:05 +0400
Subject: [PATCH 39/43] fix: empty string keeps requests relative in prod
---
ui/src/api/client.ts | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts
index a1214c1..08b81a7 100644
--- a/ui/src/api/client.ts
+++ b/ui/src/api/client.ts
@@ -1,11 +1,12 @@
import axios, { type AxiosError } from 'axios';
/**
- * In dev the UI runs on :5173 (Vite) while the Go API runs on :8080.
- * In production the UI is served by the same origin as the API,
- * so an empty string keeps requests relative.
+ * Prefer env-driven API base (VITE_API_BASE_URL).
+ * In local dev, fallback remains http://localhost:8080.
+ * In production, empty string keeps requests relative (same-origin).
*/
-export const API_BASE = import.meta.env.DEV ? 'http://localhost:8080' : '';
+export const API_BASE =
+ import.meta.env.VITE_API_BASE_URL ?? (import.meta.env.DEV ? 'http://localhost:8080' : '');
export const apiClient = axios.create({
baseURL: API_BASE,
From 9ab12ad596d4967c375b551a83453eaf615ecb51 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Thu, 16 Apr 2026 03:14:34 +0400
Subject: [PATCH 40/43] fix: magic code vs password login, parallel tests,
clipboard
---
api/internal/auth/service_test.go | 9 +++-
.../InstanceAdminAuthControls.tsx | 43 ++++++++++++++++---
ui/src/pages/LoginPage.tsx | 7 +--
3 files changed, 49 insertions(+), 10 deletions(-)
diff --git a/api/internal/auth/service_test.go b/api/internal/auth/service_test.go
index 576a01d..3583cdd 100644
--- a/api/internal/auth/service_test.go
+++ b/api/internal/auth/service_test.go
@@ -2,6 +2,8 @@ package auth
import (
"context"
+ "crypto/rand"
+ "encoding/hex"
"errors"
"testing"
@@ -13,7 +15,12 @@ import (
func newTestService(t *testing.T) (*Service, *gorm.DB) {
t.Helper()
- db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
+ var id [8]byte
+ if _, err := rand.Read(id[:]); err != nil {
+ t.Fatalf("rand: %v", err)
+ }
+ dsn := "file:mem_" + hex.EncodeToString(id[:]) + "?mode=memory&cache=shared"
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
diff --git a/ui/src/components/instance-admin/InstanceAdminAuthControls.tsx b/ui/src/components/instance-admin/InstanceAdminAuthControls.tsx
index 8cc1e58..dea57ef 100644
--- a/ui/src/components/instance-admin/InstanceAdminAuthControls.tsx
+++ b/ui/src/components/instance-admin/InstanceAdminAuthControls.tsx
@@ -1,6 +1,31 @@
import { useState } from 'react';
import { Copy } from 'lucide-react';
+async function copyTextToClipboard(text: string): Promise {
+ if (navigator.clipboard?.writeText) {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+ try {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.setAttribute('readonly', '');
+ ta.style.position = 'fixed';
+ ta.style.left = '-9999px';
+ document.body.appendChild(ta);
+ ta.select();
+ const ok = document.execCommand('copy');
+ document.body.removeChild(ta);
+ return ok;
+ } catch {
+ return false;
+ }
+}
+
export function InstanceAdminCopyRow({
label,
hint,
@@ -10,13 +35,19 @@ export function InstanceAdminCopyRow({
hint: string;
value: string;
}) {
- const [copied, setCopied] = useState(false);
+ const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle');
const onCopy = () => {
if (!value) return;
- void navigator.clipboard.writeText(value).then(() => {
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- });
+ void (async () => {
+ const ok = await copyTextToClipboard(value);
+ if (ok) {
+ setCopyState('copied');
+ setTimeout(() => setCopyState('idle'), 2000);
+ } else {
+ setCopyState('failed');
+ setTimeout(() => setCopyState('idle'), 2000);
+ }
+ })();
};
return (
@@ -29,7 +60,7 @@ export function InstanceAdminCopyRow({
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-(--txt-accent) hover:bg-(--bg-subtle) disabled:opacity-40"
>
- {copied ? 'Copied' : 'Copy'}
+ {copyState === 'copied' ? 'Copied' : copyState === 'failed' ? 'Copy failed' : 'Copy'}
{
From 0eebb99f6c21bfb178faf52a4fcbc0effa173335 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Thu, 16 Apr 2026 03:24:17 +0400
Subject: [PATCH 41/43] fix: Lax, not Strict
- the browser would not attach oauth_state on the top-level GET from the IdP back to your callback
- Lax still sends the cookie on that kind of redirect, while avoiding sending it on cross-site subrequests
---
api/internal/handler/oauth.go | 20 ++++++++++++++++++--
1 file changed, 18 insertions(+), 2 deletions(-)
diff --git a/api/internal/handler/oauth.go b/api/internal/handler/oauth.go
index d06a6ad..b908927 100644
--- a/api/internal/handler/oauth.go
+++ b/api/internal/handler/oauth.go
@@ -85,7 +85,15 @@ func (h *OAuthHandler) Initiate(c *gin.Context) {
sessionVal = state + "|" + nextPath
}
- c.SetCookie("oauth_state", sessionVal, 600, "/", "", isSecureRequest(c), true)
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: "oauth_state",
+ Value: sessionVal,
+ Path: "/",
+ MaxAge: 600,
+ HttpOnly: true,
+ Secure: isSecureRequest(c),
+ SameSite: http.SameSiteLaxMode,
+ })
c.Redirect(http.StatusTemporaryRedirect, provider.AuthURL(state))
}
@@ -128,7 +136,15 @@ func (h *OAuthHandler) Callback(c *gin.Context) {
return
}
- c.SetCookie("oauth_state", "", -1, "/", "", isSecureRequest(c), true)
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: "oauth_state",
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ Secure: isSecureRequest(c),
+ SameSite: http.SameSiteLaxMode,
+ })
ctx := c.Request.Context()
tokenData, err := provider.Exchange(ctx, code)
From 6599acc3a7dbe3f79d68914d71b3bedbb25b4457 Mon Sep 17 00:00:00 2001
From: nazarli-shabnam
Date: Thu, 16 Apr 2026 03:39:23 +0400
Subject: [PATCH 42/43] feat: requires an SMTP host in the instance email
settings and returns error when isnt configured
---
api/internal/auth/service.go | 13 ++---
api/internal/handler/auth.go | 49 +++++++++++++++++--
.../InstanceAdminAuthenticationPage.tsx | 47 +++++++++++-------
3 files changed, 83 insertions(+), 26 deletions(-)
diff --git a/api/internal/auth/service.go b/api/internal/auth/service.go
index dc598d2..b0eb37e 100644
--- a/api/internal/auth/service.go
+++ b/api/internal/auth/service.go
@@ -16,11 +16,12 @@ import (
)
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")
- ErrUserDeactivated = errors.New("user account deactivated")
+ 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")
+ ErrUserDeactivated = errors.New("user account deactivated")
+ ErrPasswordResetNotConfigured = errors.New("password reset not configured")
)
const bcryptCost = 12
@@ -288,7 +289,7 @@ func (s *Service) EmailCheck(ctx context.Context, email string) (exists bool, er
// 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")
+ return "", ErrPasswordResetNotConfigured
}
email = strings.TrimSpace(strings.ToLower(email))
u, err := s.userStore.GetByEmail(ctx, email)
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index 36db198..8a28012 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -78,6 +78,33 @@ func (h *AuthHandler) log() *slog.Logger {
return slog.Default()
}
+// smtpConfigured reports whether instance email settings include an SMTP host (outbound email).
+func (h *AuthHandler) smtpConfigured(ctx context.Context) bool {
+ if h.Settings == nil {
+ return false
+ }
+ emailRow, _ := h.Settings.Get(ctx, "email")
+ if emailRow != nil && emailRow.Value != nil {
+ host, _ := emailRow.Value["host"].(string)
+ return strings.TrimSpace(host) != ""
+ }
+ return false
+}
+
+// forgotPasswordInfraError returns a client-safe 503 message when reset email cannot be sent.
+func (h *AuthHandler) forgotPasswordInfraError(ctx context.Context) string {
+ if !h.smtpConfigured(ctx) {
+ return "Outbound email is not configured. Set SMTP (host) in Instance admin → Email."
+ }
+ if h.Queue == nil {
+ return "Email queue unavailable. Start RabbitMQ and check RABBITMQ_URL (API logs show connection errors)."
+ }
+ if strings.TrimSpace(h.AppBaseURL) == "" {
+ return "Password reset is unavailable: application base URL is not configured. Ask an administrator to set APP_BASE_URL (or equivalent) for the API."
+ }
+ return ""
+}
+
// SignIn authenticates with email/password and sets a session cookie.
// POST /auth/sign-in/
func (h *AuthHandler) SignIn(c *gin.Context) {
@@ -637,24 +664,40 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) {
return
}
body.Email = strings.ToLower(addr.Address)
+
+ if msg := h.forgotPasswordInfraError(ctx); msg != "" {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": msg})
+ return
+ }
+
token, err := h.Auth.ForgotPassword(ctx, body.Email)
if err != nil {
h.log().Error("forgot password error", "error", err)
+ if errors.Is(err, auth.ErrPasswordResetNotConfigured) {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Password reset is not available on this instance."})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong. Please try again later."})
+ return
}
- if token != "" && h.Queue != nil && h.AppBaseURL != "" {
+ if token != "" {
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{
+ if pubErr := h.Queue.PublishSendEmail(ctx, queue.SendEmailPayload{
To: body.Email,
Subject: subject,
Body: bodyText,
Kind: "forgot_password",
Extra: map[string]string{"reset_link": resetLink},
- })
+ }); pubErr != nil {
+ h.log().Error("forgot password publish email", "error", pubErr)
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Password reset email could not be sent right now. Please try again later."})
+ return
+ }
}
c.JSON(http.StatusOK, gin.H{"message": "If an account exists for that email, a reset link has been sent."})
}
diff --git a/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx b/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx
index b962960..821d479 100644
--- a/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx
+++ b/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx
@@ -304,30 +304,43 @@ export function InstanceAdminAuthenticationPage() {
- {item.isOAuth && item.editPath && configured && (
+ {item.isOAuth && item.editPath && (
<>
- Edit
+ {configured ? (
+ 'Edit'
+ ) : (
+ <>
+
+ Configure
+ >
+ )}
- handleToggle(item.key, v)}
- disabled={saving}
- />
+
+ handleToggle(item.key, v)}
+ disabled={saving || (!configured && !on)}
+ />
+
>
)}
- {item.isOAuth && item.editPath && !configured && (
-
-
- Configure
-
- )}
{!item.isOAuth && (
Date: Thu, 16 Apr 2026 04:01:02 +0400
Subject: [PATCH 43/43] fix: server signout uses same session source as auth
middleware; redundant index removed
---
api/internal/handler/auth.go | 2 +-
api/internal/middleware/auth.go | 21 ++++++++++++++-------
api/migrations/000002_auth_schema.up.sql | 1 -
ui/src/api/client.ts | 5 +++++
ui/src/contexts/AuthContext.tsx | 3 ++-
5 files changed, 22 insertions(+), 10 deletions(-)
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index 8a28012..d372985 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -207,7 +207,7 @@ func (h *AuthHandler) SignUp(c *gin.Context) {
// SignOut invalidates the session and clears the session cookie.
// POST /auth/sign-out/
func (h *AuthHandler) SignOut(c *gin.Context) {
- sessionKey, _ := c.Cookie(middleware.SessionCookieName)
+ sessionKey := middleware.SessionKeyFromCookieOrBearer(c)
if sessionKey != "" {
_ = h.Auth.SignOut(c.Request.Context(), sessionKey)
}
diff --git a/api/internal/middleware/auth.go b/api/internal/middleware/auth.go
index 8a644f9..cac97cb 100644
--- a/api/internal/middleware/auth.go
+++ b/api/internal/middleware/auth.go
@@ -15,19 +15,26 @@ const (
UserContextKey = "user"
)
+// SessionKeyFromCookieOrBearer returns the session id from the session cookie or Authorization: Bearer.
+// Must stay in sync with how authenticated clients send the session (including OAuth SPA fragment flow).
+func SessionKeyFromCookieOrBearer(c *gin.Context) string {
+ sessionKey, _ := c.Cookie(SessionCookieName)
+ if sessionKey == "" {
+ if authHeader := c.GetHeader("Authorization"); len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "bearer ") {
+ sessionKey = strings.TrimSpace(authHeader[7:])
+ }
+ }
+ return sessionKey
+}
+
// RequireAuth loads the user from session and returns 401 if not authenticated.
func RequireAuth(authSvc *auth.Service, log *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
- sessionKey, _ := c.Cookie(SessionCookieName)
- if sessionKey == "" {
- if authHeader := c.GetHeader("Authorization"); len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "bearer ") {
- sessionKey = authHeader[7:]
- }
- }
+ sessionKey := SessionKeyFromCookieOrBearer(c)
user, err := authSvc.UserFromSession(c.Request.Context(), sessionKey)
if err != nil || user == nil {
if log != nil {
- log.Debug("auth required", "error", err, "has_cookie", sessionKey != "")
+ log.Debug("auth required", "error", err, "has_session_key", sessionKey != "")
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
diff --git a/api/migrations/000002_auth_schema.up.sql b/api/migrations/000002_auth_schema.up.sql
index 6465457..46eced5 100644
--- a/api/migrations/000002_auth_schema.up.sql
+++ b/api/migrations/000002_auth_schema.up.sql
@@ -8,4 +8,3 @@ CREATE TABLE IF NOT EXISTS password_reset_tokens (
);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);
-CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token) WHERE used_at IS NULL;
diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts
index 08b81a7..47d6c2f 100644
--- a/ui/src/api/client.ts
+++ b/ui/src/api/client.ts
@@ -16,6 +16,11 @@ export const apiClient = axios.create({
},
});
+/** Clears Bearer token set from OAuth URL fragment (dev / cross-origin); cookie sessions unaffected. */
+export function clearApiBearerAuthHeader(): void {
+ delete apiClient.defaults.headers.common['Authorization'];
+}
+
// When sending FormData (e.g. file upload), omit Content-Type so the browser sets
// multipart/form-data with the correct boundary. Otherwise the server gets
// Content-Type: application/json and cannot parse the multipart form → 400.
diff --git a/ui/src/contexts/AuthContext.tsx b/ui/src/contexts/AuthContext.tsx
index a7f753c..bbf9b82 100644
--- a/ui/src/contexts/AuthContext.tsx
+++ b/ui/src/contexts/AuthContext.tsx
@@ -10,7 +10,7 @@ import {
} from 'react';
import type { User } from '../types';
import type { UserApiResponse } from '../api/types';
-import { apiClient } from '../api/client';
+import { apiClient, clearApiBearerAuthHeader } from '../api/client';
import { authService } from '../services/authService';
function mapApiUserToUser(api: UserApiResponse): User {
@@ -79,6 +79,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
await authService.signOut();
} finally {
+ clearApiBearerAuthHeader();
setUser(null);
}
}, []);