From bb020e227e8cb9b4f315d48df2db2d2b2c363a55 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Fri, 25 Jul 2025 16:30:00 +0200 Subject: [PATCH 01/18] feat(backend): added email notification component --- backend/README.md | 86 ++++ backend/configuration/configuration.go | 36 ++ backend/methods/users.go | 7 +- backend/models/local_entities.go | 1 - backend/openapi.yaml | 17 +- backend/services/email/smtp.go | 226 ++++++++++ backend/services/email/templates.go | 112 +++++ backend/services/email/templates/welcome.html | 394 ++++++++++++++++++ backend/services/email/templates/welcome.txt | 40 ++ backend/services/email/welcome.go | 334 +++++++++++++++ backend/services/local/users.go | 120 +++++- backend/services/logto/applications.go | 23 +- backend/services/logto/roles.go | 16 + 13 files changed, 1385 insertions(+), 27 deletions(-) create mode 100644 backend/services/email/smtp.go create mode 100644 backend/services/email/templates.go create mode 100644 backend/services/email/templates/welcome.html create mode 100644 backend/services/email/templates/welcome.txt create mode 100644 backend/services/email/welcome.go diff --git a/backend/README.md b/backend/README.md index 6cbf2ea12..a97f85170 100644 --- a/backend/README.md +++ b/backend/README.md @@ -48,6 +48,18 @@ DATABASE_URL=postgresql://backend:backend@localhost:5432/noc?sslmode=disable # Redis connection URL REDIS_URL=redis://localhost:6379 + +# =========================================== +# SMTP EMAIL CONFIGURATION (Optional) +# =========================================== +# SMTP server configuration for welcome emails +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USERNAME=your-email@gmail.com +SMTP_PASSWORD=your-app-password +SMTP_FROM=noreply@yourdomain.com +SMTP_FROM_NAME=Nethesis Operation Center +SMTP_TLS=true ``` **Auto-derived URLs:** @@ -57,6 +69,80 @@ REDIS_URL=redis://localhost:6379 - `LOGTO_MANAGEMENT_BASE_URL` = `https://{TENANT_ID}.logto.app/api` - `JWT_ISSUER` = `{TENANT_DOMAIN}` +## Email Configuration + +The backend automatically sends welcome emails to newly created users with their temporary password and login instructions. Email functionality is optional and degrades gracefully if not configured. + +### SMTP Setup + +Configure SMTP settings in your environment: + +```bash +# SMTP server details +SMTP_HOST=smtp.gmail.com # Your SMTP server hostname +SMTP_PORT=587 # SMTP port (587 for TLS, 465 for SSL, 25 for plain) +SMTP_USERNAME=your-email@gmail.com # SMTP authentication username +SMTP_PASSWORD=your-app-password # SMTP authentication password +SMTP_FROM=noreply@yourdomain.com # From email address for outgoing emails +SMTP_FROM_NAME=Your Company Name # Display name for sender +SMTP_TLS=true # Enable TLS encryption (recommended) +``` + +### Supported Providers + +**Gmail/Google Workspace:** +```bash +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USERNAME=your-email@gmail.com +SMTP_PASSWORD=your-app-password # Use App Password, not account password +SMTP_TLS=true +``` + +**AWS SES:** +```bash +SMTP_HOST=email-smtp.us-east-1.amazonaws.com +SMTP_PORT=587 +SMTP_USERNAME=your-ses-smtp-username +SMTP_PASSWORD=your-ses-smtp-password +SMTP_TLS=true +``` + +**SendGrid:** +```bash +SMTP_HOST=smtp.sendgrid.net +SMTP_PORT=587 +SMTP_USERNAME=apikey +SMTP_PASSWORD=your-sendgrid-api-key +SMTP_TLS=true +``` + +### Email Features + +- **Automatic Delivery**: Welcome emails sent when users are created via API +- **Modern Templates**: Responsive HTML and text templates with dark/light mode support +- **Secure Password Delivery**: Auto-generated temporary passwords sent securely +- **Smart Links**: Login URLs point directly to password change page +- **Graceful Degradation**: User creation succeeds even if email delivery fails +- **Security**: Passwords never logged, email content is sanitized + +### Template Customization + +Email templates are located in `services/email/templates/`: +- `welcome.html` - Modern HTML template with dark mode support +- `welcome.txt` - Plain text fallback template + +Templates support Go template syntax with variables: +- `{{.UserName}}` - User's full name +- `{{.UserEmail}}` - User's email address +- `{{.OrganizationName}}` - Organization name +- `{{.OrganizationType}}` - Organization type (Owner, Distributor, etc.) +- `{{.UserRoles}}` - Array of user role names +- `{{.TempPassword}}` - Generated temporary password +- `{{.LoginURL}}` - Direct link to password change page +- `{{.SupportEmail}}` - Support contact email +- `{{.CompanyName}}` - Company name from SMTP configuration + ## Architecture ### Two-Layer Authorization diff --git a/backend/configuration/configuration.go b/backend/configuration/configuration.go index a60b4a4ad..b9691d2f8 100644 --- a/backend/configuration/configuration.go +++ b/backend/configuration/configuration.go @@ -54,6 +54,14 @@ type Configuration struct { DefaultPageSize int `json:"default_page_size"` // System types configuration SystemTypes []string `json:"system_types"` + // SMTP configuration for sending emails + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + SMTPUsername string `json:"smtp_username"` + SMTPPassword string `json:"smtp_password"` + SMTPFrom string `json:"smtp_from"` + SMTPFromName string `json:"smtp_from_name"` + SMTPTLS bool `json:"smtp_tls"` } var Config = Configuration{} @@ -159,6 +167,18 @@ func Init() { Config.SystemTypes = []string{"ns8", "nsec"} } + // SMTP configuration + Config.SMTPHost = os.Getenv("SMTP_HOST") + Config.SMTPPort = parseIntWithDefault("SMTP_PORT", 587) + Config.SMTPUsername = os.Getenv("SMTP_USERNAME") + Config.SMTPPassword = os.Getenv("SMTP_PASSWORD") + Config.SMTPFrom = os.Getenv("SMTP_FROM") + Config.SMTPFromName = os.Getenv("SMTP_FROM_NAME") + if Config.SMTPFromName == "" { + Config.SMTPFromName = "Nethesis Operation Center" + } + Config.SMTPTLS = parseBoolWithDefault("SMTP_TLS", true) + // Log successful configuration load logger.LogConfigLoad("env", "configuration", true, nil) } @@ -220,3 +240,19 @@ func parseStringSliceWithDefault(envVar string, defaultValue []string) []string return result } + +// parseBoolWithDefault parses a boolean from environment variable or returns default +func parseBoolWithDefault(envVar string, defaultValue bool) bool { + envValue := os.Getenv(envVar) + if envValue == "" { + return defaultValue + } + + if value, err := strconv.ParseBool(envValue); err == nil { + return value + } + + // If parsing fails, log warning and use default + logger.LogConfigLoad("env", envVar, false, fmt.Errorf("invalid boolean format, using default %t", defaultValue)) + return defaultValue +} diff --git a/backend/methods/users.go b/backend/methods/users.go index 4c0d14ba0..8788a1b10 100644 --- a/backend/methods/users.go +++ b/backend/methods/users.go @@ -30,12 +30,7 @@ func CreateUser(c *gin.Context) { return } - // Validate password using our secure validator - isValid, errorCodes := helpers.ValidatePasswordStrength(request.Password) - if !isValid { - c.JSON(http.StatusBadRequest, response.PasswordValidationBadRequest(errorCodes)) - return - } + // Password will be auto-generated by the service // Get current user context user, ok := helpers.GetUserFromContext(c) diff --git a/backend/models/local_entities.go b/backend/models/local_entities.go index 2f56a16a9..0f6adfcb8 100644 --- a/backend/models/local_entities.go +++ b/backend/models/local_entities.go @@ -145,7 +145,6 @@ type CreateLocalUserRequest struct { Email string `json:"email" validate:"required,email,max=255"` Name string `json:"name" validate:"required,min=1,max=255"` Phone *string `json:"phone,omitempty"` - Password string `json:"password" validate:"required,min=8"` UserRoleIDs []string `json:"userRoleIds,omitempty"` OrganizationID *string `json:"organizationId,omitempty"` CustomData map[string]interface{} `json:"customData,omitempty"` diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 277dfd783..2228818fb 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -454,10 +454,11 @@ components: UserInput: type: object + description: | + User creation request. Password is automatically generated by the server and sent via email. required: - email - name - - password - userRoleIds - organizationId properties: @@ -470,20 +471,6 @@ components: type: string description: Full name example: "John Doe" - password: - type: string - minLength: 12 - maxLength: 128 - description: | - Password meeting security requirements: - - At least 12 characters long - - At least one uppercase letter (A-Z) - - At least one lowercase letter (a-z) - - At least one digit (0-9) - - At least one special character (!@#$%^&*...) - - No more than 3 consecutive identical characters - - Cannot contain common weak patterns - example: "MySecureP4ssw9rd!" userRoleIds: type: array items: diff --git a/backend/services/email/smtp.go b/backend/services/email/smtp.go new file mode 100644 index 000000000..41793f0d3 --- /dev/null +++ b/backend/services/email/smtp.go @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2025 Nethesis S.r.l. + * http://www.nethesis.it - info@nethesis.it + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * author: Edoardo Spadoni + */ + +package email + +import ( + "crypto/tls" + "fmt" + "net/smtp" + + "github.com/nethesis/my/backend/configuration" + "github.com/nethesis/my/backend/logger" +) + +// EmailService handles SMTP email sending +type EmailService struct { + host string + port int + username string + password string + from string + fromName string + useTLS bool +} + +// NewEmailService creates a new email service instance +func NewEmailService() *EmailService { + return &EmailService{ + host: configuration.Config.SMTPHost, + port: configuration.Config.SMTPPort, + username: configuration.Config.SMTPUsername, + password: configuration.Config.SMTPPassword, + from: configuration.Config.SMTPFrom, + fromName: configuration.Config.SMTPFromName, + useTLS: configuration.Config.SMTPTLS, + } +} + +// EmailData contains all data needed for sending emails +type EmailData struct { + To string + Subject string + HTMLBody string + TextBody string +} + +// SendEmail sends an email using SMTP +func (e *EmailService) SendEmail(data EmailData) error { + // Validate configuration + if e.host == "" || e.from == "" { + return fmt.Errorf("SMTP configuration incomplete: host and from address are required") + } + + // Prepare message + from := fmt.Sprintf("%s <%s>", e.fromName, e.from) + + headers := make(map[string]string) + headers["From"] = from + headers["To"] = data.To + headers["Subject"] = data.Subject + headers["MIME-Version"] = "1.0" + + // Multi-part message with HTML and text + boundary := "boundary-nethesis-email" + headers["Content-Type"] = fmt.Sprintf("multipart/alternative; boundary=%s", boundary) + + // Build message + message := "" + for k, v := range headers { + message += fmt.Sprintf("%s: %s\r\n", k, v) + } + message += "\r\n" + + // Text part + if data.TextBody != "" { + message += fmt.Sprintf("--%s\r\n", boundary) + message += "Content-Type: text/plain; charset=UTF-8\r\n" + message += "Content-Transfer-Encoding: 7bit\r\n\r\n" + message += data.TextBody + "\r\n\r\n" + } + + // HTML part + if data.HTMLBody != "" { + message += fmt.Sprintf("--%s\r\n", boundary) + message += "Content-Type: text/html; charset=UTF-8\r\n" + message += "Content-Transfer-Encoding: 7bit\r\n\r\n" + message += data.HTMLBody + "\r\n\r\n" + } + + message += fmt.Sprintf("--%s--\r\n", boundary) + + // Send email + err := e.sendSMTP([]string{data.To}, []byte(message)) + if err != nil { + logger.Error(). + Err(err). + Str("to", data.To). + Str("subject", data.Subject). + Str("smtp_host", e.host). + Int("smtp_port", e.port). + Msg("Failed to send email") + return fmt.Errorf("failed to send email: %w", err) + } + + logger.Info(). + Str("to", data.To). + Str("subject", data.Subject). + Str("smtp_host", e.host). + Int("smtp_port", e.port). + Msg("Email sent successfully") + + return nil +} + +// sendSMTP handles the actual SMTP sending +func (e *EmailService) sendSMTP(to []string, body []byte) error { + addr := fmt.Sprintf("%s:%d", e.host, e.port) + + // Create connection + conn, err := smtp.Dial(addr) + if err != nil { + return fmt.Errorf("failed to connect to SMTP server: %w", err) + } + defer func() { _ = conn.Close() }() + + // Start TLS if enabled + if e.useTLS { + tlsConfig := &tls.Config{ + ServerName: e.host, + } + if err := conn.StartTLS(tlsConfig); err != nil { + return fmt.Errorf("failed to start TLS: %w", err) + } + } + + // Authenticate if credentials provided + if e.username != "" && e.password != "" { + auth := smtp.PlainAuth("", e.username, e.password, e.host) + if err := conn.Auth(auth); err != nil { + return fmt.Errorf("SMTP authentication failed: %w", err) + } + } + + // Set sender + if err := conn.Mail(e.from); err != nil { + return fmt.Errorf("failed to set sender: %w", err) + } + + // Set recipients + for _, recipient := range to { + if err := conn.Rcpt(recipient); err != nil { + return fmt.Errorf("failed to set recipient %s: %w", recipient, err) + } + } + + // Send body + w, err := conn.Data() + if err != nil { + return fmt.Errorf("failed to get data writer: %w", err) + } + + _, err = w.Write(body) + if err != nil { + return fmt.Errorf("failed to write email body: %w", err) + } + + err = w.Close() + if err != nil { + return fmt.Errorf("failed to close data writer: %w", err) + } + + return nil +} + +// IsConfigured checks if SMTP is properly configured +func (e *EmailService) IsConfigured() bool { + return e.host != "" && e.from != "" +} + +// TestConnection tests the SMTP connection +func (e *EmailService) TestConnection() error { + if !e.IsConfigured() { + return fmt.Errorf("SMTP not configured") + } + + addr := fmt.Sprintf("%s:%d", e.host, e.port) + + conn, err := smtp.Dial(addr) + if err != nil { + return fmt.Errorf("failed to connect to SMTP server: %w", err) + } + defer func() { _ = conn.Close() }() + + // Test TLS if enabled + if e.useTLS { + tlsConfig := &tls.Config{ + ServerName: e.host, + } + if err := conn.StartTLS(tlsConfig); err != nil { + return fmt.Errorf("failed to start TLS: %w", err) + } + } + + // Test authentication if credentials provided + if e.username != "" && e.password != "" { + auth := smtp.PlainAuth("", e.username, e.password, e.host) + if err := conn.Auth(auth); err != nil { + return fmt.Errorf("SMTP authentication failed: %w", err) + } + } + + logger.Info(). + Str("smtp_host", e.host). + Int("smtp_port", e.port). + Bool("smtp_tls", e.useTLS). + Bool("smtp_auth", e.username != ""). + Msg("SMTP connection test successful") + + return nil +} diff --git a/backend/services/email/templates.go b/backend/services/email/templates.go new file mode 100644 index 000000000..279d1ef76 --- /dev/null +++ b/backend/services/email/templates.go @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2025 Nethesis S.r.l. + * http://www.nethesis.it - info@nethesis.it + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * author: Edoardo Spadoni + */ + +package email + +import ( + "bytes" + "fmt" + "html/template" + "os" + "path/filepath" + "runtime" +) + +// WelcomeEmailData contains data for welcome email template +type WelcomeEmailData struct { + UserName string + UserEmail string + OrganizationName string + OrganizationType string + UserRoles []string + TempPassword string + LoginURL string + SupportEmail string + CompanyName string +} + +// TemplateService handles email template rendering +type TemplateService struct { + templateDir string +} + +// NewTemplateService creates a new template service +func NewTemplateService() *TemplateService { + // Get the current directory (services/email) + _, filename, _, _ := runtime.Caller(0) + currentDir := filepath.Dir(filename) + templateDir := filepath.Join(currentDir, "templates") + + return &TemplateService{ + templateDir: templateDir, + } +} + +// GenerateWelcomeEmail generates HTML and text versions of welcome email +func (ts *TemplateService) GenerateWelcomeEmail(data WelcomeEmailData) (htmlBody, textBody string, err error) { + // Generate HTML version + htmlBody, err = ts.renderTemplate("welcome.html", data) + if err != nil { + return "", "", fmt.Errorf("failed to render HTML template: %w", err) + } + + // Generate text version + textBody, err = ts.renderTemplate("welcome.txt", data) + if err != nil { + return "", "", fmt.Errorf("failed to render text template: %w", err) + } + + return htmlBody, textBody, nil +} + +// renderTemplate loads and renders a template file +func (ts *TemplateService) renderTemplate(templateName string, data interface{}) (string, error) { + // Load template file + templatePath := filepath.Join(ts.templateDir, templateName) + templateContent, err := os.ReadFile(templatePath) + if err != nil { + return "", fmt.Errorf("failed to read template file %s: %w", templatePath, err) + } + + // Parse template + tmpl, err := template.New(templateName).Parse(string(templateContent)) + if err != nil { + return "", fmt.Errorf("failed to parse template %s: %w", templateName, err) + } + + // Execute template + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute template %s: %w", templateName, err) + } + + return buf.String(), nil +} + +// GetTemplatePath returns the absolute path to template directory +func (ts *TemplateService) GetTemplatePath() string { + return ts.templateDir +} + +// ValidateTemplates checks if required template files exist +func (ts *TemplateService) ValidateTemplates() error { + requiredTemplates := []string{ + "welcome.html", + "welcome.txt", + } + + for _, templateName := range requiredTemplates { + templatePath := filepath.Join(ts.templateDir, templateName) + if _, err := os.Stat(templatePath); os.IsNotExist(err) { + return fmt.Errorf("required template file not found: %s", templatePath) + } + } + + return nil +} diff --git a/backend/services/email/templates/welcome.html b/backend/services/email/templates/welcome.html new file mode 100644 index 000000000..f1fce4431 --- /dev/null +++ b/backend/services/email/templates/welcome.html @@ -0,0 +1,394 @@ + + + + + + + + Welcome to {{.OrganizationName}} + + + + + + \ No newline at end of file diff --git a/backend/services/email/templates/welcome.txt b/backend/services/email/templates/welcome.txt new file mode 100644 index 000000000..77a334381 --- /dev/null +++ b/backend/services/email/templates/welcome.txt @@ -0,0 +1,40 @@ +đŸŽ¯ Welcome to {{.OrganizationName}}{{if .OrganizationType}} ({{.OrganizationType}}){{end}}! + +Hello {{.UserName}}, + +Your account has been created and is ready to go! Below you'll find everything you need to get started. + +═══════════════════════════════════════════════════════════════ + +📧 Email: {{.UserEmail}} +đŸĸ Organization: {{.OrganizationName}}{{if .OrganizationType}} ({{.OrganizationType}}){{end}} +{{if .UserRoles}}👤 Role{{if gt (len .UserRoles) 1}}s{{end}}: {{range $index, $role := .UserRoles}}{{if $index}}, {{end}}{{$role}}{{end}}{{end}} +✅ Status: Active + +═══════════════════════════════════════════════════════════════ + +🔑 TEMPORARY PASSWORD: +{{.TempPassword}} + +âš ī¸ Change this password immediately after login + +═══════════════════════════════════════════════════════════════ + +🚀 GETTING STARTED: + +1. Visit: {{.LoginURL}} +2. Enter your email: {{.UserEmail}} +3. Use the temporary password above +4. Set your new secure password +5. Start exploring the platform! + +═══════════════════════════════════════════════════════════════ + +đŸ’Ŧ SUPPORT: +Need help? Contact us at {{.SupportEmail}} + +Best regards, +{{.CompanyName}} Team + +═══════════════════════════════════════════════════════════════ +This email was sent automatically. Please do not reply. \ No newline at end of file diff --git a/backend/services/email/welcome.go b/backend/services/email/welcome.go new file mode 100644 index 000000000..3bbf0925e --- /dev/null +++ b/backend/services/email/welcome.go @@ -0,0 +1,334 @@ +/* + * Copyright (C) 2025 Nethesis S.r.l. + * http://www.nethesis.it - info@nethesis.it + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * author: Edoardo Spadoni + */ + +package email + +import ( + "crypto/rand" + "fmt" + "math/big" + "strings" + + "github.com/nethesis/my/backend/configuration" + "github.com/nethesis/my/backend/helpers" + "github.com/nethesis/my/backend/logger" + "github.com/nethesis/my/backend/services/logto" +) + +// WelcomeEmailService handles sending welcome emails to new users +type WelcomeEmailService struct { + emailService *EmailService + templateService *TemplateService +} + +// NewWelcomeEmailService creates a new welcome email service +func NewWelcomeEmailService() *WelcomeEmailService { + return &WelcomeEmailService{ + emailService: NewEmailService(), + templateService: NewTemplateService(), + } +} + +// SendWelcomeEmail sends a welcome email to a newly created user (legacy method) +func (w *WelcomeEmailService) SendWelcomeEmail(userEmail, userName, organizationName, userRole, tempPassword string) error { + // Convert to new format for backward compatibility + userRoles := []string{} + if userRole != "" { + userRoles = append(userRoles, userRole) + } + return w.SendWelcomeEmailWithRoles(userEmail, userName, organizationName, "", userRoles, tempPassword) +} + +// SendWelcomeEmailWithRoles sends a welcome email with support for multiple user roles +func (w *WelcomeEmailService) SendWelcomeEmailWithRoles(userEmail, userName, organizationName, organizationType string, userRoles []string, tempPassword string) error { + // Check if email service is configured + if !w.emailService.IsConfigured() { + logger.Warn(). + Str("user_email", userEmail). + Msg("SMTP not configured, skipping welcome email") + return nil // Don't fail user creation if email is not configured + } + + // Validate templates + if err := w.templateService.ValidateTemplates(); err != nil { + logger.Error(). + Err(err). + Str("user_email", userEmail). + Msg("Email templates validation failed") + return fmt.Errorf("email templates validation failed: %w", err) + } + + // Prepare template data + templateData := WelcomeEmailData{ + UserName: userName, + UserEmail: userEmail, + OrganizationName: organizationName, + OrganizationType: organizationType, + UserRoles: userRoles, + TempPassword: tempPassword, + LoginURL: w.getLoginURL(), + SupportEmail: w.getSupportEmail(), + CompanyName: w.getCompanyName(), + } + + // Generate email content + htmlBody, textBody, err := w.templateService.GenerateWelcomeEmail(templateData) + if err != nil { + logger.Error(). + Err(err). + Str("user_email", userEmail). + Msg("Failed to generate email content") + return fmt.Errorf("failed to generate email content: %w", err) + } + + // Prepare email data + emailData := EmailData{ + To: userEmail, + Subject: fmt.Sprintf("Welcome to %s - Account Created", organizationName), + HTMLBody: htmlBody, + TextBody: textBody, + } + + // Send email + if err := w.emailService.SendEmail(emailData); err != nil { + logger.Error(). + Err(err). + Str("user_email", userEmail). + Str("organization_name", organizationName). + Msg("Failed to send welcome email") + return fmt.Errorf("failed to send welcome email: %w", err) + } + + logger.Info(). + Str("user_email", userEmail). + Str("user_name", userName). + Str("organization_name", organizationName). + Str("organization_type", organizationType). + Strs("user_roles", userRoles). + Msg("Welcome email sent successfully") + + return nil +} + +// GenerateValidTemporaryPassword generates a secure temporary password that passes our validation +func (w *WelcomeEmailService) GenerateValidTemporaryPassword() (string, error) { + const maxAttempts = 10 + + for attempt := 0; attempt < maxAttempts; attempt++ { + password, err := w.generatePassword() + if err != nil { + return "", fmt.Errorf("failed to generate password: %w", err) + } + + // Validate using our existing validator + isValid, errors := helpers.ValidatePasswordStrength(password) + if isValid { + logger.Debug(). + Int("attempt", attempt+1). + Msg("Generated valid temporary password") + return password, nil + } + + logger.Debug(). + Int("attempt", attempt+1). + Strs("validation_errors", errors). + Msg("Generated password failed validation, retrying") + } + + return "", fmt.Errorf("failed to generate valid password after %d attempts", maxAttempts) +} + +// generatePassword creates a password that should meet our validation requirements +func (w *WelcomeEmailService) generatePassword() (string, error) { + // Generate a 14-character password (exceeds minimum requirement of 12) + const length = 14 + + // Character sets that match our validator requirements + const ( + lowerChars = "abcdefghijklmnopqrstuvwxyz" + upperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + numberChars = "0123456789" + // Use only safe special characters that are in our validator regex + specialChars = "!@#$%^&*()_+-=[]{}|;:,.<>?~" + ) + + password := make([]byte, length) + + // Ensure at least 2 characters from each required category (for better distribution) + requirements := []struct { + chars string + count int + }{ + {lowerChars, 3}, // At least 3 lowercase + {upperChars, 3}, // At least 3 uppercase + {numberChars, 2}, // At least 2 digits + {specialChars, 2}, // At least 2 special chars + } + + pos := 0 + + // Fill required characters + for _, req := range requirements { + for i := 0; i < req.count && pos < length; i++ { + char, err := w.randomChar(req.chars) + if err != nil { + return "", err + } + password[pos] = char + pos++ + } + } + + // Fill remaining positions with random mix + allChars := lowerChars + upperChars + numberChars + specialChars + for pos < length { + char, err := w.randomChar(allChars) + if err != nil { + return "", err + } + password[pos] = char + pos++ + } + + // Shuffle the password to avoid predictable patterns + for i := range password { + j, err := rand.Int(rand.Reader, big.NewInt(int64(len(password)))) + if err != nil { + return "", fmt.Errorf("failed to shuffle password: %w", err) + } + password[i], password[j.Int64()] = password[j.Int64()], password[i] + } + + return string(password), nil +} + +// randomChar returns a random character from the given character set +func (w *WelcomeEmailService) randomChar(charSet string) (byte, error) { + if len(charSet) == 0 { + return 0, fmt.Errorf("character set cannot be empty") + } + + index, err := rand.Int(rand.Reader, big.NewInt(int64(len(charSet)))) + if err != nil { + return 0, err + } + + return charSet[index.Int64()], nil +} + +// getLoginURL returns the login URL for the application +func (w *WelcomeEmailService) getLoginURL() string { + // Try to get the frontend application's redirect URI from Logto + if frontendURL := w.getFrontendRedirectURI(); frontendURL != "" { + return frontendURL + } + + // Try to get from tenant domain configuration + if configuration.Config.TenantDomain != "" { + return fmt.Sprintf("https://%s/account?change-password=true", configuration.Config.TenantDomain) + } + + // Fallback to tenant ID based URL + if configuration.Config.TenantID != "" { + return fmt.Sprintf("https://%s.logto.app/account?change-password=true", configuration.Config.TenantID) + } + + // Final fallback + return "https://localhost:3000/account?change-password=true" +} + +// getFrontendRedirectURI gets the frontend application's redirect URI from Logto +func (w *WelcomeEmailService) getFrontendRedirectURI() string { + // Create Logto client + client := logto.NewManagementClient() + + // Get all applications + apps, err := client.GetApplications() + if err != nil { + logger.Debug(). + Err(err). + Msg("Failed to get applications from Logto for redirect URI lookup") + return "" + } + + // Look for the frontend application + for _, app := range apps { + if strings.ToLower(app.Name) == "frontend" && app.Type == "SPA" { + // Get the first post logout redirect URI + if len(app.OidcClientMetadata.PostLogoutRedirectUris) > 0 { + baseURL := app.OidcClientMetadata.PostLogoutRedirectUris[0] + // Remove /login if present and add /account?change-password=true + baseURL = strings.TrimSuffix(baseURL, "/login") + baseURL = strings.TrimSuffix(baseURL, "/") + return baseURL + "/account?change-password=true" + } + // Fallback to redirect URIs if post logout not available + if len(app.OidcClientMetadata.RedirectUris) > 0 { + baseURL := app.OidcClientMetadata.RedirectUris[0] + // Remove /login if present and add /account?change-password=true + baseURL = strings.TrimSuffix(baseURL, "/login") + baseURL = strings.TrimSuffix(baseURL, "/") + return baseURL + "/account?change-password=true" + } + } + } + + logger.Debug(). + Msg("Frontend application not found in Logto applications") + return "" +} + +// getSupportEmail returns the support email address +func (w *WelcomeEmailService) getSupportEmail() string { + // Try to get from SMTP configuration + if configuration.Config.SMTPFrom != "" { + return configuration.Config.SMTPFrom + } + + // Fallback + return "support@example.com" +} + +// getCompanyName returns the company name +func (w *WelcomeEmailService) getCompanyName() string { + // Try to get from SMTP configuration + if configuration.Config.SMTPFromName != "" { + return configuration.Config.SMTPFromName + } + + // Fallback + return "Nethesis Operation Center" +} + +// IsConfigured checks if the welcome email service is properly configured +func (w *WelcomeEmailService) IsConfigured() bool { + return w.emailService.IsConfigured() +} + +// TestConfiguration tests the email service configuration +func (w *WelcomeEmailService) TestConfiguration() error { + // Test SMTP connection + if err := w.emailService.TestConnection(); err != nil { + return fmt.Errorf("SMTP connection test failed: %w", err) + } + + // Test template validation + if err := w.templateService.ValidateTemplates(); err != nil { + return fmt.Errorf("template validation failed: %w", err) + } + + // Test password generation + _, err := w.GenerateValidTemporaryPassword() + if err != nil { + return fmt.Errorf("password generation test failed: %w", err) + } + + logger.Info().Msg("Welcome email service configuration test successful") + return nil +} diff --git a/backend/services/local/users.go b/backend/services/local/users.go index 98ffb8431..55a036ec2 100644 --- a/backend/services/local/users.go +++ b/backend/services/local/users.go @@ -21,6 +21,7 @@ import ( "github.com/nethesis/my/backend/logger" "github.com/nethesis/my/backend/models" "github.com/nethesis/my/backend/response" + "github.com/nethesis/my/backend/services/email" "github.com/nethesis/my/backend/services/logto" ) @@ -59,6 +60,17 @@ func (s *LocalUserService) CreateUser(req *models.CreateLocalUserRequest, create req.Username = s.generateUsernameFromEmail(req.Email) } + // Always generate a temporary password for new users + welcomeService := email.NewWelcomeEmailService() + tempPassword, err := welcomeService.GenerateValidTemporaryPassword() + if err != nil { + return nil, fmt.Errorf("failed to generate temporary password: %w", err) + } + logger.Info(). + Str("username", req.Username). + Str("email", req.Email). + Msg("Generated temporary password for new user") + // 1. Create in Logto FIRST for validation (before consuming local resources) // Start with user-provided custom data (allows custom fields) customData := make(map[string]interface{}) @@ -77,7 +89,7 @@ func (s *LocalUserService) CreateUser(req *models.CreateLocalUserRequest, create logtoUserReq := models.CreateUserRequest{ Username: req.Username, - Password: req.Password, + Password: tempPassword, Name: req.Name, PrimaryEmail: req.Email, CustomData: customData, @@ -276,6 +288,59 @@ func (s *LocalUserService) CreateUser(req *models.CreateLocalUserRequest, create Str("created_by", createdByUserID). Msg("User created successfully with Logto sync") + // 9. Send welcome email with temporary password (non-blocking) + if req.Email != "" { // Always send email since we always generate temporary password + go func() { + welcomeService := email.NewWelcomeEmailService() + + // Get organization name and type for the email + orgName := "the organization" // fallback + orgType := "" + if req.OrganizationID != nil && *req.OrganizationID != "" { + orgName = s.getOrganizationNameByLogtoID(*req.OrganizationID) + if orgName == "" { + orgName = "the organization" + } + orgType = s.determineOrganizationRoleName(*req.OrganizationID) + } + + // Get user roles for the email + userRoles := []string{} + if len(req.UserRoleIDs) > 0 { + userRoles = s.getUserRoleNamesByIDs(req.UserRoleIDs) + } + + // Send welcome email with updated data structure + err := welcomeService.SendWelcomeEmailWithRoles( + req.Email, + req.Name, + orgName, + orgType, + userRoles, + tempPassword, // The temporary password we generated + ) + + if err != nil { + logger.Error(). + Err(err). + Str("user_id", user.ID). + Str("username", user.Username). + Str("email", req.Email). + Str("organization_name", orgName). + Strs("user_roles", userRoles). + Msg("Failed to send welcome email to user") + } else { + logger.Info(). + Str("user_id", user.ID). + Str("username", user.Username). + Str("email", req.Email). + Str("organization_name", orgName). + Strs("user_roles", userRoles). + Msg("Welcome email sent successfully to user") + } + }() + } + return user, nil } @@ -1175,3 +1240,56 @@ func (s *LocalUserService) determineOrganizationRoleName(organizationID string) // If not found in any table, it might be the owner organization return "Owner" } + +// getOrganizationNameByLogtoID gets the organization name from database using logto_id +func (s *LocalUserService) getOrganizationNameByLogtoID(logtoID string) string { + var name string + + // Check distributors table + query := `SELECT name FROM distributors WHERE logto_id = $1 AND active = TRUE` + err := database.DB.QueryRow(query, logtoID).Scan(&name) + if err == nil && name != "" { + return name + } + + // Check resellers table + query = `SELECT name FROM resellers WHERE logto_id = $1 AND active = TRUE` + err = database.DB.QueryRow(query, logtoID).Scan(&name) + if err == nil && name != "" { + return name + } + + // Check customers table + query = `SELECT name FROM customers WHERE logto_id = $1 AND active = TRUE` + err = database.DB.QueryRow(query, logtoID).Scan(&name) + if err == nil && name != "" { + return name + } + + // If not found in any table, return empty string + return "" +} + +// getUserRoleNamesByIDs gets user role names from Logto by their IDs +func (s *LocalUserService) getUserRoleNamesByIDs(roleIDs []string) []string { + if len(roleIDs) == 0 { + return []string{} + } + + roleNames := []string{} + for _, roleID := range roleIDs { + role, err := s.logtoClient.GetRoleByID(roleID) + if err != nil { + logger.Warn(). + Err(err). + Str("role_id", roleID). + Msg("Failed to get user role name from Logto") + continue + } + if role != nil { + roleNames = append(roleNames, role.Name) + } + } + + return roleNames +} diff --git a/backend/services/logto/applications.go b/backend/services/logto/applications.go index b485305fe..6e5ae9b1a 100644 --- a/backend/services/logto/applications.go +++ b/backend/services/logto/applications.go @@ -24,11 +24,11 @@ import ( "github.com/nethesis/my/backend/models" ) -// GetThirdPartyApplications retrieves all third-party applications from Logto -func (c *LogtoManagementClient) GetThirdPartyApplications() ([]models.LogtoThirdPartyApp, error) { +// GetApplications retrieves all applications from Logto +func (c *LogtoManagementClient) GetApplications() ([]models.LogtoThirdPartyApp, error) { logger.ComponentLogger("logto").Debug(). - Str("operation", "get_applications"). - Msg("Fetching third-party applications from Logto") + Str("operation", "get_all_applications"). + Msg("Fetching all applications from Logto") // Use makeRequest which handles token refresh automatically resp, err := c.makeRequest("GET", "/applications", nil) @@ -50,6 +50,21 @@ func (c *LogtoManagementClient) GetThirdPartyApplications() ([]models.LogtoThird Int("total_apps", len(logtoApps)). Msg("Fetched all applications from Logto") + return logtoApps, nil +} + +// GetThirdPartyApplications retrieves all third-party applications from Logto +func (c *LogtoManagementClient) GetThirdPartyApplications() ([]models.LogtoThirdPartyApp, error) { + logger.ComponentLogger("logto").Debug(). + Str("operation", "get_third_party_applications"). + Msg("Fetching third-party applications from Logto") + + // Get all applications first + logtoApps, err := c.GetApplications() + if err != nil { + return nil, fmt.Errorf("failed to get applications: %w", err) + } + // Filter only third-party applications var logtoThirdPartyApps []models.LogtoThirdPartyApp for _, app := range logtoApps { diff --git a/backend/services/logto/roles.go b/backend/services/logto/roles.go index 402400763..7131cddff 100644 --- a/backend/services/logto/roles.go +++ b/backend/services/logto/roles.go @@ -141,6 +141,22 @@ func (c *LogtoManagementClient) GetRoleByName(roleName string) (*models.LogtoRol return nil, fmt.Errorf("role '%s' not found", roleName) } +// GetRoleByID finds a role by ID +func (c *LogtoManagementClient) GetRoleByID(roleID string) (*models.LogtoRole, error) { + roles, err := c.GetAllRoles() + if err != nil { + return nil, err + } + + for _, role := range roles { + if role.ID == roleID { + return &role, nil + } + } + + return nil, fmt.Errorf("role with ID '%s' not found", roleID) +} + // GetAllOrganizationRoles fetches all organization roles from Logto Management API func (c *LogtoManagementClient) GetAllOrganizationRoles() ([]models.LogtoOrganizationRole, error) { resp, err := c.makeRequest("GET", "/organization-roles", nil) From ecdeeacc76db1c538ed2c2bff2f879bdc724bb7f Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Fri, 25 Jul 2025 22:26:01 +0200 Subject: [PATCH 02/18] fix(backend): reorganize code for email service --- backend/.env.example | 13 ++ backend/README.md | 63 +++++-- backend/helpers/context.go | 25 --- backend/helpers/password_generation.go | 124 ++++++++++++++ backend/middleware/rbac.go | 27 +-- backend/middleware/rbac_integration_test.go | 20 +-- backend/services/email/smtp.go | 60 ++----- backend/services/email/templates.go | 47 +++--- backend/services/email/templates/welcome.html | 4 +- backend/services/email/welcome.go | 154 +----------------- backend/services/local/users.go | 102 ++++-------- backend/services/logto/applications.go | 8 + backend/services/logto/auth.go | 4 + backend/services/logto/client.go | 18 +- backend/services/logto/organizations.go | 4 + backend/services/logto/roles.go | 8 + backend/services/logto/users.go | 4 + render.yaml | 31 ++++ 18 files changed, 358 insertions(+), 358 deletions(-) create mode 100644 backend/helpers/password_generation.go diff --git a/backend/.env.example b/backend/.env.example index dbb08ba87..370160e85 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -22,6 +22,19 @@ DATABASE_URL=postgresql://noc_user:noc_password@localhost:5432/noc?sslmode=disab # Redis connection URL REDIS_URL=redis://localhost:6379 +# =========================================== +# SMTP EMAIL CONFIGURATION (Optional) +# =========================================== +# SMTP server configuration for welcome emails +# If not configured, welcome emails will be skipped (user creation still succeeds) +#SMTP_HOST=smtp.gmail.com +#SMTP_PORT=587 +#SMTP_USERNAME=your-email@gmail.com +#SMTP_PASSWORD=your-app-password +#SMTP_FROM=noreply@yourdomain.com +#SMTP_FROM_NAME=Nethesis Operation Center +#SMTP_TLS=true + # =========================================== # OPTIONAL CONFIGURATION # =========================================== diff --git a/backend/README.md b/backend/README.md index a97f85170..db0cb44eb 100644 --- a/backend/README.md +++ b/backend/README.md @@ -250,6 +250,8 @@ make validate-docs ``` ### Testing + +#### Authentication Testing ```bash # Test token exchange curl -X POST http://localhost:8080/api/auth/exchange \ @@ -261,21 +263,58 @@ curl -X GET http://localhost:8080/api/me \ -H "Authorization: Bearer YOUR_CUSTOM_JWT" ``` +#### Email Testing +```bash +# Test welcome email service configuration +curl -X POST http://localhost:8080/api/users \ + -H "Authorization: Bearer YOUR_JWT" \ + -H "Content-Type: application/json" \ + -d '{ + "email": "test@example.com", + "name": "Test User", + "userRoleIds": ["role_123"], + "organizationId": "org_456" + }' + +# Check logs for email delivery status +docker logs backend-container 2>&1 | grep "Welcome email" +``` + +#### SMTP Connection Testing +The backend automatically validates SMTP configuration on startup. Check logs for: +``` +{"level":"info","message":"Welcome email service configuration test successful"} +``` + +If SMTP is misconfigured, you'll see: +``` +{"level":"warn","message":"SMTP not configured, skipping welcome email"} +``` + ## Project Structure ``` backend/ -├── main.go # Server entry point -├── cache/ # Redis caching system -├── configuration/ # Environment config -├── helpers/ # Utilities for JWT context -├── jwt/ # Utilities for JWT claims -├── logger/ # Structured logging -├── methods/ # HTTP handlers -├── middleware/ # Auth and RBAC middleware -├── models/ # Data structures -├── response/ # HTTP response helpers -├── services/ # Business logic -└── .env.example # Environment variables template +├── main.go # Server entry point +├── cache/ # Redis caching system +├── configuration/ # Environment config +├── helpers/ # Utilities for JWT context +├── jwt/ # Utilities for JWT claims +├── logger/ # Structured logging +├── methods/ # HTTP handlers +├── middleware/ # Auth and RBAC middleware +├── models/ # Data structures +├── response/ # HTTP response helpers +├── services/ # Business logic +│ ├── email/ # Email service +│ │ ├── smtp.go # SMTP client implementation +│ │ ├── templates.go # Template rendering service +│ │ ├── welcome.go # Welcome email service +│ │ └── templates/ # Email templates +│ │ ├── welcome.html # HTML email template +│ │ └── welcome.txt # Text email template +│ ├── local/ # Local database services +│ └── logto/ # Logto API integration +└── .env.example # Environment variables template ``` diff --git a/backend/helpers/context.go b/backend/helpers/context.go index ef5a85a06..cf40df533 100644 --- a/backend/helpers/context.go +++ b/backend/helpers/context.go @@ -37,31 +37,6 @@ func GetUserFromContext(c *gin.Context) (*models.User, bool) { return user, true } -// GetUserContext extracts user ID and role information from Gin context -// Returns userID, userOrgRole, userRole strings -func GetUserContext(c *gin.Context) (string, string, string) { - userInterface, exists := c.Get("user") - if !exists { - return "", "", "" - } - - user, ok := userInterface.(*models.User) - if !ok { - return "", "", "" - } - - userID := user.ID - userOrgRole := user.OrgRole - userRole := "" - - // Extract user role (highest privilege role) - if len(user.UserRoles) > 0 { - userRole = user.UserRoles[0] - } - - return userID, userOrgRole, userRole -} - // GetUserContextExtended extracts user ID, organization ID, and role information from Gin context // Returns userID, userOrgID, userOrgRole, userRole strings func GetUserContextExtended(c *gin.Context) (string, string, string, string) { diff --git a/backend/helpers/password_generation.go b/backend/helpers/password_generation.go new file mode 100644 index 000000000..9d9adaa1c --- /dev/null +++ b/backend/helpers/password_generation.go @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2025 Nethesis S.r.l. + * http://www.nethesis.it - info@nethesis.it + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * author: Edoardo Spadoni + */ + +package helpers + +import ( + "crypto/rand" + "fmt" + "math/big" + + "github.com/nethesis/my/backend/logger" +) + +// GeneratePassword generates a secure temporary password that passes our validation +func GeneratePassword() (string, error) { + const maxAttempts = 10 + + for attempt := 0; attempt < maxAttempts; attempt++ { + password, err := generatePassword() + if err != nil { + return "", fmt.Errorf("failed to generate password: %w", err) + } + + // Validate using our existing validator + isValid, errors := ValidatePasswordStrength(password) + if isValid { + logger.Debug(). + Int("attempt", attempt+1). + Msg("Generated valid temporary password") + return password, nil + } + + logger.Debug(). + Int("attempt", attempt+1). + Strs("validation_errors", errors). + Msg("Generated password failed validation, retrying") + } + + return "", fmt.Errorf("failed to generate valid password after %d attempts", maxAttempts) +} + +// generatePassword creates a password that should meet our validation requirements +func generatePassword() (string, error) { + // Generate a 14-character password (exceeds minimum requirement of 12) + const length = 14 + + // Character sets that match our validator requirements + const ( + lowerChars = "abcdefghijklmnopqrstuvwxyz" + upperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + numberChars = "0123456789" + // Use only safe special characters that are in our validator regex + specialChars = "!@#$%^&*()_+-=[]{}|;:,.<>?~" + ) + + password := make([]byte, length) + + // Ensure at least 2 characters from each required category (for better distribution) + requirements := []struct { + chars string + count int + }{ + {lowerChars, 3}, // At least 3 lowercase + {upperChars, 3}, // At least 3 uppercase + {numberChars, 2}, // At least 2 digits + {specialChars, 2}, // At least 2 special chars + } + + pos := 0 + + // Fill required characters + for _, req := range requirements { + for i := 0; i < req.count && pos < length; i++ { + char, err := randomChar(req.chars) + if err != nil { + return "", err + } + password[pos] = char + pos++ + } + } + + // Fill remaining positions with random mix + allChars := lowerChars + upperChars + numberChars + specialChars + for pos < length { + char, err := randomChar(allChars) + if err != nil { + return "", err + } + password[pos] = char + pos++ + } + + // Shuffle the password to avoid predictable patterns + for i := range password { + j, err := rand.Int(rand.Reader, big.NewInt(int64(len(password)))) + if err != nil { + return "", fmt.Errorf("failed to shuffle password: %w", err) + } + password[i], password[j.Int64()] = password[j.Int64()], password[i] + } + + return string(password), nil +} + +// randomChar returns a random character from the given character set +func randomChar(charSet string) (byte, error) { + if len(charSet) == 0 { + return 0, fmt.Errorf("character set cannot be empty") + } + + index, err := rand.Int(rand.Reader, big.NewInt(int64(len(charSet)))) + if err != nil { + return 0, err + } + + return charSet[index.Int64()], nil +} diff --git a/backend/middleware/rbac.go b/backend/middleware/rbac.go index 2e1b8a49f..ff8a7db1d 100644 --- a/backend/middleware/rbac.go +++ b/backend/middleware/rbac.go @@ -30,10 +30,10 @@ func RequirePermission(permission string) gin.HandlerFunc { } // Check if user has permission via User Roles (technical capabilities) - hasUserPermission := hasPermissionInList(user.UserPermissions, permission) + hasUserPermission := hasStringInList(user.UserPermissions, permission) // Check if user has permission via Organization Role (business hierarchy) - hasOrgPermission := hasPermissionInList(user.OrgPermissions, permission) + hasOrgPermission := hasStringInList(user.OrgPermissions, permission) if hasUserPermission || hasOrgPermission { logger.RequestLogger(c, "rbac").Info(). @@ -88,7 +88,7 @@ func RequireUserRole(role string) gin.HandlerFunc { return } - if !hasRoleInList(user.UserRoles, role) { + if !hasStringInList(user.UserRoles, role) { logger.RequestLogger(c, "rbac").Warn(). Str("operation", "user_role_denied"). Str("required_user_role", role). @@ -224,7 +224,7 @@ func RequireAnyUserRole(roles ...string) gin.HandlerFunc { } for _, role := range roles { - if hasRoleInList(user.UserRoles, role) { + if hasStringInList(user.UserRoles, role) { logger.RequestLogger(c, "rbac").Info(). Str("operation", "any_user_role_granted"). Strs("required_user_roles", roles). @@ -283,18 +283,9 @@ func getUserFromContext(c *gin.Context) (*models.User, bool) { return user, true } -func hasPermissionInList(permissions []string, permission string) bool { - for _, p := range permissions { - if p == permission { - return true - } - } - return false -} - -func hasRoleInList(roles []string, role string) bool { - for _, r := range roles { - if r == role { +func hasStringInList(list []string, target string) bool { + for _, item := range list { + if item == target { return true } } @@ -327,10 +318,10 @@ func RequireResourcePermission(resource string) gin.HandlerFunc { } // Check if user has permission via User Roles (technical capabilities) - hasUserPermission := hasPermissionInList(user.UserPermissions, requiredPermission) + hasUserPermission := hasStringInList(user.UserPermissions, requiredPermission) // Check if user has permission via Organization Role (business hierarchy) - hasOrgPermission := hasPermissionInList(user.OrgPermissions, requiredPermission) + hasOrgPermission := hasStringInList(user.OrgPermissions, requiredPermission) if hasUserPermission || hasOrgPermission { logger.RequestLogger(c, "rbac").Info(). diff --git a/backend/middleware/rbac_integration_test.go b/backend/middleware/rbac_integration_test.go index 56bd0331c..b07b0fcd4 100644 --- a/backend/middleware/rbac_integration_test.go +++ b/backend/middleware/rbac_integration_test.go @@ -381,22 +381,22 @@ func TestGetUserFromContext(t *testing.T) { } func TestHelperFunctions(t *testing.T) { - t.Run("hasPermissionInList", func(t *testing.T) { + t.Run("hasStringInList", func(t *testing.T) { permissions := []string{"read:systems", "manage:accounts", "view:logs"} - assert.True(t, hasPermissionInList(permissions, "read:systems")) - assert.True(t, hasPermissionInList(permissions, "manage:accounts")) - assert.False(t, hasPermissionInList(permissions, "delete:systems")) - assert.False(t, hasPermissionInList([]string{}, "any:permission")) + assert.True(t, hasStringInList(permissions, "read:systems")) + assert.True(t, hasStringInList(permissions, "manage:accounts")) + assert.False(t, hasStringInList(permissions, "delete:systems")) + assert.False(t, hasStringInList([]string{}, "any:permission")) }) - t.Run("hasRoleInList", func(t *testing.T) { + t.Run("hasStringInList", func(t *testing.T) { roles := []string{"Admin", "Support", "Viewer"} - assert.True(t, hasRoleInList(roles, "Admin")) - assert.True(t, hasRoleInList(roles, "Support")) - assert.False(t, hasRoleInList(roles, "SuperAdmin")) - assert.False(t, hasRoleInList([]string{}, "Admin")) + assert.True(t, hasStringInList(roles, "Admin")) + assert.True(t, hasStringInList(roles, "Support")) + assert.False(t, hasStringInList(roles, "SuperAdmin")) + assert.False(t, hasStringInList([]string{}, "Admin")) }) } diff --git a/backend/services/email/smtp.go b/backend/services/email/smtp.go index 41793f0d3..0273cbffc 100644 --- a/backend/services/email/smtp.go +++ b/backend/services/email/smtp.go @@ -50,6 +50,10 @@ type EmailData struct { TextBody string } +// ============================================================================= +// PUBLIC METHODS +// ============================================================================= + // SendEmail sends an email using SMTP func (e *EmailService) SendEmail(data EmailData) error { // Validate configuration @@ -118,6 +122,15 @@ func (e *EmailService) SendEmail(data EmailData) error { return nil } +// IsConfigured checks if SMTP is properly configured +func (e *EmailService) IsConfigured() bool { + return e.host != "" && e.from != "" +} + +// ============================================================================= +// PRIVATE METHODS +// ============================================================================= + // sendSMTP handles the actual SMTP sending func (e *EmailService) sendSMTP(to []string, body []byte) error { addr := fmt.Sprintf("%s:%d", e.host, e.port) @@ -177,50 +190,3 @@ func (e *EmailService) sendSMTP(to []string, body []byte) error { return nil } - -// IsConfigured checks if SMTP is properly configured -func (e *EmailService) IsConfigured() bool { - return e.host != "" && e.from != "" -} - -// TestConnection tests the SMTP connection -func (e *EmailService) TestConnection() error { - if !e.IsConfigured() { - return fmt.Errorf("SMTP not configured") - } - - addr := fmt.Sprintf("%s:%d", e.host, e.port) - - conn, err := smtp.Dial(addr) - if err != nil { - return fmt.Errorf("failed to connect to SMTP server: %w", err) - } - defer func() { _ = conn.Close() }() - - // Test TLS if enabled - if e.useTLS { - tlsConfig := &tls.Config{ - ServerName: e.host, - } - if err := conn.StartTLS(tlsConfig); err != nil { - return fmt.Errorf("failed to start TLS: %w", err) - } - } - - // Test authentication if credentials provided - if e.username != "" && e.password != "" { - auth := smtp.PlainAuth("", e.username, e.password, e.host) - if err := conn.Auth(auth); err != nil { - return fmt.Errorf("SMTP authentication failed: %w", err) - } - } - - logger.Info(). - Str("smtp_host", e.host). - Int("smtp_port", e.port). - Bool("smtp_tls", e.useTLS). - Bool("smtp_auth", e.username != ""). - Msg("SMTP connection test successful") - - return nil -} diff --git a/backend/services/email/templates.go b/backend/services/email/templates.go index 279d1ef76..2833b4f9c 100644 --- a/backend/services/email/templates.go +++ b/backend/services/email/templates.go @@ -48,6 +48,10 @@ func NewTemplateService() *TemplateService { } } +// ============================================================================= +// PUBLIC METHODS +// ============================================================================= + // GenerateWelcomeEmail generates HTML and text versions of welcome email func (ts *TemplateService) GenerateWelcomeEmail(data WelcomeEmailData) (htmlBody, textBody string, err error) { // Generate HTML version @@ -65,6 +69,27 @@ func (ts *TemplateService) GenerateWelcomeEmail(data WelcomeEmailData) (htmlBody return htmlBody, textBody, nil } +// ValidateTemplates checks if required template files exist +func (ts *TemplateService) ValidateTemplates() error { + requiredTemplates := []string{ + "welcome.html", + "welcome.txt", + } + + for _, templateName := range requiredTemplates { + templatePath := filepath.Join(ts.templateDir, templateName) + if _, err := os.Stat(templatePath); os.IsNotExist(err) { + return fmt.Errorf("required template file not found: %s", templatePath) + } + } + + return nil +} + +// ============================================================================= +// PRIVATE METHODS +// ============================================================================= + // renderTemplate loads and renders a template file func (ts *TemplateService) renderTemplate(templateName string, data interface{}) (string, error) { // Load template file @@ -88,25 +113,3 @@ func (ts *TemplateService) renderTemplate(templateName string, data interface{}) return buf.String(), nil } - -// GetTemplatePath returns the absolute path to template directory -func (ts *TemplateService) GetTemplatePath() string { - return ts.templateDir -} - -// ValidateTemplates checks if required template files exist -func (ts *TemplateService) ValidateTemplates() error { - requiredTemplates := []string{ - "welcome.html", - "welcome.txt", - } - - for _, templateName := range requiredTemplates { - templatePath := filepath.Join(ts.templateDir, templateName) - if _, err := os.Stat(templatePath); os.IsNotExist(err) { - return fmt.Errorf("required template file not found: %s", templatePath) - } - } - - return nil -} diff --git a/backend/services/email/templates/welcome.html b/backend/services/email/templates/welcome.html index f1fce4431..4529cb890 100644 --- a/backend/services/email/templates/welcome.html +++ b/backend/services/email/templates/welcome.html @@ -9,7 +9,7 @@ - - + + diff --git a/backend/services/email/templates/welcome.txt b/backend/services/email/templates/welcome.txt index 77a334381..cfa8b103b 100644 --- a/backend/services/email/templates/welcome.txt +++ b/backend/services/email/templates/welcome.txt @@ -1,4 +1,4 @@ -đŸŽ¯ Welcome to {{.OrganizationName}}{{if .OrganizationType}} ({{.OrganizationType}}){{end}}! +Welcome to My Nethesis! Hello {{.UserName}}, @@ -9,7 +9,6 @@ Your account has been created and is ready to go! Below you'll find everything y 📧 Email: {{.UserEmail}} đŸĸ Organization: {{.OrganizationName}}{{if .OrganizationType}} ({{.OrganizationType}}){{end}} {{if .UserRoles}}👤 Role{{if gt (len .UserRoles) 1}}s{{end}}: {{range $index, $role := .UserRoles}}{{if $index}}, {{end}}{{$role}}{{end}}{{end}} -✅ Status: Active ═══════════════════════════════════════════════════════════════ From b3c6812cb8d4e9fe818716e02f193103e6470490 Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Wed, 30 Jul 2025 12:38:40 +0200 Subject: [PATCH 12/18] fix: login logo --- frontend/src/assets/login_logo.svg | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/frontend/src/assets/login_logo.svg b/frontend/src/assets/login_logo.svg index b80b77d6e..c04669762 100644 --- a/frontend/src/assets/login_logo.svg +++ b/frontend/src/assets/login_logo.svg @@ -1,14 +1,13 @@ - - - - - - - - - - - - - + + + + + + + + + + + + From eb2f2822a473950e47d99db13fcfeeb499af04d0 Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Wed, 30 Jul 2025 12:39:00 +0200 Subject: [PATCH 13/18] fix: show validation error on username --- frontend/src/components/users/CreateOrEditUserDrawer.vue | 8 +++++++- frontend/src/i18n/en/translation.json | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/users/CreateOrEditUserDrawer.vue b/frontend/src/components/users/CreateOrEditUserDrawer.vue index d2ed613e8..fbc15722a 100644 --- a/frontend/src/components/users/CreateOrEditUserDrawer.vue +++ b/frontend/src/components/users/CreateOrEditUserDrawer.vue @@ -343,7 +343,13 @@ async function saveUser() { ref="nameRef" v-model.trim="name" :label="$t('users.name')" - :invalid-message="validationIssues.name?.[0] ? $t(validationIssues.name[0]) : ''" + :invalid-message=" + validationIssues.name?.[0] + ? $t(validationIssues.name[0]) + : validationIssues.username?.[0] + ? $t(validationIssues.username[0]) + : '' + " :disabled="saving" /> diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index 591c8887c..db3eaa0ed 100644 --- a/frontend/src/i18n/en/translation.json +++ b/frontend/src/i18n/en/translation.json @@ -204,7 +204,8 @@ "copy_credentials": "Copy credentials", "credentials_copied": "User credentials copied", "password_changed": "Password changed", - "password_changed_description": "Your password has been changed" + "password_changed_description": "Your password has been changed", + "username_already_exists": "Username already exists" }, "third_party_apps": { "description_example_company_com": "Description of Example app" From 0d77a448eb9abbb76cc90df70e316cdbd2ca79e5 Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Wed, 30 Jul 2025 12:49:44 +0200 Subject: [PATCH 14/18] fix: i18n strings for user roles --- frontend/src/components/account/ProfilePanel.vue | 2 +- frontend/src/components/users/UsersTable.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/account/ProfilePanel.vue b/frontend/src/components/account/ProfilePanel.vue index b8b6d99bd..96030f781 100644 --- a/frontend/src/components/account/ProfilePanel.vue +++ b/frontend/src/components/account/ProfilePanel.vue @@ -174,7 +174,7 @@ function validate(profile: ProfileInfo): boolean { { Date: Tue, 29 Jul 2025 08:39:20 +0200 Subject: [PATCH 15/18] chore: trigger rebuild From 5711ee53c86f3895bddeb18f812f3bcfb23f3fb0 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Wed, 30 Jul 2025 13:55:15 +0200 Subject: [PATCH 16/18] build: added render-build trigger file --- backend/.render-build-trigger | 10 ++++++++++ backend/Containerfile | 3 +++ collect/.render-build-trigger | 10 ++++++++++ collect/Containerfile | 3 +++ frontend/.render-build-trigger | 10 ++++++++++ frontend/Containerfile | 3 +++ 6 files changed, 39 insertions(+) create mode 100644 backend/.render-build-trigger create mode 100644 collect/.render-build-trigger create mode 100644 frontend/.render-build-trigger diff --git a/backend/.render-build-trigger b/backend/.render-build-trigger new file mode 100644 index 000000000..1b4b5aaff --- /dev/null +++ b/backend/.render-build-trigger @@ -0,0 +1,10 @@ +# Render Build Trigger File +# This file is used to force Docker service rebuilds in PR previews +# Modify LAST_UPDATE to trigger rebuilds + +LAST_UPDATE=2025-07-30T00:00:00Z + +# Instructions: +# 1. To force rebuild of Docker services in a PR, update LAST_UPDATE +# 2. Run: perl -i -pe "s/LAST_UPDATE=.*/LAST_UPDATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)/" .render-build-trigger +# 2. Commit and push changes to trigger Docker rebuilds \ No newline at end of file diff --git a/backend/Containerfile b/backend/Containerfile index e3d845c51..b130d5e26 100644 --- a/backend/Containerfile +++ b/backend/Containerfile @@ -7,6 +7,9 @@ RUN apk add --no-cache git ca-certificates # Set working directory WORKDIR /app +# Copy build trigger file to force rebuilds when it changes +COPY .render-build-trigger /tmp/build-trigger + # Copy go mod files first for better caching COPY go.mod go.sum ./ diff --git a/collect/.render-build-trigger b/collect/.render-build-trigger new file mode 100644 index 000000000..1b4b5aaff --- /dev/null +++ b/collect/.render-build-trigger @@ -0,0 +1,10 @@ +# Render Build Trigger File +# This file is used to force Docker service rebuilds in PR previews +# Modify LAST_UPDATE to trigger rebuilds + +LAST_UPDATE=2025-07-30T00:00:00Z + +# Instructions: +# 1. To force rebuild of Docker services in a PR, update LAST_UPDATE +# 2. Run: perl -i -pe "s/LAST_UPDATE=.*/LAST_UPDATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)/" .render-build-trigger +# 2. Commit and push changes to trigger Docker rebuilds \ No newline at end of file diff --git a/collect/Containerfile b/collect/Containerfile index 52b7591c9..7bb850faa 100644 --- a/collect/Containerfile +++ b/collect/Containerfile @@ -7,6 +7,9 @@ RUN apk add --no-cache git ca-certificates # Set working directory WORKDIR /app +# Copy build trigger file to force rebuilds when it changes +COPY .render-build-trigger /tmp/build-trigger + # Copy go mod files first for better caching COPY go.mod go.sum ./ diff --git a/frontend/.render-build-trigger b/frontend/.render-build-trigger new file mode 100644 index 000000000..1b4b5aaff --- /dev/null +++ b/frontend/.render-build-trigger @@ -0,0 +1,10 @@ +# Render Build Trigger File +# This file is used to force Docker service rebuilds in PR previews +# Modify LAST_UPDATE to trigger rebuilds + +LAST_UPDATE=2025-07-30T00:00:00Z + +# Instructions: +# 1. To force rebuild of Docker services in a PR, update LAST_UPDATE +# 2. Run: perl -i -pe "s/LAST_UPDATE=.*/LAST_UPDATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)/" .render-build-trigger +# 2. Commit and push changes to trigger Docker rebuilds \ No newline at end of file diff --git a/frontend/Containerfile b/frontend/Containerfile index 7fee5d769..e2c8ed830 100644 --- a/frontend/Containerfile +++ b/frontend/Containerfile @@ -3,6 +3,9 @@ FROM node:20-alpine AS builder WORKDIR /app +# Copy build trigger file to force rebuilds when it changes +COPY .render-build-trigger /tmp/build-trigger + # Copy package files COPY package*.json ./ From 5913011d83bede6bdc864b333fea948720c3e86e Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Wed, 30 Jul 2025 14:02:53 +0200 Subject: [PATCH 17/18] build: added github action for build trigger --- .github/workflows/pr-build-trigger.yml | 107 ++++++++++++++++++ ...pr-template.yml => pr-update-template.yml} | 0 2 files changed, 107 insertions(+) create mode 100644 .github/workflows/pr-build-trigger.yml rename .github/workflows/{update-pr-template.yml => pr-update-template.yml} (100%) diff --git a/.github/workflows/pr-build-trigger.yml b/.github/workflows/pr-build-trigger.yml new file mode 100644 index 000000000..1429e34df --- /dev/null +++ b/.github/workflows/pr-build-trigger.yml @@ -0,0 +1,107 @@ +name: Update Build Triggers + +on: + pull_request: + types: [opened, reopened] + branches: [ main ] + +jobs: + update-build-triggers: + name: Update Build Triggers + runs-on: ubuntu-latest + + # Only run if not triggered by the bot itself to avoid infinite loops + if: github.actor != 'github-actions[bot]' + + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Configure Git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Update build trigger files + run: | + # Get current timestamp in ISO format + TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) + echo "Updating build triggers with timestamp: $TIMESTAMP" + + # Update all .render-build-trigger files + UPDATED_FILES=() + + for component in backend collect frontend proxy; do + if [ -f "$component/.render-build-trigger" ]; then + echo "Updating $component/.render-build-trigger" + perl -i -pe "s/LAST_UPDATE=.*/LAST_UPDATE=$TIMESTAMP/" "$component/.render-build-trigger" + UPDATED_FILES+=("$component/.render-build-trigger") + else + echo "Warning: $component/.render-build-trigger not found, skipping" + fi + done + + # Check if any files were actually changed + if git diff --quiet; then + echo "No changes detected in build trigger files" + echo "changed=false" >> $GITHUB_OUTPUT + else + echo "Build trigger files updated successfully" + echo "changed=true" >> $GITHUB_OUTPUT + + # Show what changed + echo "Changed files:" + git diff --name-only + + # Show the diff + echo "Changes:" + git diff + fi + id: update + + - name: Commit and push changes + if: steps.update.outputs.changed == 'true' + run: | + # Add all modified .render-build-trigger files + git add */.render-build-trigger + + # Commit the changes + git commit -m "chore: update build triggers for PR deployment + + Auto-updated .render-build-trigger files to ensure all services + are deployed in PR preview environments. + + 🤖 Generated by GitHub Actions" + + # Push changes back to the PR branch + git push origin ${{ github.head_ref }} + + - name: Add comment to PR + if: steps.update.outputs.changed == 'true' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '🚀 **Build triggers updated!**\n\nAll `.render-build-trigger` files have been automatically updated to ensure fresh deployments of all services in the PR preview environment.\n\n_This comment was generated automatically by GitHub Actions._' + }) + + - name: Summary + run: | + if [ "${{ steps.update.outputs.changed }}" == "true" ]; then + echo "✅ Build triggers updated and committed to PR" + echo "All Docker services will be rebuilt in Render preview environment" + else + echo "â„šī¸ No build trigger updates needed" + echo "Build trigger timestamps are already current" + fi \ No newline at end of file diff --git a/.github/workflows/update-pr-template.yml b/.github/workflows/pr-update-template.yml similarity index 100% rename from .github/workflows/update-pr-template.yml rename to .github/workflows/pr-update-template.yml From 8dd406e504b18730e1437523e7bb35bc5ed5f9c2 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Wed, 30 Jul 2025 16:20:46 +0200 Subject: [PATCH 18/18] fix(backend): update welcome.html template --- backend/services/email/templates.go | 9 +- backend/services/email/templates/welcome.html | 755 +++++++----------- 2 files changed, 294 insertions(+), 470 deletions(-) diff --git a/backend/services/email/templates.go b/backend/services/email/templates.go index 2833b4f9c..64a828b35 100644 --- a/backend/services/email/templates.go +++ b/backend/services/email/templates.go @@ -16,6 +16,7 @@ import ( "os" "path/filepath" "runtime" + "strings" ) // WelcomeEmailData contains data for welcome email template @@ -99,8 +100,14 @@ func (ts *TemplateService) renderTemplate(templateName string, data interface{}) return "", fmt.Errorf("failed to read template file %s: %w", templatePath, err) } + // Create template with custom functions + funcMap := template.FuncMap{ + "lower": strings.ToLower, + "upper": strings.ToUpper, + } + // Parse template - tmpl, err := template.New(templateName).Parse(string(templateContent)) + tmpl, err := template.New(templateName).Funcs(funcMap).Parse(string(templateContent)) if err != nil { return "", fmt.Errorf("failed to parse template %s: %w", templateName, err) } diff --git a/backend/services/email/templates/welcome.html b/backend/services/email/templates/welcome.html index 9f0b97bf5..eb9dc041a 100644 --- a/backend/services/email/templates/welcome.html +++ b/backend/services/email/templates/welcome.html @@ -3,477 +3,294 @@ - - Welcome to {{.OrganizationName}} - - - + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+ Nethesis logo +
+ Welcome to My Nethesis +
+ Your account is ready to go +
+
+ + + + + + + + + +
+ Hello {{.UserName}}, +
+ Welcome to My Nethesis! Your account has been successfully created and you're ready to get started. Below you'll find your login credentials and everything you need to access the platform. +
+ + + + + + + + + + + {{if .UserRoles}} + + + + {{end}} + + + + + + +
+ + + + +
+ + + + + + + +
+ EMAIL +
+ {{.UserEmail}} +
+
+
+ + + + +
+ + + + + + + +
+ ROLE{{if gt (len .UserRoles) 1}}S{{end}} +
+ {{range $index, $role := .UserRoles}}{{if $index}}, {{end}}{{$role}}{{end}} +
+
+
+ + + + +
+ + + + + + + +
+ ORGANIZATION +
+ {{.OrganizationName}} + {{if .OrganizationType}} + {{$orgTypeLower := .OrganizationType | lower}} +

+ + {{if eq $orgTypeLower "owner"}}👑 Owner{{end}} + {{if eq $orgTypeLower "distributor"}}🌍 Distributor{{end}} + {{if eq $orgTypeLower "reseller"}}đŸĸ Reseller{{end}} + {{if eq $orgTypeLower "customer"}}🏠 Customer{{end}} + + {{end}} +
+
+
+ + + + + + +
+ + + + + + +
+ â„šī¸ Getting started +
+ + + + + + +
+ 1. Click the "Login & Change Password" button below
+ 2. Enter your email: {{.UserEmail}}
+ 3. Use the temporary password provided
+ 4. Set your new secure password
+ 5. Start exploring My Nethesis +
+ +
+ + + + + + +
+ + + + +
+ + + + + + + +
+ TEMPORARY PASSWORD +
+ + + + +
+ {{.TempPassword}} +
+
+
+
+ + + + + + +
+ + âžĄī¸ Login and change password + +
+ +
+ + + + + + + + + + +
+ {{.CompanyName}} +
+ Need help? Contact us at {{.SupportEmail}} +
+ This email was sent automatically. Please do not reply. +
+
+
- + \ No newline at end of file