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 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/.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/backend/Makefile b/backend/Makefile index b8524ad49..7470e2c3f 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -215,11 +215,11 @@ validate-docs: echo "Install with: npm install -g @apidevtools/swagger-cli"; \ fi -# Database migration (placeholder) +# Database migrations are handled automatically on backend startup .PHONY: db-migrate db-migrate: - @echo "Running database migrations..." - @echo "Note: Migrations are handled automatically by the application on startup" + @echo "Database migrations are handled automatically when the backend starts" + @echo "Simply run 'make run' or 'go run main.go' to apply pending migrations" # Pre-commit checks .PHONY: pre-commit diff --git a/backend/README.md b/backend/README.md index 6cbf2ea12..d4dfe72ae 100644 --- a/backend/README.md +++ b/backend/README.md @@ -48,6 +48,19 @@ 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 +# 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 ``` **Auto-derived URLs:** @@ -57,6 +70,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 @@ -164,6 +251,8 @@ make validate-docs ``` ### Testing + +#### Authentication Testing ```bash # Test token exchange curl -X POST http://localhost:8080/api/auth/exchange \ @@ -175,21 +264,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/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/database/database.go b/backend/database/database.go index 168a18922..505ee0526 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -10,6 +10,8 @@ import ( "fmt" "os" "path/filepath" + "sort" + "strings" "time" _ "github.com/lib/pq" @@ -54,6 +56,11 @@ func Init() error { return fmt.Errorf("failed to initialize database schema: %w", err) } + // Run database migrations + if err := runMigrations(); err != nil { + return fmt.Errorf("failed to run database migrations: %w", err) + } + return nil } @@ -102,3 +109,150 @@ func initSchemaFromFile() error { logger.ComponentLogger("database").Info().Msg("Database schema initialized successfully") return nil } + +// runMigrations runs all pending database migrations +func runMigrations() error { + logger.ComponentLogger("database").Info().Msg("Running database migrations") + + // Create migrations table if it doesn't exist + if err := createMigrationsTable(); err != nil { + return fmt.Errorf("failed to create migrations table: %w", err) + } + + // Get list of executed migrations + executedMigrations, err := getExecutedMigrations() + if err != nil { + return fmt.Errorf("failed to get executed migrations: %w", err) + } + + // Get list of migration files + migrationFiles, err := getMigrationFiles() + if err != nil { + return fmt.Errorf("failed to get migration files: %w", err) + } + + // Execute pending migrations + for _, migrationFile := range migrationFiles { + migrationName := strings.TrimSuffix(filepath.Base(migrationFile), ".sql") + + // Skip if already executed + if contains(executedMigrations, migrationName) { + continue + } + + logger.ComponentLogger("database").Info(). + Str("migration", migrationName). + Msg("Executing migration") + + if err := executeMigration(migrationFile, migrationName); err != nil { + return fmt.Errorf("failed to execute migration %s: %w", migrationName, err) + } + + logger.ComponentLogger("database").Info(). + Str("migration", migrationName). + Msg("Migration executed successfully") + } + + logger.ComponentLogger("database").Info().Msg("All migrations completed successfully") + return nil +} + +// createMigrationsTable creates the migrations tracking table +func createMigrationsTable() error { + query := ` + CREATE TABLE IF NOT EXISTS migrations ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + executed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + ); + ` + _, err := DB.Exec(query) + return err +} + +// getExecutedMigrations returns list of already executed migrations +func getExecutedMigrations() ([]string, error) { + rows, err := DB.Query("SELECT name FROM migrations ORDER BY executed_at") + if err != nil { + return nil, err + } + defer func() { + if err := rows.Close(); err != nil { + logger.ComponentLogger("database").Error().Err(err).Msg("Failed to close rows") + } + }() + + var migrations []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + migrations = append(migrations, name) + } + + return migrations, rows.Err() +} + +// getMigrationFiles returns sorted list of migration files +func getMigrationFiles() ([]string, error) { + migrationsDir := filepath.Join("database", "migrations") + + // Check if migrations directory exists + if _, err := os.Stat(migrationsDir); os.IsNotExist(err) { + logger.ComponentLogger("database").Info().Msg("No migrations directory found, skipping migrations") + return []string{}, nil + } + + files, err := filepath.Glob(filepath.Join(migrationsDir, "*.sql")) + if err != nil { + return nil, err + } + + // Sort files to ensure consistent execution order + sort.Strings(files) + return files, nil +} + +// executeMigration executes a single migration file +func executeMigration(filePath, migrationName string) error { + // Read migration file + content, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read migration file: %w", err) + } + + // Start transaction + tx, err := DB.Begin() + if err != nil { + return fmt.Errorf("failed to start transaction: %w", err) + } + defer func() { + if err := tx.Rollback(); err != nil { + logger.ComponentLogger("database").Error().Err(err).Msg("Failed to rollback transaction") + } + }() + + // Execute migration SQL + if _, err := tx.Exec(string(content)); err != nil { + return fmt.Errorf("failed to execute migration SQL: %w", err) + } + + // Record migration as executed + if _, err := tx.Exec("INSERT INTO migrations (name) VALUES ($1)", migrationName); err != nil { + return fmt.Errorf("failed to record migration: %w", err) + } + + // Commit transaction + return tx.Commit() +} + +// contains checks if a slice contains a string +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/backend/database/migrations/001_add_latest_login_at.sql b/backend/database/migrations/001_add_latest_login_at.sql new file mode 100644 index 000000000..7c0879391 --- /dev/null +++ b/backend/database/migrations/001_add_latest_login_at.sql @@ -0,0 +1,9 @@ +-- Migration: Add latest_login_at field to users table +-- This migration adds the latest_login_at field introduced in the email branch +-- Date: 2025-07-30 + +-- Add latest_login_at column to users table +ALTER TABLE users ADD COLUMN IF NOT EXISTS latest_login_at TIMESTAMP WITH TIME ZONE; + +-- Add performance index for latest_login_at +CREATE INDEX IF NOT EXISTS idx_users_latest_login_at ON users(latest_login_at DESC); \ No newline at end of file diff --git a/backend/database/schema.sql b/backend/database/schema.sql index f8601be63..3ad7b4d8b 100644 --- a/backend/database/schema.sql +++ b/backend/database/schema.sql @@ -87,6 +87,7 @@ CREATE TABLE IF NOT EXISTS users ( created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), logto_synced_at TIMESTAMP WITH TIME ZONE, + latest_login_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE, -- Soft delete timestamp (NULL = not deleted) suspended_at TIMESTAMP WITH TIME ZONE -- Suspension timestamp (NULL = not suspended) ); @@ -100,6 +101,7 @@ CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at); CREATE INDEX IF NOT EXISTS idx_users_suspended_at ON users(suspended_at); CREATE INDEX IF NOT EXISTS idx_users_logto_synced ON users(logto_synced_at); CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_users_latest_login_at ON users(latest_login_at DESC); -- Systems table - updated to reference customers table CREATE TABLE IF NOT EXISTS systems ( diff --git a/backend/entities/local_users.go b/backend/entities/local_users.go index fe0151835..7e73f8eab 100644 --- a/backend/entities/local_users.go +++ b/backend/entities/local_users.go @@ -95,7 +95,7 @@ func (r *LocalUserRepository) Create(req *models.CreateLocalUserRequest) (*model func (r *LocalUserRepository) GetByID(id string) (*models.LocalUser, error) { query := ` SELECT u.id, u.logto_id, u.username, u.email, u.name, u.phone, u.organization_id, u.user_role_ids, u.custom_data, - u.created_at, u.updated_at, u.logto_synced_at, u.deleted_at, u.suspended_at, + u.created_at, u.updated_at, u.logto_synced_at, u.latest_login_at, u.deleted_at, u.suspended_at, COALESCE(d.name, r.name, c.name) as organization_name, COALESCE(d.id, r.id, c.id) as organization_local_id FROM users u @@ -112,7 +112,7 @@ func (r *LocalUserRepository) GetByID(id string) (*models.LocalUser, error) { err := r.db.QueryRow(query, id).Scan( &user.ID, &user.LogtoID, &user.Username, &user.Email, &user.Name, &user.Phone, &user.OrganizationID, &userRoleIDsJSON, &customDataJSON, - &user.CreatedAt, &user.UpdatedAt, &user.LogtoSyncedAt, &user.DeletedAt, &user.SuspendedAt, + &user.CreatedAt, &user.UpdatedAt, &user.LogtoSyncedAt, &user.LatestLoginAt, &user.DeletedAt, &user.SuspendedAt, &user.OrganizationName, &user.OrganizationLocalID, ) @@ -151,7 +151,7 @@ func (r *LocalUserRepository) GetByID(id string) (*models.LocalUser, error) { func (r *LocalUserRepository) GetByLogtoID(logtoID string) (*models.LocalUser, error) { query := ` SELECT u.id, u.logto_id, u.username, u.email, u.name, u.phone, u.organization_id, u.user_role_ids, u.custom_data, - u.created_at, u.updated_at, u.logto_synced_at, u.deleted_at, u.suspended_at, + u.created_at, u.updated_at, u.logto_synced_at, u.latest_login_at, u.deleted_at, u.suspended_at, COALESCE(d.name, r.name, c.name) as organization_name, COALESCE(d.id, r.id, c.id) as organization_local_id FROM users u @@ -168,7 +168,7 @@ func (r *LocalUserRepository) GetByLogtoID(logtoID string) (*models.LocalUser, e err := r.db.QueryRow(query, logtoID).Scan( &user.ID, &user.LogtoID, &user.Username, &user.Email, &user.Name, &user.Phone, &user.OrganizationID, &userRoleIDsJSON, &customDataJSON, - &user.CreatedAt, &user.UpdatedAt, &user.LogtoSyncedAt, &user.DeletedAt, &user.SuspendedAt, + &user.CreatedAt, &user.UpdatedAt, &user.LogtoSyncedAt, &user.LatestLoginAt, &user.DeletedAt, &user.SuspendedAt, &user.OrganizationName, &user.OrganizationLocalID, ) @@ -339,6 +339,28 @@ func (r *LocalUserRepository) ReactivateUser(id string) error { return nil } +// UpdateLatestLogin updates the latest_login_at field for a user +func (r *LocalUserRepository) UpdateLatestLogin(userID string) error { + query := `UPDATE users SET latest_login_at = $2, updated_at = $2 WHERE id = $1` + + now := time.Now() + result, err := r.db.Exec(query, userID, now) + if err != nil { + return fmt.Errorf("failed to update latest login: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("user not found") + } + + return nil +} + // List returns paginated list of users based on hierarchical RBAC (matches other repository patterns) func (r *LocalUserRepository) List(userOrgRole, userOrgID, excludeUserID string, page, pageSize int) ([]*models.LocalUser, int, error) { // Get all organization IDs the user can access hierarchically @@ -390,7 +412,7 @@ func (r *LocalUserRepository) ListByOrganizations(allowedOrgIDs []string, exclud query := fmt.Sprintf(` SELECT u.id, u.logto_id, u.username, u.email, u.name, u.phone, u.organization_id, u.user_role_ids, u.custom_data, - u.created_at, u.updated_at, u.logto_synced_at, u.deleted_at, u.suspended_at, + u.created_at, u.updated_at, u.logto_synced_at, u.latest_login_at, u.deleted_at, u.suspended_at, COALESCE(d.name, r.name, c.name) as organization_name, COALESCE(d.id, r.id, c.id) as organization_local_id FROM users u @@ -416,7 +438,7 @@ func (r *LocalUserRepository) ListByOrganizations(allowedOrgIDs []string, exclud err := rows.Scan( &user.ID, &user.LogtoID, &user.Username, &user.Email, &user.Name, &user.Phone, &user.OrganizationID, &userRoleIDsJSON, &customDataJSON, - &user.CreatedAt, &user.UpdatedAt, &user.LogtoSyncedAt, &user.DeletedAt, &user.SuspendedAt, + &user.CreatedAt, &user.UpdatedAt, &user.LogtoSyncedAt, &user.LatestLoginAt, &user.DeletedAt, &user.SuspendedAt, &user.OrganizationName, &user.OrganizationLocalID, ) if err != nil { 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/methods/auth.go b/backend/methods/auth.go index 4ce0df72b..383c58df1 100644 --- a/backend/methods/auth.go +++ b/backend/methods/auth.go @@ -87,6 +87,16 @@ func ExchangeToken(c *gin.Context) { ID: localUser.ID, // Local database ID as primary ID LogtoID: localUser.LogtoID, // Logto ID for reference } + + // Update latest login timestamp + if updateErr := userService.UpdateLatestLogin(localUser.ID); updateErr != nil { + logger.RequestLogger(c, "auth").Warn(). + Err(updateErr). + Str("operation", "update_latest_login"). + Str("user_id", localUser.ID). + Msg("Failed to update latest login timestamp") + // Don't fail the request if this update fails + } } else { // User not in local DB, create temporary user with empty local ID user = models.User{ 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/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/models/local_entities.go b/backend/models/local_entities.go index 2f56a16a9..34560c829 100644 --- a/backend/models/local_entities.go +++ b/backend/models/local_entities.go @@ -82,6 +82,7 @@ type LocalUser struct { CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` LogtoSyncedAt *time.Time `json:"logto_synced_at" db:"logto_synced_at"` + LatestLoginAt *time.Time `json:"latest_login_at" db:"latest_login_at"` DeletedAt *time.Time `json:"deleted_at" db:"deleted_at"` // Soft delete timestamp SuspendedAt *time.Time `json:"suspended_at" db:"suspended_at"` // Suspension timestamp @@ -145,7 +146,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/models/local_entities_unit_test.go b/backend/models/local_entities_unit_test.go index c5292f159..90f237636 100644 --- a/backend/models/local_entities_unit_test.go +++ b/backend/models/local_entities_unit_test.go @@ -395,7 +395,6 @@ func TestCreateLocalUserRequest(t *testing.T) { Email: "newuser@example.com", Name: "New User", Phone: &phone, - Password: "securepassword123", UserRoleIDs: userRoleIDs, OrganizationID: &orgID, CustomData: customData, @@ -405,7 +404,6 @@ func TestCreateLocalUserRequest(t *testing.T) { assert.Equal(t, "newuser@example.com", request.Email) assert.Equal(t, "New User", request.Name) assert.Equal(t, &phone, request.Phone) - assert.Equal(t, "securepassword123", request.Password) assert.Equal(t, userRoleIDs, request.UserRoleIDs) assert.Equal(t, &orgID, request.OrganizationID) assert.Equal(t, customData, request.CustomData) diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 277dfd783..634899073 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -439,6 +439,12 @@ components: nullable: true description: Last Logto synchronization timestamp example: "2025-06-21T10:45:00Z" + latest_login_at: + type: string + format: date-time + nullable: true + description: Timestamp of the last successful login via /auth/exchange endpoint. NULL means user has never logged in. + example: "2025-06-21T15:30:45Z" deleted_at: type: string format: date-time @@ -454,10 +460,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 +477,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..0273cbffc --- /dev/null +++ b/backend/services/email/smtp.go @@ -0,0 +1,192 @@ +/* + * 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 +} + +// ============================================================================= +// PUBLIC METHODS +// ============================================================================= + +// 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 +} + +// 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) + + // 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 +} diff --git a/backend/services/email/templates.go b/backend/services/email/templates.go new file mode 100644 index 000000000..64a828b35 --- /dev/null +++ b/backend/services/email/templates.go @@ -0,0 +1,122 @@ +/* + * 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" + "strings" +) + +// 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, + } +} + +// ============================================================================= +// 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 + 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 +} + +// 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 + 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) + } + + // Create template with custom functions + funcMap := template.FuncMap{ + "lower": strings.ToLower, + "upper": strings.ToUpper, + } + + // Parse template + tmpl, err := template.New(templateName).Funcs(funcMap).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 +} diff --git a/backend/services/email/templates/welcome.html b/backend/services/email/templates/welcome.html new file mode 100644 index 000000000..eb9dc041a --- /dev/null +++ b/backend/services/email/templates/welcome.html @@ -0,0 +1,296 @@ + + + + + + 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 diff --git a/backend/services/email/templates/welcome.txt b/backend/services/email/templates/welcome.txt new file mode 100644 index 000000000..cfa8b103b --- /dev/null +++ b/backend/services/email/templates/welcome.txt @@ -0,0 +1,39 @@ +Welcome to My Nethesis! + +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}} + +═══════════════════════════════════════════════════════════════ + +🔑 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..97d55b581 --- /dev/null +++ b/backend/services/email/welcome.go @@ -0,0 +1,196 @@ +/* + * 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 ( + "fmt" + "strings" + + "github.com/nethesis/my/backend/configuration" + "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(), + } +} + +// ============================================================================= +// PUBLIC METHODS +// ============================================================================= + +// SendWelcomeEmail sends a welcome email with organization and user roles information +func (w *WelcomeEmailService) SendWelcomeEmail(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 +} + +// ============================================================================= +// PRIVATE METHODS +// ============================================================================= + +// 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?changePassword=true", configuration.Config.TenantDomain) + } + + // Fallback to tenant ID based URL + if configuration.Config.TenantID != "" { + return fmt.Sprintf("https://%s.logto.app/account?changePassword=true", configuration.Config.TenantID) + } + + // Final fallback + return "https://localhost:3000/account?changePassword=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?changePassword=true + baseURL = strings.TrimSuffix(baseURL, "/login") + baseURL = strings.TrimSuffix(baseURL, "/") + return baseURL + "/account?changePassword=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?changePassword=true + baseURL = strings.TrimSuffix(baseURL, "/login") + baseURL = strings.TrimSuffix(baseURL, "/") + return baseURL + "/account?changePassword=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" +} diff --git a/backend/services/local/users.go b/backend/services/local/users.go index 98ffb8431..62442c090 100644 --- a/backend/services/local/users.go +++ b/backend/services/local/users.go @@ -18,9 +18,11 @@ import ( "github.com/nethesis/my/backend/database" "github.com/nethesis/my/backend/entities" + "github.com/nethesis/my/backend/helpers" "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 +61,16 @@ func (s *LocalUserService) CreateUser(req *models.CreateLocalUserRequest, create req.Username = s.generateUsernameFromEmail(req.Email) } + // Always generate a temporary password for new users + tempPassword, err := helpers.GeneratePassword() + 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,72 @@ 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 != "" { + go func() { + welcomeService := email.NewWelcomeEmailService() + + // Get enriched user data using existing repository logic + userRepo := entities.NewLocalUserRepository() + user, err := userRepo.GetByID(user.ID) + if err != nil { + logger.Error(). + Err(err). + Str("user_id", user.ID). + Str("username", user.Username). + Str("email", req.Email). + Msg("Failed to get enriched user data for welcome email") + return + } + + // Extract organization data from enriched user object + orgName := "the organization" // fallback + orgType := "" + if user.Organization != nil { + if user.Organization.Name != "" { + orgName = user.Organization.Name + } + // Determine organization type from the organization ID + orgType = s.determineOrganizationRoleName(user.Organization.LogtoID) + } + + // Extract user roles data from enriched user object + userRoles := make([]string, len(user.Roles)) + for i, role := range user.Roles { + userRoles[i] = role.Name + } + + // Send welcome email using existing method + err = welcomeService.SendWelcomeEmail( + user.Email, + user.Name, + orgName, + orgType, + userRoles, + tempPassword, + ) + + 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 } @@ -305,6 +383,11 @@ func (s *LocalUserService) GetUserByLogtoID(logtoID string) (*models.LocalUser, return s.userRepo.GetByLogtoID(logtoID) } +// UpdateLatestLogin updates the latest_login_at timestamp for a user +func (s *LocalUserService) UpdateLatestLogin(userID string) error { + return s.userRepo.UpdateLatestLogin(userID) +} + // ListUsers returns paginated users based on hierarchical RBAC (excluding specified user) func (s *LocalUserService) ListUsers(userOrgRole, userOrgID, excludeUserID string, page, pageSize int) ([]*models.LocalUser, int, error) { return s.userRepo.List(userOrgRole, userOrgID, excludeUserID, page, pageSize) diff --git a/backend/services/logto/applications.go b/backend/services/logto/applications.go index b485305fe..6c0ec3833 100644 --- a/backend/services/logto/applications.go +++ b/backend/services/logto/applications.go @@ -24,11 +24,15 @@ import ( "github.com/nethesis/my/backend/models" ) -// GetThirdPartyApplications retrieves all third-party applications from Logto -func (c *LogtoManagementClient) GetThirdPartyApplications() ([]models.LogtoThirdPartyApp, error) { +// ============================================================================= +// PUBLIC METHODS +// ============================================================================= + +// 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 +54,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 { @@ -135,6 +154,10 @@ func FilterApplicationsByAccess(logtoApps []models.LogtoThirdPartyApp, organizat return filteredApps } +// ============================================================================= +// PRIVATE METHODS +// ============================================================================= + // canAccessApplication checks if a user with given roles can access an application func canAccessApplication(app models.LogtoThirdPartyApp, organizationRoles []string, userRoles []string) bool { // Extract access control from custom data diff --git a/backend/services/logto/auth.go b/backend/services/logto/auth.go index 3e5e46b39..9dcbacaa7 100644 --- a/backend/services/logto/auth.go +++ b/backend/services/logto/auth.go @@ -21,6 +21,10 @@ import ( "github.com/nethesis/my/backend/models" ) +// ============================================================================= +// PUBLIC METHODS +// ============================================================================= + // GetUserInfoFromLogto fetches user information from Logto using access token func GetUserInfoFromLogto(accessToken string) (*models.LogtoUserInfo, error) { // Create request to Logto userinfo endpoint diff --git a/backend/services/logto/client.go b/backend/services/logto/client.go index be2e937b9..fbfe8cf43 100644 --- a/backend/services/logto/client.go +++ b/backend/services/logto/client.go @@ -50,6 +50,19 @@ func NewManagementClient() *LogtoManagementClient { } } +// ============================================================================= +// PUBLIC METHODS +// ============================================================================= + +// makeRequest makes an authenticated request to the Management API with token refresh retry +func (c *LogtoManagementClient) makeRequest(method, endpoint string, body io.Reader) (*http.Response, error) { + return c.makeRequestWithRetry(method, endpoint, body, false) +} + +// ============================================================================= +// PRIVATE METHODS +// ============================================================================= + // getAccessToken obtains an access token for the Management API with enhanced caching func (c *LogtoManagementClient) getAccessToken() (string, error) { // First, try to get token from cache (read lock) @@ -137,11 +150,6 @@ func invalidateToken() { globalTokenCache.tokenExpiry = time.Time{} } -// makeRequest makes an authenticated request to the Management API with token refresh retry -func (c *LogtoManagementClient) makeRequest(method, endpoint string, body io.Reader) (*http.Response, error) { - return c.makeRequestWithRetry(method, endpoint, body, false) -} - // makeRequestWithRetry makes an authenticated request with optional retry for token refresh func (c *LogtoManagementClient) makeRequestWithRetry(method, endpoint string, body io.Reader, isRetry bool) (*http.Response, error) { start := time.Now() diff --git a/backend/services/logto/organizations.go b/backend/services/logto/organizations.go index 7825599d8..7df9a9dd1 100644 --- a/backend/services/logto/organizations.go +++ b/backend/services/logto/organizations.go @@ -19,6 +19,10 @@ import ( "github.com/nethesis/my/backend/models" ) +// ============================================================================= +// PUBLIC METHODS +// ============================================================================= + func (c *LogtoManagementClient) GetUserOrganizations(userID string) ([]models.LogtoOrganization, error) { resp, err := c.makeRequest("GET", fmt.Sprintf("/users/%s/organizations", userID), nil) if err != nil { diff --git a/backend/services/logto/roles.go b/backend/services/logto/roles.go index 402400763..68341a340 100644 --- a/backend/services/logto/roles.go +++ b/backend/services/logto/roles.go @@ -20,6 +20,10 @@ import ( "github.com/nethesis/my/backend/models" ) +// ============================================================================= +// PUBLIC METHODS +// ============================================================================= + // GetUserRoles fetches user roles from Logto Management API func (c *LogtoManagementClient) GetUserRoles(userID string) ([]models.LogtoRole, error) { resp, err := c.makeRequest("GET", fmt.Sprintf("/users/%s/roles", userID), nil) @@ -141,6 +145,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) @@ -475,6 +495,10 @@ func EnrichUserWithRolesAndPermissions(userID string) (*models.User, error) { return user, nil } +// ============================================================================= +// PRIVATE METHODS +// ============================================================================= + // removeDuplicates removes duplicate strings from a slice func removeDuplicates(slice []string) []string { keys := make(map[string]bool) diff --git a/backend/services/logto/users.go b/backend/services/logto/users.go index aae8b6ef1..3102778bd 100644 --- a/backend/services/logto/users.go +++ b/backend/services/logto/users.go @@ -19,6 +19,10 @@ import ( "github.com/nethesis/my/backend/models" ) +// ============================================================================= +// PUBLIC METHODS +// ============================================================================= + // GetUserByID fetches a specific user by ID func (c *LogtoManagementClient) GetUserByID(userID string) (*models.LogtoUser, error) { resp, err := c.makeRequest("GET", fmt.Sprintf("/users/%s", userID), nil) 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 ./ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index df36fcfb7..cae52ed64 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ 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 @@ - - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/frontend/src/assets/logo_dark.svg b/frontend/src/assets/logo_dark.svg index dc6d77f77..a7efb48a5 100644 --- a/frontend/src/assets/logo_dark.svg +++ b/frontend/src/assets/logo_dark.svg @@ -1,14 +1,13 @@ - - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/frontend/src/assets/logo_light.svg b/frontend/src/assets/logo_light.svg index 341a80ae0..c8266d333 100644 --- a/frontend/src/assets/logo_light.svg +++ b/frontend/src/assets/logo_light.svg @@ -1,14 +1,13 @@ - - - - - - - - - - - - - + + + + + + + + + + + + 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 { diff --git a/frontend/src/components/users/UsersTable.vue b/frontend/src/components/users/UsersTable.vue index 20db8ccb4..285fddd95 100644 --- a/frontend/src/components/users/UsersTable.vue +++ b/frontend/src/components/users/UsersTable.vue @@ -310,7 +310,7 @@ const onClosePasswordChangedModal = () => {