feat: improve app security and error handling

Improve overall app security by:
- using dynamic statements for all sql querries
- introducing environment variables for initial admin password
- introducing enironment variable for cors address
- improving error handling
This commit is contained in:
Patryk Hegenberg 2025-11-09 12:13:47 +01:00
parent 95057c1b8d
commit 3ac1947106
11 changed files with 1333 additions and 453 deletions

View file

@ -140,7 +140,7 @@ http://localhost:8080
**Standard-Anmeldedaten:** **Standard-Anmeldedaten:**
- Benutzername: `admin` - 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! ⚠️ **WICHTIG**: Ändern Sie das Admin-Passwort sofort nach der ersten Anmeldung!
@ -180,12 +180,14 @@ export JWT_SECRET=development-secret
### Umgebungsvariablen ### Umgebungsvariablen
| Variable | Beschreibung | Standard | Erforderlich | | Variable | Beschreibung | Standard | Erforderlich |
| ------------- | ------------------------------- | ------------------- | ------------ | | ------------------------ | ------------------------------------------------ | --------------------------------- | ------------ |
| `PORT` | HTTP-Server Port | `8080` | Nein | | `PORT` | HTTP-Server Port | `8080` | Nein |
| `DB_PATH` | Pfad zur SQLite-Datenbank | `./timetracking.db` | Nein | | `DB_PATH` | Pfad zur SQLite-Datenbank | `./timetracking.db` | Nein |
| `JWT_SECRET` | Geheimnis für JWT-Token | - | **Ja** | | `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 | | `TZ` | Zeitzone | `Europe/Berlin` | Nein |
| `ENVIRONMENT` | `production` für HTTPS-Redirect | - | 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 ### Docker-Volumes
@ -203,7 +205,7 @@ Die Datenbank wird unter `/data/timetracking.db` im Container gespeichert.
### Ersteinrichtung als Administrator ### 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**: 2. **Admin-Passwort ändern**:
- Gehe zu "Benutzer" Tab - Gehe zu "Benutzer" Tab
@ -311,7 +313,7 @@ Benutzer-Anmeldung
```json ```json
{ {
"username": "admin", "username": "admin",
"password": "admin123" "password": "<your-initial-admin-password>"
} }
``` ```

205
backend/errors.go Normal file
View file

@ -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,
}
}

View file

@ -12,7 +12,9 @@ require (
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect 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/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/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect

View file

@ -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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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= 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.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 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= 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 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=

View file

@ -3,10 +3,13 @@ package main
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"log"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@ -15,24 +18,60 @@ type App struct {
DB *sql.DB 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 { func (app *App) LoginHandler(c echo.Context) error {
var req LoginRequest var req LoginRequest
if err := c.Bind(&req); err != nil { 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) user, err := GetUserByUsername(app.DB, req.Username)
if err != nil { 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 { 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) token, err := createToken(user.ID, user.Username, user.IsAdmin)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "error creating token") return HandleError(c, ErrInternalMsg(err))
} }
response := LoginResponse{ response := LoginResponse{
@ -47,7 +86,7 @@ func (app *App) LoginHandler(c echo.Context) error {
func (app *App) GetSchedulesHandler(c echo.Context) error { func (app *App) GetSchedulesHandler(c echo.Context) error {
schedules, err := GetAllSchedules(app.DB) schedules, err := GetAllSchedules(app.DB)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrDatabaseMsg(err))
} }
return c.JSON(http.StatusOK, schedules) 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 { func (app *App) CreateScheduleHandler(c echo.Context) error {
var schedule Schedule var schedule Schedule
if err := c.Bind(&schedule); err != nil { 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 { 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 { func (app *App) DeleteScheduleHandler(c echo.Context) error {
id, err := strconv.Atoi(c.QueryParam("id")) id, err := strconv.Atoi(c.QueryParam("id"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid id") return HandleError(c, ErrInvalidInputMsg("Stundenplan-ID"))
} }
if err := DeleteSchedule(app.DB, id); err != nil { 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) 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 { func (app *App) GetYearlyHoursSummaryHandler(c echo.Context) error {
hours, err := GetYearlyHoursSummary(app.DB) hours, err := GetYearlyHoursSummary(app.DB)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrDatabaseMsg(err))
} }
if hours == nil { if hours == nil {
hours = []WeeklyHours{} hours = []WeeklyHours{}
@ -90,11 +145,6 @@ func (app *App) GetYearlyHoursSummaryHandler(c echo.Context) error {
} }
func (app *App) AdminCreateTimeEntryHandler(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 { var req struct {
UserID int `json:"user_id"` UserID int `json:"user_id"`
Date string `json:"date"` Date string `json:"date"`
@ -103,7 +153,17 @@ func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error {
} }
if err := c.Bind(&req); err != nil { 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{ entry := TimeEntry{
@ -115,16 +175,16 @@ func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error {
} }
if err := CreateManualTimeEntry(app.DB, &entry, req.Hours); err != nil { 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 { func (app *App) GetUsersHandler(c echo.Context) error {
users, err := GetAllUsers(app.DB) users, err := GetAllUsers(app.DB)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrDatabaseMsg(err))
} }
if users == nil { if users == nil {
users = []User{} users = []User{}
@ -135,39 +195,62 @@ func (app *App) GetUsersHandler(c echo.Context) error {
func (app *App) DeleteUserHandler(c echo.Context) error { func (app *App) DeleteUserHandler(c echo.Context) error {
id, err := strconv.Atoi(c.QueryParam("id")) id, err := strconv.Atoi(c.QueryParam("id"))
if err != nil { 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 { 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) return c.NoContent(http.StatusNoContent)
} }
func (app *App) CreateTimeEntryHandler(c echo.Context) error { 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 var entry TimeEntry
if err := c.Bind(&entry); err != nil { 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 { 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 { func (app *App) GetMyTimeEntriesHandler(c echo.Context) error {
userID := c.Get("user_id").(int) claims, err := getClaims(c)
entries, err := GetTimeEntriesByUser(app.DB, userID)
if err != nil { 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 { if entries == nil {
entries = []TimeEntry{} entries = []TimeEntry{}
@ -179,12 +262,16 @@ func (app *App) GetMyTimeEntriesHandler(c echo.Context) error {
func (app *App) GetWeekDates(c echo.Context) error { func (app *App) GetWeekDates(c echo.Context) error {
year, err := strconv.Atoi(c.QueryParam("year")) year, err := strconv.Atoi(c.QueryParam("year"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") return HandleError(c, ErrInvalidInputMsg("Jahr"))
} }
week, err := strconv.Atoi(c.QueryParam("week")) week, err := strconv.Atoi(c.QueryParam("week"))
if err != nil { 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) dates := calculateWeekDates(year, week)
@ -192,21 +279,24 @@ func (app *App) GetWeekDates(c echo.Context) error {
} }
func (app *App) CheckWeekHasEntries(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")) year, err := strconv.Atoi(c.QueryParam("year"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") return HandleError(c, ErrInvalidInputMsg("Jahr"))
} }
week, err := strconv.Atoi(c.QueryParam("week")) week, err := strconv.Atoi(c.QueryParam("week"))
if err != nil { 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 { 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}) 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 { func (app *App) GetAllTimeEntriesHandler(c echo.Context) error {
entries, err := GetAllTimeEntries(app.DB) entries, err := GetAllTimeEntries(app.DB)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrDatabaseMsg(err))
} }
if entries == nil { if entries == nil {
entries = []TimeEntry{} entries = []TimeEntry{}
@ -226,7 +316,7 @@ func (app *App) GetAllTimeEntriesHandler(c echo.Context) error {
func (app *App) GetWeeklyHoursHandler(c echo.Context) error { func (app *App) GetWeeklyHoursHandler(c echo.Context) error {
hours, err := GetWeeklyHours(app.DB) hours, err := GetWeeklyHours(app.DB)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrDatabaseMsg(err))
} }
if hours == nil { if hours == nil {
hours = []WeeklyHours{} hours = []WeeklyHours{}
@ -235,20 +325,23 @@ func (app *App) GetWeeklyHoursHandler(c echo.Context) error {
} }
func (app *App) DeleteWeekEntries(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")) year, err := strconv.Atoi(c.QueryParam("year"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") return HandleError(c, ErrInvalidInputMsg("Jahr"))
} }
week, err := strconv.Atoi(c.QueryParam("week")) week, err := strconv.Atoi(c.QueryParam("week"))
if err != nil { 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 { if err := DeleteTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrDatabaseMsg(err))
} }
return c.NoContent(http.StatusNoContent) return c.NoContent(http.StatusNoContent)
@ -310,52 +403,70 @@ type BatchTimeEntryRequest struct {
} }
func (app *App) CreateBatchTimeEntriesHandler(c echo.Context) error { 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 var req BatchTimeEntryRequest
if err := c.Bind(&req); err != nil { 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() tx, err := app.DB.Begin()
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "transaction error") return HandleError(c, ErrDatabaseMsg(err))
} }
defer tx.Rollback() defer tx.Rollback()
stmt, err := tx.Prepare("INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?)") stmt, err := tx.Prepare("INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?)")
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "prepare error") return HandleError(c, ErrDatabaseMsg(err))
} }
defer stmt.Close() defer stmt.Close()
for _, entry := range req.Entries { 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 { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "insert error") return HandleError(c, ErrDatabaseMsg(err))
} }
} }
if err := tx.Commit(); err != nil { 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 { func (app *App) UpdateUserHandler(c echo.Context) error {
userID, err := strconv.Atoi(c.Param("id")) userID, err := strconv.Atoi(c.Param("id"))
if err != nil { 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 var req UpdateUserRequest
if err := c.Bind(&req); err != nil { 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 { 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) 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 { func (app *App) ResetPasswordHandler(c echo.Context) error {
userID, err := strconv.Atoi(c.Param("id")) userID, err := strconv.Atoi(c.Param("id"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID") return HandleError(c, ErrInvalidInputMsg("Benutzer-ID"))
} }
var req ResetPasswordRequest var req ResetPasswordRequest
if err := c.Bind(&req); err != nil { 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) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil { 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 { 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) 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 { func (app *App) UpdateTimeEntryHandler(c echo.Context) error {
entryID, err := strconv.Atoi(c.Param("id")) entryID, err := strconv.Atoi(c.Param("id"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID") return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID"))
} }
var req UpdateTimeEntryRequest var req UpdateTimeEntryRequest
if err := c.Bind(&req); err != nil { 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 { 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) 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 { func (app *App) DeleteTimeEntryHandler(c echo.Context) error {
entryID, err := strconv.Atoi(c.Param("id")) entryID, err := strconv.Atoi(c.Param("id"))
if err != nil { 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 { 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) return c.NoContent(http.StatusNoContent)
} }
func (app *App) GetMyInfoHandler(c echo.Context) error { func (app *App) GetMyInfoHandler(c echo.Context) error {
userID := c.Get("user_id").(int) claims, err := getClaims(c)
user, err := GetUserByID(app.DB, userID)
if err != nil { 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) 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 { func (app *App) CreateUserHandler(c echo.Context) error {
var req CreateUserRequest var req CreateUserRequest
if err := c.Bind(&req); err != nil { 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) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password") return HandleError(c, ErrInternalMsg(err))
} }
if req.YearlyHours == 0 { 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 { 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) 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 { func (app *App) GetSchoolYearsHandler(c echo.Context) error {
years, err := GetAllSchoolYears(app.DB) years, err := GetAllSchoolYears(app.DB)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrDatabaseMsg(err))
} }
if years == nil { if years == nil {
years = []SchoolYear{} years = []SchoolYear{}
@ -462,11 +615,24 @@ func (app *App) GetSchoolYearsHandler(c echo.Context) error {
func (app *App) CreateSchoolYearHandler(c echo.Context) error { func (app *App) CreateSchoolYearHandler(c echo.Context) error {
var req CreateSchoolYearRequest var req CreateSchoolYearRequest
if err := c.Bind(&req); err != nil { 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 { 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) 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 { func (app *App) SetActiveSchoolYearHandler(c echo.Context) error {
id, err := strconv.Atoi(c.Param("id")) id, err := strconv.Atoi(c.Param("id"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID") return HandleError(c, ErrInvalidInputMsg("Schuljahr-ID"))
} }
if err := SetActiveSchoolYear(app.DB, id); err != nil { 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) 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 { func (app *App) GetActiveSchoolYearHandler(c echo.Context) error {
year, err := GetActiveSchoolYear(app.DB) year, err := GetActiveSchoolYear(app.DB)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrDatabaseMsg(err))
} }
if year == nil { if year == nil {
return c.JSON(http.StatusOK, map[string]any{"active": false}) 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 { 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) schoolYear, err := GetActiveSchoolYear(app.DB)
if err != nil || schoolYear == nil { if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "No active school year found") return HandleError(c, ErrDatabaseMsg(err))
}
if schoolYear == nil {
return HandleError(c, ErrNoActiveSchoolYearMsg())
} }
summary, err := GetYearlyHoursSummary(app.DB) summary, err := GetYearlyHoursSummary(app.DB)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrDatabaseMsg(err))
} }
pdfBytes, err := GenerateYearlySummaryPDF(schoolYear, summary) pdfBytes, err := GenerateYearlySummaryPDF(schoolYear, summary)
if err != nil { 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) filename := fmt.Sprintf("Jahresuebersicht_%s.pdf", schoolYear.Name)

View file

@ -4,6 +4,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strings"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
@ -24,8 +25,20 @@ func main() {
e.Use(middleware.Logger()) e.Use(middleware.Logger())
e.Use(middleware.Recover()) 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{ e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"}, AllowOrigins: allowOrigins,
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization}, AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization},
})) }))

View file

@ -1,17 +1,13 @@
package main package main
import ( import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http" "net/http"
"os" "os"
"strings"
"sync" "sync"
"time" "time"
"github.com/golang-jwt/jwt/v5"
echojwt "github.com/labstack/echo-jwt/v4"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
"golang.org/x/time/rate" "golang.org/x/time/rate"
@ -28,104 +24,43 @@ func init() {
} }
func createToken(userID int, username string, isAdmin bool) (string, error) { func createToken(userID int, username string, isAdmin bool) (string, error) {
claims := Claims{ claims := &Claims{
UserID: userID, UserID: userID,
Username: username, Username: username,
IsAdmin: isAdmin, IsAdmin: isAdmin,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)),
},
} }
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
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
} }
func JWTMiddleware() echo.MiddlewareFunc { func JWTMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return echojwt.WithConfig(echojwt.Config{
return func(c echo.Context) error { NewClaimsFunc: func(c echo.Context) jwt.Claims {
authHeader := c.Request().Header.Get("Authorization") return new(Claims)
if authHeader == "" { },
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") SigningKey: jwtSecret,
} })
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)
}
}
} }
func AdminMiddleware() echo.MiddlewareFunc { func AdminMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
isAdmin, ok := c.Get("is_admin").(bool) user, ok := c.Get("user").(*jwt.Token)
if !ok || !isAdmin { if !ok {
return echo.NewHTTPError(http.StatusForbidden, "Access denied") 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) return next(c)
} }

View file

@ -1,6 +1,9 @@
package main package main
import "time" import (
"github.com/golang-jwt/jwt/v5"
"time"
)
type TimeEntry struct { type TimeEntry struct {
ID int `json:"id"` ID int `json:"id"`
@ -96,4 +99,5 @@ type Claims struct {
UserID int `json:"user_id"` UserID int `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
jwt.RegisteredClaims
} }

View file

@ -1,16 +1,191 @@
<!DOCTYPE html> <!doctype html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge"> <meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Zeiterfassung</title> <title>Zeiterfassung</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
<style> <style>
/* Toast-Container */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 12px;
max-width: 400px;
pointer-events: none;
}
/* Basis-Toast */
.toast {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(10px);
pointer-events: all;
min-width: 320px;
transition: all 0.3s ease;
border-left: 4px solid;
}
.toast:hover {
transform: translateX(-5px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
/* Toast-Content */
.toast-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.toast-icon {
font-size: 1.25rem;
display: flex;
align-items: center;
}
.toast-message {
font-size: 0.95rem;
line-height: 1.4;
color: #2c3e50;
font-weight: 500;
}
/* Close-Button */
.toast-close {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
margin-left: 12px;
color: rgba(0, 0, 0, 0.4);
transition: color 0.2s ease;
font-size: 1rem;
}
.toast-close:hover {
color: rgba(0, 0, 0, 0.7);
}
/* Toast-Typen */
.toast-error {
background: linear-gradient(135deg, #fff5f5 0%, #ffe5e5 100%);
border-left-color: #e53e3e;
}
.toast-error .toast-icon {
color: #e53e3e;
}
.toast-success {
background: linear-gradient(135deg, #f0fff4 0%, #e6ffed 100%);
border-left-color: #38a169;
}
.toast-success .toast-icon {
color: #38a169;
}
.toast-info {
background: linear-gradient(135deg, #ebf8ff 0%, #e0f3ff 100%);
border-left-color: #3182ce;
}
.toast-info .toast-icon {
color: #3182ce;
}
.toast-warning {
background: linear-gradient(135deg, #fffaf0 0%, #fff5e6 100%);
border-left-color: #dd6b20;
}
.toast-warning .toast-icon {
color: #dd6b20;
}
/* Animationen */
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
.toast.dismissing {
animation: slideOut 0.3s ease-in forwards;
}
/* Mobile Anpassungen */
@media screen and (max-width: 768px) {
.toast-container {
top: 10px;
right: 10px;
left: 10px;
max-width: none;
}
.toast {
min-width: auto;
width: 100%;
}
.toast-message {
font-size: 0.9rem;
}
}
/* Dark Mode Support (optional) */
@media (prefers-color-scheme: dark) {
.toast {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.toast-message {
color: #1a202c;
}
.toast-close {
color: rgba(0, 0, 0, 0.5);
}
.toast-close:hover {
color: rgba(0, 0, 0, 0.8);
}
}
body { body {
min-height: 100vh; min-height: 100vh;
} }
@ -24,7 +199,8 @@
flex-direction: column; flex-direction: column;
} }
.level-left, .level-right { .level-left,
.level-right {
width: 100%; width: 100%;
} }
@ -47,11 +223,17 @@
} }
@keyframes fa-spin { @keyframes fa-spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
</style> </style>
</head> </head>
<body> <body>
<div id="elm"></div> <div id="elm"></div>
@ -59,42 +241,45 @@
<script> <script>
function getStoredData() { function getStoredData() {
try { try {
const data = localStorage.getItem('timetracking'); const data = localStorage.getItem("timetracking");
if (data) { if (data) {
return JSON.parse(data); return JSON.parse(data);
} }
} catch (e) { } catch (e) {
console.error('Failed to parse stored data:', e); console.error("Failed to parse stored data:", e);
} }
return {token: null, isAdmin: false}; return {token: null, isAdmin: false};
} }
function saveData(token, isAdmin) { function saveData(token, isAdmin) {
try { try {
localStorage.setItem('timetracking', JSON.stringify({ localStorage.setItem(
"timetracking",
JSON.stringify({
token: token, token: token,
isAdmin: isAdmin isAdmin: isAdmin,
})); }),
);
} catch (e) { } catch (e) {
console.error('Failed to save data:', e); console.error("Failed to save data:", e);
} }
} }
function clearData() { function clearData() {
try { try {
localStorage.removeItem('timetracking'); localStorage.removeItem("timetracking");
} catch (e) { } catch (e) {
console.error('Failed to clear data:', e); console.error("Failed to clear data:", e);
} }
} }
const storedData = getStoredData(); const storedData = getStoredData();
const app = Elm.Main.init({ const app = Elm.Main.init({
node: document.getElementById('elm'), node: document.getElementById("elm"),
flags: { flags: {
token: storedData.token, token: storedData.token,
isAdmin: storedData.isAdmin isAdmin: storedData.isAdmin,
} },
}); });
app.ports.saveToken.subscribe(function (data) { app.ports.saveToken.subscribe(function (data) {
@ -110,18 +295,18 @@
app.ports.confirmDeleteResponse.send(confirmed); app.ports.confirmDeleteResponse.send(confirmed);
}); });
document.addEventListener('DOMContentLoaded', () => { document.addEventListener("DOMContentLoaded", () => {
function setupBurgerMenu() { function setupBurgerMenu() {
const burgers = document.querySelectorAll('.navbar-burger'); const burgers = document.querySelectorAll(".navbar-burger");
burgers.forEach(burger => { burgers.forEach((burger) => {
burger.addEventListener('click', () => { burger.addEventListener("click", () => {
const target = burger.dataset.target; const target = burger.dataset.target;
const menu = document.getElementById(target); const menu = document.getElementById(target);
if (menu) { if (menu) {
burger.classList.toggle('is-active'); burger.classList.toggle("is-active");
menu.classList.toggle('is-active'); menu.classList.toggle("is-active");
} }
}); });
}); });
@ -133,17 +318,21 @@
setupBurgerMenu(); setupBurgerMenu();
}); });
observer.observe(document.getElementById('elm'), { observer.observe(document.getElementById("elm"), {
childList: true, childList: true,
subtree: true subtree: true,
}); });
}); });
if ('serviceWorker' in navigator && window.location.protocol === 'https:') { if (
navigator.serviceWorker.register('/sw.js').catch(() => { "serviceWorker" in navigator &&
console.log('Service Worker registration failed'); window.location.protocol === "https:"
) {
navigator.serviceWorker.register("/sw.js").catch(() => {
console.log("Service Worker registration failed");
}); });
} }
</script> </script>
</body> </body>
</html> </html>

View file

@ -1,19 +1,191 @@
<!DOCTYPE html> <!doctype html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge"> <meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Zeiterfassung</title> <title>Zeiterfassung</title>
<!-- Bulma CSS --> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<!-- Font Awesome --> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style> <style>
/* Custom Styles */ /* Toast-Container */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 12px;
max-width: 400px;
pointer-events: none;
}
/* Basis-Toast */
.toast {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(10px);
pointer-events: all;
min-width: 320px;
transition: all 0.3s ease;
border-left: 4px solid;
}
.toast:hover {
transform: translateX(-5px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
/* Toast-Content */
.toast-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.toast-icon {
font-size: 1.25rem;
display: flex;
align-items: center;
}
.toast-message {
font-size: 0.95rem;
line-height: 1.4;
color: #2c3e50;
font-weight: 500;
}
/* Close-Button */
.toast-close {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
margin-left: 12px;
color: rgba(0, 0, 0, 0.4);
transition: color 0.2s ease;
font-size: 1rem;
}
.toast-close:hover {
color: rgba(0, 0, 0, 0.7);
}
/* Toast-Typen */
.toast-error {
background: linear-gradient(135deg, #fff5f5 0%, #ffe5e5 100%);
border-left-color: #e53e3e;
}
.toast-error .toast-icon {
color: #e53e3e;
}
.toast-success {
background: linear-gradient(135deg, #f0fff4 0%, #e6ffed 100%);
border-left-color: #38a169;
}
.toast-success .toast-icon {
color: #38a169;
}
.toast-info {
background: linear-gradient(135deg, #ebf8ff 0%, #e0f3ff 100%);
border-left-color: #3182ce;
}
.toast-info .toast-icon {
color: #3182ce;
}
.toast-warning {
background: linear-gradient(135deg, #fffaf0 0%, #fff5e6 100%);
border-left-color: #dd6b20;
}
.toast-warning .toast-icon {
color: #dd6b20;
}
/* Animationen */
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
.toast.dismissing {
animation: slideOut 0.3s ease-in forwards;
}
/* Mobile Anpassungen */
@media screen and (max-width: 768px) {
.toast-container {
top: 10px;
right: 10px;
left: 10px;
max-width: none;
}
.toast {
min-width: auto;
width: 100%;
}
.toast-message {
font-size: 0.9rem;
}
}
/* Dark Mode Support (optional) */
@media (prefers-color-scheme: dark) {
.toast {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.toast-message {
color: #1a202c;
}
.toast-close {
color: rgba(0, 0, 0, 0.5);
}
.toast-close:hover {
color: rgba(0, 0, 0, 0.8);
}
}
body { body {
min-height: 100vh; min-height: 100vh;
} }
@ -22,13 +194,13 @@
overflow-x: auto; overflow-x: auto;
} }
/* Responsive Verbesserungen */
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.level { .level {
flex-direction: column; flex-direction: column;
} }
.level-left, .level-right { .level-left,
.level-right {
width: 100%; width: 100%;
} }
@ -46,119 +218,121 @@
} }
} }
/* Loading Spinner */
.fa-spinner { .fa-spinner {
animation: fa-spin 1s infinite linear; animation: fa-spin 1s infinite linear;
} }
@keyframes fa-spin { @keyframes fa-spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
</style> </style>
</head> </head>
<body> <body>
<div id="elm"></div> <div id="elm"></div>
<script src="/elm.js"></script> <script src="/elm.js"></script>
<script> <script>
// LocalStorage Helper
function getStoredData() { function getStoredData() {
try { try {
const data = localStorage.getItem('timetracking'); const data = localStorage.getItem("timetracking");
if (data) { if (data) {
return JSON.parse(data); return JSON.parse(data);
} }
} catch (e) { } catch (e) {
console.error('Failed to parse stored data:', e); console.error("Failed to parse stored data:", e);
} }
return {token: null, isAdmin: false}; return {token: null, isAdmin: false};
} }
function saveData(token, isAdmin) { function saveData(token, isAdmin) {
try { try {
localStorage.setItem('timetracking', JSON.stringify({ localStorage.setItem(
"timetracking",
JSON.stringify({
token: token, token: token,
isAdmin: isAdmin isAdmin: isAdmin,
})); }),
);
} catch (e) { } catch (e) {
console.error('Failed to save data:', e); console.error("Failed to save data:", e);
} }
} }
function clearData() { function clearData() {
try { try {
localStorage.removeItem('timetracking'); localStorage.removeItem("timetracking");
} catch (e) { } catch (e) {
console.error('Failed to clear data:', e); console.error("Failed to clear data:", e);
} }
} }
// Initialisiere Elm App mit gespeicherten Daten
const storedData = getStoredData(); const storedData = getStoredData();
const app = Elm.Main.init({ const app = Elm.Main.init({
node: document.getElementById('elm'), node: document.getElementById("elm"),
flags: { flags: {
token: storedData.token, token: storedData.token,
isAdmin: storedData.isAdmin isAdmin: storedData.isAdmin,
} },
}); });
// Port: Token speichern
app.ports.saveToken.subscribe(function (data) { app.ports.saveToken.subscribe(function (data) {
saveData(data.token, data.isAdmin); saveData(data.token, data.isAdmin);
}); });
// Port: Token entfernen
app.ports.removeToken.subscribe(function () { app.ports.removeToken.subscribe(function () {
clearData(); clearData();
}); });
// Port: Lösch-Bestätigung
app.ports.confirmDelete.subscribe(function (message) { app.ports.confirmDelete.subscribe(function (message) {
const confirmed = confirm(message); const confirmed = confirm(message);
app.ports.confirmDeleteResponse.send(confirmed); app.ports.confirmDeleteResponse.send(confirmed);
}); });
// BUGFIX: Responsive Navbar Toggle document.addEventListener("DOMContentLoaded", () => {
document.addEventListener('DOMContentLoaded', () => {
// Funktion für Burger-Menu
function setupBurgerMenu() { function setupBurgerMenu() {
const burgers = document.querySelectorAll('.navbar-burger'); const burgers = document.querySelectorAll(".navbar-burger");
burgers.forEach(burger => { burgers.forEach((burger) => {
burger.addEventListener('click', () => { burger.addEventListener("click", () => {
const target = burger.dataset.target; const target = burger.dataset.target;
const menu = document.getElementById(target); const menu = document.getElementById(target);
if (menu) { if (menu) {
burger.classList.toggle('is-active'); burger.classList.toggle("is-active");
menu.classList.toggle('is-active'); menu.classList.toggle("is-active");
} }
}); });
}); });
} }
// Initial setup
setupBurgerMenu(); setupBurgerMenu();
// Observer für dynamische Änderungen (wenn Elm DOM updated)
const observer = new MutationObserver((mutations) => { const observer = new MutationObserver((mutations) => {
setupBurgerMenu(); setupBurgerMenu();
}); });
observer.observe(document.getElementById('elm'), { observer.observe(document.getElementById("elm"), {
childList: true, childList: true,
subtree: true subtree: true,
}); });
}); });
// Service Worker für Offline-Fähigkeit (optional) if (
if ('serviceWorker' in navigator && window.location.protocol === 'https:') { "serviceWorker" in navigator &&
navigator.serviceWorker.register('/sw.js').catch(() => { window.location.protocol === "https:"
console.log('Service Worker registration failed'); ) {
navigator.serviceWorker.register("/sw.js").catch(() => {
console.log("Service Worker registration failed");
}); });
} }
</script> </script>
</body> </body>
</html> </html>

View file

@ -10,6 +10,7 @@ import Html.Events exposing (..)
import Http import Http
import Json.Decode as Decode exposing (Decoder, bool, field, float, int, list, string) import Json.Decode as Decode exposing (Decoder, bool, field, float, int, list, string)
import Json.Encode as Encode import Json.Encode as Encode
import Process
import Task import Task
import Time import Time
@ -99,6 +100,23 @@ type alias Model =
, newSchoolYear : NewSchoolYear , newSchoolYear : NewSchoolYear
, activeSchoolYear : Maybe SchoolYear , activeSchoolYear : Maybe SchoolYear
, editingSchoolYearId : Maybe Int , 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 "" "" "" , newSchoolYear = NewSchoolYear "" "" ""
, activeSchoolYear = Nothing , activeSchoolYear = Nothing
, editingSchoolYearId = Nothing , editingSchoolYearId = Nothing
, toasts = []
, nextToastId = 0
} }
cmd = cmd =
@ -309,7 +329,11 @@ init flags =
, fetchSchedules (Just token) , fetchSchedules (Just token)
, fetchYearlyHoursSummary token , fetchYearlyHoursSummary token
, if flags.isAdmin then , if flags.isAdmin then
fetchSchoolYears token Cmd.batch
[ fetchSchoolYears token
, fetchUsers token
, fetchAllTimeEntries token
]
else else
fetchMyInfo token fetchMyInfo token
@ -434,6 +458,9 @@ type Msg
| SchoolYearDeleted (Result Http.Error ()) | SchoolYearDeleted (Result Http.Error ())
| DownloadYearlySummaryPDF | DownloadYearlySummaryPDF
| YearlySummaryPDFReceived (Result Http.Error Bytes.Bytes) | YearlySummaryPDFReceived (Result Http.Error Bytes.Bytes)
| ShowToast String ToastType
| DismissToast Int
| AutoDismissToast Int
update : Msg -> Model -> ( Model, Cmd Msg ) update : Msg -> Model -> ( Model, Cmd Msg )
@ -487,6 +514,7 @@ update msg model =
, Cmd.batch , Cmd.batch
[ saveToken tokenData [ saveToken tokenData
, fetchSchedules (Just result.token) , fetchSchedules (Just result.token)
, Task.perform (\_ -> ShowToast ("Willkommen, " ++ result.username ++ "!") SuccessToast) (Task.succeed ())
, if not result.isAdmin then , if not result.isAdmin then
Cmd.batch Cmd.batch
[ fetchMyTimeEntries result.token [ fetchMyTimeEntries result.token
@ -506,8 +534,25 @@ update msg model =
] ]
) )
LoginResponse (Err _) -> LoginResponse (Err err) ->
( { model | error = Just "Login fehlgeschlagen", isProcessing = False }, Cmd.none ) 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 -> Logout ->
( { model ( { model
@ -527,8 +572,8 @@ update msg model =
SchedulesReceived (Ok schedules) -> SchedulesReceived (Ok schedules) ->
( { model | schedules = schedules }, Cmd.none ) ( { model | schedules = schedules }, Cmd.none )
SchedulesReceived (Err _) -> SchedulesReceived (Err err) ->
( { model | error = Just "Fehler beim Laden des Stundenplans" }, Cmd.none ) ( model, handleApiError err )
ToggleScheduleSelection scheduleId dayOfWeek -> ToggleScheduleSelection scheduleId dayOfWeek ->
let let
@ -564,14 +609,15 @@ update msg model =
} }
, Cmd.batch , Cmd.batch
[ fetchMyTimeEntries token [ fetchMyTimeEntries token
, Task.perform (\_ -> ShowToast "Zeiteinträge erfolgreich gespeichert!" SuccessToast) (Task.succeed ())
] ]
) )
Nothing -> Nothing ->
( model, Cmd.none ) ( model, Cmd.none )
TimeEntriesSaved (Err _) -> TimeEntriesSaved (Err err) ->
( { model | error = Just "Fehler beim Speichern" }, Cmd.none ) ( model, handleApiError err )
PreviousWeek -> PreviousWeek ->
let let
@ -628,8 +674,8 @@ update msg model =
WeekDatesReceived (Ok weekDates) -> WeekDatesReceived (Ok weekDates) ->
( { model | weekDates = Just weekDates }, Cmd.none ) ( { model | weekDates = Just weekDates }, Cmd.none )
WeekDatesReceived (Err _) -> WeekDatesReceived (Err err) ->
( { model | error = Just "Fehler beim Laden der Wochendaten" }, Cmd.none ) ( model, handleApiError err )
CheckWeekHasEntries -> CheckWeekHasEntries ->
case model.token of case model.token of
@ -642,8 +688,8 @@ update msg model =
WeekHasEntriesReceived (Ok hasEntries) -> WeekHasEntriesReceived (Ok hasEntries) ->
( { model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none ) ( { model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none )
WeekHasEntriesReceived (Err _) -> WeekHasEntriesReceived (Err err) ->
( model, Cmd.none ) ( model, handleApiError err )
SetTime time -> SetTime time ->
let let
@ -740,14 +786,17 @@ update msg model =
, selectedEntries = [] , selectedEntries = []
, hasEntriesForCurrentWeek = False , hasEntriesForCurrentWeek = False
} }
, fetchMyTimeEntries token , Cmd.batch
[ fetchMyTimeEntries token
, Task.perform (\_ -> ShowToast "Wocheneinträge erfolgreich gelöscht" SuccessToast) (Task.succeed ())
]
) )
Nothing -> Nothing ->
( model, Cmd.none ) ( model, Cmd.none )
WeekEntriesDeleted (Err _) -> WeekEntriesDeleted (Err err) ->
( { model | error = Just "Fehler beim Löschen" }, Cmd.none ) ( model, handleApiError err )
SwitchTab tab -> SwitchTab tab ->
let let
@ -844,7 +893,7 @@ update msg model =
|| String.isEmpty model.newSchedule.startTime || String.isEmpty model.newSchedule.startTime
|| String.isEmpty model.newSchedule.endTime || String.isEmpty model.newSchedule.endTime
then then
( { model | error = Just "Bitte alle Felder ausfüllen" }, Cmd.none ) ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) )
else else
case model.token of case model.token of
@ -866,37 +915,17 @@ update msg model =
, error = Nothing , error = Nothing
, isProcessing = False , isProcessing = False
} }
, fetchSchedules model.token , Cmd.batch
[ fetchSchedules model.token
, Task.perform (\_ -> ShowToast "Stundenplan erfolgreich erstellt!" SuccessToast) (Task.succeed ())
]
) )
Nothing -> Nothing ->
( model, Cmd.none ) ( model, Cmd.none )
ScheduleCreated (Err err) -> ScheduleCreated (Err err) ->
let ( { model | isProcessing = False }, handleApiError err )
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
)
DeleteSchedule scheduleId -> DeleteSchedule scheduleId ->
case model.token of case model.token of
@ -909,13 +938,18 @@ update msg model =
ScheduleDeleted (Ok _) -> ScheduleDeleted (Ok _) ->
case model.token of case model.token of
Just token -> 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 -> Nothing ->
( model, Cmd.none ) ( model, Cmd.none )
ScheduleDeleted (Err _) -> ScheduleDeleted (Err err) ->
( { model | error = Just "Fehler beim Löschen" }, Cmd.none ) ( model, handleApiError err )
UpdateNewUsername username -> UpdateNewUsername username ->
let let
@ -962,13 +996,18 @@ update msg model =
in in
case model.token of case model.token of
Just token -> Just token ->
( { model | newUser = emptyUser }, fetchUsers token ) ( { model | newUser = emptyUser }
, Cmd.batch
[ fetchUsers token
, Task.perform (\_ -> ShowToast "Benutzer erfolgreich erstellt!" SuccessToast) (Task.succeed ())
]
)
Nothing -> Nothing ->
( model, Cmd.none ) ( model, Cmd.none )
UserCreated (Err _) -> UserCreated (Err err) ->
( { model | error = Just "Fehler beim Erstellen des Benutzers" }, Cmd.none ) ( model, handleApiError err )
DeleteUser userId -> DeleteUser userId ->
case model.token of case model.token of
@ -987,14 +1026,17 @@ update msg model =
, editingUserId = Nothing , editingUserId = Nothing
, resetPasswordUserId = Nothing , resetPasswordUserId = Nothing
} }
, fetchUsers token , Cmd.batch
[ fetchUsers token
, Task.perform (\_ -> ShowToast "Benutzer erfolgreich gelöscht" SuccessToast) (Task.succeed ())
]
) )
Nothing -> Nothing ->
( model, Cmd.none ) ( model, Cmd.none )
UserDeleted (Err _) -> UserDeleted (Err err) ->
( { model | error = Just "Fehler beim Löschen des Benutzers", pendingDeleteId = Nothing }, Cmd.none ) ( { model | pendingDeleteId = Nothing }, handleApiError err )
FetchUsers -> FetchUsers ->
case model.token of case model.token of
@ -1007,8 +1049,8 @@ update msg model =
UsersReceived (Ok users) -> UsersReceived (Ok users) ->
( { model | users = users }, Cmd.none ) ( { model | users = users }, Cmd.none )
UsersReceived (Err _) -> UsersReceived (Err err) ->
( { model | error = Just "Fehler beim Laden der Benutzer" }, Cmd.none ) ( model, handleApiError err )
FetchMyTimeEntries -> FetchMyTimeEntries ->
case model.token of case model.token of
@ -1039,8 +1081,8 @@ update msg model =
, Cmd.none , Cmd.none
) )
MyTimeEntriesReceived (Err _) -> MyTimeEntriesReceived (Err err) ->
( { model | error = Just "Fehler beim Laden der Einträge" }, Cmd.none ) ( model, handleApiError err )
FetchAllTimeEntries -> FetchAllTimeEntries ->
case model.token of case model.token of
@ -1053,8 +1095,8 @@ update msg model =
AllTimeEntriesReceived (Ok entries) -> AllTimeEntriesReceived (Ok entries) ->
( { model | timeEntries = entries }, Cmd.none ) ( { model | timeEntries = entries }, Cmd.none )
AllTimeEntriesReceived (Err _) -> AllTimeEntriesReceived (Err err) ->
( { model | error = Just "Fehler beim Laden der Zeiteinträge" }, Cmd.none ) ( model, handleApiError err )
FetchWeeklyHours -> FetchWeeklyHours ->
case model.token of case model.token of
@ -1067,8 +1109,8 @@ update msg model =
WeeklyHoursReceived (Ok hours) -> WeeklyHoursReceived (Ok hours) ->
( { model | weeklyHours = hours }, Cmd.none ) ( { model | weeklyHours = hours }, Cmd.none )
WeeklyHoursReceived (Err _) -> WeeklyHoursReceived (Err err) ->
( { model | error = Just "Fehler beim Laden der Wochenstunden" }, Cmd.none ) ( model, handleApiError err )
FetchYearlyHoursSummary -> FetchYearlyHoursSummary ->
case model.token of case model.token of
@ -1081,8 +1123,8 @@ update msg model =
YearlyHoursSummaryReceived (Ok summary) -> YearlyHoursSummaryReceived (Ok summary) ->
( { model | yearlyHoursSummary = summary }, Cmd.none ) ( { model | yearlyHoursSummary = summary }, Cmd.none )
YearlyHoursSummaryReceived (Err _) -> YearlyHoursSummaryReceived (Err err) ->
( { model | error = Just "Fehler beim Laden der Jahresübersicht" }, Cmd.none ) ( model, handleApiError err )
MyWeeklySummaryReceived (Ok summary) -> MyWeeklySummaryReceived (Ok summary) ->
( { model | userWeeklySummary = Just summary }, Cmd.none ) ( { model | userWeeklySummary = Just summary }, Cmd.none )
@ -1176,16 +1218,16 @@ update msg model =
} }
, Cmd.batch , Cmd.batch
[ fetchAllTimeEntries token [ fetchAllTimeEntries token
, fetchWeeklyHours token
, fetchYearlyHoursSummary token , fetchYearlyHoursSummary token
, Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gelöscht" SuccessToast) (Task.succeed ())
] ]
) )
Nothing -> Nothing ->
( model, Cmd.none ) ( model, Cmd.none )
TimeEntryDeleted (Err _) -> TimeEntryDeleted (Err err) ->
( { model | error = Just "Fehler beim Löschen des Eintrags", pendingDeleteId = Nothing }, Cmd.none ) ( { model | pendingDeleteId = Nothing }, handleApiError err )
EditUserWorkHours userId -> EditUserWorkHours userId ->
case List.filter (\u -> u.id == userId) model.users |> List.head of case List.filter (\u -> u.id == userId) model.users |> List.head of
@ -1247,18 +1289,21 @@ update msg model =
( { model ( { model
| resetPasswordUserId = Nothing | resetPasswordUserId = Nothing
, resetPasswordNew = "" , resetPasswordNew = ""
, error = Just "Passwort erfolgreich zurückgesetzt" , error = Nothing
} }
, case model.token of , Cmd.batch
[ case model.token of
Just token -> Just token ->
fetchUsers token fetchUsers token
Nothing -> Nothing ->
Cmd.none Cmd.none
, Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt" SuccessToast) (Task.succeed ())
]
) )
ResetPasswordSaved (Err _) -> ResetPasswordSaved (Err err) ->
( { model | error = Just "Fehler beim Zurücksetzen des Passworts" }, Cmd.none ) ( model, handleApiError err )
StartEditingTimeEntry entryId entry -> StartEditingTimeEntry entryId entry ->
( { model ( { model
@ -1332,14 +1377,17 @@ update msg model =
, pendingDeleteId = Nothing , pendingDeleteId = Nothing
, error = Nothing , error = Nothing
} }
, fetchAllTimeEntries token , Cmd.batch
[ fetchAllTimeEntries token
, Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gespeichert!" SuccessToast) (Task.succeed ())
]
) )
Nothing -> Nothing ->
( model, Cmd.none ) ( model, Cmd.none )
TimeEntrySaved (Err _) -> TimeEntrySaved (Err err) ->
( { model | error = Just "Fehler beim Speichern des Eintrags" }, Cmd.none ) ( model, handleApiError err )
ConfirmDeleteTimeEntry entryId -> ConfirmDeleteTimeEntry entryId ->
( { model | pendingDeleteId = Just entryId }, confirmDelete "Soll dieser Zeiteintrag gelöscht werden?" ) ( { 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, 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 _) -> UserWorkHoursSaved (Ok _) ->
case model.token of case model.token of
@ -1389,14 +1437,17 @@ update msg model =
, editingUserId = Nothing , editingUserId = Nothing
, error = Nothing , error = Nothing
} }
, fetchUsers token , Cmd.batch
[ fetchUsers token
, Task.perform (\_ -> ShowToast "Arbeitszeit erfolgreich gespeichert!" SuccessToast) (Task.succeed ())
]
) )
Nothing -> Nothing ->
( model, Cmd.none ) ( model, Cmd.none )
UserWorkHoursSaved (Err _) -> UserWorkHoursSaved (Err err) ->
( { model | error = Just "Fehler beim Speichern der Arbeitszeit" }, Cmd.none ) ( model, handleApiError err )
UpdateUserPassword input -> UpdateUserPassword input ->
( { model | userPasswordInput = input }, Cmd.none ) ( { model | userPasswordInput = input }, Cmd.none )
@ -1408,10 +1459,10 @@ update msg model =
( model, resetUserPassword token userId model.userPasswordInput ) ( model, resetUserPassword token userId model.userPasswordInput )
else 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 _) -> UserPasswordSaved (Ok _) ->
( { model ( { model
@ -1419,11 +1470,11 @@ update msg model =
, selectedUserId = Nothing , selectedUserId = Nothing
, error = Nothing , error = Nothing
} }
, Cmd.none , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt!" SuccessToast) (Task.succeed ())
) )
UserPasswordSaved (Err _) -> UserPasswordSaved (Err err) ->
( { model | error = Just "Fehler beim Zurücksetzen des Passworts" }, Cmd.none ) ( model, handleApiError err )
SelectUserForManualEntry userId -> SelectUserForManualEntry userId ->
let let
@ -1472,15 +1523,15 @@ update msg model =
, Cmd.batch , Cmd.batch
[ fetchAllTimeEntries token [ fetchAllTimeEntries token
, fetchYearlyHoursSummary token , fetchYearlyHoursSummary token
, fetchWeeklyHours token , Task.perform (\_ -> ShowToast "Manueller Eintrag erfolgreich erstellt!" SuccessToast) (Task.succeed ())
] ]
) )
Nothing -> Nothing ->
( model, Cmd.none ) ( model, Cmd.none )
AdminTimeEntrySaved (Err _) -> AdminTimeEntrySaved (Err err) ->
( { model | error = Just "Fehler beim Erstellen des Eintrags", isProcessing = False }, Cmd.none ) ( { model | isProcessing = False }, handleApiError err )
FetchMyInfo -> FetchMyInfo ->
case model.token of case model.token of
@ -1493,8 +1544,8 @@ update msg model =
MyInfoReceived (Ok user) -> MyInfoReceived (Ok user) ->
( { model | users = [ user ] }, Cmd.none ) ( { model | users = [ user ] }, Cmd.none )
MyInfoReceived (Err _) -> MyInfoReceived (Err err) ->
( { model | error = Just "Fehler beim Laden deiner Daten" }, Cmd.none ) ( model, handleApiError err )
FetchSchoolYears -> FetchSchoolYears ->
case model.token of case model.token of
@ -1507,8 +1558,8 @@ update msg model =
SchoolYearsReceived (Ok years) -> SchoolYearsReceived (Ok years) ->
( { model | schoolYears = years }, Cmd.none ) ( { model | schoolYears = years }, Cmd.none )
SchoolYearsReceived (Err _) -> SchoolYearsReceived (Err err) ->
( { model | error = Just "Fehler beim Laden der Schuljahre" }, Cmd.none ) ( model, handleApiError err )
FetchActiveSchoolYear -> FetchActiveSchoolYear ->
case model.token of case model.token of
@ -1560,7 +1611,7 @@ update msg model =
|| String.isEmpty model.newSchoolYear.startDate || String.isEmpty model.newSchoolYear.startDate
|| String.isEmpty model.newSchoolYear.endDate || String.isEmpty model.newSchoolYear.endDate
then then
( { model | error = Just "Bitte alle Felder ausfüllen" }, Cmd.none ) ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) )
else else
case model.token of case model.token of
@ -1578,19 +1629,17 @@ update msg model =
, error = Nothing , error = Nothing
, isProcessing = False , isProcessing = False
} }
, fetchSchoolYears token , Cmd.batch
[ fetchSchoolYears token
, Task.perform (\_ -> ShowToast "Schuljahr erfolgreich erstellt!" SuccessToast) (Task.succeed ())
]
) )
Nothing -> Nothing ->
( model, Cmd.none ) ( model, Cmd.none )
SchoolYearCreated (Err _) -> SchoolYearCreated (Err err) ->
( { model ( { model | isProcessing = False }, handleApiError err )
| error = Just "Fehler beim Erstellen des Schuljahres"
, isProcessing = False
}
, Cmd.none
)
ActivateSchoolYear id -> ActivateSchoolYear id ->
case model.token of case model.token of
@ -1607,14 +1656,15 @@ update msg model =
, Cmd.batch , Cmd.batch
[ fetchSchoolYears token [ fetchSchoolYears token
, fetchActiveSchoolYear token , fetchActiveSchoolYear token
, Task.perform (\_ -> ShowToast "Schuljahr erfolgreich aktiviert!" SuccessToast) (Task.succeed ())
] ]
) )
Nothing -> Nothing ->
( model, Cmd.none ) ( model, Cmd.none )
SchoolYearActivated (Err _) -> SchoolYearActivated (Err err) ->
( { model | error = Just "Fehler beim Aktivieren" }, Cmd.none ) ( model, handleApiError err )
DeleteSchoolYear id -> DeleteSchoolYear id ->
case model.token of case model.token of
@ -1627,13 +1677,18 @@ update msg model =
SchoolYearDeleted (Ok _) -> SchoolYearDeleted (Ok _) ->
case model.token of case model.token of
Just token -> 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 -> Nothing ->
( model, Cmd.none ) ( model, Cmd.none )
SchoolYearDeleted (Err _) -> SchoolYearDeleted (Err err) ->
( { model | error = Just "Fehler beim Löschen" }, Cmd.none ) ( model, handleApiError err )
DownloadYearlySummaryPDF -> DownloadYearlySummaryPDF ->
case model.token of case model.token of
@ -1650,11 +1705,47 @@ update msg model =
in in
( { model | isProcessing = False }, File.Download.bytes filename "application/pdf" pdfBytes ) ( { model | isProcessing = False }, File.Download.bytes filename "application/pdf" pdfBytes )
YearlySummaryPDFReceived (Err _) -> YearlySummaryPDFReceived (Err err) ->
( { model ( { model | isProcessing = False }, handleApiError err )
| error = Just "Fehler beim Herunterladen der PDF"
, isProcessing = False 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
| 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 , Cmd.none
) )
@ -2031,9 +2122,67 @@ calculateHours startTime endTime =
-- VIEW -- 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 -> Html Msg
view model = view model =
div [ class "container" ] div [ class "app-container" ]
[ viewToasts model.toasts
, div [ class "container" ]
[ case model.page of [ case model.page of
LoginPage -> LoginPage ->
viewLogin model viewLogin model
@ -2044,6 +2193,7 @@ view model =
AdminDashboard -> AdminDashboard ->
viewAdminDashboard model viewAdminDashboard model
] ]
]
viewLogin : Model -> Html Msg viewLogin : Model -> Html Msg
@ -2054,12 +2204,6 @@ viewLogin model =
[ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ] [ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ]
[ div [ class "box" ] [ div [ class "box" ]
[ h1 [ class "title has-text-centered" ] [ text "Zeiterfassung Login" ] [ 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" ] , div [ class "field" ]
[ label [ class "label" ] [ text "Benutzername" ] [ label [ class "label" ] [ text "Benutzername" ]
, div [ class "control" ] , div [ class "control" ]
@ -2249,12 +2393,6 @@ viewUserDashboard model =
text "" text ""
, h3 [ class "subtitle mt-6" ] [ text "Jahresgesamtzeit" ] , h3 [ class "subtitle mt-6" ] [ text "Jahresgesamtzeit" ]
, viewUserYearlyTotal model , 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 , timeout = Nothing
, tracker = 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 ())