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:
parent
95057c1b8d
commit
3ac1947106
11 changed files with 1333 additions and 453 deletions
22
README.md
22
README.md
|
|
@ -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!
|
||||||
|
|
||||||
|
|
@ -179,13 +179,15 @@ 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** |
|
||||||
| `TZ` | Zeitzone | `Europe/Berlin` | Nein |
|
| `INITIAL_ADMIN_PASSWORD` | Initiales Passwort für den Admin-Benutzer | `changeme` | **Ja** |
|
||||||
| `ENVIRONMENT` | `production` für HTTPS-Redirect | - | Nein |
|
| `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
|
### 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
205
backend/errors.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,69 +241,72 @@
|
||||||
<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(
|
||||||
token: token,
|
"timetracking",
|
||||||
isAdmin: isAdmin
|
JSON.stringify({
|
||||||
}));
|
token: token,
|
||||||
|
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) {
|
||||||
saveData(data.token, data.isAdmin);
|
saveData(data.token, data.isAdmin);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.ports.removeToken.subscribe(function() {
|
app.ports.removeToken.subscribe(function () {
|
||||||
clearData();
|
clearData();
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
token: token,
|
"timetracking",
|
||||||
isAdmin: isAdmin
|
JSON.stringify({
|
||||||
}));
|
token: token,
|
||||||
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Just token ->
|
[ case model.token of
|
||||||
fetchUsers token
|
Just 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 | 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
|
( { model
|
||||||
| error = Just "Fehler beim Herunterladen der PDF"
|
| toasts = model.toasts ++ [ newToast ]
|
||||||
, isProcessing = False
|
, 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,18 +2122,77 @@ 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" ]
|
||||||
[ case model.page of
|
[ viewToasts model.toasts
|
||||||
LoginPage ->
|
, div [ class "container" ]
|
||||||
viewLogin model
|
[ case model.page of
|
||||||
|
LoginPage ->
|
||||||
|
viewLogin model
|
||||||
|
|
||||||
UserDashboard ->
|
UserDashboard ->
|
||||||
viewUserDashboard model
|
viewUserDashboard model
|
||||||
|
|
||||||
AdminDashboard ->
|
AdminDashboard ->
|
||||||
viewAdminDashboard model
|
viewAdminDashboard model
|
||||||
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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 ())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue