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/api/.env.example b/api/.env.example index 2ec0799..959de80 100644 --- a/api/.env.example +++ b/api/.env.example @@ -20,8 +20,19 @@ 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. -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 + +# 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. + +# HMAC key for email login codes (set in production). +# MAGIC_CODE_SECRET= # MinIO (S3-compatible) MINIO_ENDPOINT=localhost:9000 @@ -34,4 +45,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 4e85a9f..fe077cf 100644 --- a/api/cmd/api/main.go +++ b/api/cmd/api/main.go @@ -44,7 +44,11 @@ func main() { os.Exit(1) } - sqlDB, _ := db.DB() + sqlDB, err := db.DB() + if err != nil { + log.Error("get underlying sql.DB", "error", err) + os.Exit(1) + } defer sqlDB.Close() // Redis @@ -80,12 +84,16 @@ func main() { } r := router.New(router.Config{ - Log: log, - DB: db, - Redis: rdb, - Queue: queuePublisher, - Minio: mc, - CORSAllowOrigin: cfg.CORSAllowOrigin, + 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/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= 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 390a66f..b0eb37e 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" @@ -15,22 +16,64 @@ import ( ) var ( - ErrInvalidCredentials = errors.New("invalid email or password") - ErrEmailTaken = errors.New("email already registered") - ErrUsernameTaken = errors.New("username already taken") + 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 +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 + +func init() { + h, _ := bcrypt.GenerateFromPassword([]byte("timing-safe-dummy"), bcryptCost) + dummyHash = h +} + type Service struct { - userStore *store.UserStore - sessionStore *store.SessionStore + userStore *store.UserStore + sessionStore *store.SessionStore + resetTokenStore *store.PasswordResetTokenStore + accountStore *store.AccountStore } -func NewService(userStore *store.UserStore, sessionStore *store.SessionStore) *Service { - return &Service{userStore: userStore, sessionStore: sessionStore} +func NewService(userStore *store.UserStore, sessionStore *store.SessionStore, resetTokenStore *store.PasswordResetTokenStore) *Service { + return &Service{userStore: userStore, sessionStore: sessionStore, resetTokenStore: resetTokenStore} } +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"` @@ -44,6 +87,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 { @@ -80,17 +126,97 @@ 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)) + 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, + IsPasswordAutoset: 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 { + return "", nil, ErrInvalidCredentials + } + if !u.IsActive { + return "", nil, ErrUserDeactivated + } + 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) { email := strings.TrimSpace(strings.ToLower(req.Email)) u, err := s.userStore.GetByEmail(ctx, email) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { + _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password)) return "", nil, ErrInvalidCredentials } return "", nil, err } if !u.IsActive { - return "", nil, ErrInvalidCredentials + return "", nil, ErrUserDeactivated } if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(req.Password)); err != nil { return "", nil, ErrInvalidCredentials @@ -117,13 +243,14 @@ func (s *Service) UserFromSession(ctx context.Context, sessionKey string) (*mode return s.userStore.GetByID(ctx, data.UserID) } -// UpdateProfile updates the user's profile (first name, last name, display name, timezone). Email is not updatable. func (s *Service) UpdateProfile(ctx context.Context, u *model.User) error { return s.userStore.Update(ctx, u) } -// ChangePassword verifies current password and sets a new one. Returns ErrInvalidCredentials if current password is wrong or user not found. func (s *Service) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error { + if err := ValidatePasswordStrength(newPassword); err != nil { + return err + } u, err := s.userStore.GetByID(ctx, userID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -145,6 +272,171 @@ func (s *Service) ChangePassword(ctx context.Context, userID uuid.UUID, currentP return s.userStore.Update(ctx, u) } +// EmailCheck determines whether an email is already registered. +func (s *Service) EmailCheck(ctx context.Context, email string) (exists bool, err error) { + email = strings.TrimSpace(strings.ToLower(email)) + u, err := s.userStore.GetByEmail(ctx, email) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + return u != nil, nil +} + +// ForgotPassword generates a reset token for the given email. +// Returns ("", nil) when the email does not exist (to prevent user enumeration). +func (s *Service) ForgotPassword(ctx context.Context, email string) (token string, err error) { + if s.resetTokenStore == nil { + return "", ErrPasswordResetNotConfigured + } + email = strings.TrimSpace(strings.ToLower(email)) + u, err := s.userStore.GetByEmail(ctx, email) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil + } + return "", err + } + if u == nil || !u.IsActive { + return "", nil + } + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + return "", err + } + token = hex.EncodeToString(tokenBytes) + if err := s.resetTokenStore.Create(ctx, u.ID, token); err != nil { + return "", err + } + return token, nil +} + +// ResetPassword validates the reset token and sets a new password. +// 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 + } + rt, err := s.resetTokenStore.GetValid(ctx, token) + if err != nil || rt == nil { + return ErrResetTokenInvalid + } + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost) + if err != nil { + return err + } + u, err := s.userStore.GetByID(ctx, rt.UserID) + if err != nil { + return ErrResetTokenInvalid + } + if !u.IsActive { + return ErrResetTokenInvalid + } + u.Password = string(hash) + if err := s.userStore.Update(ctx, u); err != nil { + return err + } + _ = s.resetTokenStore.InvalidateForUser(ctx, rt.UserID) + 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 { + if err := ValidatePasswordStrength(newPassword); err != nil { + return err + } + 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). +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, false, errors.New("oauth: email is required") + } + + u, err := s.userStore.GetByEmail(ctx, email) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil, false, err + } + + if u != nil && !u.IsActive { + return "", nil, false, errors.New("account is deactivated") + } + + newUser := u == nil + if newUser { + 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, + IsPasswordAutoset: true, + } + if err := s.userStore.Create(ctx, u); err != nil { + return "", nil, false, 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, false, err + } + return sessionKey, u, newUser, nil +} + 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/auth/service_test.go b/api/internal/auth/service_test.go new file mode 100644 index 0000000..3583cdd --- /dev/null +++ b/api/internal/auth/service_test.go @@ -0,0 +1,270 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "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() + + 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) + } + + // 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, + is_password_autoset 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") + } +} + +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() + 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) + } +} diff --git a/api/internal/config/config.go b/api/internal/config/config.go index b831ee2..2fa2808 100644 --- a/api/internal/config/config.go +++ b/api/internal/config/config.go @@ -42,6 +42,14 @@ 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 } func (c *Config) DSN() string { @@ -87,6 +95,9 @@ 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", ""), } return cfg, nil 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/handler/auth.go b/api/internal/handler/auth.go index 08a5063..d372985 100644 --- a/api/internal/handler/auth.go +++ b/api/internal/handler/auth.go @@ -1,15 +1,24 @@ -// Package handler implements HTTP handlers for the API. package handler import ( + "context" + "crypto/rand" + "crypto/subtle" "errors" + "fmt" + "log/slog" + "math/big" "net/http" + "net/mail" + "regexp" "strings" "time" "github.com/Devlaner/devlane/api/internal/auth" "github.com/Devlaner/devlane/api/internal/middleware" "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/queue" + "github.com/Devlaner/devlane/api/internal/redis" "github.com/Devlaner/devlane/api/internal/store" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -17,12 +26,19 @@ import ( ) type AuthHandler struct { - Auth *auth.Service - Settings *store.InstanceSettingStore - Winv *store.WorkspaceInviteStore - Ws *store.WorkspaceStore - NotifPrefs *store.UserNotificationPreferenceStore - ApiTokens *store.ApiTokenStore + 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 { @@ -49,9 +65,46 @@ 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 } +func (h *AuthHandler) log() *slog.Logger { + if h.Log != nil { + return h.Log + } + 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) { @@ -69,7 +122,11 @@ 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.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 } @@ -131,24 +188,18 @@ func (h *AuthHandler) SignUp(c *gin.Context) { LastName: req.LastName, }) if err != nil { - if err == auth.ErrEmailTaken { - c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"}) + 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 err == auth.ErrUsernameTaken { - c.JSON(http.StatusConflict, gin.H{"error": "Username already taken"}) + if errors.Is(err, auth.ErrEmailTaken) { + c.JSON(http.StatusConflict, gin.H{"error": "An account with this email already exists"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Sign up failed"}) return } - if inv != nil && h.Winv != nil && h.Ws != nil { - now := time.Now() - inv.Accepted = true - inv.RespondedAt = &now - _ = h.Winv.Update(ctx, inv) - _ = h.Ws.AddMember(ctx, &model.WorkspaceMember{WorkspaceID: inv.WorkspaceID, MemberID: user.ID, Role: inv.Role}) - } + postSignUpWorkflow(ctx, h.postSignUpDeps(), user) setSessionCookie(c, sessionKey) c.JSON(http.StatusCreated, userResponse(user)) } @@ -156,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) } @@ -177,12 +228,12 @@ func (h *AuthHandler) Me(c *gin.Context) { // UpdateMeRequest is the body for PATCH /api/users/me/ type UpdateMeRequest struct { - FirstName *string `json:"first_name"` - LastName *string `json:"last_name"` - DisplayName *string `json:"display_name"` - UserTimezone *string `json:"user_timezone"` - Avatar *string `json:"avatar"` - CoverImage *string `json:"cover_image"` + FirstName *string `json:"first_name" binding:"omitempty,max=255"` + LastName *string `json:"last_name" binding:"omitempty,max=255"` + DisplayName *string `json:"display_name" binding:"omitempty,max=255"` + UserTimezone *string `json:"user_timezone" binding:"omitempty,max=100"` + Avatar *string `json:"avatar" binding:"omitempty,max=2048"` + CoverImage *string `json:"cover_image" binding:"omitempty,max=2048"` } // UpdateMe updates the authenticated user's profile (email is not updatable). @@ -243,7 +294,11 @@ 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.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 } @@ -398,8 +453,8 @@ func (h *AuthHandler) ListTokens(c *gin.Context) { type CreateTokenRequest struct { Label string `json:"label" binding:"required"` Description string `json:"description"` - ExpiresIn *string `json:"expires_in"` // e.g. "7d", "30d", "90d", "365d", or empty for never - ExpiredAt *string `json:"expired_at"` // ISO date for custom expiry + ExpiresIn *string `json:"expires_in"` + ExpiredAt *string `json:"expired_at"` } // CreateToken creates a new API token and returns it once (including secret). @@ -495,6 +550,420 @@ func (h *AuthHandler) RevokeToken(c *gin.Context) { c.Status(http.StatusNoContent) } +// InstanceAuthConfig returns public auth configuration (no auth required). +// GET /auth/config/ +func (h *AuthHandler) InstanceAuthConfig(c *gin.Context) { + isPasswordEnabled := true + isMagicCodeEnabled := true + enableSignup := true + isSmtpConfigured := false + ctx := c.Request.Context() + googleAllowed := false + githubAllowed := false + gitlabAllowed := false + if h.Settings != nil { + 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 { + host, _ := emailRow.Value["host"].(string) + isSmtpConfigured = strings.TrimSpace(host) != "" + } + } + 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, + "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"] = 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) { + var body struct { + Email string `json:"email" binding:"required,email"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + exists, err := h.Auth.EmailCheck(c.Request.Context(), body.Email) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Check failed"}) + return + } + allowPublicSignup := true + if h.Settings != nil { + row, _ := h.Settings.Get(c.Request.Context(), "auth") + if row != nil { + allowPublicSignup = authBool(row.Value, "allow_public_signup", true) + } + } + c.JSON(http.StatusOK, gin.H{ + "existing": exists, + "status": "CREDENTIAL", + "allow_public_signup": allowPublicSignup, + }) +} + +// ForgotPassword initiates a password reset flow by sending an email. +// POST /auth/forgot-password/ +func (h *AuthHandler) ForgotPassword(c *gin.Context) { + var body struct { + Email string `json:"email" binding:"required"` + } + 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) + + 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 != "" { + 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, + ) + 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."}) +} + +// ResetPassword validates a reset token and sets a new password. +// POST /auth/reset-password/ +func (h *AuthHandler) ResetPassword(c *gin.Context) { + var body struct { + Token string `json:"token" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=8"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + 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.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.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 + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reset password"}) + return + } + 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) + + 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 + } + postSignUpWorkflow(ctx, h.postSignUpDeps(), user) + 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.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 + } + 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 + } + return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") +} + func setSessionCookie(c *gin.Context, sessionKey string) { http.SetCookie(c.Writer, &http.Cookie{ Name: middleware.SessionCookieName, @@ -503,7 +972,7 @@ func setSessionCookie(c *gin.Context, sessionKey string) { MaxAge: 14 * 24 * 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode, - Secure: false, + Secure: isSecureRequest(c), }) } @@ -515,27 +984,184 @@ func clearSessionCookie(c *gin.Context) { MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, + Secure: isSecureRequest(c), }) } +var autoSlugRe = regexp.MustCompile(`[^a-z0-9-]+`) + +// 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 + } + + // 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) + } + if displayName == "" && u.Email != nil { + displayName = strings.Split(*u.Email, "@")[0] + } + + wsName := displayName + "'s Workspace" + slug := strings.Trim(autoSlugRe.ReplaceAllString(strings.ToLower(displayName), "-"), "-") + if slug == "" { + slug = "workspace" + } + + exists, _ := deps.Workspaces.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 := 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 := 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, + } +} + +// 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.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 + } + 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/instance.go b/api/internal/handler/instance.go index 1cbce22..5cf298d 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,53 @@ 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{} + 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 new file mode 100644 index 0000000..b908927 --- /dev/null +++ b/api/internal/handler/oauth.go @@ -0,0 +1,280 @@ +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/Devlaner/devlane/api/internal/store" + "github.com/gin-gonic/gin" +) + +type OAuthHandler struct { + 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 { + if h.Log != nil { + return h.Log + } + return slog.Default() +} + +// 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") { + scheme = "https" + } + 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 := oauthCallbackBase(c, h.APIPublicURL) + 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.resolveProvider(c, 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 + } + + 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)) +} + +func (h *OAuthHandler) Callback(c *gin.Context) { + providerName := c.Param("provider") + provider, ok := h.resolveProvider(c, 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 + } + + 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) + 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 + } + + // 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, + 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 + } + + 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{ + 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) + + // 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 := oauthCallbackBase(c, h.APIPublicURL) + spaOrigin := strings.TrimSuffix(strings.TrimSpace(h.AppBaseURL), "/") + if spaOrigin != "" && !strings.EqualFold(spaOrigin, callbackOrigin) { + redirectURL += "#session_token=" + url.QueryEscape(sessionKey) + } + + 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 +} + +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/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/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/mail/mail.go b/api/internal/mail/mail.go index 53a8cfd..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") @@ -64,6 +69,10 @@ func getEmailSettings(ctx context.Context, s *store.InstanceSettingStore) (*smtp // settings and sends mail. If not configured or send fails, logs and returns error. func NewSMTPEmailSender(instanceSettings *store.InstanceSettingStore, log *slog.Logger) func(ctx context.Context, to, subject, body string) error { return func(ctx context.Context, to, subject, body string) error { + if instanceSettings == nil { + LogSkip(log, "instance settings store is nil", to, fmt.Errorf("no settings store")) + return fmt.Errorf("email not configured: no settings store") + } cfg, err := getEmailSettings(ctx, instanceSettings) if err != nil { LogSkip(log, "instance email not configured", to, err) diff --git a/api/internal/middleware/auth.go b/api/internal/middleware/auth.go index 7ee2132..cac97cb 100644 --- a/api/internal/middleware/auth.go +++ b/api/internal/middleware/auth.go @@ -3,6 +3,7 @@ package middleware import ( "log/slog" "net/http" + "strings" "github.com/Devlaner/devlane/api/internal/auth" "github.com/Devlaner/devlane/api/internal/model" @@ -14,20 +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 == "" { - // Also check Authorization header for Bearer (session key) for API clients - if authHeader := c.GetHeader("Authorization"); len(authHeader) > 7 && 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/internal/model/account.go b/api/internal/model/account.go new file mode 100644 index 0000000..58c2209 --- /dev/null +++ b/api/internal/model/account.go @@ -0,0 +1,33 @@ +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;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" } + +func (a *Account) BeforeCreate(tx *gorm.DB) error { + if a.ID == uuid.Nil { + a.ID = uuid.New() + } + return nil +} 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/password_reset_token.go b/api/internal/model/password_reset_token.go new file mode 100644 index 0000000..663c57c --- /dev/null +++ b/api/internal/model/password_reset_token.go @@ -0,0 +1,26 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type PasswordResetToken struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` + UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"` + Token string `gorm:"type:varchar(128);uniqueIndex;not null" json:"-"` + ExpiresAt time.Time `gorm:"not null" json:"expires_at"` + UsedAt *time.Time `json:"used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func (PasswordResetToken) TableName() string { return "password_reset_tokens" } + +func (t *PasswordResetToken) BeforeCreate(tx *gorm.DB) error { + if t.ID == uuid.Nil { + t.ID = uuid.New() + } + return nil +} diff --git a/api/internal/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 ef25a06..d45a53c 100644 --- a/api/internal/model/user.go +++ b/api/internal/model/user.go @@ -9,22 +9,23 @@ 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"` - IsOnboarded bool `gorm:"column:is_onboarded;default:false" json:"is_onboarded"` - UserTimezone string `gorm:"column:user_timezone;default:UTC" json:"user_timezone"` + 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"` } func (User) TableName() string { return "users" } diff --git a/api/internal/oauth/github.go b/api/internal/oauth/github.go new file mode 100644 index 0000000..45c4f55 --- /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(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(ctx, 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(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(ctx, 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(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 := oauthHTTPClient.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..63cd2b5 --- /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(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}, + "grant_type": {"authorization_code"}, + } + tokenURL := g.host + "/oauth/token" + resp, err := httpPostForm(ctx, 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(ctx context.Context, token *TokenData) (*UserInfo, error) { + userURL := g.host + "/api/v4/user" + resp, err := httpGetJSON(ctx, 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..0f74f47 --- /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(ctx 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(ctx, 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(ctx context.Context, token *TokenData) (*UserInfo, error) { + resp, err := httpGetJSON(ctx, 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..73529a0 --- /dev/null +++ b/api/internal/oauth/oauth.go @@ -0,0 +1,100 @@ +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") +) + +// 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 + 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(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 + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for k, v := range extraHeaders { + req.Header.Set(k, v) + } + resp, err := oauthHTTPClient.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(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 := oauthHTTPClient.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 d288c65..4def304 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,10 +61,49 @@ 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, 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 { + switch n := v.(type) { + case int64: + retryCount = n + case int32: + retryCount = int64(n) + case int: + retryCount = int64(n) + } + } + } + const maxRetries = 3 + if retryCount >= maxRetries { + if c.log != nil { + c.log.Error("task permanently failed, discarding", "queue", queue, "retries", retryCount, "error", err) + } + _ = d.Ack(false) + return + } if c.log != nil { - c.log.Warn("task failed", "queue", queue, "error", err) + 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 { + 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.Nack(false, true) + _ = d.Ack(false) return } _ = d.Ack(false) 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). diff --git a/api/internal/router/router.go b/api/internal/router/router.go index df44598..46fc725 100644 --- a/api/internal/router/router.go +++ b/api/internal/router/router.go @@ -17,13 +17,18 @@ 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 } // New builds and returns the Gin engine with /api/ and /auth/ routes. @@ -69,9 +74,33 @@ func New(cfg Config) *gin.Engine { apiTokenStore := store.NewApiTokenStore(cfg.DB) userFavoriteStore := store.NewUserFavoriteStore(cfg.DB) + // Password reset tokens + passwordResetTokenStore := store.NewPasswordResetTokenStore(cfg.DB) + accountStore := store.NewAccountStore(cfg.DB) + // Auth - authSvc := auth.NewService(userStore, sessionStore) - authHandler := &handler.AuthHandler{Auth: authSvc, Settings: instanceSettingStore, Winv: workspaceInviteStore, Ws: workspaceStore, NotifPrefs: userNotifPrefStore, ApiTokens: apiTokenStore} + authSvc := auth.NewService(userStore, sessionStore, passwordResetTokenStore) + authSvc.SetAccountStore(accountStore) + 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, + 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} r.GET("/api/instance/setup-status/", instanceHandler.SetupStatus) @@ -99,12 +128,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, @@ -135,6 +158,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) @@ -274,10 +298,30 @@ func New(cfg Config) *gin.Engine { // Auth routes (no auth required) authGroup := r.Group("/auth") { + authGroup.GET("/config/", authHandler.InstanceAuthConfig) + authGroup.POST("/email-check/", authHandler.EmailCheck) authGroup.POST("/sign-in/", authHandler.SignIn) authGroup.POST("/sign-up/", authHandler.SignUp) authGroup.POST("/sign-out/", authHandler.SignOut) + authGroup.POST("/forgot-password/", authHandler.ForgotPassword) + authGroup.POST("/reset-password/", authHandler.ResetPassword) + 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. + oauthHandler := &handler.OAuthHandler{ + 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) // Legacy /api/v1 v1 := r.Group("/api/v1") 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/internal/store/account.go b/api/internal/store/account.go new file mode 100644 index 0000000..366abd4 --- /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", "access_token_expired_at", "refresh_token", "refresh_token_expired_at", "id_token", "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 new file mode 100644 index 0000000..925789e --- /dev/null +++ b/api/internal/store/password_reset_token.go @@ -0,0 +1,55 @@ +package store + +import ( + "context" + "time" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/google/uuid" + "gorm.io/gorm" +) + +const resetTokenExpiry = 30 * time.Minute + +type PasswordResetTokenStore struct{ db *gorm.DB } + +func NewPasswordResetTokenStore(db *gorm.DB) *PasswordResetTokenStore { + return &PasswordResetTokenStore{db: db} +} + +func (s *PasswordResetTokenStore) Create(ctx context.Context, userID uuid.UUID, token string) error { + return s.db.WithContext(ctx).Create(&model.PasswordResetToken{ + UserID: userID, + Token: token, + ExpiresAt: time.Now().UTC().Add(resetTokenExpiry), + }).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). + Where("token = ? AND used_at IS NULL AND expires_at > ?", token, time.Now().UTC()). + First(&t).Error + if err != nil { + return nil, err + } + return &t, nil +} + +func (s *PasswordResetTokenStore) MarkUsed(ctx context.Context, id uuid.UUID) error { + now := time.Now().UTC() + return s.db.WithContext(ctx). + Model(&model.PasswordResetToken{}). + Where("id = ?", id). + Update("used_at", now).Error +} diff --git a/api/migrations/000002_auth_schema.down.sql b/api/migrations/000002_auth_schema.down.sql new file mode 100644 index 0000000..68371a2 --- /dev/null +++ b/api/migrations/000002_auth_schema.down.sql @@ -0,0 +1 @@ +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..46eced5 --- /dev/null +++ b/api/migrations/000002_auth_schema.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + 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, + 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); 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(' ')}`] : []), 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 diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index a29506d..47d6c2f 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -1,20 +1,26 @@ 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 + * 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.VITE_API_BASE_URL ?? (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', }, }); +/** 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/api/types.ts b/ui/src/api/types.ts index 29a9385..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) */ @@ -224,6 +226,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; @@ -301,6 +304,55 @@ export interface SignUpRequest { invite_token?: string; } +/** POST /auth/email-check/ response */ +export interface EmailCheckResponse { + existing: boolean; + status: 'CREDENTIAL'; + allow_public_signup: boolean; +} + +/** POST /auth/forgot-password/ request */ +export interface ForgotPasswordRequest { + email: string; +} + +/** POST /auth/reset-password/ request */ +export interface ResetPasswordRequest { + token: string; + new_password: string; +} + +/** GET /auth/config/ response */ +export interface AuthConfigResponse { + is_email_password_enabled: boolean; + is_magic_code_enabled: boolean; + enable_signup: boolean; + is_smtp_configured: boolean; + 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). */ + oauth_js_origin?: 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/) */ export type InstanceSettingsResponse = Record>; @@ -336,6 +388,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/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.

); diff --git a/ui/src/components/auth/AuthPageShell.tsx b/ui/src/components/auth/AuthPageShell.tsx new file mode 100644 index 0000000..a16b4f7 --- /dev/null +++ b/ui/src/components/auth/AuthPageShell.tsx @@ -0,0 +1,37 @@ +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} +
+

+ By {mode === 'sign-in' ? 'signing in' : 'signing up'}, you agree to our terms of service and + privacy policy. +

+
+ ); +} diff --git a/ui/src/components/instance-admin/InstanceAdminAuthControls.tsx b/ui/src/components/instance-admin/InstanceAdminAuthControls.tsx new file mode 100644 index 0000000..dea57ef --- /dev/null +++ b/ui/src/components/instance-admin/InstanceAdminAuthControls.tsx @@ -0,0 +1,98 @@ +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, + value, +}: { + label: string; + hint: string; + value: string; +}) { + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle'); + const onCopy = () => { + if (!value) return; + void (async () => { + const ok = await copyTextToClipboard(value); + if (ok) { + setCopyState('copied'); + setTimeout(() => setCopyState('idle'), 2000); + } else { + setCopyState('failed'); + setTimeout(() => setCopyState('idle'), 2000); + } + })(); + }; + return ( +
+
+ + +
+ +

{hint}

+
+ ); +} + +export function InstanceAdminToggleSwitch({ + checked, + onChange, + disabled, +}: { + checked: boolean; + onChange: (v: boolean) => void; + disabled?: boolean; +}) { + return ( + + ); +} 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'; 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/components/layout/ModuleDetailHeader.tsx b/ui/src/components/layout/ModuleDetailHeader.tsx index c6999f7..3dcc93c 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,9 @@ export function ModuleDetailHeader({ {projectName} - + + / + Modules - + + / +
)} - + + / + { - 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/contexts/AuthContext.tsx b/ui/src/contexts/AuthContext.tsx index 78fff4e..bbf9b82 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, clearApiBearerAuthHeader } 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)); @@ -57,19 +70,16 @@ 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 () => { try { await authService.signOut(); } finally { + clearApiBearerAuthHeader(); setUser(null); } }, []); diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 55a1bf8..f9efa25 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -1,7 +1,7 @@ import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; +import { API_BASE } from '../api/client'; import type { WorkspaceMemberApiResponse } from '../api/types'; -import { config } from '../config/env'; /** * Merges Tailwind classes with clsx, resolving conflicts via tailwind-merge. @@ -23,9 +23,11 @@ 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; + if (API_BASE && path.startsWith('/api/')) { + return `${API_BASE.replace(/\/$/, '')}${path}`; + } + return path; } /** Normalize UUID strings for comparison (case + hyphen insensitive). */ diff --git a/ui/src/pages/CreateWorkspacePage.tsx b/ui/src/pages/CreateWorkspacePage.tsx new file mode 100644 index 0000000..cb6e93d --- /dev/null +++ b/ui/src/pages/CreateWorkspacePage.tsx @@ -0,0 +1,152 @@ +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, + ...(organizationSize.trim() ? { organization_size: organizationSize.trim() } : {}), + }); + 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/pages/ForgotPasswordPage.tsx b/ui/src/pages/ForgotPasswordPage.tsx new file mode 100644 index 0000000..5aed982 --- /dev/null +++ b/ui/src/pages/ForgotPasswordPage.tsx @@ -0,0 +1,127 @@ +import { useState, useCallback, useEffect } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +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; + +export function ForgotPasswordPage() { + const location = useLocation(); + const prefilledEmail = (location.state as { email?: string } | null)?.email ?? ''; + + const [email, setEmail] = useState(prefilledEmail); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [cooldown, setCooldown] = useState(0); + + useEffect(() => { + if (cooldown <= 0) return; + const timer = setInterval(() => setCooldown((c) => c - 1), 1000); + return () => clearInterval(timer); + }, [cooldown]); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsSubmitting(true); + try { + const normalized = email.trim().toLowerCase(); + await authService.forgotPassword({ email: normalized }); + setEmail(normalized); + setSuccess(true); + setCooldown(RESEND_COOLDOWN_SECONDS); + } catch (err: unknown) { + setError(getApiErrorMessage(err) || 'Something went wrong. Please try again.'); + } finally { + setIsSubmitting(false); + } + }, + [email], + ); + + const handleResend = useCallback(async () => { + if (cooldown > 0) return; + setError(''); + setIsSubmitting(true); + try { + const normalized = email.trim().toLowerCase(); + await authService.forgotPassword({ email: normalized }); + setEmail(normalized); + setCooldown(RESEND_COOLDOWN_SECONDS); + } catch (err: unknown) { + setError(getApiErrorMessage(err) || 'Something went wrong. Please try again.'); + } finally { + setIsSubmitting(false); + } + }, [email, cooldown]); + + return ( + +
+ + + Back to sign in + + +

Reset your password

+

+ Enter your email and we'll send you a link to reset your password. +

+ + {error && ( +
+ + {error} +
+ )} + + {success && ( +
+ + + If {email} is registered, you'll receive a reset link shortly. + Check your inbox and spam folder. + +
+ )} + +
+ setEmail(e.target.value)} + placeholder="you@example.com" + required + autoComplete="email" + disabled={success && cooldown > 0} + autoFocus + /> + + {!success ? ( + + ) : ( + + )} +
+
+
+ ); +} 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 4cd909a..f125f48 100644 --- a/ui/src/pages/LoginPage.tsx +++ b/ui/src/pages/LoginPage.tsx @@ -1,78 +1,456 @@ -import { useState } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; -import { Button, Input, Card, CardContent } from '../components/ui'; +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 } from 'lucide-react'; +import { AuthPageShell } from '../components/auth/AuthPageShell'; + +type AuthStep = 'email' | 'password' | 'code'; +type AuthMode = 'sign-in' | 'sign-up'; 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 }; 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); const [password, setPassword] = useState(''); + const [magicCode, setMagicCode] = useState(''); + const [showPassword, setShowPassword] = 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; - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); + 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) { + setMode('sign-in'); + } else { + navigate('/sign-up', { state: { email, inviteToken }, replace: true }); + return; + } + + 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; + } + + setStep('password'); + } catch { + setStep('password'); + setMode('sign-in'); + } finally { + setIsSubmitting(false); + } + }, + [ + email, + inviteToken, + isPasswordEnabled, + isMagicCodeEnabled, + isSmtpConfigured, + sendMagicCode, + navigate, + ], + ); + + const switchToMagicCode = useCallback(async () => { setError(''); setIsSubmitting(true); try { - const success = await login(email, password); - if (success) { - navigate(returnPath, { replace: true }); - } else { - setError('Invalid email or password.'); - } - } catch { - setError('Something went wrong. Please try again.'); + 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(); + setError(''); + setIsSubmitting(true); + try { + await login(email, password); + navigate(returnPath, { replace: true }); + } catch (err: unknown) { + setError(getApiErrorMessage(err) || 'Something went wrong. Please try again.'); + } finally { + setIsSubmitting(false); + } + }, + [email, password, login, 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, + ...(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, inviteToken, setUserFromApi, navigate, returnPath], + ); + + const goBackToEmail = useCallback(() => { + setStep('email'); + setPassword(''); + setMagicCode(''); + setError(''); + }, []); + + const goBackToPassword = useCallback(() => { + setStep('password'); + setMagicCode(''); + setError(''); + }, []); + + const title = useMemo(() => { + if (step === 'email') return 'Get started with Devlane'; + 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 'Enter your password to sign in.'; + }, [step]); return ( -
- - -

Sign in to Devlane

-

- Enter your email and password to continue. + +

+

{title}

+

{subtitle}

+ {step === 'email' && isPasswordEnabled && canUseMagicCode && ( +

+ After you continue, you can use your password or choose a one-time email code instead.

-
- setEmail(e.target.value)} - placeholder="you@example.com" - required - autoComplete="email" - /> + )} + + {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' && ( +
+
+ +
+ +
+ setPassword(e.target.value)} + placeholder="Enter password" + required + 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' && ( +
+
+ +
+ setPassword(e.target.value)} - placeholder="••••••••" + label="6-digit code" + type="text" + inputMode="numeric" + autoComplete="one-time-code" + value={magicCode} + onChange={(e) => setMagicCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="000000" required - error={error || undefined} - autoComplete="current-password" + maxLength={6} + autoFocus={mode === 'sign-in'} /> + + + + + {isPasswordEnabled && ( + + )}
- - -
+ )} +
+ ); } diff --git a/ui/src/pages/ResetPasswordPage.tsx b/ui/src/pages/ResetPasswordPage.tsx new file mode 100644 index 0000000..6d7ab46 --- /dev/null +++ b/ui/src/pages/ResetPasswordPage.tsx @@ -0,0 +1,226 @@ +import { useState, useCallback, useMemo } from 'react'; +import { useSearchParams, Link } from 'react-router-dom'; +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; + 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) { + setError(getApiErrorMessage(err)); + } 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/pages/SetPasswordPage.tsx b/ui/src/pages/SetPasswordPage.tsx new file mode 100644 index 0000000..22bba0d --- /dev/null +++ b/ui/src/pages/SetPasswordPage.tsx @@ -0,0 +1,183 @@ +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..80b29c9 --- /dev/null +++ b/ui/src/pages/SignUpPage.tsx @@ -0,0 +1,589 @@ +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/pages/instance-admin/InstanceAdminAuthGitHubPage.tsx b/ui/src/pages/instance-admin/InstanceAdminAuthGitHubPage.tsx new file mode 100644 index 0000000..d673f09 --- /dev/null +++ b/ui/src/pages/instance-admin/InstanceAdminAuthGitHubPage.tsx @@ -0,0 +1,234 @@ +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 { Eye, EyeOff } from 'lucide-react'; + +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..a9a1bc1 --- /dev/null +++ b/ui/src/pages/instance-admin/InstanceAdminAuthGitLabPage.tsx @@ -0,0 +1,247 @@ +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 { Eye, EyeOff } from 'lucide-react'; + +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..7d94bee --- /dev/null +++ b/ui/src/pages/instance-admin/InstanceAdminAuthGooglePage.tsx @@ -0,0 +1,249 @@ +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 { Eye, EyeOff } from 'lucide-react'; + +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..821d479 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 { InstanceAdminToggleSwitch } from '../../components/instance-admin'; import { instanceSettingsService } from '../../services/instanceService'; 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,53 @@ 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 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,9 +153,9 @@ 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(() => { @@ -135,8 +173,10 @@ export function InstanceAdminAuthenticationPage() { github: a.github ?? false, gitlab: a.gitlab ?? false, }); + const o = (settings.oauth || {}) as InstanceOAuthSection; + setOauth(o); }) - .catch((err) => { + .catch((err: unknown) => { if (!cancelled) setError(getApiErrorMessage(err)); }) .finally(() => { @@ -148,6 +188,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 +250,7 @@ export function InstanceAdminAuthenticationPage() { } return ( -
+

Manage authentication modes for your instance @@ -223,15 +271,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 +286,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' + ) : ( + <> + + Configure + + )} + + + handleToggle(item.key, v)} + disabled={saving || (!configured && !on)} + /> + + )} - + )}
    ); 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) { 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/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 4ba075f..1eaa8e9 100644 --- a/ui/src/routes/index.tsx +++ b/ui/src/routes/index.tsx @@ -15,6 +15,22 @@ 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 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 }), @@ -105,6 +121,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 }), @@ -140,6 +171,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 })), ); @@ -267,6 +303,30 @@ const router = createBrowserRouter([ ), }, + { + path: 'authentication/google', + element: ( + }> + + + ), + }, + { + path: 'authentication/github', + element: ( + }> + + + ), + }, + { + path: 'authentication/gitlab', + element: ( + }> + + + ), + }, { path: 'ai', element: ( @@ -293,6 +353,40 @@ const router = createBrowserRouter([ ), }, + { + path: 'forgot-password', + element: ( + }> + + + ), + }, + { + path: 'reset-password', + element: ( + }> + + + ), + }, + { + path: 'sign-up', + element: ( + }> + + + ), + }, + { + path: 'accounts/set-password', + element: ( + + }> + + + + ), + }, { path: 'invite', element: ( @@ -308,6 +402,16 @@ const router = createBrowserRouter([ }, ], }, + { + path: 'create-workspace', + element: ( + + }> + + + + ), + }, { element: , children: [ diff --git a/ui/src/services/authService.ts b/ui/src/services/authService.ts index a93ca4e..60b581c 100644 --- a/ui/src/services/authService.ts +++ b/ui/src/services/authService.ts @@ -1,19 +1,22 @@ import { apiClient } from '../api/client'; -import type { UserApiResponse, SignInRequest, SignUpRequest } from '../api/types'; +import type { + UserApiResponse, + SignInRequest, + SignUpRequest, + EmailCheckResponse, + ForgotPasswordRequest, + ResetPasswordRequest, + AuthConfigResponse, + MagicCodeRequestPayload, + MagicCodeVerifyPayload, +} 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; @@ -23,6 +26,16 @@ export const authService = { await apiClient.post('/auth/sign-out/'); }, + 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 getMe(): Promise { try { const { data } = await apiClient.get('/api/users/me/'); @@ -31,4 +44,32 @@ export const authService = { return null; } }, + + async emailCheck(email: string): Promise { + const { data } = await apiClient.post('/auth/email-check/', { email }); + return data; + }, + + async getAuthConfig(): Promise { + 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; + }, + + async setPassword(payload: { password: string }): Promise { + const { data } = await apiClient.post('/auth/set-password/', payload); + return data; + }, };