diff --git a/backend/appsystem/version.go b/backend/appsystem/version.go index e8d139f..c7658e2 100644 --- a/backend/appsystem/version.go +++ b/backend/appsystem/version.go @@ -5,7 +5,7 @@ import ( ) // Version is the application version -const Version = "0.2.2" +const Version = "0.2.4" var ( // GitCommit will be set by build flags or detected at runtime diff --git a/backend/character/handlers.go b/backend/character/handlers.go index 81f6c0d..549a49f 100644 --- a/backend/character/handlers.go +++ b/backend/character/handlers.go @@ -191,6 +191,12 @@ func ToFeChar(object *models.Char) *models.FeChar { feC := &models.FeChar{ Char: *object, } + for idx, fertigkeit := range object.Fertigkeiten { + fertigkeit.Bonus = GetSkillBonus(&object.Eigenschaften, &fertigkeit) + if fertigkeit.Bonus > 0 { + object.Fertigkeiten[idx].Bonus = fertigkeit.Bonus + } + } skills, innateSkills, categories := splitSkills(object.Fertigkeiten) feC.Fertigkeiten = skills feC.InnateSkills = innateSkills @@ -199,6 +205,32 @@ func ToFeChar(object *models.Char) *models.FeChar { return feC } +func GetSkillBonus(eigenschaften *[]models.Eigenschaft, skill *models.SkFertigkeit) int { + bonus := 0 + gsmsk := skill.GetSkillByName() + if gsmsk.Bonuseigenschaft != "check" { + for _, eigenschaft := range *eigenschaften { + if eigenschaft.Name == gsmsk.Bonuseigenschaft { + if eigenschaft.Value < 6 { + bonus = -2 + break + } else if eigenschaft.Value < 21 { + bonus = -1 + break + } else if eigenschaft.Value > 81 && eigenschaft.Value < 96 { + bonus = 1 + break + } else if eigenschaft.Value >= 96 { + bonus = 2 + break + } + } + } + } + skill.Bonus = bonus + return bonus +} + func splitSkills(object []models.SkFertigkeit) ([]models.SkFertigkeit, []models.SkFertigkeit, map[string][]models.SkFertigkeit) { var normSkills []models.SkFertigkeit var innateSkills []models.SkFertigkeit diff --git a/backend/character/handlers_test.go b/backend/character/handlers_test.go index 35412f7..2406bd2 100644 --- a/backend/character/handlers_test.go +++ b/backend/character/handlers_test.go @@ -1198,3 +1198,15 @@ func TestSearchBeliefs(t *testing.T) { }) } } + +func TestToFeChar(t *testing.T) { + // Setup test database + database.SetupTestDB(true) + defer database.ResetTestDB() + char := &models.Char{} + char.FirstID("18") + feChar := ToFeChar(char) + assert.Equal(t, "18", feChar.ID) + assert.Equal(t, 2, feChar.Fertigkeiten[6].Bonus) + +} diff --git a/backend/character/routes.go b/backend/character/routes.go index 9604c8f..9bf05f9 100644 --- a/backend/character/routes.go +++ b/backend/character/routes.go @@ -33,20 +33,24 @@ func RegisterRoutes(r *gin.RouterGroup) { // im Frontend wir nur noch der neue Endpunkt benutzt //charGrp.POST("/lerncost", GetLernCost) // alter Hauptendpunkt für alle Kostenberechnungen (verwendet lerningCostsData) charGrp.POST("/lerncost-new", GetLernCostNewSystem) // neuer Hauptendpunkt für alle Kostenberechnungen (verwendet neue Datenbank) + charGrp.POST("/lerncost", GetLernCostNewSystem) // neuer Hauptendpunkt für alle Kostenberechnungen (verwendet neue Datenbank) charGrp.POST("/improve-skill-new", ImproveSkill) // Fertigkeit verbessern + charGrp.POST("/improve-skill", ImproveSkill) // Fertigkeit verbessern // Lernen und Verbessern (mit automatischem Audit-Log) charGrp.POST("/:id/learn-skill-new", LearnSkill) // Fertigkeit lernen (neues System) - //charGrp.POST("/:id/learn-skill", LearnSkillOld) // Fertigkeit lernen (altes System) + charGrp.POST("/:id/learn-skill", LearnSkill) // Fertigkeit lernen (altes System) charGrp.POST("/:id/learn-spell-new", LearnSpell) // Zauber lernen (neues System) - //charGrp.POST("/:id/learn-spell", LearnSpellOld) // Zauber lernen (altes System) + charGrp.POST("/:id/learn-spell", LearnSpell) // Zauber lernen (altes System) // Fertigkeiten-Information //charGrp.GET("/:id/available-skills", GetAvailableSkillsOld) // Verfügbare Fertigkeiten mit Kosten (bereits gelernte ausgeschlossen) charGrp.POST("/available-skills-new", GetAvailableSkillsNewSystem) // Verfügbare Fertigkeiten mit Kosten (bereits gelernte ausgeschlossen) + charGrp.POST("/available-skills", GetAvailableSkillsNewSystem) // Verfügbare Fertigkeiten mit Kosten (bereits gelernte ausgeschlossen) charGrp.POST("/available-skills-creation", GetAvailableSkillsForCreation) // Verfügbare Fertigkeiten mit Lernkosten für Charaktererstellung charGrp.POST("/available-spells-creation", GetAvailableSpellsForCreation) // Verfügbare Zauber mit Lernkosten für Charaktererstellung charGrp.POST("/available-spells-new", GetAvailableSpellsNewSystem) // Verfügbare Zauber mit Kosten (bereits gelernte ausgeschlossen) + charGrp.POST("/available-spells", GetAvailableSpellsNewSystem) // Verfügbare Zauber mit Kosten (bereits gelernte ausgeschlossen) charGrp.GET("/spell-details", GetSpellDetails) // Detaillierte Informationen zu einem bestimmten Zauber // Belohnungsarten für verschiedene Lernszenarien diff --git a/backend/config/config.go b/backend/config/config.go index 1e44d30..0441d61 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -32,6 +32,13 @@ type Config struct { // PDF Templates TemplatesDir string // Directory where PDF templates are stored ExportTempDir string // Directory for temporary PDF exports + + // Mail Configuration + MailHost string // SMTP server host + MailPort int // SMTP server port + MailUsername string // SMTP username + MailPassword string // SMTP password + MailFrom string // Default sender email address } // Cfg ist die globale Konfigurationsvariable @@ -56,6 +63,11 @@ func defaultConfig() *Config { FrontendURL: "http://localhost:5173", // Default frontend URL for development TemplatesDir: "./templates", // Default templates directory ExportTempDir: "./xporttemp", // Default export temp directory + MailHost: "", // No default, must be configured + MailPort: 465, // Default SMTP SSL port + MailUsername: "", // No default, must be configured + MailPassword: "", // No default, must be configured + MailFrom: "", // No default, must be configured } } @@ -136,6 +148,28 @@ func LoadConfig() *Config { config.ExportTempDir = exportTempDir } + // Mail Configuration + if mailHost := os.Getenv("MAIL_HOST"); mailHost != "" { + config.MailHost = mailHost + } + if mailPort := os.Getenv("MAIL_PORT"); mailPort != "" { + if port, err := strconv.Atoi(mailPort); err == nil { + config.MailPort = port + } + } + if mailUsername := os.Getenv("MAIL_USERNAME"); mailUsername != "" { + config.MailUsername = mailUsername + } + if mailPassword := os.Getenv("MAIL_PASSWORD"); mailPassword != "" { + config.MailPassword = mailPassword + } + if mailFrom := os.Getenv("MAIL_FROM"); mailFrom != "" { + config.MailFrom = mailFrom + } else if config.MailUsername != "" { + // Fallback: Verwende Username als From-Adresse + config.MailFrom = config.MailUsername + } + fmt.Printf("DEBUG LoadConfig - Finale Config: Environment='%s', DevTesting='%s', DatabaseType='%s'\n Complete: %v\n", config.Environment, config.DevTesting, config.DatabaseType, config) diff --git a/backend/mail/smtp.go b/backend/mail/smtp.go new file mode 100644 index 0000000..7f39524 --- /dev/null +++ b/backend/mail/smtp.go @@ -0,0 +1,163 @@ +package mail + +import ( + "crypto/tls" + "fmt" + "net/smtp" + + "bamort/config" + "bamort/logger" +) + +// Client represents an SMTP mail client +type Client struct { + host string + port int + username string + password string + from string +} + +// Message represents an email message +type Message struct { + To string + Subject string + Body string +} + +// NewClient creates a new SMTP mail client from config +func NewClient() *Client { + cfg := config.Cfg + return &Client{ + host: cfg.MailHost, + port: cfg.MailPort, + username: cfg.MailUsername, + password: cfg.MailPassword, + from: cfg.MailFrom, + } +} + +// Send sends an email message via SMTP +func (c *Client) Send(msg Message) error { + if c.host == "" { + logger.Warn("SMTP Host nicht konfiguriert - E-Mail-Versand übersprungen") + return fmt.Errorf("SMTP host not configured") + } + + logger.Debug("Sende E-Mail an %s via SMTP %s:%d", msg.To, c.host, c.port) + + // Prepare email headers and body + headers := make(map[string]string) + headers["From"] = c.from + headers["To"] = msg.To + headers["Subject"] = msg.Subject + headers["MIME-Version"] = "1.0" + headers["Content-Type"] = "text/html; charset=\"UTF-8\"" + + // Build message + message := "" + for key, value := range headers { + message += fmt.Sprintf("%s: %s\r\n", key, value) + } + message += "\r\n" + msg.Body + + // Connect to SMTP server + serverAddr := fmt.Sprintf("%s:%d", c.host, c.port) + + // Create TLS config + tlsConfig := &tls.Config{ + ServerName: c.host, + InsecureSkipVerify: false, + } + + var err error + var client *smtp.Client + + // Port 465 requires direct TLS connection, port 587 uses STARTTLS + if c.port == 465 { + // Direct TLS connection (implicit SSL) + conn, err := tls.Dial("tcp", serverAddr, tlsConfig) + if err != nil { + logger.Error("Fehler beim Verbinden mit SMTP-Server (TLS): %s", err.Error()) + return fmt.Errorf("failed to connect to SMTP server: %w", err) + } + defer conn.Close() + + client, err = smtp.NewClient(conn, c.host) + if err != nil { + logger.Error("Fehler beim Erstellen des SMTP-Clients: %s", err.Error()) + return fmt.Errorf("failed to create SMTP client: %w", err) + } + } else { + // Port 587 or other - use STARTTLS + client, err = smtp.Dial(serverAddr) + if err != nil { + logger.Error("Fehler beim Verbinden mit SMTP-Server: %s", err.Error()) + return fmt.Errorf("failed to dial SMTP server: %w", err) + } + defer client.Close() + + // Start TLS if available + if ok, _ := client.Extension("STARTTLS"); ok { + if err = client.StartTLS(tlsConfig); err != nil { + logger.Error("Fehler beim STARTTLS: %s", err.Error()) + return fmt.Errorf("failed to start TLS: %w", err) + } + } + } + + // Authenticate + if c.username != "" && c.password != "" { + auth := smtp.PlainAuth("", c.username, c.password, c.host) + if err = client.Auth(auth); err != nil { + logger.Error("Fehler bei der SMTP-Authentifizierung: %s", err.Error()) + return fmt.Errorf("failed to authenticate: %w", err) + } + } + + // Set sender + if err = client.Mail(c.from); err != nil { + logger.Error("Fehler beim Setzen des Absenders: %s", err.Error()) + return fmt.Errorf("failed to set sender: %w", err) + } + + // Set recipient + if err = client.Rcpt(msg.To); err != nil { + logger.Error("Fehler beim Setzen des Empfängers: %s", err.Error()) + return fmt.Errorf("failed to set recipient: %w", err) + } + + // Send message body + writer, err := client.Data() + if err != nil { + logger.Error("Fehler beim Öffnen des Data-Writers: %s", err.Error()) + return fmt.Errorf("failed to open data writer: %w", err) + } + + _, err = writer.Write([]byte(message)) + if err != nil { + logger.Error("Fehler beim Schreiben der Nachricht: %s", err.Error()) + writer.Close() + return fmt.Errorf("failed to write message: %w", err) + } + + err = writer.Close() + if err != nil { + logger.Error("Fehler beim Schließen des Data-Writers: %s", err.Error()) + return fmt.Errorf("failed to close data writer: %w", err) + } + + // Quit + if err = client.Quit(); err != nil { + logger.Error("Fehler beim Beenden der SMTP-Verbindung: %s", err.Error()) + return fmt.Errorf("failed to quit SMTP connection: %w", err) + } + + logger.Info("E-Mail erfolgreich an %s versendet", msg.To) + return nil +} + +// IsConfigured returns true if the mail client is properly configured +func (c *Client) IsConfigured() bool { + return c.host != "" && c.port > 0 && c.from != "" +} diff --git a/backend/mail/smtp_test.go b/backend/mail/smtp_test.go new file mode 100644 index 0000000..0e54e17 --- /dev/null +++ b/backend/mail/smtp_test.go @@ -0,0 +1,140 @@ +package mail + +import ( + "os" + "testing" + + "bamort/config" +) + +// setupTestEnvironment setzt die Test-Umgebung auf +func setupTestEnvironment(t *testing.T) { + original := os.Getenv("ENVIRONMENT") + os.Setenv("ENVIRONMENT", "test") + t.Cleanup(func() { + if original != "" { + os.Setenv("ENVIRONMENT", original) + } else { + os.Unsetenv("ENVIRONMENT") + } + }) + // Reload config with test environment + config.Cfg = config.LoadConfig() +} + +func TestNewClient(t *testing.T) { + setupTestEnvironment(t) + + // Set test mail config + os.Setenv("MAIL_HOST", "smtp.example.com") + os.Setenv("MAIL_PORT", "465") + os.Setenv("MAIL_USERNAME", "test@example.com") + os.Setenv("MAIL_PASSWORD", "testpass") + os.Setenv("MAIL_FROM", "sender@example.com") + + // Reload config + config.Cfg = config.LoadConfig() + + client := NewClient() + + if client.host != "smtp.example.com" { + t.Errorf("Expected host 'smtp.example.com', got '%s'", client.host) + } + if client.port != 465 { + t.Errorf("Expected port 465, got %d", client.port) + } + if client.username != "test@example.com" { + t.Errorf("Expected username 'test@example.com', got '%s'", client.username) + } + if client.from != "sender@example.com" { + t.Errorf("Expected from 'sender@example.com', got '%s'", client.from) + } +} + +func TestIsConfigured(t *testing.T) { + tests := []struct { + name string + client *Client + expected bool + }{ + { + name: "Fully configured", + client: &Client{ + host: "smtp.example.com", + port: 465, + from: "test@example.com", + }, + expected: true, + }, + { + name: "Missing host", + client: &Client{ + host: "", + port: 465, + from: "test@example.com", + }, + expected: false, + }, + { + name: "Missing port", + client: &Client{ + host: "smtp.example.com", + port: 0, + from: "test@example.com", + }, + expected: false, + }, + { + name: "Missing from", + client: &Client{ + host: "smtp.example.com", + port: 465, + from: "", + }, + expected: false, + }, + { + name: "Empty client", + client: &Client{ + host: "", + port: 0, + from: "", + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.client.IsConfigured() + if result != tt.expected { + t.Errorf("Expected IsConfigured() to return %v, got %v", tt.expected, result) + } + }) + } +} + +func TestSend_NotConfigured(t *testing.T) { + setupTestEnvironment(t) + + // Create client without config + client := &Client{ + host: "", + port: 0, + from: "", + } + + msg := Message{ + To: "recipient@example.com", + Subject: "Test", + Body: "Test body", + } + + err := client.Send(msg) + if err == nil { + t.Error("Expected error when sending with unconfigured client, got nil") + } + if err.Error() != "SMTP host not configured" { + t.Errorf("Expected error 'SMTP host not configured', got '%s'", err.Error()) + } +} diff --git a/backend/user/handlers.go b/backend/user/handlers.go index 2903cab..7ab9ea0 100644 --- a/backend/user/handlers.go +++ b/backend/user/handlers.go @@ -7,6 +7,7 @@ package user import ( "bamort/logger" + "bamort/mail" "crypto/md5" "crypto/rand" "encoding/hex" @@ -239,8 +240,7 @@ func generateResetHash() (string, error) { return hex.EncodeToString(bytes), nil } -// sendResetEmail simuliert das Senden einer E-Mail (hier nur Logging) -// In einer echten Implementierung würde hier ein E-Mail-Service verwendet +// sendResetEmail sends a password reset email via SMTP func sendResetEmail(email, username, resetHash, frontendURL string) error { // Verwende die mitgegebene Frontend-URL oder fallback auf Standard baseURL := frontendURL @@ -250,25 +250,81 @@ func sendResetEmail(email, username, resetHash, frontendURL string) error { resetLink := fmt.Sprintf("%s/reset-password?token=%s", baseURL, resetHash) - logger.Info("=== PASSWORD RESET EMAIL ===") - logger.Info("An: %s", email) - logger.Info("Betreff: Passwort zurücksetzen für %s", username) - logger.Info("Nachricht:") - logger.Info("Hallo %s,", username) - logger.Info("") - logger.Info("Sie haben eine Passwort-Zurücksetzung angefordert.") - logger.Info("Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:") - logger.Info("") - logger.Info("%s", resetLink) - logger.Info("") - logger.Info("Dieser Link ist 14 Tage gültig.") - logger.Info("Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.") - logger.Info("") - logger.Info("=== END EMAIL ===") + // Create mail client + mailClient := mail.NewClient() - // TODO: Hier echte E-Mail-Integration hinzufügen - // z.B. SendGrid, SMTP, etc. + // If mail is not configured, fallback to logging + if !mailClient.IsConfigured() { + logger.Warn("SMTP nicht konfiguriert - E-Mail wird nur geloggt") + logger.Info("=== PASSWORD RESET EMAIL ===") + logger.Info("An: %s", email) + logger.Info("Betreff: Passwort zurücksetzen für %s", username) + logger.Info("Nachricht:") + logger.Info("Hallo %s,", username) + logger.Info("") + logger.Info("Sie haben eine Passwort-Zurücksetzung angefordert.") + logger.Info("Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:") + logger.Info("") + logger.Info("%s", resetLink) + logger.Info("") + logger.Info("Dieser Link ist 14 Tage gültig.") + logger.Info("Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.") + logger.Info("") + logger.Info("=== END EMAIL ===") + return nil + } + // Build HTML email body + htmlBody := fmt.Sprintf(` + + + + + + +
+
+

Passwort zurücksetzen

+
+
+

Hallo %s,

+

Sie haben eine Passwort-Zurücksetzung für Ihren Bamort-Account angefordert.

+

Klicken Sie auf den folgenden Button, um Ihr Passwort zurückzusetzen:

+

+ Passwort zurücksetzen +

+

Oder kopieren Sie diesen Link in Ihren Browser:

+

%s

+

Dieser Link ist 14 Tage gültig.

+

Falls Sie diese Anfrage nicht gestellt haben, können Sie diese E-Mail ignorieren. Ihr Passwort bleibt unverändert.

+
+ +
+ +`, username, resetLink, resetLink) + + // Send email + msg := mail.Message{ + To: email, + Subject: "Passwort zurücksetzen - Bamort", + Body: htmlBody, + } + + if err := mailClient.Send(msg); err != nil { + logger.Error("Fehler beim Versenden der Reset-E-Mail an %s: %s", email, err.Error()) + return err + } + + logger.Info("Password-Reset-E-Mail erfolgreich an %s versendet", email) return nil } diff --git a/docker/.env.dev b/docker/.env.dev index 9b170fc..bdc62ad 100644 --- a/docker/.env.dev +++ b/docker/.env.dev @@ -18,4 +18,10 @@ API_URL=http://192.168.0.48:8180 API_PORT=8180 BASE_URL=http://localhost:5173 TEMPLATES_DIR=./templates -EXPORT_TEMP_DIR=./export_temp \ No newline at end of file +EXPORT_TEMP_DIR=./export_temp + +# Mail Configuration (for development) +MAIL_HOST=mail.wuenscheonline.de +MAIL_PORT=465 +MAIL_USERNAME=bamort@trokan.de +MAIL_PASSWORD=XXXmhqbv.DW+XXX diff --git a/docker/.env.prd b/docker/.env.prd index be6d581..e29dc5c 100644 --- a/docker/.env.prd +++ b/docker/.env.prd @@ -18,3 +18,9 @@ API_PORT=8180 BASE_URL=https://bamort.trokan.de TEMPLATES_DIR=./templates EXPORT_TEMP_DIR=./export_temp + +# Mail Configuration (for production) +MAIL_HOST=mail.wuenscheonline.de +MAIL_PORT=465 +MAIL_USERNAME=bamort@trokan.de +MAIL_PASSWORD=XXXXmhqbv.DW+XXX diff --git a/frontend/VERSION.md b/frontend/VERSION.md index 78c7a87..9ee6a4b 100644 --- a/frontend/VERSION.md +++ b/frontend/VERSION.md @@ -1,6 +1,6 @@ # Frontend Version Management -## Current Version: 0.2.2 +## Current Version: 0.2.3 The frontend version is managed independently from the backend. diff --git a/frontend/package.json b/frontend/package.json index 1018dc2..2c766d5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "bamort-frontend", - "version": "0.2.2", + "version": "0.2.3", "private": true, "license": "SEE LICENSE IN LICENSE", "type": "module", diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index bc7c8a1..d95dd5d 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -1674,7 +1674,7 @@ a:focus { display: flex; align-items: center; justify-content: center; - z-index: 1000; + z-index: 1100; } /* Modal content container */ @@ -1727,11 +1727,13 @@ a:focus { /* Modal actions (alternative to footer) */ .modal-actions { display: flex; - justify-content: flex-end; + justify-content: space-between; + align-items: center; gap: 10px; - margin-top: 20px; - padding-top: 15px; - border-top: 1px solid #eee; + padding: 20px 24px; + border-top: 1px solid #dee2e6; + background: #f8f9fa; + flex-shrink: 0; } /* Close button */ @@ -1778,6 +1780,8 @@ a:focus { position: absolute; top: 0; left: 0; + display: flex; + flex-direction: column; } /* ======================================== @@ -4403,3 +4407,23 @@ a:focus { max-height: 150px; } } +.help-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + margin-left: 6px; + border: 1px solid #999; + border-radius: 50%; + font-size: 12px; + line-height: 1; + /*cursor: help;*/ + background: #f5f5f5; + color: #555; +} + +.help-icon:hover { + background: #e0e0e0; + color: #222; +} \ No newline at end of file diff --git a/frontend/src/components/CharacterCreation/CharacterBasicInfo.vue b/frontend/src/components/CharacterCreation/CharacterBasicInfo.vue index 11e1a77..15702d6 100644 --- a/frontend/src/components/CharacterCreation/CharacterBasicInfo.vue +++ b/frontend/src/components/CharacterCreation/CharacterBasicInfo.vue @@ -549,7 +549,7 @@ export default { color: #666; margin-top: 15px; } - +/* .help-icon { display: inline-flex; align-items: center; @@ -561,7 +561,6 @@ export default { border-radius: 50%; font-size: 12px; line-height: 1; - /*cursor: help;*/ background: #f5f5f5; color: #555; } @@ -570,4 +569,5 @@ export default { background: #e0e0e0; color: #222; } +*/ diff --git a/frontend/src/components/DatasheetView.vue b/frontend/src/components/DatasheetView.vue index a87a32e..7926ba3 100644 --- a/frontend/src/components/DatasheetView.vue +++ b/frontend/src/components/DatasheetView.vue @@ -36,6 +36,14 @@
+

{{ $t('char') }}:

diff --git a/frontend/src/components/ForgotPasswordForm.vue b/frontend/src/components/ForgotPasswordForm.vue index c1f276b..6293c81 100644 --- a/frontend/src/components/ForgotPasswordForm.vue +++ b/frontend/src/components/ForgotPasswordForm.vue @@ -1,57 +1,56 @@