mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a47be6ca6 | ||
|
|
16be3db0c6 | ||
|
|
744e51d1e1 | ||
|
|
b3af75d430 | ||
|
|
6f7320abeb | ||
|
|
a1655d35a6 | ||
|
|
9b6e801184 | ||
|
|
105777ab6f | ||
|
|
3a1a88d5cf | ||
|
|
699ca16814 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
ansible/
|
||||
postgresus_data/
|
||||
postgresus-data/
|
||||
databasus-data/
|
||||
@@ -9,4 +10,5 @@ node_modules/
|
||||
/articles
|
||||
|
||||
.DS_Store
|
||||
/scripts
|
||||
/scripts
|
||||
.vscode/settings.json
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -253,13 +253,22 @@ PG_BIN="/usr/lib/postgresql/17/bin"
|
||||
|
||||
# Generate runtime configuration for frontend
|
||||
echo "Generating runtime configuration..."
|
||||
cat > /app/ui/build/runtime-config.js << 'JSEOF'
|
||||
|
||||
# Detect if email is configured (both SMTP_HOST and DATABASUS_URL must be set)
|
||||
if [ -n "\${SMTP_HOST:-}" ] && [ -n "\${DATABASUS_URL:-}" ]; then
|
||||
IS_EMAIL_CONFIGURED="true"
|
||||
else
|
||||
IS_EMAIL_CONFIGURED="false"
|
||||
fi
|
||||
|
||||
cat > /app/ui/build/runtime-config.js <<JSEOF
|
||||
// Runtime configuration injected at container startup
|
||||
// This file is generated dynamically and should not be edited manually
|
||||
window.__RUNTIME_CONFIG__ = {
|
||||
IS_CLOUD: '\${IS_CLOUD:-false}',
|
||||
GITHUB_CLIENT_ID: '\${GITHUB_CLIENT_ID:-}',
|
||||
GOOGLE_CLIENT_ID: '\${GOOGLE_CLIENT_ID:-}'
|
||||
GOOGLE_CLIENT_ID: '\${GOOGLE_CLIENT_ID:-}',
|
||||
IS_EMAIL_CONFIGURED: '\$IS_EMAIL_CONFIGURED'
|
||||
};
|
||||
JSEOF
|
||||
|
||||
|
||||
@@ -114,6 +114,15 @@ type EnvVariables struct {
|
||||
TestSupabaseUsername string `env:"TEST_SUPABASE_USERNAME"`
|
||||
TestSupabasePassword string `env:"TEST_SUPABASE_PASSWORD"`
|
||||
TestSupabaseDatabase string `env:"TEST_SUPABASE_DATABASE"`
|
||||
|
||||
// SMTP configuration (optional)
|
||||
SMTPHost string `env:"SMTP_HOST"`
|
||||
SMTPPort int `env:"SMTP_PORT"`
|
||||
SMTPUser string `env:"SMTP_USER"`
|
||||
SMTPPassword string `env:"SMTP_PASSWORD"`
|
||||
|
||||
// Application URL (optional) - used for email links
|
||||
DatabasusURL string `env:"DATABASUS_URL"`
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
22
backend/internal/features/email/di.go
Normal file
22
backend/internal/features/email/di.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/config"
|
||||
"databasus-backend/internal/util/logger"
|
||||
)
|
||||
|
||||
var env = config.GetEnv()
|
||||
var log = logger.GetLogger()
|
||||
|
||||
var emailSMTPSender = &EmailSMTPSender{
|
||||
log,
|
||||
env.SMTPHost,
|
||||
env.SMTPPort,
|
||||
env.SMTPUser,
|
||||
env.SMTPPassword,
|
||||
env.SMTPHost != "" && env.SMTPPort != 0,
|
||||
}
|
||||
|
||||
func GetEmailSMTPSender() *EmailSMTPSender {
|
||||
return emailSMTPSender
|
||||
}
|
||||
245
backend/internal/features/email/email.go
Normal file
245
backend/internal/features/email/email.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ImplicitTLSPort = 465
|
||||
DefaultTimeout = 5 * time.Second
|
||||
DefaultHelloName = "localhost"
|
||||
MIMETypeHTML = "text/html"
|
||||
MIMECharsetUTF8 = "UTF-8"
|
||||
)
|
||||
|
||||
type EmailSMTPSender struct {
|
||||
logger *slog.Logger
|
||||
smtpHost string
|
||||
smtpPort int
|
||||
smtpUser string
|
||||
smtpPassword string
|
||||
isConfigured bool
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) SendEmail(to, subject, body string) error {
|
||||
if !s.isConfigured {
|
||||
s.logger.Warn("Skipping email send, SMTP not initialized", "to", to, "subject", subject)
|
||||
return nil
|
||||
}
|
||||
|
||||
from := s.smtpUser
|
||||
if from == "" {
|
||||
from = "noreply@" + s.smtpHost
|
||||
}
|
||||
|
||||
emailContent := s.buildEmailContent(to, subject, body, from)
|
||||
isAuthRequired := s.smtpUser != "" && s.smtpPassword != ""
|
||||
|
||||
if s.smtpPort == ImplicitTLSPort {
|
||||
return s.sendImplicitTLS(to, from, emailContent, isAuthRequired)
|
||||
}
|
||||
|
||||
return s.sendStartTLS(to, from, emailContent, isAuthRequired)
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) buildEmailContent(to, subject, body, from string) []byte {
|
||||
// Encode Subject header using RFC 2047 to avoid SMTPUTF8 requirement
|
||||
encodedSubject := encodeRFC2047(subject)
|
||||
subjectHeader := fmt.Sprintf("Subject: %s\r\n", encodedSubject)
|
||||
dateHeader := fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
|
||||
|
||||
mimeHeaders := fmt.Sprintf(
|
||||
"MIME-version: 1.0;\nContent-Type: %s; charset=\"%s\";\n\n",
|
||||
MIMETypeHTML,
|
||||
MIMECharsetUTF8,
|
||||
)
|
||||
|
||||
// Encode From header display name if it contains non-ASCII
|
||||
encodedFrom := encodeRFC2047(from)
|
||||
fromHeader := fmt.Sprintf("From: %s\r\n", encodedFrom)
|
||||
|
||||
toHeader := fmt.Sprintf("To: %s\r\n", to)
|
||||
|
||||
return []byte(fromHeader + toHeader + subjectHeader + dateHeader + mimeHeaders + body)
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) sendImplicitTLS(
|
||||
to, from string,
|
||||
emailContent []byte,
|
||||
isAuthRequired bool,
|
||||
) error {
|
||||
createClient := func() (*smtp.Client, func(), error) {
|
||||
return s.createImplicitTLSClient()
|
||||
}
|
||||
|
||||
client, cleanup, err := s.authenticateWithRetry(createClient, isAuthRequired)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
return s.sendEmail(client, to, from, emailContent)
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) sendStartTLS(
|
||||
to, from string,
|
||||
emailContent []byte,
|
||||
isAuthRequired bool,
|
||||
) error {
|
||||
createClient := func() (*smtp.Client, func(), error) {
|
||||
return s.createStartTLSClient()
|
||||
}
|
||||
|
||||
client, cleanup, err := s.authenticateWithRetry(createClient, isAuthRequired)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
return s.sendEmail(client, to, from, emailContent)
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) createImplicitTLSClient() (*smtp.Client, func(), error) {
|
||||
addr := net.JoinHostPort(s.smtpHost, fmt.Sprintf("%d", s.smtpPort))
|
||||
tlsConfig := &tls.Config{ServerName: s.smtpHost}
|
||||
dialer := &net.Dialer{Timeout: DefaultTimeout}
|
||||
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, s.smtpHost)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
||||
}
|
||||
|
||||
return client, func() { _ = client.Quit() }, nil
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) createStartTLSClient() (*smtp.Client, func(), error) {
|
||||
addr := net.JoinHostPort(s.smtpHost, fmt.Sprintf("%d", s.smtpPort))
|
||||
dialer := &net.Dialer{Timeout: DefaultTimeout}
|
||||
|
||||
conn, err := dialer.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, s.smtpHost)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
||||
}
|
||||
|
||||
if err := client.Hello(DefaultHelloName); err != nil {
|
||||
_ = client.Quit()
|
||||
_ = conn.Close()
|
||||
return nil, nil, fmt.Errorf("SMTP hello failed: %w", err)
|
||||
}
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: s.smtpHost}); err != nil {
|
||||
_ = client.Quit()
|
||||
_ = conn.Close()
|
||||
return nil, nil, fmt.Errorf("STARTTLS failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return client, func() { _ = client.Quit() }, nil
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) authenticateWithRetry(
|
||||
createClient func() (*smtp.Client, func(), error),
|
||||
isAuthRequired bool,
|
||||
) (*smtp.Client, func(), error) {
|
||||
client, cleanup, err := createClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !isAuthRequired {
|
||||
return client, cleanup, nil
|
||||
}
|
||||
|
||||
// Try PLAIN auth first
|
||||
plainAuth := smtp.PlainAuth("", s.smtpUser, s.smtpPassword, s.smtpHost)
|
||||
if err := client.Auth(plainAuth); err == nil {
|
||||
return client, cleanup, nil
|
||||
}
|
||||
|
||||
// PLAIN auth failed, connection may be closed - recreate and try LOGIN auth
|
||||
cleanup()
|
||||
|
||||
client, cleanup, err = createClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
loginAuth := &loginAuth{username: s.smtpUser, password: s.smtpPassword}
|
||||
if err := client.Auth(loginAuth); err != nil {
|
||||
cleanup()
|
||||
return nil, nil, fmt.Errorf("SMTP authentication failed: %w", err)
|
||||
}
|
||||
|
||||
return client, cleanup, nil
|
||||
}
|
||||
|
||||
func (s *EmailSMTPSender) sendEmail(client *smtp.Client, to, from string, content []byte) error {
|
||||
if err := client.Mail(from); err != nil {
|
||||
return fmt.Errorf("failed to set sender: %w", err)
|
||||
}
|
||||
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("failed to set recipient: %w", err)
|
||||
}
|
||||
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get data writer: %w", err)
|
||||
}
|
||||
|
||||
if _, err = writer.Write(content); err != nil {
|
||||
return fmt.Errorf("failed to write email content: %w", err)
|
||||
}
|
||||
|
||||
if err = writer.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close data writer: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeRFC2047(s string) string {
|
||||
return mime.QEncoding.Encode("UTF-8", s)
|
||||
}
|
||||
|
||||
type loginAuth struct {
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", []byte{}, nil
|
||||
}
|
||||
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
switch string(fromServer) {
|
||||
case "Username:", "User Name\x00":
|
||||
return []byte(a.username), nil
|
||||
case "Password:", "Password\x00":
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
return []byte(a.username), nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -130,6 +130,7 @@ func (e *EmailNotifier) buildEmailContent(heading, message, from string) []byte
|
||||
// This ensures compatibility with SMTP servers that don't support SMTPUTF8
|
||||
encodedSubject := encodeRFC2047(heading)
|
||||
subject := fmt.Sprintf("Subject: %s\r\n", encodedSubject)
|
||||
dateHeader := fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
|
||||
|
||||
mimeHeaders := fmt.Sprintf(
|
||||
"MIME-version: 1.0;\nContent-Type: %s; charset=\"%s\";\n\n",
|
||||
@@ -143,7 +144,7 @@ func (e *EmailNotifier) buildEmailContent(heading, message, from string) []byte
|
||||
|
||||
toHeader := fmt.Sprintf("To: %s\r\n", e.TargetEmail)
|
||||
|
||||
return []byte(fromHeader + toHeader + subject + mimeHeaders + message)
|
||||
return []byte(fromHeader + toHeader + subject + dateHeader + mimeHeaders + message)
|
||||
}
|
||||
|
||||
func (e *EmailNotifier) sendImplicitTLS(
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package system_healthcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"databasus-backend/internal/config"
|
||||
"databasus-backend/internal/features/backups/backups/backuping"
|
||||
"databasus-backend/internal/features/disk"
|
||||
"databasus-backend/internal/storage"
|
||||
cache_utils "databasus-backend/internal/util/cache"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HealthcheckService struct {
|
||||
@@ -15,6 +18,20 @@ type HealthcheckService struct {
|
||||
}
|
||||
|
||||
func (s *HealthcheckService) IsHealthy() error {
|
||||
return s.performHealthCheck()
|
||||
}
|
||||
|
||||
func (s *HealthcheckService) performHealthCheck() error {
|
||||
// Check if cache is available with PING
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := cache_utils.GetValkeyClient()
|
||||
pingResult := client.Do(ctx, client.B().Ping().Build())
|
||||
if pingResult.Error() != nil {
|
||||
return errors.New("cannot connect to valkey")
|
||||
}
|
||||
|
||||
diskUsage, err := s.diskService.GetDiskUsage()
|
||||
if err != nil {
|
||||
return errors.New("cannot get disk usage")
|
||||
@@ -40,6 +57,7 @@ func (s *HealthcheckService) IsHealthy() error {
|
||||
if config.GetEnv().IsProcessingNode {
|
||||
if !s.backuperNode.IsBackuperRunning() {
|
||||
return errors.New("backuper node is not running for more than 5 minutes")
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
package users_controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
users_dto "databasus-backend/internal/features/users/dto"
|
||||
users_enums "databasus-backend/internal/features/users/enums"
|
||||
users_models "databasus-backend/internal/features/users/models"
|
||||
users_services "databasus-backend/internal/features/users/services"
|
||||
users_testing "databasus-backend/internal/features/users/testing"
|
||||
"databasus-backend/internal/storage"
|
||||
test_utils "databasus-backend/internal/util/testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func Test_SendResetPasswordCode_WithValidEmail_CodeSent(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
request := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
request,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
assert.Equal(t, 1, len(mockEmailSender.SentEmails))
|
||||
assert.Equal(t, user.Email, mockEmailSender.SentEmails[0].To)
|
||||
assert.Contains(t, mockEmailSender.SentEmails[0].Subject, "Password Reset")
|
||||
}
|
||||
|
||||
func Test_SendResetPasswordCode_WithNonExistentUser_ReturnsSuccess(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
request := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: "nonexistent" + uuid.New().String() + "@example.com",
|
||||
}
|
||||
|
||||
// Should return success to prevent enumeration attacks
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
request,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
// But no email should be sent
|
||||
assert.Equal(t, 0, len(mockEmailSender.SentEmails))
|
||||
}
|
||||
|
||||
func Test_SendResetPasswordCode_WithInvitedUser_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
email := "invited" + uuid.New().String() + "@example.com"
|
||||
|
||||
inviteRequest := users_dto.InviteUserRequestDTO{
|
||||
Email: email,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/invite",
|
||||
"Bearer "+adminUser.Token,
|
||||
inviteRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
request := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: email,
|
||||
}
|
||||
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
request,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(resp.Body), "only active users")
|
||||
}
|
||||
|
||||
func Test_SendResetPasswordCode_WithRateLimitExceeded_ReturnsTooManyRequests(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
request := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
// Make 3 requests (should succeed)
|
||||
for range 3 {
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
request,
|
||||
http.StatusOK,
|
||||
)
|
||||
}
|
||||
|
||||
// 4th request should be rate limited
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
request,
|
||||
http.StatusTooManyRequests,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(resp.Body), "Rate limit exceeded")
|
||||
}
|
||||
|
||||
func Test_SendResetPasswordCode_WithInvalidJSON_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
|
||||
resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{
|
||||
Method: "POST",
|
||||
URL: "/api/v1/users/send-reset-password-code",
|
||||
Body: "invalid json",
|
||||
ExpectedStatus: http.StatusBadRequest,
|
||||
})
|
||||
|
||||
assert.Contains(t, string(resp.Body), "Invalid request format")
|
||||
}
|
||||
|
||||
func Test_ResetPassword_WithValidCode_PasswordReset(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
email := "resettest" + uuid.New().String() + "@example.com"
|
||||
oldPassword := "oldpassword123"
|
||||
newPassword := "newpassword456"
|
||||
|
||||
// Create user
|
||||
signupRequest := users_dto.SignUpRequestDTO{
|
||||
Email: email,
|
||||
Password: oldPassword,
|
||||
Name: "Test User",
|
||||
}
|
||||
test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK)
|
||||
|
||||
// Request reset code
|
||||
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: email,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
sendCodeRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
// Extract code from email
|
||||
assert.Equal(t, 1, len(mockEmailSender.SentEmails))
|
||||
emailBody := mockEmailSender.SentEmails[0].Body
|
||||
code := extractCodeFromEmail(emailBody)
|
||||
t.Logf("Extracted code: %s from email body (length: %d)", code, len(code))
|
||||
assert.NotEmpty(t, code, "Code should be extracted from email")
|
||||
assert.Len(t, code, 6, "Code should be 6 digits")
|
||||
|
||||
// Reset password
|
||||
resetRequest := users_dto.ResetPasswordRequestDTO{
|
||||
Email: email,
|
||||
Code: code,
|
||||
NewPassword: newPassword,
|
||||
}
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Logf("Reset password failed with body: %s", string(resp.Body))
|
||||
}
|
||||
|
||||
// Verify old password doesn't work
|
||||
oldSigninRequest := users_dto.SignInRequestDTO{
|
||||
Email: email,
|
||||
Password: oldPassword,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/signin",
|
||||
"",
|
||||
oldSigninRequest,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
// Verify new password works
|
||||
newSigninRequest := users_dto.SignInRequestDTO{
|
||||
Email: email,
|
||||
Password: newPassword,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/signin",
|
||||
"",
|
||||
newSigninRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
}
|
||||
|
||||
func Test_ResetPassword_WithExpiredCode_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
// Create expired reset code directly in database
|
||||
code := "123456"
|
||||
hashedCode, _ := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
|
||||
expiredCode := &users_models.PasswordResetCode{
|
||||
ID: uuid.New(),
|
||||
UserID: user.UserID,
|
||||
HashedCode: string(hashedCode),
|
||||
ExpiresAt: time.Now().UTC().Add(-1 * time.Hour), // Expired 1 hour ago
|
||||
IsUsed: false,
|
||||
CreatedAt: time.Now().UTC().Add(-2 * time.Hour),
|
||||
}
|
||||
storage.GetDb().Create(expiredCode)
|
||||
|
||||
resetRequest := users_dto.ResetPasswordRequestDTO{
|
||||
Email: user.Email,
|
||||
Code: code,
|
||||
NewPassword: "newpassword123",
|
||||
}
|
||||
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(resp.Body), "invalid or expired")
|
||||
}
|
||||
|
||||
func Test_ResetPassword_WithUsedCode_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
email := "usedcode" + uuid.New().String() + "@example.com"
|
||||
|
||||
// Create user
|
||||
signupRequest := users_dto.SignUpRequestDTO{
|
||||
Email: email,
|
||||
Password: "password123",
|
||||
Name: "Test User",
|
||||
}
|
||||
test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK)
|
||||
|
||||
// Request reset code
|
||||
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: email,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
sendCodeRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
code := extractCodeFromEmail(mockEmailSender.SentEmails[0].Body)
|
||||
|
||||
// Use code first time
|
||||
resetRequest := users_dto.ResetPasswordRequestDTO{
|
||||
Email: email,
|
||||
Code: code,
|
||||
NewPassword: "newpassword123",
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
// Try to use same code again
|
||||
resetRequest2 := users_dto.ResetPasswordRequestDTO{
|
||||
Email: email,
|
||||
Code: code,
|
||||
NewPassword: "anotherpassword456",
|
||||
}
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest2,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(resp.Body), "invalid or expired")
|
||||
}
|
||||
|
||||
func Test_ResetPassword_WithWrongCode_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
// Request reset code
|
||||
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: user.Email,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
sendCodeRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
// Try to reset with wrong code
|
||||
resetRequest := users_dto.ResetPasswordRequestDTO{
|
||||
Email: user.Email,
|
||||
Code: "999999", // Wrong code
|
||||
NewPassword: "newpassword123",
|
||||
}
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(resp.Body), "invalid")
|
||||
}
|
||||
|
||||
func Test_ResetPassword_WithInvalidNewPassword_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
resetRequest := users_dto.ResetPasswordRequestDTO{
|
||||
Email: user.Email,
|
||||
Code: "123456",
|
||||
NewPassword: "short", // Too short
|
||||
}
|
||||
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
}
|
||||
|
||||
func Test_ResetPassword_EmailSendFailure_ReturnsError(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
mockEmailSender.ShouldFail = true
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
|
||||
request := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
resp := test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
request,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
assert.Contains(t, string(resp.Body), "failed to send email")
|
||||
}
|
||||
|
||||
func Test_ResetPasswordFlow_E2E_CompletesSuccessfully(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
mockEmailSender := users_testing.NewMockEmailSender()
|
||||
users_services.GetUserService().SetEmailSender(mockEmailSender)
|
||||
|
||||
email := "e2e" + uuid.New().String() + "@example.com"
|
||||
initialPassword := "initialpass123"
|
||||
newPassword := "brandnewpass456"
|
||||
|
||||
// 1. Create user via signup
|
||||
signupRequest := users_dto.SignUpRequestDTO{
|
||||
Email: email,
|
||||
Password: initialPassword,
|
||||
Name: "E2E Test User",
|
||||
}
|
||||
test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK)
|
||||
|
||||
// 2. Verify can sign in with initial password
|
||||
signinRequest := users_dto.SignInRequestDTO{
|
||||
Email: email,
|
||||
Password: initialPassword,
|
||||
}
|
||||
var signinResponse users_dto.SignInResponseDTO
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/signin",
|
||||
"",
|
||||
signinRequest,
|
||||
http.StatusOK,
|
||||
&signinResponse,
|
||||
)
|
||||
assert.NotEmpty(t, signinResponse.Token)
|
||||
|
||||
// 3. Request password reset code
|
||||
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
|
||||
Email: email,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/send-reset-password-code",
|
||||
"",
|
||||
sendCodeRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
// 4. Verify email was sent
|
||||
assert.Equal(t, 1, len(mockEmailSender.SentEmails))
|
||||
code := extractCodeFromEmail(mockEmailSender.SentEmails[0].Body)
|
||||
assert.NotEmpty(t, code)
|
||||
|
||||
// 5. Reset password using code
|
||||
resetRequest := users_dto.ResetPasswordRequestDTO{
|
||||
Email: email,
|
||||
Code: code,
|
||||
NewPassword: newPassword,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/reset-password",
|
||||
"",
|
||||
resetRequest,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
// 6. Verify old password no longer works
|
||||
oldSignin := users_dto.SignInRequestDTO{
|
||||
Email: email,
|
||||
Password: initialPassword,
|
||||
}
|
||||
test_utils.MakePostRequest(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/signin",
|
||||
"",
|
||||
oldSignin,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
// 7. Verify new password works
|
||||
newSignin := users_dto.SignInRequestDTO{
|
||||
Email: email,
|
||||
Password: newPassword,
|
||||
}
|
||||
var finalResponse users_dto.SignInResponseDTO
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/users/signin",
|
||||
"",
|
||||
newSignin,
|
||||
http.StatusOK,
|
||||
&finalResponse,
|
||||
)
|
||||
assert.NotEmpty(t, finalResponse.Token)
|
||||
}
|
||||
|
||||
func Test_ResetPassword_WithInvalidJSON_ReturnsBadRequest(t *testing.T) {
|
||||
router := createUserTestRouter()
|
||||
|
||||
resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{
|
||||
Method: "POST",
|
||||
URL: "/api/v1/users/reset-password",
|
||||
Body: "invalid json",
|
||||
ExpectedStatus: http.StatusBadRequest,
|
||||
})
|
||||
|
||||
assert.Contains(t, string(resp.Body), "Invalid request format")
|
||||
}
|
||||
|
||||
// Helper function to extract 6-digit code from email HTML body
|
||||
func extractCodeFromEmail(emailBody string) string {
|
||||
// Look for pattern: <h1 ... >CODE</h1>
|
||||
// First find <h1
|
||||
h1Start := 0
|
||||
for i := 0; i < len(emailBody)-3; i++ {
|
||||
if emailBody[i:i+3] == "<h1" {
|
||||
h1Start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if h1Start == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Find the > after <h1
|
||||
contentStart := h1Start
|
||||
for i := h1Start; i < len(emailBody); i++ {
|
||||
if emailBody[i] == '>' {
|
||||
contentStart = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Find </h1>
|
||||
contentEnd := contentStart
|
||||
for i := contentStart; i < len(emailBody)-5; i++ {
|
||||
if emailBody[i:i+5] == "</h1>" {
|
||||
contentEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if contentEnd <= contentStart {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Extract content and remove whitespace
|
||||
content := emailBody[contentStart:contentEnd]
|
||||
code := ""
|
||||
for i := 0; i < len(content); i++ {
|
||||
if isDigit(content[i]) {
|
||||
code += string(content[i])
|
||||
}
|
||||
}
|
||||
|
||||
if len(code) == 6 {
|
||||
return code
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func isDigit(b byte) bool {
|
||||
return b >= '0' && b <= '9'
|
||||
}
|
||||
@@ -28,6 +28,10 @@ func (c *UserController) RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.GET("/users/admin/has-password", c.IsAdminHasPassword)
|
||||
router.POST("/users/admin/set-password", c.SetAdminPassword)
|
||||
|
||||
// Password reset (no auth required)
|
||||
router.POST("/users/send-reset-password-code", c.SendResetPasswordCode)
|
||||
router.POST("/users/reset-password", c.ResetPassword)
|
||||
|
||||
// OAuth callbacks
|
||||
router.POST("/auth/github/callback", c.HandleGitHubOAuth)
|
||||
router.POST("/auth/google/callback", c.HandleGoogleOAuth)
|
||||
@@ -340,3 +344,70 @@ func (c *UserController) HandleGoogleOAuth(ctx *gin.Context) {
|
||||
|
||||
ctx.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// SendResetPasswordCode
|
||||
// @Summary Send password reset code
|
||||
// @Description Send a password reset code to the user's email
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body users_dto.SendResetPasswordCodeRequestDTO true "Email address"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 429 {object} map[string]string
|
||||
// @Router /users/send-reset-password-code [post]
|
||||
func (c *UserController) SendResetPasswordCode(ctx *gin.Context) {
|
||||
var request user_dto.SendResetPasswordCodeRequestDTO
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
|
||||
return
|
||||
}
|
||||
|
||||
allowed, _ := c.rateLimiter.CheckLimit(
|
||||
request.Email,
|
||||
"reset-password",
|
||||
3,
|
||||
1*time.Hour,
|
||||
)
|
||||
if !allowed {
|
||||
ctx.JSON(
|
||||
http.StatusTooManyRequests,
|
||||
gin.H{"error": "Rate limit exceeded. Please try again later."},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
err := c.userService.SendResetPasswordCode(request.Email)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"message": "If the email exists, a reset code has been sent"})
|
||||
}
|
||||
|
||||
// ResetPassword
|
||||
// @Summary Reset password with code
|
||||
// @Description Reset user password using the code sent via email
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body users_dto.ResetPasswordRequestDTO true "Reset password data"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Router /users/reset-password [post]
|
||||
func (c *UserController) ResetPassword(ctx *gin.Context) {
|
||||
var request user_dto.ResetPasswordRequestDTO
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
|
||||
return
|
||||
}
|
||||
|
||||
err := c.userService.ResetPassword(request.Email, request.Code, request.NewPassword)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"})
|
||||
}
|
||||
|
||||
@@ -92,3 +92,13 @@ type OAuthCallbackResponseDTO struct {
|
||||
Token string `json:"token"`
|
||||
IsNewUser bool `json:"isNewUser"`
|
||||
}
|
||||
|
||||
type SendResetPasswordCodeRequestDTO struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
|
||||
type ResetPasswordRequestDTO struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
NewPassword string `json:"newPassword" binding:"required,min=8"`
|
||||
}
|
||||
|
||||
@@ -7,3 +7,7 @@ import (
|
||||
type AuditLogWriter interface {
|
||||
WriteAuditLog(message string, userID *uuid.UUID, workspaceID *uuid.UUID)
|
||||
}
|
||||
|
||||
type EmailSender interface {
|
||||
SendEmail(to, subject, body string) error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package users_models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PasswordResetCode struct {
|
||||
ID uuid.UUID `json:"id" gorm:"column:id"`
|
||||
UserID uuid.UUID `json:"userId" gorm:"column:user_id"`
|
||||
HashedCode string `json:"-" gorm:"column:hashed_code"`
|
||||
ExpiresAt time.Time `json:"expiresAt" gorm:"column:expires_at"`
|
||||
IsUsed bool `json:"isUsed" gorm:"column:is_used"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
func (PasswordResetCode) TableName() string {
|
||||
return "password_reset_codes"
|
||||
}
|
||||
|
||||
func (p *PasswordResetCode) IsValid() bool {
|
||||
return !p.IsUsed && time.Now().UTC().Before(p.ExpiresAt)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package users_repositories
|
||||
|
||||
var userRepository = &UserRepository{}
|
||||
var usersSettingsRepository = &UsersSettingsRepository{}
|
||||
var passwordResetRepository = &PasswordResetRepository{}
|
||||
|
||||
func GetUserRepository() *UserRepository {
|
||||
return userRepository
|
||||
@@ -10,3 +11,7 @@ func GetUserRepository() *UserRepository {
|
||||
func GetUsersSettingsRepository() *UsersSettingsRepository {
|
||||
return usersSettingsRepository
|
||||
}
|
||||
|
||||
func GetPasswordResetRepository() *PasswordResetRepository {
|
||||
return passwordResetRepository
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package users_repositories
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
users_models "databasus-backend/internal/features/users/models"
|
||||
"databasus-backend/internal/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PasswordResetRepository struct{}
|
||||
|
||||
func (r *PasswordResetRepository) CreateResetCode(code *users_models.PasswordResetCode) error {
|
||||
if code.ID == uuid.Nil {
|
||||
code.ID = uuid.New()
|
||||
}
|
||||
|
||||
return storage.GetDb().Create(code).Error
|
||||
}
|
||||
|
||||
func (r *PasswordResetRepository) GetValidCodeByUserID(
|
||||
userID uuid.UUID,
|
||||
) (*users_models.PasswordResetCode, error) {
|
||||
var code users_models.PasswordResetCode
|
||||
err := storage.GetDb().
|
||||
Where("user_id = ? AND is_used = ? AND expires_at > ?", userID, false, time.Now().UTC()).
|
||||
Order("created_at DESC").
|
||||
First(&code).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &code, nil
|
||||
}
|
||||
|
||||
func (r *PasswordResetRepository) MarkCodeAsUsed(codeID uuid.UUID) error {
|
||||
return storage.GetDb().Model(&users_models.PasswordResetCode{}).
|
||||
Where("id = ?", codeID).
|
||||
Update("is_used", true).Error
|
||||
}
|
||||
|
||||
func (r *PasswordResetRepository) DeleteExpiredCodes() error {
|
||||
return storage.GetDb().
|
||||
Where("expires_at < ?", time.Now().UTC()).
|
||||
Delete(&users_models.PasswordResetCode{}).Error
|
||||
}
|
||||
|
||||
func (r *PasswordResetRepository) CountRecentCodesByUserID(
|
||||
userID uuid.UUID,
|
||||
since time.Time,
|
||||
) (int64, error) {
|
||||
var count int64
|
||||
|
||||
err := storage.GetDb().Model(&users_models.PasswordResetCode{}).
|
||||
Where("user_id = ? AND created_at > ?", userID, since).
|
||||
Count(&count).Error
|
||||
|
||||
return count, err
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package users_services
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/features/email"
|
||||
"databasus-backend/internal/features/encryption/secrets"
|
||||
users_repositories "databasus-backend/internal/features/users/repositories"
|
||||
)
|
||||
@@ -10,6 +11,8 @@ var userService = &UserService{
|
||||
secrets.GetSecretKeyService(),
|
||||
settingsService,
|
||||
nil,
|
||||
email.GetEmailSMTPSender(),
|
||||
users_repositories.GetPasswordResetRepository(),
|
||||
}
|
||||
var settingsService = &SettingsService{
|
||||
users_repositories.GetUsersSettingsRepository(),
|
||||
|
||||
@@ -2,6 +2,7 @@ package users_services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -27,16 +28,22 @@ import (
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
userRepository *users_repositories.UserRepository
|
||||
secretKeyService *secrets.SecretKeyService
|
||||
settingsService *SettingsService
|
||||
auditLogWriter users_interfaces.AuditLogWriter
|
||||
userRepository *users_repositories.UserRepository
|
||||
secretKeyService *secrets.SecretKeyService
|
||||
settingsService *SettingsService
|
||||
auditLogWriter users_interfaces.AuditLogWriter
|
||||
emailSender users_interfaces.EmailSender
|
||||
passwordResetRepository *users_repositories.PasswordResetRepository
|
||||
}
|
||||
|
||||
func (s *UserService) SetAuditLogWriter(writer users_interfaces.AuditLogWriter) {
|
||||
s.auditLogWriter = writer
|
||||
}
|
||||
|
||||
func (s *UserService) SetEmailSender(sender users_interfaces.EmailSender) {
|
||||
s.emailSender = sender
|
||||
}
|
||||
|
||||
func (s *UserService) SignUp(request *users_dto.SignUpRequestDTO) error {
|
||||
existingUser, err := s.userRepository.GetUserByEmail(request.Email)
|
||||
if err != nil {
|
||||
@@ -798,3 +805,164 @@ func (s *UserService) fetchGitHubPrimaryEmail(
|
||||
|
||||
return "", errors.New("github account has no accessible email")
|
||||
}
|
||||
|
||||
func (s *UserService) SendResetPasswordCode(email string) error {
|
||||
user, err := s.userRepository.GetUserByEmail(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
// Silently succeed for non-existent users to prevent enumeration attacks
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only active users can reset passwords
|
||||
if user.Status != users_enums.UserStatusActive {
|
||||
return errors.New("only active users can reset their password")
|
||||
}
|
||||
|
||||
// Check rate limiting - max 3 codes per hour
|
||||
oneHourAgo := time.Now().UTC().Add(-1 * time.Hour)
|
||||
recentCount, err := s.passwordResetRepository.CountRecentCodesByUserID(user.ID, oneHourAgo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check rate limit: %w", err)
|
||||
}
|
||||
|
||||
if recentCount >= 3 {
|
||||
return errors.New("too many password reset attempts, please try again later")
|
||||
}
|
||||
|
||||
// Generate 6-digit random code using crypto/rand for better randomness
|
||||
codeNum := make([]byte, 4)
|
||||
_, err = io.ReadFull(rand.Reader, codeNum)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate random code: %w", err)
|
||||
}
|
||||
|
||||
// Convert bytes to uint32 and modulo to get 6 digits
|
||||
randomInt := uint32(
|
||||
codeNum[0],
|
||||
)<<24 | uint32(
|
||||
codeNum[1],
|
||||
)<<16 | uint32(
|
||||
codeNum[2],
|
||||
)<<8 | uint32(
|
||||
codeNum[3],
|
||||
)
|
||||
code := fmt.Sprintf("%06d", randomInt%1000000)
|
||||
|
||||
// Hash the code
|
||||
hashedCode, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash code: %w", err)
|
||||
}
|
||||
|
||||
// Store in database with 1 hour expiration
|
||||
resetCode := &users_models.PasswordResetCode{
|
||||
ID: uuid.New(),
|
||||
UserID: user.ID,
|
||||
HashedCode: string(hashedCode),
|
||||
ExpiresAt: time.Now().UTC().Add(1 * time.Hour),
|
||||
IsUsed: false,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err := s.passwordResetRepository.CreateResetCode(resetCode); err != nil {
|
||||
return fmt.Errorf("failed to create reset code: %w", err)
|
||||
}
|
||||
|
||||
// Send email with code
|
||||
if s.emailSender != nil {
|
||||
subject := "Password Reset Code"
|
||||
body := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
|
||||
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; padding: 20px;">
|
||||
<h2 style="color: #333333; margin-bottom: 20px;">Password Reset Request</h2>
|
||||
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
|
||||
You have requested to reset your password. Please use the following code to complete the password reset process:
|
||||
</p>
|
||||
<div style="background-color: #f8f9fa; border: 2px solid #e9ecef; border-radius: 8px; padding: 20px; text-align: center; margin: 30px 0;">
|
||||
<h1 style="color: #2c3e50; font-size: 36px; margin: 0; letter-spacing: 8px; font-family: monospace;">%s</h1>
|
||||
</div>
|
||||
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
|
||||
This code will expire in <strong>1 hour</strong>.
|
||||
</p>
|
||||
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
|
||||
If you did not request a password reset, please ignore this email. Your password will remain unchanged.
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #e9ecef; margin: 30px 0;">
|
||||
<p style="color: #999999; font-size: 12px; line-height: 1.6;">
|
||||
This is an automated message. Please do not reply to this email.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`, code)
|
||||
|
||||
if err := s.emailSender.SendEmail(user.Email, subject, body); err != nil {
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
if s.auditLogWriter != nil {
|
||||
s.auditLogWriter.WriteAuditLog(
|
||||
fmt.Sprintf("Password reset code sent to: %s", user.Email),
|
||||
&user.ID,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserService) ResetPassword(email, code, newPassword string) error {
|
||||
user, err := s.userRepository.GetUserByEmail(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return errors.New("user with this email does not exist")
|
||||
}
|
||||
|
||||
// Get valid reset code for user
|
||||
resetCode, err := s.passwordResetRepository.GetValidCodeByUserID(user.ID)
|
||||
if err != nil {
|
||||
return errors.New("invalid or expired reset code")
|
||||
}
|
||||
|
||||
// Verify code matches
|
||||
err = bcrypt.CompareHashAndPassword([]byte(resetCode.HashedCode), []byte(code))
|
||||
if err != nil {
|
||||
return errors.New("invalid reset code")
|
||||
}
|
||||
|
||||
// Mark code as used
|
||||
if err := s.passwordResetRepository.MarkCodeAsUsed(resetCode.ID); err != nil {
|
||||
return fmt.Errorf("failed to mark code as used: %w", err)
|
||||
}
|
||||
|
||||
// Update user password
|
||||
if err := s.ChangeUserPassword(user.ID, newPassword); err != nil {
|
||||
return fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
|
||||
// Audit log
|
||||
if s.auditLogWriter != nil {
|
||||
s.auditLogWriter.WriteAuditLog(
|
||||
"Password reset via email code",
|
||||
&user.ID,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
33
backend/internal/features/users/testing/mocks.go
Normal file
33
backend/internal/features/users/testing/mocks.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package users_testing
|
||||
|
||||
import "errors"
|
||||
|
||||
type MockEmailSender struct {
|
||||
SentEmails []EmailCall
|
||||
ShouldFail bool
|
||||
}
|
||||
|
||||
type EmailCall struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (m *MockEmailSender) SendEmail(to, subject, body string) error {
|
||||
m.SentEmails = append(m.SentEmails, EmailCall{
|
||||
To: to,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
})
|
||||
if m.ShouldFail {
|
||||
return errors.New("mock email send failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewMockEmailSender() *MockEmailSender {
|
||||
return &MockEmailSender{
|
||||
SentEmails: []EmailCall{},
|
||||
ShouldFail: false,
|
||||
}
|
||||
}
|
||||
@@ -5,3 +5,7 @@ import "github.com/google/uuid"
|
||||
type WorkspaceDeletionListener interface {
|
||||
OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error
|
||||
}
|
||||
|
||||
type EmailSender interface {
|
||||
SendEmail(to, subject, body string) error
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package workspaces_services
|
||||
|
||||
import (
|
||||
"databasus-backend/internal/features/audit_logs"
|
||||
"databasus-backend/internal/features/email"
|
||||
users_services "databasus-backend/internal/features/users/services"
|
||||
workspaces_interfaces "databasus-backend/internal/features/workspaces/interfaces"
|
||||
workspaces_repositories "databasus-backend/internal/features/workspaces/repositories"
|
||||
"databasus-backend/internal/util/logger"
|
||||
)
|
||||
|
||||
var workspaceRepository = &workspaces_repositories.WorkspaceRepository{}
|
||||
@@ -26,6 +28,8 @@ var membershipService = &MembershipService{
|
||||
audit_logs.GetAuditLogService(),
|
||||
workspaceService,
|
||||
users_services.GetSettingsService(),
|
||||
email.GetEmailSMTPSender(),
|
||||
logger.GetLogger(),
|
||||
}
|
||||
|
||||
func GetWorkspaceService() *WorkspaceService {
|
||||
|
||||
@@ -2,7 +2,9 @@ package workspaces_services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"databasus-backend/internal/config"
|
||||
audit_logs "databasus-backend/internal/features/audit_logs"
|
||||
users_dto "databasus-backend/internal/features/users/dto"
|
||||
users_enums "databasus-backend/internal/features/users/enums"
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
users_services "databasus-backend/internal/features/users/services"
|
||||
workspaces_dto "databasus-backend/internal/features/workspaces/dto"
|
||||
workspaces_errors "databasus-backend/internal/features/workspaces/errors"
|
||||
workspaces_interfaces "databasus-backend/internal/features/workspaces/interfaces"
|
||||
workspaces_models "databasus-backend/internal/features/workspaces/models"
|
||||
workspaces_repositories "databasus-backend/internal/features/workspaces/repositories"
|
||||
|
||||
@@ -23,6 +26,8 @@ type MembershipService struct {
|
||||
auditLogService *audit_logs.AuditLogService
|
||||
workspaceService *WorkspaceService
|
||||
settingsService *users_services.SettingsService
|
||||
emailSender workspaces_interfaces.EmailSender
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (s *MembershipService) GetMembers(
|
||||
@@ -77,6 +82,12 @@ func (s *MembershipService) AddMember(
|
||||
return nil, workspaces_errors.ErrInsufficientPermissionsToInviteUsers
|
||||
}
|
||||
|
||||
// Get workspace details for email
|
||||
workspace, err := s.workspaceRepository.GetWorkspaceByID(workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get workspace: %w", err)
|
||||
}
|
||||
|
||||
inviteRequest := &users_dto.InviteUserRequestDTO{
|
||||
Email: request.Email,
|
||||
IntendedWorkspaceID: &workspaceID,
|
||||
@@ -88,6 +99,14 @@ func (s *MembershipService) AddMember(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Send invitation email
|
||||
subject := fmt.Sprintf("You've been invited to %s workspace", workspace.Name)
|
||||
body := s.buildInvitationEmailHTML(workspace.Name, addedBy.Name, string(request.Role))
|
||||
|
||||
if err := s.emailSender.SendEmail(request.Email, subject, body); err != nil {
|
||||
s.logger.Error("Failed to send invitation email", "email", request.Email, "error", err)
|
||||
}
|
||||
|
||||
membership := &workspaces_models.WorkspaceMembership{
|
||||
UserID: inviteResponse.ID,
|
||||
WorkspaceID: workspaceID,
|
||||
@@ -339,3 +358,48 @@ func (s *MembershipService) validateCanManageMembership(
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MembershipService) buildInvitationEmailHTML(
|
||||
workspaceName, inviterName, role string,
|
||||
) string {
|
||||
env := config.GetEnv()
|
||||
signUpLink := ""
|
||||
if env.DatabasusURL != "" {
|
||||
signUpLink = fmt.Sprintf(`<p style="margin: 20px 0;">
|
||||
<a href="%s/sign-up" style="display: inline-block; padding: 12px 24px; background-color: #0d6efd; color: white; text-decoration: none; border-radius: 4px;">
|
||||
Sign up
|
||||
</a>
|
||||
</p>`, env.DatabasusURL)
|
||||
} else {
|
||||
signUpLink = `<p style="margin: 20px 0; color: #666;">
|
||||
Please visit your Databasus instance to sign up and access the workspace.
|
||||
</p>`
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background-color: #f8f9fa; border-radius: 8px; padding: 30px; margin: 20px 0;">
|
||||
<h1 style="color: #0d6efd; margin-top: 0;">Workspace Invitation</h1>
|
||||
|
||||
<p style="font-size: 16px; margin: 20px 0;">
|
||||
<strong>%s</strong> has invited you to join the <strong>%s</strong> workspace as a <strong>%s</strong>.
|
||||
</p>
|
||||
|
||||
%s
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #dee2e6; margin: 30px 0;">
|
||||
|
||||
<p style="font-size: 14px; color: #6c757d; margin: 0;">
|
||||
This is an automated message from Databasus. If you didn't expect this invitation, you can safely ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`, inviterName, workspaceName, role, signUpLink)
|
||||
}
|
||||
|
||||
33
backend/internal/features/workspaces/testing/mocks.go
Normal file
33
backend/internal/features/workspaces/testing/mocks.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package workspaces_testing
|
||||
|
||||
import "errors"
|
||||
|
||||
type MockEmailSender struct {
|
||||
SendEmailCalls []EmailCall
|
||||
ShouldFail bool
|
||||
}
|
||||
|
||||
type EmailCall struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (m *MockEmailSender) SendEmail(to, subject, body string) error {
|
||||
m.SendEmailCalls = append(m.SendEmailCalls, EmailCall{
|
||||
To: to,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
})
|
||||
if m.ShouldFail {
|
||||
return errors.New("mock email send failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewMockEmailSender() *MockEmailSender {
|
||||
return &MockEmailSender{
|
||||
SendEmailCalls: []EmailCall{},
|
||||
ShouldFail: false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
CREATE TABLE password_reset_codes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
hashed_code TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
is_used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE password_reset_codes
|
||||
ADD CONSTRAINT fk_password_reset_codes_user_id
|
||||
FOREIGN KEY (user_id)
|
||||
REFERENCES users (id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX idx_password_reset_codes_user_id ON password_reset_codes (user_id);
|
||||
CREATE INDEX idx_password_reset_codes_expires_at ON password_reset_codes (expires_at);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
DROP INDEX IF EXISTS idx_password_reset_codes_expires_at;
|
||||
DROP INDEX IF EXISTS idx_password_reset_codes_user_id;
|
||||
DROP TABLE IF EXISTS password_reset_codes;
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -1 +1,5 @@
|
||||
MODE=development
|
||||
MODE=development
|
||||
VITE_GITHUB_CLIENT_ID=
|
||||
VITE_GOOGLE_CLIENT_ID=
|
||||
VITE_IS_EMAIL_CONFIGURED=false
|
||||
VITE_IS_CLOUD=false
|
||||
@@ -2,6 +2,7 @@ interface RuntimeConfig {
|
||||
IS_CLOUD?: string;
|
||||
GITHUB_CLIENT_ID?: string;
|
||||
GOOGLE_CLIENT_ID?: string;
|
||||
IS_EMAIL_CONFIGURED?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -27,7 +28,6 @@ export const GOOGLE_DRIVE_OAUTH_REDIRECT_URL = 'https://databasus.com/storages/g
|
||||
|
||||
export const APP_VERSION = (import.meta.env.VITE_APP_VERSION as string) || 'dev';
|
||||
|
||||
// First try runtime config, then build-time env var, then default to false
|
||||
export const IS_CLOUD =
|
||||
window.__RUNTIME_CONFIG__?.IS_CLOUD === 'true' || import.meta.env.VITE_IS_CLOUD === 'true';
|
||||
|
||||
@@ -37,6 +37,10 @@ export const GITHUB_CLIENT_ID =
|
||||
export const GOOGLE_CLIENT_ID =
|
||||
window.__RUNTIME_CONFIG__?.GOOGLE_CLIENT_ID || import.meta.env.VITE_GOOGLE_CLIENT_ID || '';
|
||||
|
||||
export const IS_EMAIL_CONFIGURED =
|
||||
window.__RUNTIME_CONFIG__?.IS_EMAIL_CONFIGURED === 'true' ||
|
||||
import.meta.env.VITE_IS_EMAIL_CONFIGURED === 'true';
|
||||
|
||||
export function getOAuthRedirectUri(): string {
|
||||
return `${window.location.origin}/auth/callback`;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { InviteUserResponse } from '../model/InviteUserResponse';
|
||||
import type { IsAdminHasPasswordResponse } from '../model/IsAdminHasPasswordResponse';
|
||||
import type { OAuthCallbackRequest } from '../model/OAuthCallbackRequest';
|
||||
import type { OAuthCallbackResponse } from '../model/OAuthCallbackResponse';
|
||||
import type { ResetPasswordRequest } from '../model/ResetPasswordRequest';
|
||||
import type { SendResetPasswordCodeRequest } from '../model/SendResetPasswordCodeRequest';
|
||||
import type { SetAdminPasswordRequest } from '../model/SetAdminPasswordRequest';
|
||||
import type { SignInRequest } from '../model/SignInRequest';
|
||||
import type { SignInResponse } from '../model/SignInResponse';
|
||||
@@ -134,6 +136,24 @@ export const userApi = {
|
||||
});
|
||||
},
|
||||
|
||||
async sendResetPasswordCode(request: SendResetPasswordCodeRequest): Promise<{ message: string }> {
|
||||
const requestOptions: RequestOptions = new RequestOptions();
|
||||
requestOptions.setBody(JSON.stringify(request));
|
||||
return apiHelper.fetchPostJson(
|
||||
`${getApplicationServer()}/api/v1/users/send-reset-password-code`,
|
||||
requestOptions,
|
||||
);
|
||||
},
|
||||
|
||||
async resetPassword(request: ResetPasswordRequest): Promise<{ message: string }> {
|
||||
const requestOptions: RequestOptions = new RequestOptions();
|
||||
requestOptions.setBody(JSON.stringify(request));
|
||||
return apiHelper.fetchPostJson(
|
||||
`${getApplicationServer()}/api/v1/users/reset-password`,
|
||||
requestOptions,
|
||||
);
|
||||
},
|
||||
|
||||
isAuthorized: (): boolean => !!accessTokenHelper.getAccessToken(),
|
||||
|
||||
logout: () => {
|
||||
|
||||
@@ -18,5 +18,7 @@ export type { ListUsersRequest } from './model/ListUsersRequest';
|
||||
export type { ListUsersResponse } from './model/ListUsersResponse';
|
||||
export type { ChangeUserRoleRequest } from './model/ChangeUserRoleRequest';
|
||||
export type { UsersSettings } from './model/UsersSettings';
|
||||
export type { SendResetPasswordCodeRequest } from './model/SendResetPasswordCodeRequest';
|
||||
export type { ResetPasswordRequest } from './model/ResetPasswordRequest';
|
||||
export { UserRole } from './model/UserRole';
|
||||
export { WorkspaceRole } from './model/WorkspaceRole';
|
||||
|
||||
5
frontend/src/entity/users/model/ResetPasswordRequest.ts
Normal file
5
frontend/src/entity/users/model/ResetPasswordRequest.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface ResetPasswordRequest {
|
||||
email: string;
|
||||
code: string;
|
||||
newPassword: string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface SendResetPasswordCodeRequest {
|
||||
email: string;
|
||||
}
|
||||
@@ -204,6 +204,13 @@ export const EditBackupConfigComponent = ({
|
||||
try {
|
||||
const storages = await storageApi.getStorages(database.workspaceId);
|
||||
setStorages(storages);
|
||||
|
||||
if (IS_CLOUD) {
|
||||
const systemStorages = storages.filter((s) => s.isSystem);
|
||||
if (systemStorages.length > 0) {
|
||||
updateBackupConfig({ storage: systemStorages[0] });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
alert((e as Error).message);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export const PlaygroundWarningComponent = (): JSX.Element => {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Welcome to Databasus Playground"
|
||||
title="Welcome to Databasus playground"
|
||||
open={isVisible}
|
||||
onOk={handleClose}
|
||||
okText={
|
||||
@@ -78,9 +78,10 @@ export const PlaygroundWarningComponent = (): JSX.Element => {
|
||||
<div>
|
||||
<h3 className="mb-2 text-lg font-semibold">What is Playground?</h3>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Playground is a dev environment where you can test small databases backup and see
|
||||
Databasus in action. Databasus dev team can test new features and see issues which hard
|
||||
to detect when using self hosted (without logs or reports)
|
||||
Playground is a dev environment of Databasus development team. It is used by Databasus
|
||||
dev team to test new features and see issues which hard to detect when using self hosted
|
||||
(without logs or reports).{' '}
|
||||
<b>Here you can make backups for small and not critical databases for free</b>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +115,8 @@ export const PlaygroundWarningComponent = (): JSX.Element => {
|
||||
No, because playground use only read-only users and cannot affect your DB. Only issue
|
||||
you can face is instability: playground background workers frequently reloaded so backup
|
||||
can be slower or be restarted due to app restart. Do not rely production DBs on
|
||||
playground, please
|
||||
playground, please. At once we may clean backups or something like this. At least, check
|
||||
your backups here once a week
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export const StorageCardComponent = ({
|
||||
)}
|
||||
|
||||
{storage.isSystem && (
|
||||
<div className="mt-2 inline-block rounded-lg bg-[#ffffff10] px-2 py-1 text-xs text-gray-700 dark:text-gray-300">
|
||||
<div className="mt-2 inline-block rounded-xl bg-[#00000010] px-2 py-1 text-xs text-gray-700 dark:bg-[#ffffff10] dark:text-gray-300">
|
||||
System storage
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -143,15 +143,22 @@ export const StorageComponent = ({
|
||||
) : (
|
||||
<div>
|
||||
{!isEditName ? (
|
||||
<div className="mb-5 flex items-center text-2xl font-bold">
|
||||
{storage.name}
|
||||
{(storage.isSystem && user.role === UserRole.ADMIN) ||
|
||||
(isCanManageStorages && (
|
||||
<>
|
||||
<div className="mb-5 flex items-center text-2xl font-bold">
|
||||
{storage.name}
|
||||
{(!storage.isSystem || user.role === UserRole.ADMIN) && isCanManageStorages && (
|
||||
<div className="ml-2 cursor-pointer" onClick={() => startEdit('name')}>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{storage.isSystem && (
|
||||
<span className="mt-2 inline-block rounded-xl bg-[#00000010] px-2 py-1 text-xs text-gray-700 dark:bg-[#ffffff10] dark:text-gray-300">
|
||||
System storage
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
@@ -220,19 +227,23 @@ export const StorageComponent = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Storage settings</div>
|
||||
{!storage.isSystem ||
|
||||
(user.role === UserRole.ADMIN && (
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Storage settings</div>
|
||||
|
||||
{!isEditSettings &&
|
||||
isCanManageStorages &&
|
||||
!(storage.isSystem && user.role !== UserRole.ADMIN) ? (
|
||||
<div className="ml-2 h-4 w-4 cursor-pointer" onClick={() => startEdit('settings')}>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
{!isEditSettings && isCanManageStorages ? (
|
||||
<div
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => startEdit('settings')}
|
||||
>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-1 text-sm">
|
||||
{isEditSettings && isCanManageStorages ? (
|
||||
@@ -254,7 +265,7 @@ export const StorageComponent = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditSettings && (
|
||||
{!isEditSettings && (!storage.isSystem || user.role === UserRole.ADMIN) && (
|
||||
<div className="mt-5">
|
||||
<Button
|
||||
type="primary"
|
||||
|
||||
@@ -18,6 +18,8 @@ interface Props {
|
||||
export function ShowStorageComponent({ storage, user }: Props) {
|
||||
if (!storage) return null;
|
||||
|
||||
if (storage?.isSystem && user.role !== UserRole.ADMIN) return <div />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center">
|
||||
@@ -39,33 +41,23 @@ export function ShowStorageComponent({ storage, user }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>{storage?.type === StorageType.S3 && <ShowS3StorageComponent storage={storage} />}</div>
|
||||
|
||||
<div>
|
||||
{storage?.type === StorageType.S3 && <ShowS3StorageComponent storage={storage} />}
|
||||
|
||||
{storage?.type === StorageType.GOOGLE_DRIVE && (
|
||||
<ShowGoogleDriveStorageComponent storage={storage} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{storage?.type === StorageType.NAS && <ShowNASStorageComponent storage={storage} />}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{storage?.type === StorageType.AZURE_BLOB && (
|
||||
<ShowAzureBlobStorageComponent storage={storage} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{storage?.type === StorageType.FTP && <ShowFTPStorageComponent storage={storage} />}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{storage?.type === StorageType.SFTP && <ShowSFTPStorageComponent storage={storage} />}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{storage?.type === StorageType.RCLONE && <ShowRcloneStorageComponent storage={storage} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export { AdminPasswordComponent } from './ui/AdminPasswordComponent';
|
||||
export { AuthNavbarComponent } from './ui/AuthNavbarComponent';
|
||||
export { ProfileComponent } from './ui/ProfileComponent';
|
||||
export { RequestResetPasswordComponent } from './ui/RequestResetPasswordComponent';
|
||||
export { ResetPasswordComponent } from './ui/ResetPasswordComponent';
|
||||
export { SignInComponent } from './ui/SignInComponent';
|
||||
export { SignUpComponent } from './ui/SignUpComponent';
|
||||
|
||||
123
frontend/src/features/users/ui/RequestResetPasswordComponent.tsx
Normal file
123
frontend/src/features/users/ui/RequestResetPasswordComponent.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Button, Input } from 'antd';
|
||||
import { type JSX, useState } from 'react';
|
||||
|
||||
import { userApi } from '../../../entity/users';
|
||||
import { StringUtils } from '../../../shared/lib';
|
||||
import { FormValidator } from '../../../shared/lib/FormValidator';
|
||||
|
||||
interface RequestResetPasswordComponentProps {
|
||||
onSwitchToSignIn?: () => void;
|
||||
onSwitchToResetPassword?: (email: string) => void;
|
||||
}
|
||||
|
||||
export function RequestResetPasswordComponent({
|
||||
onSwitchToSignIn,
|
||||
onSwitchToResetPassword,
|
||||
}: RequestResetPasswordComponentProps): JSX.Element {
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [isEmailError, setEmailError] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
|
||||
const validateEmail = (): boolean => {
|
||||
if (!email) {
|
||||
setEmailError(true);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!FormValidator.isValidEmail(email)) {
|
||||
setEmailError(true);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const onSendCode = async () => {
|
||||
setError('');
|
||||
setSuccessMessage('');
|
||||
|
||||
if (validateEmail()) {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await userApi.sendResetPasswordCode({ email });
|
||||
setSuccessMessage(response.message);
|
||||
|
||||
// After successful code send, switch to reset password form
|
||||
setTimeout(() => {
|
||||
if (onSwitchToResetPassword) {
|
||||
onSwitchToResetPassword(email);
|
||||
}
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
setError(StringUtils.capitalizeFirstLetter((e as Error).message));
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[300px]">
|
||||
<div className="mb-5 text-center text-2xl font-bold">Reset password</div>
|
||||
|
||||
<div className="mb-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter your email address and we'll send you a reset code.
|
||||
</div>
|
||||
|
||||
<div className="my-1 text-xs font-semibold">Your email</div>
|
||||
<Input
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmailError(false);
|
||||
setEmail(e.currentTarget.value.trim().toLowerCase());
|
||||
}}
|
||||
status={isEmailError ? 'error' : undefined}
|
||||
type="email"
|
||||
onPressEnter={() => {
|
||||
onSendCode();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-3" />
|
||||
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onSendCode();
|
||||
}}
|
||||
type="primary"
|
||||
>
|
||||
Send reset code
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 flex justify-center text-center text-sm text-red-600">{error}</div>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<div className="mt-3 flex justify-center text-center text-sm text-green-600">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onSwitchToSignIn && (
|
||||
<div className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Remember your password?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToSignIn}
|
||||
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
frontend/src/features/users/ui/ResetPasswordComponent.tsx
Normal file
221
frontend/src/features/users/ui/ResetPasswordComponent.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { EyeInvisibleOutlined, EyeTwoTone } from '@ant-design/icons';
|
||||
import { App, Button, Input } from 'antd';
|
||||
import { type JSX, useState } from 'react';
|
||||
|
||||
import { userApi } from '../../../entity/users';
|
||||
import { StringUtils } from '../../../shared/lib';
|
||||
import { FormValidator } from '../../../shared/lib/FormValidator';
|
||||
|
||||
interface ResetPasswordComponentProps {
|
||||
onSwitchToSignIn?: () => void;
|
||||
onSwitchToRequestCode?: () => void;
|
||||
initialEmail?: string;
|
||||
}
|
||||
|
||||
export function ResetPasswordComponent({
|
||||
onSwitchToSignIn,
|
||||
onSwitchToRequestCode,
|
||||
initialEmail = '',
|
||||
}: ResetPasswordComponentProps): JSX.Element {
|
||||
const { message } = App.useApp();
|
||||
const [email, setEmail] = useState(initialEmail);
|
||||
const [code, setCode] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
const [confirmPasswordVisible, setConfirmPasswordVisible] = useState(false);
|
||||
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
|
||||
const [isEmailError, setEmailError] = useState(false);
|
||||
const [isCodeError, setCodeError] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState(false);
|
||||
const [confirmPasswordError, setConfirmPasswordError] = useState(false);
|
||||
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const validateFields = (): boolean => {
|
||||
let isValid = true;
|
||||
|
||||
if (!email) {
|
||||
setEmailError(true);
|
||||
isValid = false;
|
||||
} else if (!FormValidator.isValidEmail(email)) {
|
||||
setEmailError(true);
|
||||
isValid = false;
|
||||
} else {
|
||||
setEmailError(false);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
setCodeError(true);
|
||||
isValid = false;
|
||||
} else if (!/^\d{6}$/.test(code)) {
|
||||
setCodeError(true);
|
||||
message.error('Code must be 6 digits');
|
||||
isValid = false;
|
||||
} else {
|
||||
setCodeError(false);
|
||||
}
|
||||
|
||||
if (!newPassword) {
|
||||
setPasswordError(true);
|
||||
isValid = false;
|
||||
} else if (newPassword.length < 8) {
|
||||
setPasswordError(true);
|
||||
message.error('Password must be at least 8 characters long');
|
||||
isValid = false;
|
||||
} else {
|
||||
setPasswordError(false);
|
||||
}
|
||||
|
||||
if (!confirmPassword) {
|
||||
setConfirmPasswordError(true);
|
||||
isValid = false;
|
||||
} else if (newPassword !== confirmPassword) {
|
||||
setConfirmPasswordError(true);
|
||||
message.error('Passwords do not match');
|
||||
isValid = false;
|
||||
} else {
|
||||
setConfirmPasswordError(false);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const onResetPassword = async () => {
|
||||
setError('');
|
||||
|
||||
if (validateFields()) {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await userApi.resetPassword({
|
||||
email,
|
||||
code,
|
||||
newPassword,
|
||||
});
|
||||
|
||||
message.success('Password reset successfully! Redirecting to sign in...');
|
||||
|
||||
// Redirect to sign in after successful reset
|
||||
setTimeout(() => {
|
||||
if (onSwitchToSignIn) {
|
||||
onSwitchToSignIn();
|
||||
}
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
setError(StringUtils.capitalizeFirstLetter((e as Error).message));
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[300px]">
|
||||
<div className="mb-5 text-center text-2xl font-bold">Reset Password</div>
|
||||
|
||||
<div className="mb-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter the code sent to your email and your new password.
|
||||
</div>
|
||||
|
||||
<div className="my-1 text-xs font-semibold">Your email</div>
|
||||
<Input
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmailError(false);
|
||||
setEmail(e.currentTarget.value.trim().toLowerCase());
|
||||
}}
|
||||
status={isEmailError ? 'error' : undefined}
|
||||
type="email"
|
||||
/>
|
||||
|
||||
<div className="my-1 text-xs font-semibold">Reset Code</div>
|
||||
<Input
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={(e) => {
|
||||
setCodeError(false);
|
||||
const value = e.currentTarget.value.replace(/\D/g, '').slice(0, 6);
|
||||
setCode(value);
|
||||
}}
|
||||
status={isCodeError ? 'error' : undefined}
|
||||
maxLength={6}
|
||||
/>
|
||||
|
||||
<div className="my-1 text-xs font-semibold">New Password</div>
|
||||
<Input.Password
|
||||
placeholder="********"
|
||||
value={newPassword}
|
||||
onChange={(e) => {
|
||||
setPasswordError(false);
|
||||
setNewPassword(e.currentTarget.value);
|
||||
}}
|
||||
status={passwordError ? 'error' : undefined}
|
||||
iconRender={(visible) => (visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />)}
|
||||
visibilityToggle={{ visible: passwordVisible, onVisibleChange: setPasswordVisible }}
|
||||
/>
|
||||
|
||||
<div className="my-1 text-xs font-semibold">Confirm Password</div>
|
||||
<Input.Password
|
||||
placeholder="********"
|
||||
value={confirmPassword}
|
||||
status={confirmPasswordError ? 'error' : undefined}
|
||||
onChange={(e) => {
|
||||
setConfirmPasswordError(false);
|
||||
setConfirmPassword(e.currentTarget.value);
|
||||
}}
|
||||
iconRender={(visible) => (visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />)}
|
||||
visibilityToggle={{
|
||||
visible: confirmPasswordVisible,
|
||||
onVisibleChange: setConfirmPasswordVisible,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-3" />
|
||||
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onResetPassword();
|
||||
}}
|
||||
type="primary"
|
||||
>
|
||||
Reset password
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 flex justify-center text-center text-sm text-red-600">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
{onSwitchToRequestCode && (
|
||||
<>
|
||||
Didn't receive a code?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToRequestCode}
|
||||
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
|
||||
>
|
||||
Request new code
|
||||
</button>
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
{onSwitchToSignIn && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToSignIn}
|
||||
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { EyeInvisibleOutlined, EyeTwoTone } from '@ant-design/icons';
|
||||
import { Button, Input } from 'antd';
|
||||
import { type JSX, useState } from 'react';
|
||||
|
||||
import { GITHUB_CLIENT_ID, GOOGLE_CLIENT_ID } from '../../../constants';
|
||||
import { GITHUB_CLIENT_ID, GOOGLE_CLIENT_ID, IS_EMAIL_CONFIGURED } from '../../../constants';
|
||||
import { userApi } from '../../../entity/users';
|
||||
import { StringUtils } from '../../../shared/lib';
|
||||
import { FormValidator } from '../../../shared/lib/FormValidator';
|
||||
@@ -11,9 +11,13 @@ import { GoogleOAuthComponent } from './oauth/GoogleOAuthComponent';
|
||||
|
||||
interface SignInComponentProps {
|
||||
onSwitchToSignUp?: () => void;
|
||||
onSwitchToResetPassword?: () => void;
|
||||
}
|
||||
|
||||
export function SignInComponent({ onSwitchToSignUp }: SignInComponentProps): JSX.Element {
|
||||
export function SignInComponent({
|
||||
onSwitchToSignUp,
|
||||
onSwitchToResetPassword,
|
||||
}: SignInComponentProps): JSX.Element {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
@@ -81,7 +85,9 @@ export function SignInComponent({ onSwitchToSignUp }: SignInComponentProps): JSX
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-white px-2 text-gray-500 dark:text-gray-400">or continue</span>
|
||||
<span className="bg-white px-2 text-gray-500 dark:bg-gray-900 dark:text-gray-400">
|
||||
or continue
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -131,18 +137,26 @@ export function SignInComponent({ onSwitchToSignUp }: SignInComponentProps): JSX
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onSwitchToSignUp && (
|
||||
<div className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Don't have an account?{' '}
|
||||
<div className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToSignUp}
|
||||
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
<br />
|
||||
{IS_EMAIL_CONFIGURED && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToSignUp}
|
||||
onClick={onSwitchToResetPassword}
|
||||
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
|
||||
>
|
||||
Sign up
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,9 @@ export function SignUpComponent({ onSwitchToSignIn }: SignUpComponentProps): JSX
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-white px-2 text-gray-500 dark:text-gray-400">or continue</span>
|
||||
<span className="bg-white px-2 text-gray-500 dark:bg-gray-900 dark:text-gray-400">
|
||||
or continue
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { PlaygroundWarningComponent } from '../features/playground';
|
||||
import {
|
||||
AdminPasswordComponent,
|
||||
AuthNavbarComponent,
|
||||
RequestResetPasswordComponent,
|
||||
ResetPasswordComponent,
|
||||
SignInComponent,
|
||||
SignUpComponent,
|
||||
} from '../features/users';
|
||||
@@ -14,7 +16,10 @@ import { useScreenHeight } from '../shared/hooks';
|
||||
|
||||
export function AuthPageComponent() {
|
||||
const [isAdminHasPassword, setIsAdminHasPassword] = useState(false);
|
||||
const [authMode, setAuthMode] = useState<'signIn' | 'signUp'>('signUp');
|
||||
const [authMode, setAuthMode] = useState<'signIn' | 'signUp' | 'requestReset' | 'resetPassword'>(
|
||||
'signUp',
|
||||
);
|
||||
const [resetEmail, setResetEmail] = useState('');
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
const screenHeight = useScreenHeight();
|
||||
|
||||
@@ -51,8 +56,25 @@ export function AuthPageComponent() {
|
||||
{isAdminHasPassword ? (
|
||||
authMode === 'signUp' ? (
|
||||
<SignUpComponent onSwitchToSignIn={() => setAuthMode('signIn')} />
|
||||
) : authMode === 'signIn' ? (
|
||||
<SignInComponent
|
||||
onSwitchToSignUp={() => setAuthMode('signUp')}
|
||||
onSwitchToResetPassword={() => setAuthMode('requestReset')}
|
||||
/>
|
||||
) : authMode === 'requestReset' ? (
|
||||
<RequestResetPasswordComponent
|
||||
onSwitchToSignIn={() => setAuthMode('signIn')}
|
||||
onSwitchToResetPassword={(email) => {
|
||||
setResetEmail(email);
|
||||
setAuthMode('resetPassword');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SignInComponent onSwitchToSignUp={() => setAuthMode('signUp')} />
|
||||
<ResetPasswordComponent
|
||||
onSwitchToSignIn={() => setAuthMode('signIn')}
|
||||
onSwitchToRequestCode={() => setAuthMode('requestReset')}
|
||||
initialEmail={resetEmail}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<AdminPasswordComponent onPasswordSet={checkAdminPasswordStatus} />
|
||||
|
||||
Reference in New Issue
Block a user