diff --git a/README.md b/README.md index d35de51..4922c95 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ http://localhost:8080 **Standard-Anmeldedaten:** - Benutzername: `admin` -- Passwort: `admin123` +- Passwort: Das in `docker-compose.yml` unter `INITIAL_ADMIN_PASSWORD` festgelegte Passwort. ⚠️ **WICHTIG**: Ändern Sie das Admin-Passwort sofort nach der ersten Anmeldung! @@ -179,13 +179,15 @@ export JWT_SECRET=development-secret ### Umgebungsvariablen -| Variable | Beschreibung | Standard | Erforderlich | -| ------------- | ------------------------------- | ------------------- | ------------ | -| `PORT` | HTTP-Server Port | `8080` | Nein | -| `DB_PATH` | Pfad zur SQLite-Datenbank | `./timetracking.db` | Nein | -| `JWT_SECRET` | Geheimnis für JWT-Token | - | **Ja** | -| `TZ` | Zeitzone | `Europe/Berlin` | Nein | -| `ENVIRONMENT` | `production` für HTTPS-Redirect | - | Nein | +| Variable | Beschreibung | Standard | Erforderlich | +| ------------------------ | ------------------------------------------------ | --------------------------------- | ------------ | +| `PORT` | HTTP-Server Port | `8080` | Nein | +| `DB_PATH` | Pfad zur SQLite-Datenbank | `./timetracking.db` | Nein | +| `JWT_SECRET` | Geheimnis für JWT-Token | - | **Ja** | +| `INITIAL_ADMIN_PASSWORD` | Initiales Passwort für den Admin-Benutzer | `changeme` | **Ja** | +| `TZ` | Zeitzone | `Europe/Berlin` | Nein | +| `ENVIRONMENT` | `production` für HTTPS-Redirect und striktes CORS | `development` | Nein | +| `CORS_ALLOWED_ORIGINS` | Komma-getrennte Liste von erlaubten Origins | `*` (in dev), `http://localhost:8080` (in prod) | Nein | ### Docker-Volumes @@ -203,7 +205,7 @@ Die Datenbank wird unter `/data/timetracking.db` im Container gespeichert. ### Ersteinrichtung als Administrator -1. **Anmelden** mit den Standard-Credentials (admin/admin123) +1. **Anmelden** mit den Standard-Credentials (admin/das initiale Passwort aus der Konfiguration) 2. **Admin-Passwort ändern**: - Gehe zu "Benutzer" Tab @@ -311,7 +313,7 @@ Benutzer-Anmeldung ```json { "username": "admin", - "password": "admin123" + "password": "" } ``` diff --git a/backend/errors.go b/backend/errors.go new file mode 100644 index 0000000..7ee17bd --- /dev/null +++ b/backend/errors.go @@ -0,0 +1,205 @@ +package main + +import ( + "fmt" + "net/http" +) + +type ErrorCode string + +const ( + // Authentifizierung + ErrInvalidCredentials ErrorCode = "INVALID_CREDENTIALS" + ErrUnauthorized ErrorCode = "UNAUTHORIZED" + ErrTokenExpired ErrorCode = "TOKEN_EXPIRED" + ErrAccessDenied ErrorCode = "ACCESS_DENIED" + + // Validierung + ErrInvalidInput ErrorCode = "INVALID_INPUT" + ErrMissingField ErrorCode = "MISSING_FIELD" + ErrInvalidDateFormat ErrorCode = "INVALID_DATE_FORMAT" + ErrInvalidTimeFormat ErrorCode = "INVALID_TIME_FORMAT" + + // Ressourcen + ErrNotFound ErrorCode = "NOT_FOUND" + ErrAlreadyExists ErrorCode = "ALREADY_EXISTS" + ErrCannotDelete ErrorCode = "CANNOT_DELETE" + ErrProtectedUser ErrorCode = "PROTECTED_USER" + ErrNoActiveSchool ErrorCode = "NO_ACTIVE_SCHOOL_YEAR" + + // Datenbank + ErrDatabase ErrorCode = "DATABASE_ERROR" + ErrTransaction ErrorCode = "TRANSACTION_ERROR" + ErrQueryFailed ErrorCode = "QUERY_FAILED" + + // Server + ErrInternal ErrorCode = "INTERNAL_ERROR" + ErrServiceUnavail ErrorCode = "SERVICE_UNAVAILABLE" +) + +type AppError struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` + UserMsg string `json:"user_message"` + HTTPStatus int `json:"-"` + Internal error `json:"-"` +} + +func (e *AppError) Error() string { + if e.Internal != nil { + return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Internal) + } + return fmt.Sprintf("[%s] %s", e.Code, e.Message) +} + +func NewAppError(code ErrorCode, message, userMsg string, httpStatus int, internal error) *AppError { + return &AppError{ + Code: code, + Message: message, + UserMsg: userMsg, + HTTPStatus: httpStatus, + Internal: internal, + } +} + +func ErrInvalidCredentialsMsg() *AppError { + return NewAppError( + ErrInvalidCredentials, + "Invalid username or password", + "Benutzername oder Passwort ungültig", + http.StatusUnauthorized, + nil, + ) +} + +func ErrUnauthorizedMsg() *AppError { + return NewAppError( + ErrUnauthorized, + "Unauthorized access", + "Keine Berechtigung für diese Aktion", + http.StatusUnauthorized, + nil, + ) +} + +func ErrTokenExpiredMsg() *AppError { + return NewAppError( + ErrTokenExpired, + "Token has expired", + "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an", + http.StatusUnauthorized, + nil, + ) +} + +func ErrAccessDeniedMsg() *AppError { + return NewAppError( + ErrAccessDenied, + "Access denied - admin privileges required", + "Zugriff verweigert. Administrator-Rechte erforderlich", + http.StatusForbidden, + nil, + ) +} + +func ErrInvalidInputMsg(field string) *AppError { + return NewAppError( + ErrInvalidInput, + fmt.Sprintf("Invalid input for field: %s", field), + fmt.Sprintf("Ungültige Eingabe im Feld: %s", field), + http.StatusBadRequest, + nil, + ) +} + +func ErrMissingFieldMsg(field string) *AppError { + return NewAppError( + ErrMissingField, + fmt.Sprintf("Required field missing: %s", field), + fmt.Sprintf("Pflichtfeld fehlt: %s", field), + http.StatusBadRequest, + nil, + ) +} + +func ErrNotFoundMsg(resource string) *AppError { + return NewAppError( + ErrNotFound, + fmt.Sprintf("%s not found", resource), + fmt.Sprintf("%s nicht gefunden", resource), + http.StatusNotFound, + nil, + ) +} + +func ErrAlreadyExistsMsg(resource string) *AppError { + return NewAppError( + ErrAlreadyExists, + fmt.Sprintf("%s already exists", resource), + fmt.Sprintf("%s existiert bereits", resource), + http.StatusConflict, + nil, + ) +} + +func ErrCannotDeleteMsg(resource, reason string) *AppError { + return NewAppError( + ErrCannotDelete, + fmt.Sprintf("Cannot delete %s: %s", resource, reason), + fmt.Sprintf("%s kann nicht gelöscht werden: %s", resource, reason), + http.StatusBadRequest, + nil, + ) +} + +func ErrProtectedUserMsg() *AppError { + return NewAppError( + ErrProtectedUser, + "Cannot modify protected admin user", + "Der Admin-Benutzer ist geschützt und kann nicht geändert werden", + http.StatusForbidden, + nil, + ) +} + +func ErrNoActiveSchoolYearMsg() *AppError { + return NewAppError( + ErrNoActiveSchool, + "No active school year configured", + "Kein aktives Schuljahr konfiguriert. Bitte aktivieren Sie ein Schuljahr", + http.StatusNotFound, + nil, + ) +} + +func ErrDatabaseMsg(internal error) *AppError { + return NewAppError( + ErrDatabase, + "Database operation failed", + "Ein Datenbankfehler ist aufgetreten. Bitte versuchen Sie es erneut", + http.StatusInternalServerError, + internal, + ) +} + +func ErrInternalMsg(internal error) *AppError { + return NewAppError( + ErrInternal, + "Internal server error", + "Ein interner Fehler ist aufgetreten. Bitte versuchen Sie es später erneut", + http.StatusInternalServerError, + internal, + ) +} + +type ErrorResponse struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` +} + +func (e *AppError) ToResponse() ErrorResponse { + return ErrorResponse{ + Code: e.Code, + Message: e.UserMsg, + } +} diff --git a/backend/go.mod b/backend/go.mod index 7b185e7..2a1d344 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,7 +12,9 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/labstack/echo-jwt/v4 v4.3.1 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/backend/go.sum b/backend/go.sum index fee7803..6c63134 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -4,6 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -11,6 +13,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc= github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= +github.com/labstack/echo-jwt/v4 v4.3.1 h1:d8+/qf8nx7RxeL46LtoIwHJsH2PNN8xXCQ/jDianycE= +github.com/labstack/echo-jwt/v4 v4.3.1/go.mod h1:yJi83kN8S/5vePVPd+7ID75P4PqPNVRs2HVeuvYJH00= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= diff --git a/backend/handlers.go b/backend/handlers.go index e9567d6..067a4ea 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -3,10 +3,13 @@ package main import ( "database/sql" "fmt" + "log" "net/http" "strconv" + "strings" "time" + "github.com/golang-jwt/jwt/v5" "github.com/labstack/echo/v4" "golang.org/x/crypto/bcrypt" ) @@ -15,24 +18,60 @@ type App struct { DB *sql.DB } +func HandleError(c echo.Context, err *AppError) error { + log.Printf("[%s] %s", err.Code, err.Error()) + + return c.JSON(err.HTTPStatus, err.ToResponse()) +} + +func getClaims(c echo.Context) (*Claims, error) { + user, ok := c.Get("user").(*jwt.Token) + if !ok { + return nil, fmt.Errorf("JWT token missing or invalid") + } + + claims, ok := user.Claims.(*Claims) + if !ok { + return nil, fmt.Errorf("failed to parse JWT claims") + } + + return claims, nil +} + +func isDuplicateError(err error) bool { + return err != nil && (err.Error() == "UNIQUE constraint failed" || + strings.Contains(err.Error(), "UNIQUE") || + strings.Contains(err.Error(), "duplicate")) +} + func (app *App) LoginHandler(c echo.Context) error { var req LoginRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid request") + return HandleError(c, ErrInvalidInputMsg("Login-Daten")) + } + + if req.Username == "" { + return HandleError(c, ErrMissingFieldMsg("Benutzername")) + } + if req.Password == "" { + return HandleError(c, ErrMissingFieldMsg("Passwort")) } user, err := GetUserByUsername(app.DB, req.Username) if err != nil { - return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials") + if err == sql.ErrNoRows { + return HandleError(c, ErrInvalidCredentialsMsg()) + } + return HandleError(c, ErrDatabaseMsg(err)) } if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { - return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials") + return HandleError(c, ErrInvalidCredentialsMsg()) } token, err := createToken(user.ID, user.Username, user.IsAdmin) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "error creating token") + return HandleError(c, ErrInternalMsg(err)) } response := LoginResponse{ @@ -47,7 +86,7 @@ func (app *App) LoginHandler(c echo.Context) error { func (app *App) GetSchedulesHandler(c echo.Context) error { schedules, err := GetAllSchedules(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } return c.JSON(http.StatusOK, schedules) } @@ -55,24 +94,40 @@ func (app *App) GetSchedulesHandler(c echo.Context) error { func (app *App) CreateScheduleHandler(c echo.Context) error { var schedule Schedule if err := c.Bind(&schedule); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid request") + return HandleError(c, ErrInvalidInputMsg("Stundenplan-Daten")) + } + + if schedule.StartTime == "" { + return HandleError(c, ErrMissingFieldMsg("Startzeit")) + } + if schedule.EndTime == "" { + return HandleError(c, ErrMissingFieldMsg("Endzeit")) + } + if schedule.Title == "" { + return HandleError(c, ErrMissingFieldMsg("Titel")) } if err := CreateSchedule(app.DB, &schedule); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if isDuplicateError(err) { + return HandleError(c, ErrAlreadyExistsMsg("Stundenplan-Eintrag")) + } + return HandleError(c, ErrDatabaseMsg(err)) } - return c.JSON(http.StatusCreated, map[string]string{"message": "schedule created"}) + return c.JSON(http.StatusCreated, map[string]string{"message": "Stundenplan erstellt"}) } func (app *App) DeleteScheduleHandler(c echo.Context) error { id, err := strconv.Atoi(c.QueryParam("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid id") + return HandleError(c, ErrInvalidInputMsg("Stundenplan-ID")) } if err := DeleteSchedule(app.DB, id); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Stundenplan")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusNoContent) @@ -81,7 +136,7 @@ func (app *App) DeleteScheduleHandler(c echo.Context) error { func (app *App) GetYearlyHoursSummaryHandler(c echo.Context) error { hours, err := GetYearlyHoursSummary(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } if hours == nil { hours = []WeeklyHours{} @@ -90,11 +145,6 @@ func (app *App) GetYearlyHoursSummaryHandler(c echo.Context) error { } func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error { - isAdmin, _ := c.Get("is_admin").(bool) - if !isAdmin { - return echo.NewHTTPError(http.StatusForbidden, "Only admins can create entries for others") - } - var req struct { UserID int `json:"user_id"` Date string `json:"date"` @@ -103,7 +153,17 @@ func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error { } if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid request") + return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten")) + } + + if req.UserID == 0 { + return HandleError(c, ErrMissingFieldMsg("Benutzer")) + } + if req.Date == "" { + return HandleError(c, ErrMissingFieldMsg("Datum")) + } + if req.Hours == 0 { + return HandleError(c, ErrMissingFieldMsg("Stunden")) } entry := TimeEntry{ @@ -115,16 +175,16 @@ func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error { } if err := CreateManualTimeEntry(app.DB, &entry, req.Hours); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } - return c.NoContent(http.StatusCreated) + return c.NoContent(http.StatusNoContent) } func (app *App) GetUsersHandler(c echo.Context) error { users, err := GetAllUsers(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } if users == nil { users = []User{} @@ -135,39 +195,62 @@ func (app *App) GetUsersHandler(c echo.Context) error { func (app *App) DeleteUserHandler(c echo.Context) error { id, err := strconv.Atoi(c.QueryParam("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid id") + return HandleError(c, ErrInvalidInputMsg("Benutzer-ID")) + } + + if id == 1 { + return HandleError(c, ErrProtectedUserMsg()) } if err := DeleteUser(app.DB, id); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Benutzer")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusNoContent) } func (app *App) CreateTimeEntryHandler(c echo.Context) error { - userID := c.Get("user_id").(int) + claims, err := getClaims(c) + if err != nil { + return HandleError(c, ErrUnauthorizedMsg()) + } var entry TimeEntry if err := c.Bind(&entry); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid request") + return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten")) } - entry.UserID = userID + if entry.Date == "" { + return HandleError(c, ErrMissingFieldMsg("Datum")) + } + if entry.StartTime == "" { + return HandleError(c, ErrMissingFieldMsg("Startzeit")) + } + if entry.EndTime == "" { + return HandleError(c, ErrMissingFieldMsg("Endzeit")) + } + + entry.UserID = claims.UserID if err := CreateTimeEntry(app.DB, &entry); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } - return c.JSON(http.StatusCreated, map[string]string{"message": "time entry created"}) + return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteintrag erstellt"}) } func (app *App) GetMyTimeEntriesHandler(c echo.Context) error { - userID := c.Get("user_id").(int) - - entries, err := GetTimeEntriesByUser(app.DB, userID) + claims, err := getClaims(c) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrUnauthorizedMsg()) + } + + entries, err := GetTimeEntriesByUser(app.DB, claims.UserID) + if err != nil { + return HandleError(c, ErrDatabaseMsg(err)) } if entries == nil { entries = []TimeEntry{} @@ -179,12 +262,16 @@ func (app *App) GetMyTimeEntriesHandler(c echo.Context) error { func (app *App) GetWeekDates(c echo.Context) error { year, err := strconv.Atoi(c.QueryParam("year")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") + return HandleError(c, ErrInvalidInputMsg("Jahr")) } week, err := strconv.Atoi(c.QueryParam("week")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") + return HandleError(c, ErrInvalidInputMsg("Woche")) + } + + if week < 1 || week > 53 { + return HandleError(c, ErrInvalidInputMsg("Woche (muss zwischen 1 und 53 liegen)")) } dates := calculateWeekDates(year, week) @@ -192,21 +279,24 @@ func (app *App) GetWeekDates(c echo.Context) error { } func (app *App) CheckWeekHasEntries(c echo.Context) error { - userID := c.Get("user_id").(int) + claims, err := getClaims(c) + if err != nil { + return HandleError(c, ErrUnauthorizedMsg()) + } year, err := strconv.Atoi(c.QueryParam("year")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") + return HandleError(c, ErrInvalidInputMsg("Jahr")) } week, err := strconv.Atoi(c.QueryParam("week")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") + return HandleError(c, ErrInvalidInputMsg("Woche")) } - hasEntries, err := CheckUserHasEntriesForWeek(app.DB, userID, year, week) + hasEntries, err := CheckUserHasEntriesForWeek(app.DB, claims.UserID, year, week) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } return c.JSON(http.StatusOK, map[string]bool{"has_entries": hasEntries}) @@ -215,7 +305,7 @@ func (app *App) CheckWeekHasEntries(c echo.Context) error { func (app *App) GetAllTimeEntriesHandler(c echo.Context) error { entries, err := GetAllTimeEntries(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } if entries == nil { entries = []TimeEntry{} @@ -226,7 +316,7 @@ func (app *App) GetAllTimeEntriesHandler(c echo.Context) error { func (app *App) GetWeeklyHoursHandler(c echo.Context) error { hours, err := GetWeeklyHours(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } if hours == nil { hours = []WeeklyHours{} @@ -235,20 +325,23 @@ func (app *App) GetWeeklyHoursHandler(c echo.Context) error { } func (app *App) DeleteWeekEntries(c echo.Context) error { - userID := c.Get("user_id").(int) + claims, err := getClaims(c) + if err != nil { + return HandleError(c, ErrUnauthorizedMsg()) + } year, err := strconv.Atoi(c.QueryParam("year")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") + return HandleError(c, ErrInvalidInputMsg("Jahr")) } week, err := strconv.Atoi(c.QueryParam("week")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") + return HandleError(c, ErrInvalidInputMsg("Woche")) } - if err := DeleteTimeEntriesByUserAndWeek(app.DB, userID, year, week); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err := DeleteTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil { + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusNoContent) @@ -310,52 +403,70 @@ type BatchTimeEntryRequest struct { } func (app *App) CreateBatchTimeEntriesHandler(c echo.Context) error { - userID := c.Get("user_id").(int) + claims, err := getClaims(c) + if err != nil { + return HandleError(c, ErrUnauthorizedMsg()) + } var req BatchTimeEntryRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid request") + return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten")) + } + + if len(req.Entries) == 0 { + return HandleError(c, ErrMissingFieldMsg("Zeiteinträge")) } tx, err := app.DB.Begin() if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "transaction error") + return HandleError(c, ErrDatabaseMsg(err)) } defer tx.Rollback() stmt, err := tx.Prepare("INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?)") if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "prepare error") + return HandleError(c, ErrDatabaseMsg(err)) } defer stmt.Close() for _, entry := range req.Entries { - _, err := stmt.Exec(userID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime) + _, err := stmt.Exec(claims.UserID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "insert error") + return HandleError(c, ErrDatabaseMsg(err)) } } if err := tx.Commit(); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "commit error") + return HandleError(c, ErrDatabaseMsg(err)) } - return c.JSON(http.StatusCreated, map[string]string{"message": "entries created"}) + return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteinträge erstellt"}) } func (app *App) UpdateUserHandler(c echo.Context) error { userID, err := strconv.Atoi(c.Param("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID") + return HandleError(c, ErrInvalidInputMsg("Benutzer-ID")) + } + + if userID == 1 { + return HandleError(c, ErrProtectedUserMsg()) } var req UpdateUserRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + return HandleError(c, ErrInvalidInputMsg("Benutzerdaten")) + } + + if req.YearlyHours <= 0 { + return HandleError(c, ErrInvalidInputMsg("Jahresarbeitsstunden (muss positiv sein)")) } if err := UpdateUser(app.DB, userID, req.YearlyHours); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Benutzer")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusOK) @@ -364,21 +475,28 @@ func (app *App) UpdateUserHandler(c echo.Context) error { func (app *App) ResetPasswordHandler(c echo.Context) error { userID, err := strconv.Atoi(c.Param("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID") + return HandleError(c, ErrInvalidInputMsg("Benutzer-ID")) } var req ResetPasswordRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + return HandleError(c, ErrInvalidInputMsg("Passwort-Daten")) + } + + if len(req.NewPassword) < 6 { + return HandleError(c, ErrInvalidInputMsg("Passwort (mindestens 6 Zeichen)")) } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password") + return HandleError(c, ErrInternalMsg(err)) } if err := ResetUserPassword(app.DB, userID, string(hashedPassword)); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Benutzer")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusOK) @@ -387,16 +505,29 @@ func (app *App) ResetPasswordHandler(c echo.Context) error { func (app *App) UpdateTimeEntryHandler(c echo.Context) error { entryID, err := strconv.Atoi(c.Param("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID") + return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID")) } var req UpdateTimeEntryRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten")) + } + + if req.Date == "" { + return HandleError(c, ErrMissingFieldMsg("Datum")) + } + if req.StartTime == "" { + return HandleError(c, ErrMissingFieldMsg("Startzeit")) + } + if req.EndTime == "" { + return HandleError(c, ErrMissingFieldMsg("Endzeit")) } if err := UpdateTimeEntry(app.DB, entryID, req.Date, req.StartTime, req.EndTime, req.Type); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Zeiteintrag")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusOK) @@ -405,22 +536,31 @@ func (app *App) UpdateTimeEntryHandler(c echo.Context) error { func (app *App) DeleteTimeEntryHandler(c echo.Context) error { entryID, err := strconv.Atoi(c.Param("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID") + return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID")) } if err := DeleteTimeEntry(app.DB, entryID); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Zeiteintrag")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusNoContent) } func (app *App) GetMyInfoHandler(c echo.Context) error { - userID := c.Get("user_id").(int) - - user, err := GetUserByID(app.DB, userID) + claims, err := getClaims(c) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrUnauthorizedMsg()) + } + + user, err := GetUserByID(app.DB, claims.UserID) + if err != nil { + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Benutzer")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.JSON(http.StatusOK, user) @@ -429,12 +569,22 @@ func (app *App) GetMyInfoHandler(c echo.Context) error { func (app *App) CreateUserHandler(c echo.Context) error { var req CreateUserRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + return HandleError(c, ErrInvalidInputMsg("Benutzerdaten")) + } + + if req.Username == "" { + return HandleError(c, ErrMissingFieldMsg("Benutzername")) + } + if req.Password == "" { + return HandleError(c, ErrMissingFieldMsg("Passwort")) + } + if len(req.Password) < 6 { + return HandleError(c, ErrInvalidInputMsg("Passwort (mindestens 6 Zeichen)")) } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password") + return HandleError(c, ErrInternalMsg(err)) } if req.YearlyHours == 0 { @@ -442,7 +592,10 @@ func (app *App) CreateUserHandler(c echo.Context) error { } if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin, req.YearlyHours); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if isDuplicateError(err) { + return HandleError(c, ErrAlreadyExistsMsg("Benutzername")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusCreated) @@ -451,7 +604,7 @@ func (app *App) CreateUserHandler(c echo.Context) error { func (app *App) GetSchoolYearsHandler(c echo.Context) error { years, err := GetAllSchoolYears(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } if years == nil { years = []SchoolYear{} @@ -462,11 +615,24 @@ func (app *App) GetSchoolYearsHandler(c echo.Context) error { func (app *App) CreateSchoolYearHandler(c echo.Context) error { var req CreateSchoolYearRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + return HandleError(c, ErrInvalidInputMsg("Schuljahr-Daten")) + } + + if req.Name == "" { + return HandleError(c, ErrMissingFieldMsg("Name")) + } + if req.StartDate == "" { + return HandleError(c, ErrMissingFieldMsg("Startdatum")) + } + if req.EndDate == "" { + return HandleError(c, ErrMissingFieldMsg("Enddatum")) } if err := CreateSchoolYear(app.DB, req.Name, req.StartDate, req.EndDate); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if isDuplicateError(err) { + return HandleError(c, ErrAlreadyExistsMsg("Schuljahr")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusCreated) @@ -475,11 +641,14 @@ func (app *App) CreateSchoolYearHandler(c echo.Context) error { func (app *App) SetActiveSchoolYearHandler(c echo.Context) error { id, err := strconv.Atoi(c.Param("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID") + return HandleError(c, ErrInvalidInputMsg("Schuljahr-ID")) } if err := SetActiveSchoolYear(app.DB, id); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Schuljahr")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusNoContent) @@ -488,7 +657,7 @@ func (app *App) SetActiveSchoolYearHandler(c echo.Context) error { func (app *App) GetActiveSchoolYearHandler(c echo.Context) error { year, err := GetActiveSchoolYear(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } if year == nil { return c.JSON(http.StatusOK, map[string]any{"active": false}) @@ -497,24 +666,22 @@ func (app *App) GetActiveSchoolYearHandler(c echo.Context) error { } func (app *App) GenerateYearlySummaryPDFHandler(c echo.Context) error { - isAdmin, _ := c.Get("is_admin").(bool) - if !isAdmin { - return echo.NewHTTPError(http.StatusForbidden, "Only admins can generate PDFs") - } - schoolYear, err := GetActiveSchoolYear(app.DB) - if err != nil || schoolYear == nil { - return echo.NewHTTPError(http.StatusNotFound, "No active school year found") + if err != nil { + return HandleError(c, ErrDatabaseMsg(err)) + } + if schoolYear == nil { + return HandleError(c, ErrNoActiveSchoolYearMsg()) } summary, err := GetYearlyHoursSummary(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } pdfBytes, err := GenerateYearlySummaryPDF(schoolYear, summary) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate PDF: "+err.Error()) + return HandleError(c, ErrInternalMsg(err)) } filename := fmt.Sprintf("Jahresuebersicht_%s.pdf", schoolYear.Name) diff --git a/backend/main.go b/backend/main.go index 78ccf01..7e1903e 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,6 +4,7 @@ import ( "log" "net/http" "os" + "strings" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" @@ -24,8 +25,20 @@ func main() { e.Use(middleware.Logger()) e.Use(middleware.Recover()) + + // CORS Configuration + allowOrigins := []string{"*"} // Default for development + if os.Getenv("ENVIRONMENT") == "production" { + origins := os.Getenv("CORS_ALLOWED_ORIGINS") + if origins != "" { + allowOrigins = strings.Split(origins, ",") + } else { + log.Println("Warning: ENVIRONMENT is 'production' but CORS_ALLOWED_ORIGINS is not set. Allowing all origins.") + } + } + e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ - AllowOrigins: []string{"*"}, + AllowOrigins: allowOrigins, AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization}, })) diff --git a/backend/middleware.go b/backend/middleware.go index 4ee2231..78d693c 100644 --- a/backend/middleware.go +++ b/backend/middleware.go @@ -1,17 +1,13 @@ package main import ( - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" "net/http" "os" - "strings" "sync" "time" + "github.com/golang-jwt/jwt/v5" + echojwt "github.com/labstack/echo-jwt/v4" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "golang.org/x/time/rate" @@ -28,104 +24,43 @@ func init() { } func createToken(userID int, username string, isAdmin bool) (string, error) { - claims := Claims{ + claims := &Claims{ UserID: userID, Username: username, IsAdmin: isAdmin, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)), + }, } - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) - - claimsWithExp := map[string]any{ - "user_id": claims.UserID, - "username": claims.Username, - "is_admin": claims.IsAdmin, - "exp": time.Now().Add(2 * time.Hour).Unix(), - } - - payload, _ := json.Marshal(claimsWithExp) - payloadEncoded := base64.RawURLEncoding.EncodeToString(payload) - - message := header + "." + payloadEncoded - - h := hmac.New(sha256.New, jwtSecret) - h.Write([]byte(message)) - signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) - - return message + "." + signature, nil -} - -func verifyToken(tokenString string) (*Claims, error) { - parts := strings.Split(tokenString, ".") - if len(parts) != 3 { - return nil, fmt.Errorf("invalid token format") - } - - message := parts[0] + "." + parts[1] - h := hmac.New(sha256.New, jwtSecret) - h.Write([]byte(message)) - expectedSignature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) - - if parts[2] != expectedSignature { - return nil, fmt.Errorf("invalid signature") - } - - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return nil, err - } - - var claimsMap map[string]any - if err := json.Unmarshal(payload, &claimsMap); err != nil { - return nil, err - } - - if exp, ok := claimsMap["exp"].(float64); ok { - if time.Now().Unix() > int64(exp) { - return nil, fmt.Errorf("token expired") - } - } - - claims := &Claims{ - UserID: int(claimsMap["user_id"].(float64)), - Username: claimsMap["username"].(string), - IsAdmin: claimsMap["is_admin"].(bool), - } - - return claims, nil + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) } func JWTMiddleware() echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - authHeader := c.Request().Header.Get("Authorization") - if authHeader == "" { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") - } - - tokenString := strings.TrimPrefix(authHeader, "Bearer ") - claims, err := verifyToken(tokenString) - if err != nil { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") - } - - c.Set("user_id", claims.UserID) - c.Set("username", claims.Username) - c.Set("is_admin", claims.IsAdmin) - - c.Logger().Infof("Authenticated user: ID=%d, Username=%s", claims.UserID, claims.Username) - - return next(c) - } - } + return echojwt.WithConfig(echojwt.Config{ + NewClaimsFunc: func(c echo.Context) jwt.Claims { + return new(Claims) + }, + SigningKey: jwtSecret, + }) } func AdminMiddleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - isAdmin, ok := c.Get("is_admin").(bool) - if !ok || !isAdmin { - return echo.NewHTTPError(http.StatusForbidden, "Access denied") + user, ok := c.Get("user").(*jwt.Token) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "JWT token missing or invalid") + } + + claims, ok := user.Claims.(*Claims) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Failed to parse JWT claims") + } + + if !claims.IsAdmin { + return echo.NewHTTPError(http.StatusForbidden, "Access denied: admin rights required") } return next(c) } diff --git a/backend/models.go b/backend/models.go index 1348146..8429bb6 100644 --- a/backend/models.go +++ b/backend/models.go @@ -1,6 +1,9 @@ package main -import "time" +import ( + "github.com/golang-jwt/jwt/v5" + "time" +) type TimeEntry struct { ID int `json:"id"` @@ -96,4 +99,5 @@ type Claims struct { UserID int `json:"user_id"` Username string `json:"username"` IsAdmin bool `json:"is_admin"` + jwt.RegisteredClaims } diff --git a/backend/static/index.html b/backend/static/index.html index 6be48fa..12ae1c0 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -1,149 +1,338 @@ - + + - - - + + + Zeiterfassung - - - - - + + + + + +
- + + diff --git a/frontend/public/index.html b/frontend/public/index.html index 71337d4..12ae1c0 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,164 +1,338 @@ - + + - - - + + + Zeiterfassung - - - - - - - + + + + + +
- + + diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm index db45a90..710b286 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -10,6 +10,7 @@ import Html.Events exposing (..) import Http import Json.Decode as Decode exposing (Decoder, bool, field, float, int, list, string) import Json.Encode as Encode +import Process import Task import Time @@ -99,6 +100,23 @@ type alias Model = , newSchoolYear : NewSchoolYear , activeSchoolYear : Maybe SchoolYear , editingSchoolYearId : Maybe Int + , toasts : List Toast + , nextToastId : Int + } + + +type ToastType + = ErrorToast + | SuccessToast + | InfoToast + | WarningToast + + +type alias Toast = + { id : Int + , message : String + , toastType : ToastType + , dismissible : Bool } @@ -299,6 +317,8 @@ init flags = , newSchoolYear = NewSchoolYear "" "" "" , activeSchoolYear = Nothing , editingSchoolYearId = Nothing + , toasts = [] + , nextToastId = 0 } cmd = @@ -309,7 +329,11 @@ init flags = , fetchSchedules (Just token) , fetchYearlyHoursSummary token , if flags.isAdmin then - fetchSchoolYears token + Cmd.batch + [ fetchSchoolYears token + , fetchUsers token + , fetchAllTimeEntries token + ] else fetchMyInfo token @@ -434,6 +458,9 @@ type Msg | SchoolYearDeleted (Result Http.Error ()) | DownloadYearlySummaryPDF | YearlySummaryPDFReceived (Result Http.Error Bytes.Bytes) + | ShowToast String ToastType + | DismissToast Int + | AutoDismissToast Int update : Msg -> Model -> ( Model, Cmd Msg ) @@ -487,6 +514,7 @@ update msg model = , Cmd.batch [ saveToken tokenData , fetchSchedules (Just result.token) + , Task.perform (\_ -> ShowToast ("Willkommen, " ++ result.username ++ "!") SuccessToast) (Task.succeed ()) , if not result.isAdmin then Cmd.batch [ fetchMyTimeEntries result.token @@ -506,8 +534,25 @@ update msg model = ] ) - LoginResponse (Err _) -> - ( { model | error = Just "Login fehlgeschlagen", isProcessing = False }, Cmd.none ) + LoginResponse (Err err) -> + let + errorMsg = + case err of + Http.BadStatus 401 -> + "Benutzername oder Passwort ungültig" + + Http.Timeout -> + "Zeitüberschreitung - bitte erneut versuchen" + + Http.NetworkError -> + "Netzwerkfehler - bitte Verbindung prüfen" + + _ -> + "Anmeldung fehlgeschlagen" + in + ( { model | isProcessing = False } + , Task.perform (\_ -> ShowToast errorMsg ErrorToast) (Task.succeed ()) + ) Logout -> ( { model @@ -527,8 +572,8 @@ update msg model = SchedulesReceived (Ok schedules) -> ( { model | schedules = schedules }, Cmd.none ) - SchedulesReceived (Err _) -> - ( { model | error = Just "Fehler beim Laden des Stundenplans" }, Cmd.none ) + SchedulesReceived (Err err) -> + ( model, handleApiError err ) ToggleScheduleSelection scheduleId dayOfWeek -> let @@ -564,14 +609,15 @@ update msg model = } , Cmd.batch [ fetchMyTimeEntries token + , Task.perform (\_ -> ShowToast "Zeiteinträge erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) ] ) Nothing -> ( model, Cmd.none ) - TimeEntriesSaved (Err _) -> - ( { model | error = Just "Fehler beim Speichern" }, Cmd.none ) + TimeEntriesSaved (Err err) -> + ( model, handleApiError err ) PreviousWeek -> let @@ -628,8 +674,8 @@ update msg model = WeekDatesReceived (Ok weekDates) -> ( { model | weekDates = Just weekDates }, Cmd.none ) - WeekDatesReceived (Err _) -> - ( { model | error = Just "Fehler beim Laden der Wochendaten" }, Cmd.none ) + WeekDatesReceived (Err err) -> + ( model, handleApiError err ) CheckWeekHasEntries -> case model.token of @@ -642,8 +688,8 @@ update msg model = WeekHasEntriesReceived (Ok hasEntries) -> ( { model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none ) - WeekHasEntriesReceived (Err _) -> - ( model, Cmd.none ) + WeekHasEntriesReceived (Err err) -> + ( model, handleApiError err ) SetTime time -> let @@ -740,14 +786,17 @@ update msg model = , selectedEntries = [] , hasEntriesForCurrentWeek = False } - , fetchMyTimeEntries token + , Cmd.batch + [ fetchMyTimeEntries token + , Task.perform (\_ -> ShowToast "Wocheneinträge erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] ) Nothing -> ( model, Cmd.none ) - WeekEntriesDeleted (Err _) -> - ( { model | error = Just "Fehler beim Löschen" }, Cmd.none ) + WeekEntriesDeleted (Err err) -> + ( model, handleApiError err ) SwitchTab tab -> let @@ -844,7 +893,7 @@ update msg model = || String.isEmpty model.newSchedule.startTime || String.isEmpty model.newSchedule.endTime then - ( { model | error = Just "Bitte alle Felder ausfüllen" }, Cmd.none ) + ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) ) else case model.token of @@ -866,37 +915,17 @@ update msg model = , error = Nothing , isProcessing = False } - , fetchSchedules model.token + , Cmd.batch + [ fetchSchedules model.token + , Task.perform (\_ -> ShowToast "Stundenplan erfolgreich erstellt!" SuccessToast) (Task.succeed ()) + ] ) Nothing -> ( model, Cmd.none ) ScheduleCreated (Err err) -> - let - errorMsg = - case err of - Http.BadStatus 400 -> - "Ungültige Eingabe" - - Http.BadStatus 409 -> - "Dieser Stundenplan existiert bereits" - - Http.Timeout -> - "Anfrage abgelaufen" - - Http.NetworkError -> - "Netzwerkfehler" - - _ -> - "Fehler beim Erstellen" - in - ( { model - | error = Just errorMsg - , isProcessing = False - } - , Cmd.none - ) + ( { model | isProcessing = False }, handleApiError err ) DeleteSchedule scheduleId -> case model.token of @@ -909,13 +938,18 @@ update msg model = ScheduleDeleted (Ok _) -> case model.token of Just token -> - ( { model | error = Nothing }, fetchSchedules (Just token) ) + ( { model | error = Nothing } + , Cmd.batch + [ fetchSchedules (Just token) + , Task.perform (\_ -> ShowToast "Stundenplan erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) Nothing -> ( model, Cmd.none ) - ScheduleDeleted (Err _) -> - ( { model | error = Just "Fehler beim Löschen" }, Cmd.none ) + ScheduleDeleted (Err err) -> + ( model, handleApiError err ) UpdateNewUsername username -> let @@ -962,13 +996,18 @@ update msg model = in case model.token of Just token -> - ( { model | newUser = emptyUser }, fetchUsers token ) + ( { model | newUser = emptyUser } + , Cmd.batch + [ fetchUsers token + , Task.perform (\_ -> ShowToast "Benutzer erfolgreich erstellt!" SuccessToast) (Task.succeed ()) + ] + ) Nothing -> ( model, Cmd.none ) - UserCreated (Err _) -> - ( { model | error = Just "Fehler beim Erstellen des Benutzers" }, Cmd.none ) + UserCreated (Err err) -> + ( model, handleApiError err ) DeleteUser userId -> case model.token of @@ -987,14 +1026,17 @@ update msg model = , editingUserId = Nothing , resetPasswordUserId = Nothing } - , fetchUsers token + , Cmd.batch + [ fetchUsers token + , Task.perform (\_ -> ShowToast "Benutzer erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] ) Nothing -> ( model, Cmd.none ) - UserDeleted (Err _) -> - ( { model | error = Just "Fehler beim Löschen des Benutzers", pendingDeleteId = Nothing }, Cmd.none ) + UserDeleted (Err err) -> + ( { model | pendingDeleteId = Nothing }, handleApiError err ) FetchUsers -> case model.token of @@ -1007,8 +1049,8 @@ update msg model = UsersReceived (Ok users) -> ( { model | users = users }, Cmd.none ) - UsersReceived (Err _) -> - ( { model | error = Just "Fehler beim Laden der Benutzer" }, Cmd.none ) + UsersReceived (Err err) -> + ( model, handleApiError err ) FetchMyTimeEntries -> case model.token of @@ -1039,8 +1081,8 @@ update msg model = , Cmd.none ) - MyTimeEntriesReceived (Err _) -> - ( { model | error = Just "Fehler beim Laden der Einträge" }, Cmd.none ) + MyTimeEntriesReceived (Err err) -> + ( model, handleApiError err ) FetchAllTimeEntries -> case model.token of @@ -1053,8 +1095,8 @@ update msg model = AllTimeEntriesReceived (Ok entries) -> ( { model | timeEntries = entries }, Cmd.none ) - AllTimeEntriesReceived (Err _) -> - ( { model | error = Just "Fehler beim Laden der Zeiteinträge" }, Cmd.none ) + AllTimeEntriesReceived (Err err) -> + ( model, handleApiError err ) FetchWeeklyHours -> case model.token of @@ -1067,8 +1109,8 @@ update msg model = WeeklyHoursReceived (Ok hours) -> ( { model | weeklyHours = hours }, Cmd.none ) - WeeklyHoursReceived (Err _) -> - ( { model | error = Just "Fehler beim Laden der Wochenstunden" }, Cmd.none ) + WeeklyHoursReceived (Err err) -> + ( model, handleApiError err ) FetchYearlyHoursSummary -> case model.token of @@ -1081,8 +1123,8 @@ update msg model = YearlyHoursSummaryReceived (Ok summary) -> ( { model | yearlyHoursSummary = summary }, Cmd.none ) - YearlyHoursSummaryReceived (Err _) -> - ( { model | error = Just "Fehler beim Laden der Jahresübersicht" }, Cmd.none ) + YearlyHoursSummaryReceived (Err err) -> + ( model, handleApiError err ) MyWeeklySummaryReceived (Ok summary) -> ( { model | userWeeklySummary = Just summary }, Cmd.none ) @@ -1176,16 +1218,16 @@ update msg model = } , Cmd.batch [ fetchAllTimeEntries token - , fetchWeeklyHours token , fetchYearlyHoursSummary token + , Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gelöscht" SuccessToast) (Task.succeed ()) ] ) Nothing -> ( model, Cmd.none ) - TimeEntryDeleted (Err _) -> - ( { model | error = Just "Fehler beim Löschen des Eintrags", pendingDeleteId = Nothing }, Cmd.none ) + TimeEntryDeleted (Err err) -> + ( { model | pendingDeleteId = Nothing }, handleApiError err ) EditUserWorkHours userId -> case List.filter (\u -> u.id == userId) model.users |> List.head of @@ -1247,18 +1289,21 @@ update msg model = ( { model | resetPasswordUserId = Nothing , resetPasswordNew = "" - , error = Just "Passwort erfolgreich zurückgesetzt" + , error = Nothing } - , case model.token of - Just token -> - fetchUsers token + , Cmd.batch + [ case model.token of + Just token -> + fetchUsers token - Nothing -> - Cmd.none + Nothing -> + Cmd.none + , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt" SuccessToast) (Task.succeed ()) + ] ) - ResetPasswordSaved (Err _) -> - ( { model | error = Just "Fehler beim Zurücksetzen des Passworts" }, Cmd.none ) + ResetPasswordSaved (Err err) -> + ( model, handleApiError err ) StartEditingTimeEntry entryId entry -> ( { model @@ -1332,14 +1377,17 @@ update msg model = , pendingDeleteId = Nothing , error = Nothing } - , fetchAllTimeEntries token + , Cmd.batch + [ fetchAllTimeEntries token + , Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) + ] ) Nothing -> ( model, Cmd.none ) - TimeEntrySaved (Err _) -> - ( { model | error = Just "Fehler beim Speichern des Eintrags" }, Cmd.none ) + TimeEntrySaved (Err err) -> + ( model, handleApiError err ) ConfirmDeleteTimeEntry entryId -> ( { model | pendingDeleteId = Just entryId }, confirmDelete "Soll dieser Zeiteintrag gelöscht werden?" ) @@ -1379,7 +1427,7 @@ update msg model = ( model, updateUserWorkHours token userId (String.fromFloat hours) ) _ -> - ( { model | error = Just "Ungültige Eingabe für Arbeitszeit" }, Cmd.none ) + ( model, Task.perform (\_ -> ShowToast "Ungültige Eingabe für Arbeitszeit" WarningToast) (Task.succeed ()) ) UserWorkHoursSaved (Ok _) -> case model.token of @@ -1389,14 +1437,17 @@ update msg model = , editingUserId = Nothing , error = Nothing } - , fetchUsers token + , Cmd.batch + [ fetchUsers token + , Task.perform (\_ -> ShowToast "Arbeitszeit erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) + ] ) Nothing -> ( model, Cmd.none ) - UserWorkHoursSaved (Err _) -> - ( { model | error = Just "Fehler beim Speichern der Arbeitszeit" }, Cmd.none ) + UserWorkHoursSaved (Err err) -> + ( model, handleApiError err ) UpdateUserPassword input -> ( { model | userPasswordInput = input }, Cmd.none ) @@ -1408,10 +1459,10 @@ update msg model = ( model, resetUserPassword token userId model.userPasswordInput ) else - ( { model | error = Just "Passwort erforderlich" }, Cmd.none ) + ( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) ) _ -> - ( { model | error = Just "Passwort erforderlich" }, Cmd.none ) + ( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) ) UserPasswordSaved (Ok _) -> ( { model @@ -1419,11 +1470,11 @@ update msg model = , selectedUserId = Nothing , error = Nothing } - , Cmd.none + , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt!" SuccessToast) (Task.succeed ()) ) - UserPasswordSaved (Err _) -> - ( { model | error = Just "Fehler beim Zurücksetzen des Passworts" }, Cmd.none ) + UserPasswordSaved (Err err) -> + ( model, handleApiError err ) SelectUserForManualEntry userId -> let @@ -1472,15 +1523,15 @@ update msg model = , Cmd.batch [ fetchAllTimeEntries token , fetchYearlyHoursSummary token - , fetchWeeklyHours token + , Task.perform (\_ -> ShowToast "Manueller Eintrag erfolgreich erstellt!" SuccessToast) (Task.succeed ()) ] ) Nothing -> ( model, Cmd.none ) - AdminTimeEntrySaved (Err _) -> - ( { model | error = Just "Fehler beim Erstellen des Eintrags", isProcessing = False }, Cmd.none ) + AdminTimeEntrySaved (Err err) -> + ( { model | isProcessing = False }, handleApiError err ) FetchMyInfo -> case model.token of @@ -1493,8 +1544,8 @@ update msg model = MyInfoReceived (Ok user) -> ( { model | users = [ user ] }, Cmd.none ) - MyInfoReceived (Err _) -> - ( { model | error = Just "Fehler beim Laden deiner Daten" }, Cmd.none ) + MyInfoReceived (Err err) -> + ( model, handleApiError err ) FetchSchoolYears -> case model.token of @@ -1507,8 +1558,8 @@ update msg model = SchoolYearsReceived (Ok years) -> ( { model | schoolYears = years }, Cmd.none ) - SchoolYearsReceived (Err _) -> - ( { model | error = Just "Fehler beim Laden der Schuljahre" }, Cmd.none ) + SchoolYearsReceived (Err err) -> + ( model, handleApiError err ) FetchActiveSchoolYear -> case model.token of @@ -1560,7 +1611,7 @@ update msg model = || String.isEmpty model.newSchoolYear.startDate || String.isEmpty model.newSchoolYear.endDate then - ( { model | error = Just "Bitte alle Felder ausfüllen" }, Cmd.none ) + ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) ) else case model.token of @@ -1578,19 +1629,17 @@ update msg model = , error = Nothing , isProcessing = False } - , fetchSchoolYears token + , Cmd.batch + [ fetchSchoolYears token + , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich erstellt!" SuccessToast) (Task.succeed ()) + ] ) Nothing -> ( model, Cmd.none ) - SchoolYearCreated (Err _) -> - ( { model - | error = Just "Fehler beim Erstellen des Schuljahres" - , isProcessing = False - } - , Cmd.none - ) + SchoolYearCreated (Err err) -> + ( { model | isProcessing = False }, handleApiError err ) ActivateSchoolYear id -> case model.token of @@ -1607,14 +1656,15 @@ update msg model = , Cmd.batch [ fetchSchoolYears token , fetchActiveSchoolYear token + , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich aktiviert!" SuccessToast) (Task.succeed ()) ] ) Nothing -> ( model, Cmd.none ) - SchoolYearActivated (Err _) -> - ( { model | error = Just "Fehler beim Aktivieren" }, Cmd.none ) + SchoolYearActivated (Err err) -> + ( model, handleApiError err ) DeleteSchoolYear id -> case model.token of @@ -1627,13 +1677,18 @@ update msg model = SchoolYearDeleted (Ok _) -> case model.token of Just token -> - ( { model | error = Nothing }, fetchSchoolYears token ) + ( { model | error = Nothing } + , Cmd.batch + [ fetchSchoolYears token + , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) Nothing -> ( model, Cmd.none ) - SchoolYearDeleted (Err _) -> - ( { model | error = Just "Fehler beim Löschen" }, Cmd.none ) + SchoolYearDeleted (Err err) -> + ( model, handleApiError err ) DownloadYearlySummaryPDF -> case model.token of @@ -1650,11 +1705,47 @@ update msg model = in ( { model | isProcessing = False }, File.Download.bytes filename "application/pdf" pdfBytes ) - YearlySummaryPDFReceived (Err _) -> + YearlySummaryPDFReceived (Err err) -> + ( { model | isProcessing = False }, handleApiError err ) + + ShowToast message toastType -> + let + newToast = + { id = model.nextToastId + , message = message + , toastType = toastType + , dismissible = True + } + + dismissDelay = + case toastType of + ErrorToast -> + 8000 + + SuccessToast -> + 5000 + + InfoToast -> + 5000 + + WarningToast -> + 6000 + in ( { model - | error = Just "Fehler beim Herunterladen der PDF" - , isProcessing = False + | toasts = model.toasts ++ [ newToast ] + , nextToastId = model.nextToastId + 1 } + , Task.perform (\_ -> AutoDismissToast newToast.id) + (Process.sleep dismissDelay) + ) + + DismissToast toastId -> + ( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts } + , Cmd.none + ) + + AutoDismissToast toastId -> + ( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts } , Cmd.none ) @@ -2031,18 +2122,77 @@ calculateHours startTime endTime = -- VIEW +viewToasts : List Toast -> Html Msg +viewToasts toasts = + div [ class "toast-container" ] + (List.map viewToast toasts) + + +viewToast : Toast -> Html Msg +viewToast toast = + let + toastClass = + case toast.toastType of + ErrorToast -> + "toast-error" + + SuccessToast -> + "toast-success" + + InfoToast -> + "toast-info" + + WarningToast -> + "toast-warning" + + icon = + case toast.toastType of + ErrorToast -> + "fas fa-exclamation-circle" + + SuccessToast -> + "fas fa-check-circle" + + InfoToast -> + "fas fa-info-circle" + + WarningToast -> + "fas fa-exclamation-triangle" + in + div [ class ("toast " ++ toastClass), style "animation" "slideIn 0.3s ease-out" ] + [ div [ class "toast-content" ] + [ span [ class "toast-icon" ] + [ i [ class icon ] [] ] + , span [ class "toast-message" ] [ text toast.message ] + ] + , if toast.dismissible then + button + [ class "toast-close" + , onClick (DismissToast toast.id) + , attribute "aria-label" "Schließen" + ] + [ i [ class "fas fa-times" ] [] ] + + else + text "" + ] + + view : Model -> Html Msg view model = - div [ class "container" ] - [ case model.page of - LoginPage -> - viewLogin model + div [ class "app-container" ] + [ viewToasts model.toasts + , div [ class "container" ] + [ case model.page of + LoginPage -> + viewLogin model - UserDashboard -> - viewUserDashboard model + UserDashboard -> + viewUserDashboard model - AdminDashboard -> - viewAdminDashboard model + AdminDashboard -> + viewAdminDashboard model + ] ] @@ -2054,12 +2204,6 @@ viewLogin model = [ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ] [ div [ class "box" ] [ h1 [ class "title has-text-centered" ] [ text "Zeiterfassung Login" ] - , case model.error of - Just err -> - div [ class "notification is-danger" ] [ text err ] - - Nothing -> - text "" , div [ class "field" ] [ label [ class "label" ] [ text "Benutzername" ] , div [ class "control" ] @@ -2249,12 +2393,6 @@ viewUserDashboard model = text "" , h3 [ class "subtitle mt-6" ] [ text "Jahresgesamtzeit" ] , viewUserYearlyTotal model - , case model.error of - Just err -> - div [ class "notification is-danger mt-4" ] [ text err ] - - Nothing -> - text "" ] ] ] @@ -4311,3 +4449,50 @@ downloadYearlySummaryPDF token = , timeout = Nothing , tracker = Nothing } + + +type alias ApiError = + { code : String + , message : String + } + + +apiErrorDecoder : Decoder ApiError +apiErrorDecoder = + Decode.map2 ApiError + (field "code" string) + (field "message" string) + + +handleApiError : Http.Error -> Cmd Msg +handleApiError error = + let + message = + case error of + Http.BadBody body -> + case Decode.decodeString apiErrorDecoder body of + Ok apiErr -> + apiErr.message + + Err _ -> + "Ein Fehler ist aufgetreten" + + Http.BadStatus 401 -> + "Keine Berechtigung - bitte erneut anmelden" + + Http.BadStatus 403 -> + "Zugriff verweigert" + + Http.BadStatus 404 -> + "Ressource nicht gefunden" + + Http.Timeout -> + "Zeitüberschreitung - bitte erneut versuchen" + + Http.NetworkError -> + "Netzwerkfehler - bitte Verbindung prüfen" + + _ -> + "Ein unerwarteter Fehler ist aufgetreten" + in + Task.perform (\_ -> ShowToast message ErrorToast) (Task.succeed ())