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). + +
+ )} + +
+ setEmail(e.target.value)} + placeholder="name@company.com" + required + autoComplete="email" + disabled={countdown > 0} + /> + + {submitted ? ( + + ) : ( + + )} +
+ +

+ + 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.'}

-
- setEmail(e.target.value)} - placeholder="you@example.com" - required - autoComplete="email" - /> - setPassword(e.target.value)} - placeholder="••••••••" - required - error={error || undefined} - autoComplete="current-password" - /> - -
- - +
+ + {error && ( +
+ {error} +
+ )} + +
+ setEmail(e.target.value)} + placeholder="name@company.com" + required + autoComplete="email" + /> + + {isSignUp && ( +
+ setFirstName(e.target.value)} + placeholder="Shabnam" + autoComplete="given-name" + /> + setLastName(e.target.value)} + placeholder="Aliyeva" + autoComplete="family-name" + /> +
+ )} + +
+ +
+ setPassword(e.target.value)} + placeholder={isSignUp ? 'Create a password' : 'Enter password'} + required + minLength={isSignUp ? 8 : undefined} + autoComplete={isSignUp ? 'new-password' : 'current-password'} + className="w-full rounded-(--radius-md) border border-(--border-subtle) bg-(--bg-surface-1) py-2 pl-3 pr-10 text-sm text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:border-(--border-strong) focus:outline-none" + /> + +
+ {!isSignUp && ( +
+ + Forgot your password? + +
+ )} + {isSignUp && } +
+ + {isSignUp && ( +
+ +
+ setConfirmPassword(e.target.value)} + placeholder="Confirm password" + required + autoComplete="new-password" + className="w-full rounded-(--radius-md) border border-(--border-subtle) bg-(--bg-surface-1) py-2 pl-3 pr-10 text-sm text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:border-(--border-strong) focus:outline-none" + /> + +
+ {confirmPassword.length > 0 && ( +

+ {passwordsMatch ? 'Passwords match' : "Passwords don't match"} +

+ )} +
+ )} + + +
+ +

+ {isSignUp ? 'Already have an account?' : "Don't have an account?"}{' '} + +

+
+ +
+ 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} +
+ )} + +
+
+ +
+ setPassword(e.target.value)} + placeholder="Create a password" + required + minLength={8} + autoFocus + autoComplete="new-password" + className="w-full rounded-(--radius-md) border border-(--border-subtle) bg-(--bg-surface-1) py-2 pl-3 pr-10 text-sm text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:border-(--border-strong) focus:outline-none" + /> + +
+ +
+ +
+ +
+ setConfirmPassword(e.target.value)} + placeholder="Confirm password" + required + autoComplete="new-password" + className="w-full rounded-(--radius-md) border border-(--border-subtle) bg-(--bg-surface-1) py-2 pl-3 pr-10 text-sm text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:border-(--border-strong) focus:outline-none" + /> + +
+ {confirmPassword.length > 0 && ( +

+ {passwordsMatch ? 'Passwords match' : "Passwords don't match"} +

+ )} +
+ + +
+ +

+ + 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() { + ) : ( + + )} + + + +
+ ); +} 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. -

-
- setEmail(e.target.value)} - placeholder="you@example.com" - required - autoComplete="email" - /> - setPassword(e.target.value)} - placeholder="••••••••" - required - error={error || undefined} - autoComplete="current-password" - /> - -
+

{title}

+

{subtitle}

+ + {error && ( +
+ + {error} +
+ )} + + {step === 'email' && ( +
+ setEmail(e.target.value)} + placeholder="you@example.com" + required + autoComplete="email" + autoFocus + /> + +
+ )} + + {step === 'password' && ( +
+
+ +
+ + {mode === 'sign-up' && ( +
+ setFirstName(e.target.value)} + autoComplete="given-name" + autoFocus + /> + setLastName(e.target.value)} + autoComplete="family-name" + /> +
+ )} + +
+ setPassword(e.target.value)} + placeholder="Enter password" + required + autoComplete={mode === 'sign-in' ? 'current-password' : 'new-password'} + autoFocus={mode === 'sign-in'} + /> + +
+ + {mode === 'sign-up' && } + + {mode === 'sign-up' && ( +
+ setConfirmPassword(e.target.value)} + placeholder="Re-enter password" + required + autoComplete="new-password" + /> + + {confirmPassword && password !== confirmPassword && ( +

Passwords do not match

+ )} + {confirmPassword && password === confirmPassword && ( +

+ Passwords match +

+ )} +
+ )} + + {mode === 'sign-in' && isSmtpConfigured && ( +
+ + Forgot your password? + +
+ )} + + + + {allowSignup && ( +

+ {mode === 'sign-in' ? "Don't have an account?" : 'Already have an account?'}{' '} + +

+ )} + + )}
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} +
+ )} + +
+
+ setPassword(e.target.value)} + placeholder="Enter new password" + required + autoComplete="new-password" + autoFocus + /> + +
+ + + +
+ setConfirmPassword(e.target.value)} + placeholder="Re-enter new password" + required + autoComplete="new-password" + /> + + {confirmPassword && !passwordsMatch && ( +

Passwords do not match

+ )} + {passwordsMatch && ( +

+ Passwords match +

+ )} +
+ + + +

+ Remember your password?{' '} + + Sign in + +

+ +
+
+
+ ); +} 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' && ( -
- setEmail(e.target.value)} - placeholder="you@example.com" - required - autoComplete="email" - autoFocus - /> - -
+ <> + {hasOAuth && ( +
+ {oauthProviders.google && ( + + )} + {oauthProviders.github && ( + + )} + {oauthProviders.gitlab && ( + + )} +
+
+
+
+
+ or +
+
+
+ )} +
+ setEmail(e.target.value)} + placeholder="you@example.com" + required + autoComplete="email" + autoFocus + /> + +
+ )} {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 && ( + + )} + - {allowSignup && ( + {(allowSignup || !!inviteToken) && (

{mode === 'sign-in' ? "Don't have an account?" : 'Already have an account?'}{' '} +

+ + {mode === 'sign-up' && ( +
+ setFirstName(e.target.value)} + autoComplete="given-name" + autoFocus + /> + setLastName(e.target.value)} + autoComplete="family-name" + /> +
+ )} + + setMagicCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="000000" + required + maxLength={6} + autoFocus={mode === 'sign-in'} + /> + + + + + + {isPasswordEnabled && ( + + )} + + )}
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 ( +
+
+ + +
+ +

{hint}

+
+ ); +} + +function ToggleSwitch({ + checked, + onChange, + disabled, +}: { + checked: boolean; + onChange: (v: boolean) => void; + disabled?: boolean; +}) { + return ( + + ); +} + +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}

} + +
+
+

+ GitHub-provided details for Devlane +

+
+ setClientId(e.target.value)} + autoComplete="off" + placeholder="Your client ID from your GitHub OAuth App." + /> +

+ Your client ID lives in your GitHub OAuth App settings.{' '} + + Learn more + +

+
+ setClientSecret(e.target.value)} + autoComplete="new-password" + placeholder={secretSet ? '(unchanged if left blank)' : 'Enter client secret'} + /> + +
+

+ Your client secret should also be in your GitHub OAuth App settings.{' '} + + Learn more + +

+
+
+ +
+

+ Devlane-provided details for GitHub +

+
+ + +
+
+ +
+ + +
+
+
+ ); +} 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 ( +
+
+ + +
+ +

{hint}

+
+ ); +} + +function ToggleSwitch({ + checked, + onChange, + disabled, +}: { + checked: boolean; + onChange: (v: boolean) => void; + disabled?: boolean; +}) { + return ( + + ); +} + +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}

} + +
+
+

+ GitLab-provided details for Devlane +

+
+ setGitlabHost(e.target.value)} + autoComplete="off" + placeholder="https://gitlab.com" + /> +

+ Leave blank for gitlab.com. Set your self-hosted GitLab URL for on-premises + installations. +

+ setClientId(e.target.value)} + autoComplete="off" + placeholder="Your application ID from GitLab." + /> +

+ Your application ID lives in your GitLab application settings.{' '} + + Learn more + +

+
+ setClientSecret(e.target.value)} + autoComplete="new-password" + placeholder={secretSet ? '(unchanged if left blank)' : 'Enter secret'} + /> + +
+

+ Your secret should also be in your GitLab application settings.{' '} + + Learn more + +

+
+
+ +
+

+ Devlane-provided details for GitLab +

+
+ +
+
+ +
+ + +
+
+
+ ); +} 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 ( +
+
+ + +
+ +

{hint}

+
+ ); +} + +function ToggleSwitch({ + checked, + onChange, + disabled, +}: { + checked: boolean; + onChange: (v: boolean) => void; + disabled?: boolean; +}) { + return ( + + ); +} + +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}

} + +
+
+

+ Google-provided details for Devlane +

+
+ setClientId(e.target.value)} + autoComplete="off" + placeholder="Your client ID lives in your Google API Console." + /> +

+ Your client ID lives in your Google API Console.{' '} + + Learn more + +

+
+ setClientSecret(e.target.value)} + autoComplete="new-password" + placeholder={secretSet ? '(unchanged if left blank)' : 'Enter client secret'} + /> + +
+

+ Your client secret should also be in your Google API Console.{' '} + + Learn more + +

+
+
+ +
+

+ Devlane-provided details for Google +

+
+ + +
+
+ +
+ + +
+
+
+ ); +} 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 ( + + ); +} + +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', 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.isOAuth && item.editPath && configured && ( + <> + + Edit + + 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. +

    +
    + +
    + + +
    + +
    + + {baseUrl} + + setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} + placeholder="workspace-name" + className="min-w-0 flex-1 border-0 bg-transparent px-3 py-2 text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:outline-none" + /> +
    +
    + +
    + +
    + + + + +
    +
    + + {error &&

    {error}

    } + + +
    +
    +
    + ); +} 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} +
    + )} + +
    + + +
    + setPassword(e.target.value)} + placeholder="Enter password" + required + autoComplete="new-password" + autoFocus + /> + +
    + + + +
    + setConfirmPassword(e.target.value)} + placeholder="Re-enter password" + required + autoComplete="new-password" + /> + + {confirmPassword && !passwordsMatch && ( +

    Passwords do not match

    + )} + {passwordsMatch && ( +

    + Passwords match +

    + )} +
    + + + +
    +
    + ); +} 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 && ( + + )} + {oauthProviders.github && ( + + )} + {oauthProviders.gitlab && ( + + )} +
    +
    +
    +
    +
    + or +
    +
    +
    + )} +
    + setEmail(e.target.value)} + placeholder="you@example.com" + required + autoComplete="email" + autoFocus + /> + +
    + + )} + + {step === 'password' && ( +
    +
    + +
    + +
    + setFirstName(e.target.value)} + autoComplete="given-name" + autoFocus + /> + setLastName(e.target.value)} + autoComplete="family-name" + /> +
    + +
    + setPassword(e.target.value)} + placeholder="Enter password" + required + autoComplete="new-password" + /> + +
    + + + +
    + setConfirmPassword(e.target.value)} + placeholder="Re-enter password" + required + autoComplete="new-password" + /> + + {confirmPassword && password !== confirmPassword && ( +

    Passwords do not match

    + )} + {confirmPassword && password === confirmPassword && ( +

    + Passwords match +

    + )} +
    + + {canUseMagicCode && isPasswordEnabled && ( + + )} + + + + )} + + {step === 'code' && ( +
    +
    + +
    + +
    + setFirstName(e.target.value)} + autoComplete="given-name" + autoFocus + /> + setLastName(e.target.value)} + autoComplete="family-name" + /> +
    + + setMagicCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="000000" + required + maxLength={6} + /> + + + + + + {isPasswordEnabled && ( + + )} +
    + )} +
    + + ); +} 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. + +
    + )} -
    - setEmail(e.target.value)} - placeholder="you@example.com" - required - autoComplete="email" - disabled={success && cooldown > 0} - autoFocus - /> + + setEmail(e.target.value)} + placeholder="you@example.com" + required + autoComplete="email" + disabled={success && cooldown > 0} + autoFocus + /> - {!success ? ( - - ) : ( - - )} -
    + {!success ? ( + + ) : ( + + )} +
    ); 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 && ( - - )} - {oauthProviders.github && ( - - )} - {oauthProviders.gitlab && ( - - )} -
    -
    -
    -
    -
    - or -
    -
    -
    - )} -
    - setEmail(e.target.value)} - placeholder="you@example.com" - required - autoComplete="email" - autoFocus - /> - -
    - - )} - - {step === 'password' && ( -
    -
    - -
    - - {mode === 'sign-up' && ( -
    - setFirstName(e.target.value)} - autoComplete="given-name" - autoFocus - /> - setLastName(e.target.value)} - autoComplete="family-name" - /> -
    - )} - -
    - setPassword(e.target.value)} - placeholder="Enter password" - required - autoComplete={mode === 'sign-in' ? 'current-password' : 'new-password'} - autoFocus={mode === 'sign-in'} - /> - -
    - - {mode === 'sign-up' && } - - {mode === 'sign-up' && ( -
    - setConfirmPassword(e.target.value)} - placeholder="Re-enter password" - required - autoComplete="new-password" - /> +

    {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 && ( - {confirmPassword && password !== confirmPassword && ( -

    Passwords do not match

    - )} - {confirmPassword && password === confirmPassword && ( -

    - Passwords match -

    - )} -
    - )} - - {mode === 'sign-in' && isPasswordEnabled && ( -
    - {isSmtpConfigured ? ( - - Forgot your password? - - ) : ( - - To reset your password, ask your administrator to configure SMTP. - - )} -
    - )} - - {canUseMagicCode && isPasswordEnabled && ( - - )} - - - - {(allowSignup || !!inviteToken) && ( -

    - {mode === 'sign-in' ? "Don't have an account?" : 'Already have an account?'}{' '} + )} + {oauthProviders.github && ( -

    - )} - - )} - - {step === 'code' && ( -
    -
    - + )} + {oauthProviders.gitlab && ( + + )} +
    +
    +
    +
    +
    + or +
    +
    + )} + + setEmail(e.target.value)} + placeholder="you@example.com" + required + autoComplete="email" + autoFocus + /> + + + + )} - {mode === 'sign-up' && ( -
    - setFirstName(e.target.value)} - autoComplete="given-name" - autoFocus - /> - setLastName(e.target.value)} - autoComplete="family-name" - /> -
    - )} + {step === 'password' && ( +
    +
    + +
    +
    setMagicCode(e.target.value.replace(/\D/g, '').slice(0, 6))} - placeholder="000000" + label="Password" + type={showPassword ? 'text' : 'password'} + value={password} + onChange={(e) => setPassword(e.target.value)} + placeholder="Enter password" required - maxLength={6} - autoFocus={mode === 'sign-in'} + autoComplete="current-password" + autoFocus /> + +
    - + {isPasswordEnabled && ( +
    + {isSmtpConfigured ? ( + + Forgot your password? + + ) : ( + + To reset your password, ask your administrator to configure SMTP. + + )} +
    + )} + {canUseMagicCode && isPasswordEnabled && ( + )} + + + + {(allowSignup || !!inviteToken) && ( +

    + {"Don't have an account? "} + + Sign up + +

    + )} +
    + )} + + {step === 'code' && ( +
    +
    + +
    - {isPasswordEnabled && ( - - )} -
    - )} + setMagicCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="000000" + required + maxLength={6} + autoFocus={mode === 'sign-in'} + /> + + + + + + {isPasswordEnabled && ( + + )} + + )}
    ); diff --git a/ui/src/pages/ResetPasswordPage.tsx b/ui/src/pages/ResetPasswordPage.tsx index 2f1a924..b94d4fd 100644 --- a/ui/src/pages/ResetPasswordPage.tsx +++ b/ui/src/pages/ResetPasswordPage.tsx @@ -111,17 +111,17 @@ export function ResetPasswordPage() { return (
    - -

    Invalid reset link

    -

    - This password reset link is invalid or has expired. Please request a new one. -

    - - Request new reset link - + +

    Invalid reset link

    +

    + This password reset link is invalid or has expired. Please request a new one. +

    + + Request new reset link +
    ); @@ -131,14 +131,14 @@ export function ResetPasswordPage() { return (
    - -

    Password reset!

    -

    - Your password has been reset successfully. You can now sign in with your new password. -

    - - Go to sign in - + +

    Password reset!

    +

    + Your password has been reset successfully. You can now sign in with your new password. +

    + + Go to sign in +
    ); @@ -147,83 +147,83 @@ export function ResetPasswordPage() { return (
    -

    Set a new password

    -

    - Choose a strong password to secure your account. +

    Set a new password

    +

    + Choose a strong password to secure your account. +

    + + {error && ( +
    + + {error} +
    + )} + +
    +
    + setPassword(e.target.value)} + placeholder="Enter new password" + required + autoComplete="new-password" + autoFocus + /> + +
    + + + +
    + setConfirmPassword(e.target.value)} + placeholder="Re-enter new password" + required + autoComplete="new-password" + /> + + {confirmPassword && !passwordsMatch && ( +

    Passwords do not match

    + )} + {passwordsMatch && ( +

    + Passwords match +

    + )} +
    + + + +

    + Remember your password?{' '} + + Sign in +

    - - {error && ( -
    - - {error} -
    - )} - - -
    - setPassword(e.target.value)} - placeholder="Enter new password" - required - autoComplete="new-password" - autoFocus - /> - -
    - - - -
    - setConfirmPassword(e.target.value)} - placeholder="Re-enter new password" - required - autoComplete="new-password" - /> - - {confirmPassword && !passwordsMatch && ( -

    Passwords do not match

    - )} - {passwordsMatch && ( -

    - Passwords match -

    - )} -
    - - - -

    - Remember your password?{' '} - - Sign in - -

    - +
    ); diff --git a/ui/src/pages/SetPasswordPage.tsx b/ui/src/pages/SetPasswordPage.tsx index 6cc84de..22bba0d 100644 --- a/ui/src/pages/SetPasswordPage.tsx +++ b/ui/src/pages/SetPasswordPage.tsx @@ -118,13 +118,7 @@ export function SetPasswordPage() { )}
    - +
    { @@ -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 ( +
    +
    + + +
    + +

    {hint}

    +
    + ); +} + +export function InstanceAdminToggleSwitch({ + checked, + onChange, + disabled, +}: { + checked: boolean; + onChange: (v: boolean) => void; + disabled?: boolean; +}) { + return ( + + ); +} 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 ( -
    -
    - - -
    - -

    {hint}

    -
    - ); -} - -function ToggleSwitch({ - checked, - onChange, - disabled, -}: { - checked: boolean; - onChange: (v: boolean) => void; - disabled?: boolean; -}) { - return ( - - ); -} +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 ( -
    -
    - - -
    - -

    {hint}

    -
    - ); -} - -function ToggleSwitch({ - checked, - onChange, - disabled, -}: { - checked: boolean; - onChange: (v: boolean) => void; - disabled?: boolean; -}) { - return ( - - ); -} +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 ( -
    -
    - - -
    - -

    {hint}

    -
    - ); -} - -function ToggleSwitch({ - checked, - onChange, - disabled, -}: { - checked: boolean; - onChange: (v: boolean) => void; - disabled?: boolean; -}) { - return ( - - ); -} +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 ( - - ); -} - 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); } }, []);