From 68034dea7d39343c57a050a44cf9f3ebb4b85e6d Mon Sep 17 00:00:00 2001 From: Jan Meinl Date: Sat, 16 May 2026 21:10:55 +0200 Subject: [PATCH] Replaced one-pager with multiple pages and fixed security bugs --- .gitignore | 1 + .idea/git_toolbox_prj.xml | 15 + auth.go | 152 ++++- competitions.go | 4 + db.go | 8 + main.go | 81 ++- models.go | 11 +- penalties.go | 107 +++- pilots.go | 5 + users.go | 49 +- web/api.js | 10 +- web/app.js | 1163 ---------------------------------- web/common.js | 186 ++++++ web/competition.html | 17 + web/competition.js | 1232 +++++++++++++++++++++++++++++++++++++ web/competitions.html | 17 + web/competitions.js | 202 ++++++ web/force-password.html | 17 + web/force-password.js | 51 ++ web/i18n.js | 86 +++ web/index.html | 13 +- web/login.html | 17 + web/login.js | 55 ++ web/style.css | 17 + ws.go | 12 +- 25 files changed, 2311 insertions(+), 1217 deletions(-) create mode 100644 .idea/git_toolbox_prj.xml delete mode 100644 web/app.js create mode 100644 web/common.js create mode 100644 web/competition.html create mode 100644 web/competition.js create mode 100644 web/competitions.html create mode 100644 web/competitions.js create mode 100644 web/force-password.html create mode 100644 web/force-password.js create mode 100644 web/login.html create mode 100644 web/login.js diff --git a/.gitignore b/.gitignore index 0978c43..5d69134 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /penaltytracker.db /penaltytracker.db-shm /penaltytracker.db-wal +/config.json diff --git a/.idea/git_toolbox_prj.xml b/.idea/git_toolbox_prj.xml new file mode 100644 index 0000000..02b915b --- /dev/null +++ b/.idea/git_toolbox_prj.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/auth.go b/auth.go index b76539b..1afd220 100644 --- a/auth.go +++ b/auth.go @@ -6,8 +6,10 @@ import ( "encoding/hex" "encoding/json" "errors" + "net" "net/http" "strings" + "sync" "time" "golang.org/x/crypto/bcrypt" @@ -20,8 +22,13 @@ const userCtxKey ctxKey = "user" const sessionCookie = "pt_session" const sessionDuration = 24 * time.Hour * 14 +const maxUsernameLen = 64 +const maxDisplayNameLen = 128 +const maxPasswordLen = 256 +const maxFieldLen = 2000 + func registerAuthRoutes(mux *http.ServeMux) { - mux.HandleFunc("POST /api/login", handleLogin) + mux.HandleFunc("POST /api/login", loginRateLimit(handleLogin)) mux.HandleFunc("POST /api/logout", handleLogout) mux.HandleFunc("GET /api/me", requireAuth(handleMe)) mux.HandleFunc("PATCH /api/me", requireAuth(handleUpdateMe)) @@ -40,7 +47,7 @@ func ensureDefaultAdmin() error { return err } _, err = db.Exec( - "INSERT INTO users(username,password_hash,display_name,language,is_system_admin) VALUES(?,?,?,?,1)", + "INSERT INTO users(username,password_hash,display_name,language,is_system_admin,must_change_password) VALUES(?,?,?,?,1,1)", "admin", string(hash), "System Admin", "en", ) return err @@ -54,6 +61,79 @@ func newToken() (string, error) { return hex.EncodeToString(b), nil } +// ---- login rate limiter --------------------------------------------------- + +type loginAttempts struct { + times []time.Time +} + +var ( + loginMu sync.Mutex + loginAttempts_ = map[string]*loginAttempts{} +) + +const loginMaxAttempts = 8 +const loginWindow = 5 * time.Minute + +func clientIP(r *http.Request) string { + if xf := r.Header.Get("X-Forwarded-For"); xf != "" { + parts := strings.Split(xf, ",") + return strings.TrimSpace(parts[0]) + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func loginAllowed(ip string) bool { + loginMu.Lock() + defer loginMu.Unlock() + a, ok := loginAttempts_[ip] + if !ok { + a = &loginAttempts{} + loginAttempts_[ip] = a + } + cutoff := time.Now().Add(-loginWindow) + kept := a.times[:0] + for _, t := range a.times { + if t.After(cutoff) { + kept = append(kept, t) + } + } + a.times = kept + return len(a.times) < loginMaxAttempts +} + +func loginRecord(ip string) { + loginMu.Lock() + defer loginMu.Unlock() + a, ok := loginAttempts_[ip] + if !ok { + a = &loginAttempts{} + loginAttempts_[ip] = a + } + a.times = append(a.times, time.Now()) +} + +func loginRateLimit(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ip := clientIP(r) + if !loginAllowed(ip) { + writeError(w, http.StatusTooManyRequests, "too_many_attempts") + return + } + h.ServeHTTP(w, r) + } +} + +// ---- handlers -------------------------------------------------------------- + +func normalizeUsername(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} + func handleLogin(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` @@ -63,19 +143,25 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid_body") return } - req.Username = strings.TrimSpace(req.Username) + req.Username = normalizeUsername(req.Username) if req.Username == "" || req.Password == "" { writeError(w, http.StatusBadRequest, "missing_credentials") return } + if len(req.Username) > maxUsernameLen || len(req.Password) > maxPasswordLen { + writeError(w, http.StatusBadRequest, "too_long") + return + } var id int64 var hash string err := db.QueryRow("SELECT id,password_hash FROM users WHERE username=?", req.Username).Scan(&id, &hash) if err != nil { + loginRecord(clientIP(r)) writeError(w, http.StatusUnauthorized, "invalid_credentials") return } if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil { + loginRecord(clientIP(r)) writeError(w, http.StatusUnauthorized, "invalid_credentials") return } @@ -96,6 +182,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { Expires: expires, HttpOnly: true, SameSite: http.SameSiteLaxMode, + Secure: r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https"), } if crossSiteCookies { cookie.SameSite = http.SameSiteNoneMode @@ -118,6 +205,7 @@ func handleLogout(w http.ResponseWriter, r *http.Request) { MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, + Secure: r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https"), } if crossSiteCookies { clear.SameSite = http.SameSiteNoneMode @@ -132,48 +220,41 @@ func handleMe(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, u) } +// handleUpdateMe lets the logged-in user change ONLY their language and password. +// Username and display name are administrative attributes and can only be set +// through the admin user endpoints. func handleUpdateMe(w http.ResponseWriter, r *http.Request) { u := userFromCtx(r) var req struct { - Username *string `json:"username"` - Language *string `json:"language"` - DisplayName *string `json:"display_name"` - Password *string `json:"password"` + Language *string `json:"language"` + Password *string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_body") return } - if req.Username != nil { - newName := strings.TrimSpace(*req.Username) - if newName == "" { - writeError(w, http.StatusBadRequest, "missing_username") - return - } - if _, err := db.Exec("UPDATE users SET username=? WHERE id=?", newName, u.ID); err != nil { - writeError(w, http.StatusConflict, "username_taken") - return - } - } if req.Language != nil { - if _, err := db.Exec("UPDATE users SET language=? WHERE id=?", *req.Language, u.ID); err != nil { - writeError(w, http.StatusInternalServerError, "db_error") + lang := strings.TrimSpace(*req.Language) + if len(lang) > 8 { + writeError(w, http.StatusBadRequest, "invalid_language") return } - } - if req.DisplayName != nil { - if _, err := db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, u.ID); err != nil { + if _, err := db.Exec("UPDATE users SET language=? WHERE id=?", lang, u.ID); err != nil { writeError(w, http.StatusInternalServerError, "db_error") return } } if req.Password != nil && *req.Password != "" { + if len(*req.Password) > maxPasswordLen { + writeError(w, http.StatusBadRequest, "too_long") + return + } hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) if err != nil { writeError(w, http.StatusInternalServerError, "hash_error") return } - if _, err := db.Exec("UPDATE users SET password_hash=? WHERE id=?", string(hash), u.ID); err != nil { + if _, err := db.Exec("UPDATE users SET password_hash=?,must_change_password=0 WHERE id=?", string(hash), u.ID); err != nil { writeError(w, http.StatusInternalServerError, "db_error") return } @@ -184,13 +265,14 @@ func handleUpdateMe(w http.ResponseWriter, r *http.Request) { func loadUser(id int64) (*User, error) { u := &User{} - var admin int - err := db.QueryRow("SELECT id,username,display_name,language,is_system_admin FROM users WHERE id=?", id). - Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin) + var admin, mustChange int + err := db.QueryRow("SELECT id,username,display_name,language,is_system_admin,must_change_password FROM users WHERE id=?", id). + Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin, &mustChange) if err != nil { return nil, err } u.IsSystemAdmin = admin == 1 + u.MustChangePassword = mustChange == 1 return u, nil } @@ -212,6 +294,18 @@ func authUser(r *http.Request) (*User, error) { return loadUser(userID) } +// passwordChangeExempt returns true for endpoints that a user must remain able +// to access even while in the "must change password" lock state. +func passwordChangeExempt(r *http.Request) bool { + if r.URL.Path == "/api/me" { + return true + } + if r.URL.Path == "/api/logout" { + return true + } + return false +} + func requireAuth(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { u, err := authUser(r) @@ -219,6 +313,10 @@ func requireAuth(h http.HandlerFunc) http.HandlerFunc { writeError(w, http.StatusUnauthorized, "unauthorized") return } + if u.MustChangePassword && !passwordChangeExempt(r) { + writeError(w, http.StatusForbidden, "password_change_required") + return + } ctx := context.WithValue(r.Context(), userCtxKey, u) h.ServeHTTP(w, r.WithContext(ctx)) } diff --git a/competitions.go b/competitions.go index 2c995f5..7f16067 100644 --- a/competitions.go +++ b/competitions.go @@ -83,6 +83,10 @@ func handleCreateCompetition(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "missing_name") return } + if len(req.Name) > 200 { + writeError(w, http.StatusBadRequest, "too_long") + return + } allow := 0 if req.AllowAnyScorerEdit { allow = 1 diff --git a/db.go b/db.go index 332d67b..f7ff3d0 100644 --- a/db.go +++ b/db.go @@ -88,5 +88,13 @@ func migrate() error { return err } } + // Idempotent column additions for older databases. + addColumns := []string{ + `ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`, + } + for _, s := range addColumns { + // Ignore "duplicate column" errors so the migration is idempotent. + _, _ = db.Exec(s) + } return nil } diff --git a/main.go b/main.go index 22af29a..e0f510a 100644 --- a/main.go +++ b/main.go @@ -6,7 +6,9 @@ import ( "errors" "flag" "log" + "net" "net/http" + "net/url" "os" "os/signal" "path/filepath" @@ -142,9 +144,11 @@ func main() { registerRuleRoutes(mux) registerWSRoutes(mux) + handler := withSecurityHeaders(withLog(withCSRF(withCORS(mux)))) + server := &http.Server{ Addr: cfg.Addr, - Handler: withLog(withCORS(mux)), + Handler: handler, ReadHeaderTimeout: 10 * time.Second, } @@ -171,6 +175,22 @@ func withLog(next http.Handler) http.Handler { }) } +func withSecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "no-referrer") + h.Set("Permissions-Policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()") + h.Set("Cross-Origin-Resource-Policy", "same-site") + h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") + if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") { + h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } + next.ServeHTTP(w, r) + }) +} + func originAllowed(origin string) bool { if origin == "" { return false @@ -186,6 +206,65 @@ func originAllowed(origin string) bool { return false } +func sameOriginRequest(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + // Fall back to Referer + ref := r.Header.Get("Referer") + if ref == "" { + // No origin info: only safe methods allowed. State-changing must have Origin. + return false + } + u, err := url.Parse(ref) + if err != nil { + return false + } + origin = u.Scheme + "://" + u.Host + } + u, err := url.Parse(origin) + if err != nil { + return false + } + originHost, _, _ := net.SplitHostPort(u.Host) + if originHost == "" { + originHost = u.Host + } + reqHost, _, _ := net.SplitHostPort(r.Host) + if reqHost == "" { + reqHost = r.Host + } + return strings.EqualFold(originHost, reqHost) +} + +func isStateChanging(method string) bool { + switch method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + return true + } + return false +} + +func withCSRF(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !isStateChanging(r.Method) { + next.ServeHTTP(w, r) + return + } + origin := r.Header.Get("Origin") + // Allow if Origin matches a configured CORS origin. + if origin != "" && originAllowed(origin) { + next.ServeHTTP(w, r) + return + } + // Allow same-origin requests (Origin or Referer host matches request Host). + if sameOriginRequest(r) { + next.ServeHTTP(w, r) + return + } + writeError(w, http.StatusForbidden, "csrf_forbidden") + }) +} + func withCORS(next http.Handler) http.Handler { if len(corsOrigins) == 0 { return next diff --git a/models.go b/models.go index a3caac2..5890c0c 100644 --- a/models.go +++ b/models.go @@ -1,11 +1,12 @@ package main type User struct { - ID int64 `json:"id"` - Username string `json:"username"` - DisplayName string `json:"display_name"` - Language string `json:"language"` - IsSystemAdmin bool `json:"is_system_admin"` + ID int64 `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Language string `json:"language"` + IsSystemAdmin bool `json:"is_system_admin"` + MustChangePassword bool `json:"must_change_password"` } type Competition struct { diff --git a/penalties.go b/penalties.go index d7c1bca..3a0c6a9 100644 --- a/penalties.go +++ b/penalties.go @@ -9,12 +9,91 @@ import ( "strings" ) +// csvSafe prefixes potentially dangerous CSV cell content with a single quote +// so that spreadsheet apps don't interpret it as a formula. +func csvSafe(s string) string { + if s == "" { + return s + } + switch s[0] { + case '=', '+', '-', '@', '\t', '\r': + return "'" + s + } + return s +} + +func clip(s string, max int) string { + if len(s) > max { + return s[:max] + } + return s +} + func registerPenaltyRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/competitions/{id}/penalties", requireAuth(handleListPenalties)) mux.HandleFunc("POST /api/competitions/{id}/penalties", requireAuth(handleCreatePenalty)) mux.HandleFunc("PATCH /api/competitions/{id}/penalties/{pid}", requireAuth(handleUpdatePenalty)) mux.HandleFunc("DELETE /api/competitions/{id}/penalties/{pid}", requireAuth(handleDeletePenalty)) mux.HandleFunc("GET /api/competitions/{id}/penalties.csv", requireAuth(handleExportPenalties)) + mux.HandleFunc("POST /api/competitions/{id}/penalties/apply", requireAuth(handleApplyPenalties)) +} + +// handleApplyPenalties bulk-marks penalties as applied (transferred=1) for a +// given task. Requires chief_scorer or system_admin role. +func handleApplyPenalties(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id") + return + } + u := userFromCtx(r) + role, ok := canAccessCompetition(u, id) + if !ok || (role != "chief_scorer" && role != "system_admin") { + writeError(w, http.StatusForbidden, "forbidden") + return + } + var req struct { + Task string `json:"task"` + IDs []int64 `json:"ids"` + Applied bool `json:"applied"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + v := 0 + if req.Applied { + v = 1 + } + tx, err := db.Begin() + if err != nil { + writeError(w, http.StatusInternalServerError, "tx_error") + return + } + defer tx.Rollback() + if len(req.IDs) > 0 { + stmt, err := tx.Prepare("UPDATE penalties SET transferred=?, updated_at=datetime('now') WHERE id=? AND competition_id=?") + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + defer stmt.Close() + for _, pid := range req.IDs { + stmt.Exec(v, pid, id) + } + } else { + task := clip(req.Task, 64) + if _, err := tx.Exec("UPDATE penalties SET transferred=?, updated_at=datetime('now') WHERE competition_id=? AND task=?", v, id, task); err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + } + if err := tx.Commit(); err != nil { + writeError(w, http.StatusInternalServerError, "commit_error") + return + } + hub.broadcast(id, "penalties_applied", map[string]any{"task": req.Task, "applied": req.Applied, "ids": req.IDs}) + w.WriteHeader(http.StatusNoContent) } func loadPenalty(id int64) (*Penalty, error) { @@ -92,6 +171,13 @@ func handleCreatePenalty(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid_body") return } + pen.Flight = clip(pen.Flight, 64) + pen.Date = clip(pen.Date, 32) + pen.PilotNumber = clip(pen.PilotNumber, 32) + pen.RuleNumber = clip(pen.RuleNumber, 64) + pen.Task = clip(pen.Task, 64) + pen.PenaltiesText = clip(pen.PenaltiesText, 256) + pen.Description = clip(pen.Description, maxFieldLen) transferred := 0 if pen.Transferred { transferred = 1 @@ -171,31 +257,31 @@ func handleUpdatePenalty(w http.ResponseWriter, r *http.Request) { args := []any{} if req.Flight != nil { sets = append(sets, "flight=?") - args = append(args, *req.Flight) + args = append(args, clip(*req.Flight, 64)) } if req.Date != nil { sets = append(sets, "date=?") - args = append(args, *req.Date) + args = append(args, clip(*req.Date, 32)) } if req.PilotNumber != nil { sets = append(sets, "pilot_number=?") - args = append(args, *req.PilotNumber) + args = append(args, clip(*req.PilotNumber, 32)) } if req.RuleNumber != nil { sets = append(sets, "rule_number=?") - args = append(args, *req.RuleNumber) + args = append(args, clip(*req.RuleNumber, 64)) } if req.Task != nil { sets = append(sets, "task=?") - args = append(args, *req.Task) + args = append(args, clip(*req.Task, 64)) } if req.PenaltiesText != nil { sets = append(sets, "penalties_text=?") - args = append(args, *req.PenaltiesText) + args = append(args, clip(*req.PenaltiesText, 256)) } if req.Description != nil { sets = append(sets, "description=?") - args = append(args, *req.Description) + args = append(args, clip(*req.Description, maxFieldLen)) } if req.Transferred != nil { v := 0 @@ -287,7 +373,12 @@ func handleExportPenalties(w http.ResponseWriter, r *http.Request) { if transferred == 1 { t = "1" } - cw.Write([]string{strconv.FormatInt(idv, 10), flight, date, pnum, pname, rnum, task, pens, desc, creator, t, createdAt}) + cw.Write([]string{ + strconv.FormatInt(idv, 10), + csvSafe(flight), csvSafe(date), csvSafe(pnum), csvSafe(pname), + csvSafe(rnum), csvSafe(task), csvSafe(pens), csvSafe(desc), + csvSafe(creator), t, createdAt, + }) } cw.Flush() } diff --git a/pilots.go b/pilots.go index ce67b16..8cbbf5f 100644 --- a/pilots.go +++ b/pilots.go @@ -72,6 +72,11 @@ func handleCreatePilot(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "missing_fields") return } + if len(p.Number) > 32 || len(p.LastName) > 128 || len(p.FirstName) > 128 || + len(p.Country) > 64 || len(p.BalloonID) > 64 { + writeError(w, http.StatusBadRequest, "too_long") + return + } res, err := db.Exec( "INSERT INTO pilots(competition_id,number,last_name,first_name,country,balloon_id) VALUES(?,?,?,?,?,?)", id, p.Number, p.LastName, p.FirstName, p.Country, p.BalloonID, diff --git a/users.go b/users.go index ee66f88..e413c52 100644 --- a/users.go +++ b/users.go @@ -4,7 +4,6 @@ import ( "encoding/json" "net/http" "strconv" - "strings" "golang.org/x/crypto/bcrypt" ) @@ -17,7 +16,7 @@ func registerUserRoutes(mux *http.ServeMux) { } func handleListUsers(w http.ResponseWriter, r *http.Request) { - rows, err := db.Query("SELECT id,username,display_name,language,is_system_admin FROM users ORDER BY username") + rows, err := db.Query("SELECT id,username,display_name,language,is_system_admin,must_change_password FROM users ORDER BY username") if err != nil { writeError(w, http.StatusInternalServerError, "db_error") return @@ -26,9 +25,10 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) { out := []User{} for rows.Next() { var u User - var admin int - rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin) + var admin, mustChange int + rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin, &mustChange) u.IsSystemAdmin = admin == 1 + u.MustChangePassword = mustChange == 1 out = append(out, u) } writeJSON(w, http.StatusOK, out) @@ -47,11 +47,16 @@ func handleCreateUser(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid_body") return } - req.Username = strings.TrimSpace(req.Username) + req.Username = normalizeUsername(req.Username) if req.Username == "" || req.Password == "" { writeError(w, http.StatusBadRequest, "missing_fields") return } + if len(req.Username) > maxUsernameLen || len(req.Password) > maxPasswordLen || + len(req.DisplayName) > maxDisplayNameLen { + writeError(w, http.StatusBadRequest, "too_long") + return + } if req.IsSystemAdmin && !actor.IsSystemAdmin { writeError(w, http.StatusForbidden, "forbidden") return @@ -114,18 +119,39 @@ func handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) { return } var req struct { - DisplayName *string `json:"display_name"` - Password *string `json:"password"` - IsSystemAdmin *bool `json:"is_system_admin"` + Username *string `json:"username"` + DisplayName *string `json:"display_name"` + Password *string `json:"password"` + IsSystemAdmin *bool `json:"is_system_admin"` + MustChangePassword *bool `json:"must_change_password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_body") return } + if req.Username != nil { + newName := normalizeUsername(*req.Username) + if newName == "" || len(newName) > maxUsernameLen { + writeError(w, http.StatusBadRequest, "invalid_username") + return + } + if _, err := db.Exec("UPDATE users SET username=? WHERE id=?", newName, id); err != nil { + writeError(w, http.StatusConflict, "username_taken") + return + } + } if req.DisplayName != nil { + if len(*req.DisplayName) > maxDisplayNameLen { + writeError(w, http.StatusBadRequest, "too_long") + return + } db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, id) } if req.Password != nil && *req.Password != "" { + if len(*req.Password) > maxPasswordLen { + writeError(w, http.StatusBadRequest, "too_long") + return + } hash, _ := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) db.Exec("UPDATE users SET password_hash=? WHERE id=?", string(hash), id) } @@ -136,6 +162,13 @@ func handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) { } db.Exec("UPDATE users SET is_system_admin=? WHERE id=?", v, id) } + if req.MustChangePassword != nil { + v := 0 + if *req.MustChangePassword { + v = 1 + } + db.Exec("UPDATE users SET must_change_password=? WHERE id=?", v, id) + } u, _ := loadUser(id) writeJSON(w, http.StatusOK, u) } diff --git a/web/api.js b/web/api.js index 82e7e11..4c84c17 100644 --- a/web/api.js +++ b/web/api.js @@ -22,13 +22,14 @@ async function api(method, path, body) { } const API = { - login: (u, p) => api("POST", "/api/login", { username: u, password: p }), + login: (u, p) => api("POST", "/api/login", { username: String(u || "").toLowerCase().trim(), password: p }), logout: () => api("POST", "/api/logout"), me: () => api("GET", "/api/me"), updateMe: (b) => api("PATCH", "/api/me", b), listUsers: () => api("GET", "/api/users"), createUser: (b) => api("POST", "/api/users", b), + updateUser: (id, b) => api("PATCH", `/api/users/${id}`, b), deleteUser: (id) => api("DELETE", `/api/users/${id}`), listCompetitions: () => api("GET", "/api/competitions"), @@ -60,7 +61,12 @@ const API = { createPenalty: (id, b) => api("POST", `/api/competitions/${id}/penalties`, b), updatePenalty: (id, pid, b) => api("PATCH", `/api/competitions/${id}/penalties/${pid}`, b), deletePenalty: (id, pid) => api("DELETE", `/api/competitions/${id}/penalties/${pid}`), - exportPenaltiesURL: (id) => apiURL(`/api/competitions/${id}/penalties.csv`), + applyPenalties: (id, b) => api("POST", `/api/competitions/${id}/penalties/apply`, b), + exportPenaltiesCSV: async (id) => { + const res = await fetch(apiURL(`/api/competitions/${id}/penalties.csv`), { credentials: "include" }); + if (!res.ok) throw new Error("export_failed"); + return res.blob(); + }, listRules: (lang) => api("GET", `/api/rules${lang ? "?lang=" + encodeURIComponent(lang) : ""}`), }; diff --git a/web/app.js b/web/app.js deleted file mode 100644 index 3bf935d..0000000 --- a/web/app.js +++ /dev/null @@ -1,1163 +0,0 @@ -const el = (tag, attrs, ...children) => { - const node = document.createElement(tag); - if (attrs) { - for (const k in attrs) { - if (k === "class") node.className = attrs[k]; - else if (k === "style") Object.assign(node.style, attrs[k]); - else if (k.startsWith("on") && typeof attrs[k] === "function") node.addEventListener(k.slice(2), attrs[k]); - else if (k === "checked" || k === "disabled" || k === "selected") { - if (attrs[k]) node.setAttribute(k, ""); - } else if (attrs[k] !== false && attrs[k] !== null && attrs[k] !== undefined) { - node.setAttribute(k, attrs[k]); - } - } - } - for (const c of children.flat()) { - if (c === null || c === undefined || c === false) continue; - node.appendChild(typeof c === "string" || typeof c === "number" ? document.createTextNode(String(c)) : c); - } - return node; -}; - -const root = document.getElementById("app"); -const state = { - user: null, - competitions: [], - competition: null, - pilots: [], - penalties: [], - members: [], - users: [], - rules: [], - rulesByNumber: {}, - ws: null, - tab: "penalties", - sort: { col: "id", dir: "desc" }, - pilotSort: { col: "number", dir: "asc" }, - filterUntransferred: false, - editingPenalty: null, - showPenaltyModal: false, - showPilotModal: false, - showMemberModal: false, - showCompetitionModal: false, - showUserModal: false, -}; - -function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); } - -function render() { - if (!state.user) { - renderLogin(); - return; - } - if (state.competition) { - renderCompetition(); - } else { - renderHome(); - } -} - -function renderLogin() { - clear(root); - const usernameInput = el("input", { type: "text", autocomplete: "username", placeholder: t("username") }); - const passwordInput = el("input", { type: "password", autocomplete: "current-password", placeholder: t("password") }); - const errorBox = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } }); - const langSelect = el("select", - { onchange: (e) => { setLang(e.target.value); renderLogin(); } }, - ...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === CURRENT_LANG }, I18N_NAMES[l])) - ); - - async function doLogin(e) { - e.preventDefault(); - errorBox.style.display = "none"; - try { - const u = await API.login(usernameInput.value, passwordInput.value); - state.user = u; - setLang(u.language); - await loadCompetitions(); - render(); - } catch (err) { - errorBox.textContent = err.status === 401 ? t("invalid_credentials") : (err.message || t("invalid_credentials")); - errorBox.style.display = "block"; - } - } - - const form = el("form", { onsubmit: doLogin, class: "col" }, - el("h1", null, t("login_title")), - el("div", { class: "field" }, el("label", null, t("username")), usernameInput), - el("div", { class: "field" }, el("label", null, t("password")), passwordInput), - errorBox, - el("button", { type: "submit", class: "primary" }, t("sign_in")), - el("div", { class: "field", style: { marginTop: "0.5rem" } }, - el("label", null, t("language")), langSelect - ), - ); - - root.appendChild(el("div", { class: "login-wrap" }, el("div", { class: "login-box" }, form))); - usernameInput.focus(); -} - -function renderTopbar(extra) { - const langSelect = el("select", - { onchange: async (e) => { - const lang = e.target.value; - await API.updateMe({ language: lang }); - state.user.language = lang; - setLang(lang); - await loadRules(); - render(); - } }, - ...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === state.user.language }, I18N_NAMES[l])) - ); - const logoutBtn = el("button", { class: "ghost", onclick: async () => { - await API.logout(); - if (state.ws) { state.ws.close(); state.ws = null; } - state.user = null; - state.competition = null; - render(); - } }, t("logout")); - - const brand = el("a", { href: "#", onclick: (e) => { - e.preventDefault(); - if (state.ws) { state.ws.close(); state.ws = null; } - state.competition = null; - render(); - } , class: "brand" }, "Penalty Tracker"); - - const profileBtn = el("button", { class: "ghost", onclick: () => openProfileModal() }, - state.user.display_name || state.user.username); - - return el("div", { class: "topbar" }, - brand, - el("div", { class: "nav" }, - extra || null, - profileBtn, - langSelect, - logoutBtn, - ) - ); -} - -function openProfileModal() { - state.showProfileModal = true; - render(); -} - -function renderProfileModal() { - const username = el("input", { type: "text", value: state.user.username }); - const displayName = el("input", { type: "text", value: state.user.display_name || "" }); - const password = el("input", { type: "password", placeholder: t("leave_blank_keep") }); - const err = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } }); - const ok = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } }); - return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, - el("div", { class: "modal" }, - el("h3", null, t("profile")), - el("div", { class: "field" }, el("label", null, t("username")), username), - el("div", { class: "field" }, el("label", null, t("display_name")), displayName), - el("div", { class: "field" }, el("label", null, t("new_password")), password), - err, ok, - el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, - el("button", { onclick: closeAll }, t("cancel")), - el("button", { class: "primary", onclick: async () => { - err.style.display = "none"; ok.style.display = "none"; - const body = {}; - const newUsername = username.value.trim(); - if (newUsername && newUsername !== state.user.username) body.username = newUsername; - if (displayName.value !== (state.user.display_name || "")) body.display_name = displayName.value; - if (password.value) body.password = password.value; - try { - const u = await API.updateMe(body); - state.user = u; - ok.textContent = t("saved"); - ok.style.display = "block"; - setTimeout(() => { closeAll(); }, 700); - } catch (e) { - err.textContent = e.data && e.data.error === "username_taken" ? t("username_taken") : (e.message || "error"); - err.style.display = "block"; - } - } }, t("save")), - ), - ) - ); -} - -async function loadCompetitions() { - state.competitions = await API.listCompetitions(); -} - -async function loadRules() { - state.rules = await API.listRules(state.user.language); - state.rulesByNumber = {}; - for (const r of state.rules) state.rulesByNumber[r.number] = r; -} - -function renderHome() { - clear(root); - root.appendChild(renderTopbar()); - const container = el("div", { class: "container" }); - - const headerRow = el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.75rem" } }, - el("h2", { style: { margin: 0 } }, t("competitions")), - state.user.is_system_admin && el("button", { class: "primary", onclick: () => openCompetitionModal() }, t("new_competition")) - ); - container.appendChild(headerRow); - - if (state.competitions.length === 0) { - container.appendChild(el("div", { class: "muted" }, t("no_competitions"))); - } else { - const list = el("div", { class: "grid" }); - for (const c of state.competitions) { - list.appendChild(el("div", { class: "card" }, - el("h2", null, c.name), - el("div", { class: "muted", style: { marginBottom: "0.5rem" } }, - el("span", { class: "badge accent" }, t(c.role)), - ), - el("button", { class: "primary", onclick: () => openCompetition(c.id) }, t("open")), - )); - } - container.appendChild(list); - } - - if (state.user.is_system_admin) { - container.appendChild(renderUsersAdmin()); - } - - root.appendChild(container); - - if (state.showCompetitionModal) container.appendChild(renderCompetitionModal()); - if (state.showUserModal) container.appendChild(renderUserModal()); - if (state.showProfileModal) container.appendChild(renderProfileModal()); -} - -function renderUsersAdmin() { - const card = el("div", { class: "card" }); - card.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.5rem" } }, - el("h2", { style: { margin: 0 } }, t("users")), - el("button", { onclick: () => openUserModal() }, t("add_user")), - )); - if (state.users.length === 0) { - card.appendChild(el("div", { class: "muted" }, "—")); - return card; - } - const table = el("table"); - table.appendChild(el("thead", null, - el("tr", null, - el("th", null, t("username")), - el("th", null, t("display_name")), - el("th", null, t("language")), - el("th", null, t("is_admin")), - el("th", null, t("actions")), - ) - )); - const tbody = el("tbody"); - for (const u of state.users) { - tbody.appendChild(el("tr", null, - el("td", null, u.username), - el("td", null, u.display_name), - el("td", null, I18N_NAMES[u.language] || u.language), - el("td", null, u.is_system_admin ? t("yes") : t("no")), - el("td", null, - u.id !== state.user.id && el("button", { class: "action-btn danger", onclick: async () => { - if (!confirm(t("confirm_delete"))) return; - await API.deleteUser(u.id); - await loadUsers(); - render(); - } }, t("delete")), - ), - )); - } - table.appendChild(tbody); - card.appendChild(el("div", { class: "table-wrap" }, table)); - return card; -} - -async function loadUsers() { - if (state.user.is_system_admin) { - state.users = await API.listUsers(); - } -} - -function openCompetitionModal() { - state.showCompetitionModal = true; - render(); -} - -function renderCompetitionModal() { - const nameInput = el("input", { type: "text" }); - const allowInput = el("input", { type: "checkbox" }); - const modal = el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target === modal) closeAll(); } }, - el("div", { class: "modal" }, - el("h3", null, t("new_competition")), - el("div", { class: "field" }, el("label", null, t("competition_name")), nameInput), - el("label", { class: "row" }, allowInput, " " + t("allow_any_scorer_edit")), - el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, - el("button", { onclick: closeAll }, t("cancel")), - el("button", { class: "primary", onclick: async () => { - if (!nameInput.value.trim()) return; - await API.createCompetition({ name: nameInput.value.trim(), allow_any_scorer_edit: allowInput.checked }); - await loadCompetitions(); - closeAll(); - } }, t("create")), - ), - ) - ); - return modal; -} - -function openUserModal(forCompetition) { - state.showUserModal = true; - state.userModalForCompetition = forCompetition || null; - render(); -} - -function renderUserModal() { - const username = el("input", { type: "text" }); - const password = el("input", { type: "password" }); - const displayName = el("input", { type: "text" }); - const langSelect = el("select", null, - ...I18N_AVAILABLE.map((l) => el("option", { value: l }, I18N_NAMES[l])) - ); - const isAdmin = el("input", { type: "checkbox" }); - const roleSelect = el("select", null, - el("option", { value: "scorer" }, t("scorer")), - el("option", { value: "chief_scorer" }, t("chief_scorer")), - ); - const forComp = state.userModalForCompetition; - return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, - el("div", { class: "modal" }, - el("h3", null, t("add_user")), - el("div", { class: "field" }, el("label", null, t("username")), username), - el("div", { class: "field" }, el("label", null, t("password")), password), - el("div", { class: "field" }, el("label", null, t("display_name")), displayName), - el("div", { class: "field" }, el("label", null, t("language")), langSelect), - !forComp && state.user.is_system_admin && el("label", { class: "row" }, isAdmin, " " + t("is_admin")), - forComp && el("div", { class: "field" }, el("label", null, t("role")), roleSelect), - el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, - el("button", { onclick: closeAll }, t("cancel")), - el("button", { class: "primary", onclick: async () => { - if (!username.value.trim() || !password.value) return; - try { - const created = await API.createUser({ - username: username.value.trim(), - password: password.value, - display_name: displayName.value, - language: langSelect.value, - is_system_admin: !forComp && isAdmin.checked, - }); - if (forComp) { - await API.addMember(forComp, { user_id: created.id, role: roleSelect.value }); - await loadMembers(); - } - await loadUsers(); - closeAll(); - } catch (err) { - alert(err.message); - } - } }, t("create")), - ), - ) - ); -} - -function closeAll() { - state.showCompetitionModal = false; - state.showUserModal = false; - state.showPilotModal = false; - state.showPenaltyModal = false; - state.showMemberModal = false; - state.showImportModal = false; - state.showProfileModal = false; - state.editingPenalty = null; - state.editingPilot = null; - state.userModalForCompetition = null; - render(); -} - -async function openCompetition(id) { - const c = await API.getCompetition(id); - state.competition = c; - state.tab = "penalties"; - await Promise.all([loadPilots(), loadPenalties(), loadMembers(), loadRules()]); - if (state.user.is_system_admin) await loadUsers(); - if (state.ws) state.ws.close(); - state.ws = openCompetitionWS(id, { - onopen: () => { state.wsOnline = true; updateStatus(); }, - onclose: () => { state.wsOnline = false; updateStatus(); }, - onmessage: handleWSMessage, - }); - render(); -} - -function updateStatus() { - const el = document.querySelector(".connection-status"); - if (!el) return; - el.classList.toggle("online", !!state.wsOnline); - el.classList.toggle("offline", !state.wsOnline); -} - -function handleWSMessage(msg) { - if (msg.type === "penalty_created") { - const exists = state.penalties.some((p) => p.id === msg.payload.id); - if (!exists) { - state.penalties.unshift(msg.payload); - patchPenaltyTable(); - } - } else if (msg.type === "penalty_updated") { - const idx = state.penalties.findIndex((p) => p.id === msg.payload.id); - if (idx >= 0) { - state.penalties[idx] = msg.payload; - patchPenaltyTable(); - } - } else if (msg.type === "penalty_deleted") { - state.penalties = state.penalties.filter((p) => p.id !== msg.payload.id); - patchPenaltyTable(); - } else if (msg.type === "pilot_changed") { - Promise.all([loadPilots(), loadPenalties()]).then(() => { - if (state.tab === "penalties") patchPenaltyTable(); - else render(); - }); - } else if (msg.type === "competition_updated") { - API.getCompetition(state.competition.id).then((c) => { state.competition = c; render(); }); - } -} - -async function loadPilots() { state.pilots = await API.listPilots(state.competition.id); } -async function loadPenalties() { state.penalties = await API.listPenalties(state.competition.id); } -async function loadMembers() { state.members = await API.listMembers(state.competition.id); } - -function isChief() { - const r = state.competition.role; - return r === "system_admin" || r === "chief_scorer"; -} - -function canEditPenalty(p) { - if (isChief()) return true; - if (p.created_by === state.user.id) return true; - return !!state.competition.allow_any_scorer_edit; -} - -function renderCompetition() { - clear(root); - const backBtn = el("button", { class: "ghost", onclick: () => { - if (state.ws) { state.ws.close(); state.ws = null; } - state.competition = null; - render(); - } }, "← " + t("back")); - root.appendChild(renderTopbar(backBtn)); - - const container = el("div", { class: "container" }); - container.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.5rem" } }, - el("h2", { style: { margin: 0 } }, - state.competition.name, - " ", - el("span", { class: "badge accent" }, t(state.competition.role)), - ), - el("div", { class: "row" }, - el("span", { class: "connection-status " + (state.wsOnline ? "online" : "offline") }), - el("span", { class: "muted" }, state.wsOnline ? t("online") : t("offline")), - ), - )); - - const tabs = el("div", { class: "tabs" }); - const tabDefs = [ - ["penalties", t("penalties")], - ["pilots", t("pilots")], - ["rules", t("rules")], - ]; - if (isChief()) { - tabDefs.push(["members", t("members")]); - tabDefs.push(["settings", t("settings_tab")]); - } - for (const [id, label] of tabDefs) { - tabs.appendChild(el("button", { - class: state.tab === id ? "active" : "", - onclick: () => { state.tab = id; render(); } - }, label)); - } - container.appendChild(tabs); - - if (state.tab === "penalties") container.appendChild(renderPenaltiesTab()); - else if (state.tab === "pilots") container.appendChild(renderPilotsTab()); - else if (state.tab === "rules") container.appendChild(renderRulesTab()); - else if (state.tab === "members") container.appendChild(renderMembersTab()); - else if (state.tab === "settings") container.appendChild(renderSettingsTab()); - - root.appendChild(container); - - if (state.showPenaltyModal) container.appendChild(renderPenaltyModal()); - if (state.showPilotModal) container.appendChild(renderPilotModal()); - if (state.showMemberModal) container.appendChild(renderMemberModal()); - if (state.showUserModal) container.appendChild(renderUserModal()); - if (state.showImportModal) container.appendChild(renderImportModal()); - if (state.showProfileModal) container.appendChild(renderProfileModal()); -} - -const PENALTY_COLS = [ - { key: "id", label: "#" }, - { key: "flight", label: "flight" }, - { key: "date", label: "date" }, - { key: "pilot_number", label: "pilot_number" }, - { key: "pilot_name", label: "pilot_name" }, - { key: "rule_number", label: "rule_number_short" }, - { key: "task", label: "task" }, - { key: "penalties_text", label: "penalty_values" }, - { key: "description", label: "description" }, - { key: "created_by_name", label: "created_by" }, - { key: "transferred", label: "transferred" }, -]; - -function sortPenalties(list) { - const { col, dir } = state.sort; - const sign = dir === "asc" ? 1 : -1; - return [...list].sort((a, b) => { - let av = a[col], bv = b[col]; - if (typeof av === "boolean") av = av ? 1 : 0; - if (typeof bv === "boolean") bv = bv ? 1 : 0; - if (av == null) av = ""; - if (bv == null) bv = ""; - if (typeof av === "number" && typeof bv === "number") return (av - bv) * sign; - return String(av).localeCompare(String(bv), undefined, { numeric: true }) * sign; - }); -} - -function filterPenalties(list) { - if (state.filterUntransferred) list = list.filter((p) => !p.transferred); - return list; -} - -function renderPenaltiesTab() { - const card = el("div"); - - const toolbar = el("div", { class: "toolbar" }, - el("button", { class: "primary", onclick: () => openPenaltyModal() }, t("add_penalty")), - el("label", { class: "row" }, - el("input", { type: "checkbox", checked: state.filterUntransferred, - onchange: (e) => { state.filterUntransferred = e.target.checked; patchPenaltyTable(); } - }), - " " + t("transferred_only") - ), - el("div", { class: "spacer" }), - isChief() && el("a", { href: API.exportPenaltiesURL(state.competition.id), target: "_blank" }, - el("button", null, t("export_csv")) - ), - ); - card.appendChild(toolbar); - - const tableWrap = el("div", { class: "table-wrap", id: "penalty-table-wrap" }); - const table = el("table", { id: "penalty-table" }); - const thead = el("thead"); - const headRow = el("tr"); - for (const c of PENALTY_COLS) { - headRow.appendChild(el("th", { - onclick: () => { - if (state.sort.col === c.key) state.sort.dir = state.sort.dir === "asc" ? "desc" : "asc"; - else { state.sort.col = c.key; state.sort.dir = "asc"; } - patchPenaltyTable(); - } - }, - t(c.label), - el("span", { class: "sort-ind" }, state.sort.col === c.key ? (state.sort.dir === "asc" ? "▲" : "▼") : ""), - )); - } - headRow.appendChild(el("th", null, t("actions"))); - thead.appendChild(headRow); - table.appendChild(thead); - - const tbody = el("tbody", { id: "penalty-tbody" }); - table.appendChild(tbody); - tableWrap.appendChild(table); - card.appendChild(tableWrap); - - setTimeout(patchPenaltyTable, 0); - return card; -} - -function patchPenaltyTable() { - const tbody = document.getElementById("penalty-tbody"); - if (!tbody) return; - const list = sortPenalties(filterPenalties(state.penalties)); - - const headRow = document.querySelector("#penalty-table thead tr"); - if (headRow) { - const ths = headRow.querySelectorAll("th"); - PENALTY_COLS.forEach((c, i) => { - const ind = ths[i].querySelector(".sort-ind"); - if (ind) ind.textContent = state.sort.col === c.key ? (state.sort.dir === "asc" ? "▲" : "▼") : ""; - }); - } - - const existing = new Map(); - for (const tr of Array.from(tbody.children)) { - existing.set(tr.dataset.id, tr); - } - const wanted = new Set(); - let prev = null; - for (const p of list) { - const idStr = String(p.id); - wanted.add(idStr); - let tr = existing.get(idStr); - if (!tr) { - tr = buildPenaltyRow(p); - } else { - updatePenaltyRow(tr, p); - } - if (prev) { - if (prev.nextSibling !== tr) tbody.insertBefore(tr, prev.nextSibling); - } else { - if (tbody.firstChild !== tr) tbody.insertBefore(tr, tbody.firstChild); - } - prev = tr; - } - for (const [k, tr] of existing) { - if (!wanted.has(k)) tr.remove(); - } -} - -function buildPenaltyRow(p) { - const tr = document.createElement("tr"); - tr.dataset.id = String(p.id); - for (const c of PENALTY_COLS) { - const td = document.createElement("td"); - td.dataset.col = c.key; - tr.appendChild(td); - } - const actions = document.createElement("td"); - actions.dataset.col = "_actions"; - tr.appendChild(actions); - updatePenaltyRow(tr, p); - return tr; -} - -function updatePenaltyRow(tr, p) { - tr.classList.toggle("transferred", !!p.transferred); - for (const c of PENALTY_COLS) { - const td = tr.querySelector(`td[data-col="${c.key}"]`); - if (!td) continue; - if (c.key === "transferred") { - td.innerHTML = ""; - const lbl = document.createElement("label"); - lbl.className = "switch"; - const inp = document.createElement("input"); - inp.type = "checkbox"; - inp.checked = !!p.transferred; - inp.disabled = !canEditPenalty(p); - inp.addEventListener("change", async () => { - try { - await API.updatePenalty(state.competition.id, p.id, { transferred: inp.checked }); - } catch (e) { inp.checked = !inp.checked; alert(e.message); } - }); - const slider = document.createElement("span"); - slider.className = "slider"; - lbl.appendChild(inp); - lbl.appendChild(slider); - td.appendChild(lbl); - } else { - const val = p[c.key]; - td.textContent = val == null ? "" : String(val); - } - } - const actions = tr.querySelector('td[data-col="_actions"]'); - actions.innerHTML = ""; - if (canEditPenalty(p)) { - const edit = document.createElement("button"); - edit.className = "action-btn"; - edit.textContent = t("edit"); - edit.addEventListener("click", () => openPenaltyModal(p)); - const del = document.createElement("button"); - del.className = "action-btn danger"; - del.textContent = t("delete"); - del.addEventListener("click", async () => { - if (!confirm(t("confirm_delete"))) return; - await API.deletePenalty(state.competition.id, p.id); - }); - actions.appendChild(edit); - actions.appendChild(del); - } -} - -function openPenaltyModal(penalty) { - state.showPenaltyModal = true; - state.editingPenalty = penalty || null; - render(); -} - -function renderPenaltyModal() { - const p = state.editingPenalty || {}; - const flight = el("input", { type: "text", value: p.flight || "" }); - const date = el("input", { type: "date", value: p.date || new Date().toISOString().slice(0, 10) }); - const pilotSelect = el("select", { onchange: () => refreshPrior() }, - el("option", { value: "" }, "—"), - ...sortPilots(state.pilots).map((pl) => el("option", { value: pl.number, selected: pl.number === p.pilot_number }, - `${pl.number} — ${pl.last_name}, ${pl.first_name}`)) - ); - const existingRule = p.rule_number ? state.rulesByNumber[p.rule_number] : null; - const ruleSearch = el("input", { type: "text", placeholder: t("search_rule"), - value: existingRule ? `${existingRule.number} — ${existingRule.text}` : (p.rule_number || "") }); - const ruleNumber = el("input", { type: "text", placeholder: t("rule_number_short"), value: p.rule_number || "" }); - const suggestionBox = el("div", { class: "rule-card", style: { display: "none" } }); - const searchResults = el("div", { class: "results", style: { display: "none" } }); - const priorBox = el("div", { class: "prior-box", style: { display: "none" } }); - - function escalationViz(r) { - if (r.escalation_mode === "same") return el("span", { class: "muted small" }, t("escalation_same")); - if (r.escalation_mode === "doubled") return el("span", { class: "muted small" }, t("escalation_doubled")); - if (r.escalation_mode === "escalate") { - const wrap = el("div", { class: "tier-row" }); - const tiers = r.escalation_tiers || []; - tiers.forEach((tier, i) => { - wrap.appendChild(el("span", { class: "tier-pill" }, tier)); - if (i < tiers.length - 1) wrap.appendChild(el("span", { class: "tier-arrow" }, "→")); - }); - return wrap; - } - return el("span", null, ""); - } - - function refreshSuggestion() { - const rNum = ruleNumber.value.trim(); - const r = state.rulesByNumber[rNum]; - if (!r) { suggestionBox.style.display = "none"; return; } - suggestionBox.style.display = "block"; - suggestionBox.innerHTML = ""; - suggestionBox.appendChild(el("div", { class: "rule-head" }, - el("span", { class: "rule-num" }, r.number), - el("span", { class: "rule-text" }, r.text), - )); - suggestionBox.appendChild(el("div", { class: "kv" }, - el("span", { class: "k" }, t("suggested_penalty")), - el("span", { class: "badge accent" }, r.suggested_penalty), - )); - suggestionBox.appendChild(el("div", { class: "kv" }, - el("span", { class: "k" }, t("escalation")), - escalationViz(r), - )); - } - - function refreshPrior() { - const rNum = ruleNumber.value.trim(); - const pNum = pilotSelect.value; - if (!rNum || !pNum) { priorBox.style.display = "none"; return; } - const prior = state.penalties.filter((x) => - x.pilot_number === pNum && x.rule_number === rNum && x.id !== p.id - ); - priorBox.innerHTML = ""; - if (prior.length === 0) { priorBox.style.display = "none"; return; } - priorBox.style.display = "block"; - priorBox.appendChild(el("div", { class: "prior-title" }, - t("prior_penalties") + " (" + prior.length + ")" - )); - const tbl = el("table", { class: "mini-table" }); - tbl.appendChild(el("thead", null, el("tr", null, - el("th", null, t("flight")), - el("th", null, t("date")), - el("th", null, t("penalty_values")), - el("th", null, t("description")), - ))); - const tb = el("tbody"); - prior.sort((a, b) => naturalCompare(a.date, b.date) || (a.id - b.id)); - for (const pp of prior) { - tb.appendChild(el("tr", null, - el("td", null, pp.flight), - el("td", null, pp.date), - el("td", null, el("span", { class: "badge accent" }, pp.penalties_text)), - el("td", null, pp.description), - )); - } - tbl.appendChild(tb); - priorBox.appendChild(tbl); - } - - refreshSuggestion(); - refreshPrior(); - - ruleNumber.addEventListener("input", () => { refreshSuggestion(); refreshPrior(); }); - - function searchRules(q) { - q = q.trim().toLowerCase(); - if (!q) return []; - return state.rules.filter((r) => - r.number.toLowerCase().includes(q) || r.text.toLowerCase().includes(q) - ).slice(0, 30); - } - ruleSearch.addEventListener("input", () => { - const items = searchRules(ruleSearch.value); - searchResults.innerHTML = ""; - if (items.length === 0) { searchResults.style.display = "none"; return; } - searchResults.style.display = "block"; - for (const r of items) { - const it = el("div", { class: "item", - onclick: () => { - ruleNumber.value = r.number; - ruleSearch.value = `${r.number} — ${r.text}`; - searchResults.style.display = "none"; - refreshSuggestion(); - refreshPrior(); - } - }, - el("span", { class: "num" }, r.number), - r.text - ); - searchResults.appendChild(it); - } - }); - ruleSearch.addEventListener("blur", () => setTimeout(() => searchResults.style.display = "none", 200)); - - const task = el("input", { type: "text", value: p.task || "" }); - const penaltiesText = el("input", { type: "text", value: p.penalties_text || "", - placeholder: "e.g. 50 CP, Warning, +50m, No Result" }); - const description = el("textarea", null, p.description || ""); - const transferred = el("input", { type: "checkbox", checked: !!p.transferred }); - - return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, - el("div", { class: "modal" }, - el("h3", null, p.id ? t("edit") : t("add_penalty")), - el("div", { class: "grid" }, - el("div", { class: "field" }, el("label", null, t("flight")), flight), - el("div", { class: "field" }, el("label", null, t("date")), date), - el("div", { class: "field" }, el("label", null, t("pilot_number")), pilotSelect), - ), - el("div", { class: "field", style: { marginTop: "0.5rem" } }, - el("label", null, t("search_rule")), - el("div", { class: "search-box" }, ruleSearch, searchResults), - ), - el("div", { class: "field" }, el("label", null, t("rule_number_short")), ruleNumber), - suggestionBox, - priorBox, - el("div", { class: "grid", style: { marginTop: "0.5rem" } }, - el("div", { class: "field" }, el("label", null, t("task")), task), - el("div", { class: "field" }, el("label", null, t("penalty_values")), penaltiesText), - ), - el("div", { class: "field" }, el("label", null, t("description")), description), - el("label", { class: "row", style: { marginTop: "0.5rem" } }, transferred, " " + t("transferred")), - el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, - el("button", { onclick: closeAll }, t("cancel")), - el("button", { class: "primary", onclick: async () => { - const body = { - flight: flight.value, - date: date.value, - pilot_number: pilotSelect.value, - rule_number: ruleNumber.value, - task: task.value, - penalties_text: penaltiesText.value, - description: description.value, - transferred: transferred.checked, - }; - try { - if (p.id) { - await API.updatePenalty(state.competition.id, p.id, body); - } else { - await API.createPenalty(state.competition.id, body); - } - closeAll(); - } catch (e) { alert(e.message); } - } }, t("save")), - ), - ) - ); -} - -const PILOT_COLS = [ - { key: "number", label: "number" }, - { key: "last_name", label: "last_name" }, - { key: "first_name", label: "first_name" }, - { key: "country", label: "country" }, - { key: "balloon_id", label: "balloon_id" }, -]; - -function naturalCompare(a, b) { - if (a == null) a = ""; - if (b == null) b = ""; - return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" }); -} - -function sortPilots(list) { - const { col, dir } = state.pilotSort; - const sign = dir === "asc" ? 1 : -1; - return [...list].sort((a, b) => naturalCompare(a[col], b[col]) * sign); -} - -function renderPilotsTab() { - const card = el("div"); - if (isChief()) { - card.appendChild(el("div", { class: "toolbar" }, - el("button", { class: "primary", onclick: () => openPilotModal() }, t("add_pilot")), - el("button", { onclick: () => openImportModal() }, t("import_csv")), - )); - } - if (state.pilots.length === 0) { - card.appendChild(el("div", { class: "muted" }, t("no_pilots"))); - return card; - } - const table = el("table"); - const headRow = el("tr"); - for (const c of PILOT_COLS) { - headRow.appendChild(el("th", { - onclick: () => { - if (state.pilotSort.col === c.key) state.pilotSort.dir = state.pilotSort.dir === "asc" ? "desc" : "asc"; - else { state.pilotSort.col = c.key; state.pilotSort.dir = "asc"; } - render(); - } - }, - t(c.label), - el("span", { class: "sort-ind" }, state.pilotSort.col === c.key ? (state.pilotSort.dir === "asc" ? "▲" : "▼") : ""), - )); - } - if (isChief()) headRow.appendChild(el("th", null, t("actions"))); - table.appendChild(el("thead", null, headRow)); - const tbody = el("tbody"); - const sorted = sortPilots(state.pilots); - for (const pl of sorted) { - tbody.appendChild(el("tr", null, - el("td", null, pl.number), - el("td", null, pl.last_name), - el("td", null, pl.first_name), - el("td", null, pl.country), - el("td", null, pl.balloon_id), - isChief() && el("td", null, - el("button", { class: "action-btn", onclick: () => openPilotModal(pl) }, t("edit")), - el("button", { class: "action-btn danger", onclick: async () => { - if (!confirm(t("confirm_delete"))) return; - await API.deletePilot(state.competition.id, pl.id); - await loadPilots(); - render(); - } }, t("delete")), - ), - )); - } - table.appendChild(tbody); - card.appendChild(el("div", { class: "table-wrap" }, table)); - return card; -} - -function openPilotModal(pilot) { - state.showPilotModal = true; - state.editingPilot = pilot || null; - render(); -} - -function renderPilotModal() { - const p = state.editingPilot || {}; - const number = el("input", { type: "text", value: p.number || "" }); - const lastName = el("input", { type: "text", value: p.last_name || "" }); - const firstName = el("input", { type: "text", value: p.first_name || "" }); - const country = el("input", { type: "text", value: p.country || "" }); - const balloon = el("input", { type: "text", value: p.balloon_id || "" }); - return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, - el("div", { class: "modal" }, - el("h3", null, p.id ? t("edit") : t("add_pilot")), - el("div", { class: "grid" }, - el("div", { class: "field" }, el("label", null, t("number")), number), - el("div", { class: "field" }, el("label", null, t("last_name")), lastName), - el("div", { class: "field" }, el("label", null, t("first_name")), firstName), - el("div", { class: "field" }, el("label", null, t("country")), country), - el("div", { class: "field" }, el("label", null, t("balloon_id")), balloon), - ), - el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, - el("button", { onclick: closeAll }, t("cancel")), - el("button", { class: "primary", onclick: async () => { - const body = { - number: number.value.trim(), - last_name: lastName.value.trim(), - first_name: firstName.value.trim(), - country: country.value.trim(), - balloon_id: balloon.value.trim(), - }; - try { - if (p.id) await API.updatePilot(state.competition.id, p.id, body); - else await API.createPilot(state.competition.id, body); - await loadPilots(); - closeAll(); - } catch (e) { alert(e.message); } - } }, t("save")), - ), - ) - ); -} - -function openImportModal() { state.showImportModal = true; render(); } - -function renderImportModal() { - const textarea = el("textarea", { placeholder: t("csv_paste"), style: { width: "100%", minHeight: "180px" } }); - return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) { state.showImportModal = false; render(); } } }, - el("div", { class: "modal" }, - el("h3", null, t("import_csv")), - textarea, - el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, - el("button", { onclick: () => { state.showImportModal = false; render(); } }, t("cancel")), - el("button", { class: "primary", onclick: async () => { - try { - await API.importPilots(state.competition.id, textarea.value); - await loadPilots(); - state.showImportModal = false; render(); - } catch (e) { alert(e.message); } - } }, t("import_csv")), - ) - ) - ); -} - -function ruleEscalationViz(r) { - if (r.escalation_mode === "same") return el("span", { class: "muted small" }, t("escalation_same")); - if (r.escalation_mode === "doubled") return el("span", { class: "muted small" }, t("escalation_doubled")); - if (r.escalation_mode === "escalate") { - const wrap = el("div", { class: "tier-row" }); - const tiers = r.escalation_tiers || []; - tiers.forEach((tier, i) => { - wrap.appendChild(el("span", { class: "tier-pill" }, tier)); - if (i < tiers.length - 1) wrap.appendChild(el("span", { class: "tier-arrow" }, "→")); - }); - return wrap; - } - return el("span", null, ""); -} - -function renderRulesTab() { - const card = el("div"); - const search = el("input", { type: "search", placeholder: t("search_rule"), style: { width: "100%" } }); - const count = el("div", { class: "muted", style: { margin: "0.5rem 0" } }); - const list = el("div", { class: "rules-list" }); - - function refresh() { - list.innerHTML = ""; - const q = search.value.trim().toLowerCase(); - const filtered = q - ? state.rules.filter((r) => r.number.toLowerCase().includes(q) || r.text.toLowerCase().includes(q)) - : state.rules; - filtered.sort((a, b) => naturalCompare(a.number, b.number)); - count.textContent = t("showing_n_of_m", { n: filtered.length, m: state.rules.length }); - for (const r of filtered) { - list.appendChild(el("div", { class: "rule-card" }, - el("div", { class: "rule-head" }, - el("span", { class: "rule-num" }, r.number), - el("span", { class: "rule-text" }, r.text), - ), - el("div", { class: "kv" }, - el("span", { class: "k" }, t("suggested_penalty")), - el("span", { class: "badge accent" }, r.suggested_penalty), - ), - el("div", { class: "kv" }, - el("span", { class: "k" }, t("escalation")), - ruleEscalationViz(r), - ), - )); - } - if (filtered.length === 0) list.appendChild(el("div", { class: "muted" }, t("none"))); - } - search.addEventListener("input", refresh); - card.appendChild(search); - card.appendChild(count); - card.appendChild(list); - setTimeout(refresh, 0); - return card; -} - -function renderMembersTab() { - const card = el("div"); - card.appendChild(el("div", { class: "toolbar" }, - el("button", { class: "primary", onclick: () => openMemberModal() }, t("add_member")), - state.user.is_system_admin && el("button", { onclick: () => openUserModal(state.competition.id) }, t("add_user")), - )); - if (state.members.length === 0) { - card.appendChild(el("div", { class: "muted" }, t("no_members"))); - return card; - } - const table = el("table"); - table.appendChild(el("thead", null, el("tr", null, - el("th", null, t("username")), - el("th", null, t("display_name")), - el("th", null, t("role")), - el("th", null, t("actions")), - ))); - const tbody = el("tbody"); - for (const m of state.members) { - tbody.appendChild(el("tr", null, - el("td", null, m.username), - el("td", null, m.display_name), - el("td", null, t(m.role)), - el("td", null, - el("button", { class: "action-btn danger", onclick: async () => { - if (!confirm(t("confirm_delete"))) return; - await API.removeMember(state.competition.id, m.user_id); - await loadMembers(); - render(); - } }, t("remove")), - ), - )); - } - table.appendChild(tbody); - card.appendChild(el("div", { class: "table-wrap" }, table)); - return card; -} - -function openMemberModal() { - state.showMemberModal = true; - render(); -} - -function renderMemberModal() { - const username = el("input", { type: "text", placeholder: t("username") }); - const roleSelect = el("select", null, - el("option", { value: "scorer" }, t("scorer")), - el("option", { value: "chief_scorer" }, t("chief_scorer")), - ); - return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, - el("div", { class: "modal" }, - el("h3", null, t("add_member")), - el("p", { class: "muted" }, t("username")), - username, - el("div", { class: "field", style: { marginTop: "0.5rem" } }, el("label", null, t("role")), roleSelect), - el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, - el("button", { onclick: closeAll }, t("cancel")), - el("button", { class: "primary", onclick: async () => { - let users = []; - try { users = await API.listUsers(); } catch (e) { alert(t("forbidden")); return; } - const u = users.find((x) => x.username === username.value.trim()); - if (!u) { alert("User not found"); return; } - await API.addMember(state.competition.id, { user_id: u.id, role: roleSelect.value }); - await loadMembers(); - closeAll(); - } }, t("save")), - ), - ) - ); -} - -function renderSettingsTab() { - const name = el("input", { type: "text", value: state.competition.name }); - const allow = el("input", { type: "checkbox", checked: !!state.competition.allow_any_scorer_edit }); - const msg = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } }); - return el("div", { class: "card" }, - el("h2", null, t("settings_tab")), - el("div", { class: "field" }, el("label", null, t("competition_name")), name), - el("label", { class: "row", style: { marginTop: "0.5rem" } }, allow, " " + t("allow_any_scorer_edit")), - msg, - el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, - el("button", { class: "primary", onclick: async () => { - await API.updateCompetition(state.competition.id, { - name: name.value, allow_any_scorer_edit: allow.checked, - }); - const c = await API.getCompetition(state.competition.id); - state.competition = c; - msg.textContent = t("saved"); - msg.style.display = "block"; - } }, t("save_settings")), - ), - ); -} - -async function init() { - const userLang = navigator.language ? navigator.language.slice(0, 2) : "en"; - setLang(I18N_AVAILABLE.includes(userLang) ? userLang : "en"); - try { - const u = await API.me(); - state.user = u; - setLang(u.language); - await loadCompetitions(); - if (u.is_system_admin) await loadUsers(); - } catch (e) { - state.user = null; - } - render(); -} - -init(); diff --git a/web/common.js b/web/common.js new file mode 100644 index 0000000..ce52d7f --- /dev/null +++ b/web/common.js @@ -0,0 +1,186 @@ +// Shared helpers used by all pages. + +const el = (tag, attrs, ...children) => { + const node = document.createElement(tag); + if (attrs) { + for (const k in attrs) { + if (k === "class") node.className = attrs[k]; + else if (k === "style") Object.assign(node.style, attrs[k]); + else if (k.startsWith("on") && typeof attrs[k] === "function") node.addEventListener(k.slice(2), attrs[k]); + else if (k === "checked" || k === "disabled" || k === "selected") { + if (attrs[k]) node.setAttribute(k, ""); + } else if (attrs[k] !== false && attrs[k] !== null && attrs[k] !== undefined) { + node.setAttribute(k, attrs[k]); + } + } + } + for (const c of children.flat()) { + if (c === null || c === undefined || c === false) continue; + node.appendChild(typeof c === "string" || typeof c === "number" ? document.createTextNode(String(c)) : c); + } + return node; +}; + +function clearNode(node) { + while (node.firstChild) node.removeChild(node.firstChild); +} + +function naturalCompare(a, b) { + if (a == null) a = ""; + if (b == null) b = ""; + return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" }); +} + +// Page navigation helpers (true multi-page navigation, not SPA routing). +const PAGES = { + login: "login.html", + competitions: "competitions.html", + competition: "competition.html", + forcePassword: "force-password.html", +}; + +function navigate(page, params) { + let url = PAGES[page] || page; + if (params) { + const qs = new URLSearchParams(params).toString(); + if (qs) url += "?" + qs; + } + location.assign(url); +} + +// Bootstraps a page: loads the current user, enforces auth rules. +// options: +// requireAuth: true — redirect to login if no session +// forbidIfMustChange: true — redirect to force-password.html +// onlyIfMustChange: true — redirect AWAY if user must NOT change +async function bootstrapAuth(options) { + options = options || {}; + let user = null; + try { + user = await API.me(); + } catch (e) { + user = null; + } + if (options.requireAuth && !user) { + navigate("login"); + return null; + } + if (!user) return null; + setLang(user.language || CURRENT_LANG); + if (user.must_change_password && options.forbidIfMustChange) { + navigate("forcePassword"); + return null; + } + if (!user.must_change_password && options.onlyIfMustChange) { + navigate("competitions"); + return null; + } + return user; +} + +// Standard topbar shown on authenticated pages. +function renderTopbar(user, opts) { + opts = opts || {}; + const langSelect = el("select", + { onchange: async (e) => { + const lang = e.target.value; + try { await API.updateMe({ language: lang }); } catch (_) {} + user.language = lang; + setLang(lang); + location.reload(); + } }, + ...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === user.language }, I18N_NAMES[l])) + ); + + const logoutBtn = el("button", { class: "ghost", onclick: async () => { + try { await API.logout(); } catch (_) {} + navigate("login"); + } }, t("logout")); + + const brand = el("a", { href: PAGES.competitions, class: "brand" }, "Penalty Tracker"); + + const profileBtn = el("button", { class: "ghost", onclick: () => openProfileModal(user) }, + user.display_name || user.username); + + return el("div", { class: "topbar" }, + brand, + el("div", { class: "nav" }, + opts.extra || null, + profileBtn, + langSelect, + logoutBtn, + ) + ); +} + +// Self-contained profile modal: language and password only. Username/display +// name are read-only here (only system admin can change them). +function openProfileModal(user) { + const backdrop = el("div", { class: "modal-backdrop", + onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); + + const password = el("input", { type: "password", placeholder: t("leave_blank_keep") }); + const lang = el("select", null, + ...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === user.language }, I18N_NAMES[l])) + ); + const err = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } }); + const ok = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } }); + + const usernameField = el("input", { type: "text", value: user.username, disabled: true }); + const displayField = el("input", { type: "text", value: user.display_name || "", disabled: true }); + + const modal = el("div", { class: "modal" }, + el("h3", null, t("profile")), + el("div", { class: "field" }, el("label", null, t("username")), usernameField, + el("div", { class: "muted small" }, t("profile_username_readonly"))), + el("div", { class: "field" }, el("label", null, t("display_name")), displayField, + el("div", { class: "muted small" }, t("profile_displayname_readonly"))), + el("div", { class: "field" }, el("label", null, t("language")), lang), + el("div", { class: "field" }, el("label", null, t("new_password")), password), + err, ok, + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: () => backdrop.remove() }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + err.style.display = "none"; ok.style.display = "none"; + const body = {}; + if (lang.value !== user.language) body.language = lang.value; + if (password.value) body.password = password.value; + if (Object.keys(body).length === 0) { backdrop.remove(); return; } + try { + const u = await API.updateMe(body); + if (u.language !== user.language) { + user.language = u.language; + setLang(u.language); + } + ok.textContent = t("saved"); + ok.style.display = "block"; + setTimeout(() => { backdrop.remove(); location.reload(); }, 600); + } catch (e) { + err.textContent = (e.data && e.data.error) || e.message || "error"; + err.style.display = "block"; + } + } }, t("save")), + ), + ); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); +} + +// Read URL search params as an object. +function queryParams() { + const out = {}; + const sp = new URLSearchParams(location.search); + for (const [k, v] of sp.entries()) out[k] = v; + return out; +} + +// Initialize language from saved/browser preference before authenticated state +// is known. +(function initInitialLang() { + const userLang = navigator.language ? navigator.language.slice(0, 2) : "en"; + if (typeof I18N_AVAILABLE !== "undefined" && I18N_AVAILABLE.includes(userLang)) { + setLang(userLang); + } else { + setLang("en"); + } +})(); diff --git a/web/competition.html b/web/competition.html new file mode 100644 index 0000000..eb24939 --- /dev/null +++ b/web/competition.html @@ -0,0 +1,17 @@ + + + + + +Penalty Tracker — Competition + + + +
+ + + + + + + diff --git a/web/competition.js b/web/competition.js new file mode 100644 index 0000000..590713e --- /dev/null +++ b/web/competition.js @@ -0,0 +1,1232 @@ +(async function () { + const root = document.getElementById("app"); + const user = await bootstrapAuth({ requireAuth: true, forbidIfMustChange: true }); + if (!user) return; + + const params = queryParams(); + const competitionId = parseInt(params.id, 10); + if (!competitionId) { navigate("competitions"); return; } + + let competition; + try { + competition = await API.getCompetition(competitionId); + } catch (e) { + navigate("competitions"); + return; + } + + const state = { + competition, + pilots: [], + penalties: [], + members: [], + users: [], + rules: [], + rulesByNumber: {}, + ws: null, + wsOnline: false, + tab: params.tab || "penalties", + sort: { col: "date", dir: "desc" }, + pilotSort: { col: "number", dir: "asc" }, + filterApplied: "all", // "all" | "applied" | "open" + filterText: "", + }; + + async function loadAll() { + await Promise.all([loadPilots(), loadPenalties(), loadMembers(), loadRules()]); + if (user.is_system_admin) await loadUsers(); + } + async function loadPilots() { state.pilots = await API.listPilots(competitionId); } + async function loadPenalties() { state.penalties = await API.listPenalties(competitionId); } + async function loadMembers() { state.members = await API.listMembers(competitionId); } + async function loadUsers() { state.users = await API.listUsers(); } + async function loadRules() { + state.rules = await API.listRules(user.language); + state.rulesByNumber = {}; + for (const r of state.rules) state.rulesByNumber[r.number] = r; + } + + function isChief() { + const r = state.competition.role; + return r === "system_admin" || r === "chief_scorer"; + } + + function canEditPenalty(p) { + if (isChief()) return true; + if (p.created_by === user.id) return true; + return !!state.competition.allow_any_scorer_edit; + } + + function pilotCountForRule(pilotNumber, ruleNumber, excludeId) { + if (!pilotNumber || !ruleNumber) return 0; + let n = 0; + for (const p of state.penalties) { + if (p.pilot_number === pilotNumber && p.rule_number === ruleNumber && p.id !== excludeId) n++; + } + return n; + } + + // ---- Penalties tab ----------------------------------------------------- + + const PENALTY_COLS = [ + { key: "flight", label: "flight" }, + { key: "date", label: "date" }, + { key: "pilot_number", label: "pilot_number" }, + { key: "pilot_name", label: "pilot_name" }, + { key: "rule_number", label: "rule_number_short" }, + { key: "task", label: "task" }, + { key: "penalties_text", label: "penalty_values" }, + { key: "description", label: "description" }, + { key: "created_by_name", label: "created_by" }, + { key: "transferred", label: "applied" }, + ]; + + function sortPenalties(list) { + const { col, dir } = state.sort; + const sign = dir === "asc" ? 1 : -1; + return [...list].sort((a, b) => { + let av = a[col], bv = b[col]; + if (typeof av === "boolean") av = av ? 1 : 0; + if (typeof bv === "boolean") bv = bv ? 1 : 0; + if (av == null) av = ""; + if (bv == null) bv = ""; + if (typeof av === "number" && typeof bv === "number") return (av - bv) * sign; + return String(av).localeCompare(String(bv), undefined, { numeric: true }) * sign; + }); + } + + function filterPenalties(list) { + if (state.filterApplied === "applied") list = list.filter((p) => p.transferred); + else if (state.filterApplied === "open") list = list.filter((p) => !p.transferred); + const q = state.filterText.trim().toLowerCase(); + if (q) { + list = list.filter((p) => + (p.flight || "").toLowerCase().includes(q) || + (p.date || "").toLowerCase().includes(q) || + (p.pilot_number || "").toLowerCase().includes(q) || + (p.pilot_name || "").toLowerCase().includes(q) || + (p.rule_number || "").toLowerCase().includes(q) || + (p.task || "").toLowerCase().includes(q) || + (p.penalties_text || "").toLowerCase().includes(q) || + (p.description || "").toLowerCase().includes(q) || + (p.created_by_name || "").toLowerCase().includes(q) + ); + } + return list; + } + + function renderPenaltiesTab() { + const card = el("div"); + + const search = el("input", { type: "search", placeholder: t("search_penalties"), + style: { minWidth: "240px", flex: "1" }, + value: state.filterText, + oninput: (e) => { state.filterText = e.target.value; patchPenaltyTable(); } + }); + + const applyFilter = el("select", { + onchange: (e) => { state.filterApplied = e.target.value; patchPenaltyTable(); } + }, + el("option", { value: "all", selected: state.filterApplied === "all" }, t("filter_all")), + el("option", { value: "open", selected: state.filterApplied === "open" }, t("filter_open")), + el("option", { value: "applied", selected: state.filterApplied === "applied" }, t("filter_applied")), + ); + + const toolbar = el("div", { class: "toolbar" }, + el("button", { class: "primary", onclick: () => openPenaltyModal() }, t("add_penalty")), + search, + applyFilter, + el("div", { class: "spacer" }), + isChief() && el("button", { onclick: openApplyByTaskModal }, t("apply_by_task")), + isChief() && el("button", { onclick: async () => { + try { + const blob = await API.exportPenaltiesCSV(competitionId); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `penalties_${competitionId}.csv`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (e) { alert(e.message); } + } }, t("export_csv")), + ); + card.appendChild(toolbar); + + const tableWrap = el("div", { class: "table-wrap", id: "penalty-table-wrap" }); + const table = el("table", { id: "penalty-table" }); + const thead = el("thead"); + const headRow = el("tr"); + for (const c of PENALTY_COLS) { + headRow.appendChild(el("th", { + title: c.title ? t(c.title) : null, + onclick: () => { + if (state.sort.col === c.key) state.sort.dir = state.sort.dir === "asc" ? "desc" : "asc"; + else { state.sort.col = c.key; state.sort.dir = "asc"; } + patchPenaltyTable(); + } + }, + t(c.label), + el("span", { class: "sort-ind" }, state.sort.col === c.key ? (state.sort.dir === "asc" ? "▲" : "▼") : ""), + )); + } + headRow.appendChild(el("th", null, t("actions"))); + thead.appendChild(headRow); + table.appendChild(thead); + + const tbody = el("tbody", { id: "penalty-tbody" }); + table.appendChild(tbody); + tableWrap.appendChild(table); + card.appendChild(tableWrap); + + const status = el("div", { class: "muted", id: "penalty-count", style: { marginTop: "0.5rem" } }); + card.appendChild(status); + + setTimeout(patchPenaltyTable, 0); + return card; + } + + function patchPenaltyTable() { + const tbody = document.getElementById("penalty-tbody"); + if (!tbody) return; + const list = sortPenalties(filterPenalties(state.penalties)); + + const status = document.getElementById("penalty-count"); + if (status) status.textContent = t("showing_n_of_m", { n: list.length, m: state.penalties.length }); + + const headRow = document.querySelector("#penalty-table thead tr"); + if (headRow) { + const ths = headRow.querySelectorAll("th"); + PENALTY_COLS.forEach((c, i) => { + const ind = ths[i].querySelector(".sort-ind"); + if (ind) ind.textContent = state.sort.col === c.key ? (state.sort.dir === "asc" ? "▲" : "▼") : ""; + }); + } + + const existing = new Map(); + for (const tr of Array.from(tbody.children)) existing.set(tr.dataset.id, tr); + const wanted = new Set(); + let prev = null; + for (const p of list) { + const idStr = String(p.id); + wanted.add(idStr); + let tr = existing.get(idStr); + if (!tr) tr = buildPenaltyRow(p); + else updatePenaltyRow(tr, p); + if (prev) { + if (prev.nextSibling !== tr) tbody.insertBefore(tr, prev.nextSibling); + } else { + if (tbody.firstChild !== tr) tbody.insertBefore(tr, tbody.firstChild); + } + prev = tr; + } + for (const [k, tr] of existing) if (!wanted.has(k)) tr.remove(); + } + + function buildPenaltyRow(p) { + const tr = document.createElement("tr"); + tr.dataset.id = String(p.id); + for (const c of PENALTY_COLS) { + const td = document.createElement("td"); + td.dataset.col = c.key; + tr.appendChild(td); + } + const actions = document.createElement("td"); + actions.dataset.col = "_actions"; + tr.appendChild(actions); + updatePenaltyRow(tr, p); + return tr; + } + + function updatePenaltyRow(tr, p) { + tr.classList.toggle("transferred", !!p.transferred); + for (const c of PENALTY_COLS) { + const td = tr.querySelector(`td[data-col="${c.key}"]`); + if (!td) continue; + if (c.key === "transferred") { + td.innerHTML = ""; + const lbl = document.createElement("label"); + lbl.className = "switch"; + const inp = document.createElement("input"); + inp.type = "checkbox"; + inp.checked = !!p.transferred; + inp.disabled = !canEditPenalty(p); + inp.addEventListener("change", async () => { + try { + await API.updatePenalty(competitionId, p.id, { transferred: inp.checked }); + } catch (e) { inp.checked = !inp.checked; alert(e.message); } + }); + const slider = document.createElement("span"); + slider.className = "slider"; + lbl.appendChild(inp); + lbl.appendChild(slider); + td.appendChild(lbl); + } else { + const val = p[c.key]; + td.textContent = val == null ? "" : String(val); + } + } + const actions = tr.querySelector('td[data-col="_actions"]'); + actions.innerHTML = ""; + const summary = document.createElement("button"); + summary.className = "action-btn"; + summary.textContent = t("summary"); + summary.addEventListener("click", () => openPenaltySummary(p)); + actions.appendChild(summary); + if (canEditPenalty(p)) { + const edit = document.createElement("button"); + edit.className = "action-btn"; + edit.textContent = t("edit"); + edit.addEventListener("click", () => openPenaltyModal(p)); + const del = document.createElement("button"); + del.className = "action-btn danger"; + del.textContent = t("delete"); + del.addEventListener("click", async () => { + if (!confirm(t("confirm_delete"))) return; + await API.deletePenalty(competitionId, p.id); + }); + actions.appendChild(edit); + actions.appendChild(del); + } + } + + function openPenaltySummary(p) { + const rule = p.rule_number ? state.rulesByNumber[p.rule_number] : null; + const count = pilotCountForRule(p.pilot_number, p.rule_number, p.id); + const backdrop = el("div", { class: "modal-backdrop", + onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); + + function escalationViz(r) { + if (!r) return el("span", null, ""); + if (r.escalation_mode === "same") return el("span", { class: "muted small" }, t("escalation_same")); + if (r.escalation_mode === "doubled") return el("span", { class: "muted small" }, t("escalation_doubled")); + if (r.escalation_mode === "escalate") { + const wrap = el("div", { class: "tier-row" }); + (r.escalation_tiers || []).forEach((tier, i, arr) => { + wrap.appendChild(el("span", { class: "tier-pill" }, tier)); + if (i < arr.length - 1) wrap.appendChild(el("span", { class: "tier-arrow" }, "→")); + }); + return wrap; + } + return el("span", null, ""); + } + + const incidentsBox = el("div", { style: { display: "none", marginTop: "0.5rem" } }); + const incidentsBtn = el("button", { class: "action-btn", disabled: count === 0, + onclick: () => { + if (incidentsBox.style.display === "none") { + incidentsBox.innerHTML = ""; + const prior = state.penalties + .filter((x) => x.pilot_number === p.pilot_number && x.rule_number === p.rule_number && x.id !== p.id) + .sort((a, b) => naturalCompare(a.date, b.date) || (a.id - b.id)); + const tbl = el("table", { class: "mini-table" }); + tbl.appendChild(el("thead", null, el("tr", null, + el("th", null, t("flight")), + el("th", null, t("date")), + el("th", null, t("task")), + el("th", null, t("penalty_values")), + el("th", null, t("description")), + el("th", null, t("applied")), + ))); + const tb = el("tbody"); + for (const pp of prior) { + tb.appendChild(el("tr", null, + el("td", null, pp.flight || ""), + el("td", null, pp.date || ""), + el("td", null, pp.task || ""), + el("td", null, el("span", { class: "badge accent" }, pp.penalties_text || "")), + el("td", null, pp.description || ""), + el("td", null, pp.transferred ? t("yes") : t("no")), + )); + } + tbl.appendChild(tb); + incidentsBox.appendChild(tbl); + incidentsBox.style.display = "block"; + incidentsBtn.textContent = t("hide_incidents"); + } else { + incidentsBox.style.display = "none"; + incidentsBox.innerHTML = ""; + incidentsBtn.textContent = t("show_incidents") + " (" + count + ")"; + } + } }, t("show_incidents") + " (" + count + ")"); + + backdrop.appendChild(el("div", { class: "modal" }, + el("h3", null, t("penalty_summary")), + el("div", { class: "kv" }, el("span", { class: "k" }, t("pilot_number")), + el("span", null, p.pilot_number + (p.pilot_name ? " — " + p.pilot_name : ""))), + el("div", { class: "kv" }, el("span", { class: "k" }, t("flight")), el("span", null, p.flight || "—")), + el("div", { class: "kv" }, el("span", { class: "k" }, t("date")), el("span", null, p.date || "—")), + el("div", { class: "kv" }, el("span", { class: "k" }, t("task")), el("span", null, p.task || "—")), + el("div", { class: "kv" }, el("span", { class: "k" }, t("penalty_values")), + el("span", { class: "badge accent" }, p.penalties_text || "—")), + el("div", { class: "kv" }, el("span", { class: "k" }, t("description")), el("span", null, p.description || "—")), + el("div", { class: "kv" }, el("span", { class: "k" }, t("created_by")), el("span", null, p.created_by_name || "—")), + el("div", { class: "kv" }, el("span", { class: "k" }, t("applied")), + el("span", { class: p.transferred ? "badge accent" : "badge" }, p.transferred ? t("yes") : t("no"))), + el("div", { class: "row", style: { marginTop: "0.5rem" } }, incidentsBtn), + incidentsBox, + el("h4", { style: { marginTop: "1rem", marginBottom: "0.25rem" } }, t("rule")), + rule ? el("div", { class: "rule-card" }, + el("div", { class: "rule-head" }, + el("span", { class: "rule-num" }, rule.number), + el("span", { class: "rule-text" }, rule.text), + ), + el("div", { class: "kv" }, + el("span", { class: "k" }, t("suggested_penalty")), + el("span", { class: "badge accent" }, rule.suggested_penalty || "—"), + ), + el("div", { class: "kv" }, + el("span", { class: "k" }, t("escalation")), + escalationViz(rule), + ), + ) : el("div", { class: "muted" }, p.rule_number ? (p.rule_number + " — " + t("rule_not_found")) : t("none")), + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: () => backdrop.remove() }, t("close")), + ), + )); + document.body.appendChild(backdrop); + } + + // Apply workflow: select task → step through each pilot's open penalties one + // at a time → review overview → save all reviewed as applied. + function openApplyByTaskModal() { + const wiz = { + task: null, // selected task string ("" for empty-task) + pilots: [], // [{ pilotNumber, pilotName, penalties: [...] }] + cursor: 0, // index into wiz.pilots + reviewedIds: [], // penalty IDs the user has stepped past + }; + + const backdrop = el("div", { class: "modal-backdrop", + onclick: (e) => { if (e.target === backdrop) attemptClose(); } }); + const modal = el("div", { class: "modal" }); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + function attemptClose() { + if (wiz.reviewedIds.length === 0) { + backdrop.remove(); + return; + } + const choice = confirm(t("confirm_save_partial", { n: wiz.reviewedIds.length })); + if (choice) { + savePartial(); + } else { + backdrop.remove(); + } + } + + async function savePartial() { + try { + await API.applyPenalties(competitionId, { ids: wiz.reviewedIds, applied: true }); + backdrop.remove(); + await loadPenalties(); + patchPenaltyTable(); + } catch (e) { alert(e.message); } + } + + function pilotLookup(num) { + return state.pilots.find((p) => p.number === num); + } + + function renderTaskSelect() { + const groups = {}; + for (const p of state.penalties) { + if (p.transferred) continue; + const task = p.task || ""; + if (!groups[task]) groups[task] = 0; + groups[task]++; + } + const tasks = Object.keys(groups).sort(naturalCompare); + + modal.innerHTML = ""; + modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", alignItems: "center" } }, + el("h3", { style: { margin: 0 } }, t("apply_by_task")), + el("button", { class: "ghost", onclick: attemptClose }, "✕"), + )); + modal.appendChild(el("p", { class: "muted" }, t("apply_select_task"))); + + if (tasks.length === 0) { + modal.appendChild(el("div", { class: "muted", style: { padding: "1rem 0" } }, t("no_open_penalties"))); + modal.appendChild(el("div", { class: "row", style: { justifyContent: "flex-end" } }, + el("button", { onclick: attemptClose }, t("close")), + )); + return; + } + + const list = el("div"); + for (const task of tasks) { + const label = task === "" ? t("no_task") : task; + list.appendChild(el("div", { class: "row apply-row", + style: { justifyContent: "space-between", padding: "0.4rem 0", borderBottom: "1px solid var(--border)" } }, + el("div", null, el("strong", null, label), " ", + el("span", { class: "muted" }, t("open") + ": " + groups[task])), + el("button", { class: "primary", + onclick: () => startWalk(task) }, t("start_apply")), + )); + } + modal.appendChild(list); + modal.appendChild(el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: attemptClose }, t("close")), + )); + } + + function startWalk(task) { + wiz.task = task; + // Group open penalties for this task by pilot, sorted by pilot number. + const byPilot = new Map(); + for (const p of state.penalties) { + if (p.transferred) continue; + if ((p.task || "") !== task) continue; + const key = p.pilot_number || ""; + if (!byPilot.has(key)) { + byPilot.set(key, { + pilotNumber: p.pilot_number || "", + pilotName: p.pilot_name || "", + penalties: [], + }); + } + byPilot.get(key).penalties.push(p); + } + wiz.pilots = Array.from(byPilot.values()) + .sort((a, b) => naturalCompare(a.pilotNumber, b.pilotNumber)); + wiz.cursor = 0; + wiz.reviewedIds = []; + if (wiz.pilots.length === 0) renderOverview(); + else renderPilotStep(); + } + + function renderPilotStep() { + const entry = wiz.pilots[wiz.cursor]; + const pilot = pilotLookup(entry.pilotNumber); + const stepNum = wiz.cursor + 1; + const stepTotal = wiz.pilots.length; + + modal.innerHTML = ""; + modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", alignItems: "center" } }, + el("h3", { style: { margin: 0 } }, + t("apply_by_task") + " — ", + wiz.task === "" ? t("no_task") : wiz.task, + ), + el("button", { class: "ghost", onclick: attemptClose }, "✕"), + )); + modal.appendChild(el("div", { class: "muted", style: { marginBottom: "0.5rem" } }, + t("step_x_of_y", { x: stepNum, y: stepTotal }))); + + modal.appendChild(el("h4", null, + t("pilot") + ": ", + entry.pilotNumber, + entry.pilotName ? " — " + entry.pilotName : "", + )); + if (pilot) { + modal.appendChild(el("div", { class: "kv" }, el("span", { class: "k" }, t("country")), + el("span", null, pilot.country || "—"))); + modal.appendChild(el("div", { class: "kv" }, el("span", { class: "k" }, t("balloon_id")), + el("span", null, pilot.balloon_id || "—"))); + } + + const tbl = el("table", { class: "mini-table", style: { marginTop: "0.5rem" } }); + tbl.appendChild(el("thead", null, el("tr", null, + el("th", null, t("flight")), + el("th", null, t("date")), + el("th", null, t("rule_number_short")), + el("th", null, t("penalty_values")), + el("th", null, t("description")), + ))); + const tb = el("tbody"); + for (const p of entry.penalties) { + tb.appendChild(el("tr", null, + el("td", null, p.flight || ""), + el("td", null, p.date || ""), + el("td", null, p.rule_number || ""), + el("td", null, el("span", { class: "badge accent" }, p.penalties_text || "")), + el("td", null, p.description || ""), + )); + } + tbl.appendChild(tb); + modal.appendChild(tbl); + + modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginTop: "1rem" } }, + el("button", { onclick: attemptClose }, t("cancel")), + el("div", { class: "row" }, + wiz.cursor > 0 && el("button", { onclick: () => { + // step back: don't remove already-reviewed pilots (they remain + // in reviewedIds — going back is just preview). + wiz.cursor--; + renderPilotStep(); + } }, "← " + t("back")), + el("button", { class: "primary", onclick: () => { + for (const p of entry.penalties) { + if (!wiz.reviewedIds.includes(p.id)) wiz.reviewedIds.push(p.id); + } + wiz.cursor++; + if (wiz.cursor >= wiz.pilots.length) renderOverview(); + else renderPilotStep(); + } }, wiz.cursor + 1 >= wiz.pilots.length ? t("to_overview") + " →" : t("next") + " →"), + ), + )); + } + + function renderOverview() { + modal.innerHTML = ""; + modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", alignItems: "center" } }, + el("h3", { style: { margin: 0 } }, + t("apply_overview") + " — ", + wiz.task === "" ? t("no_task") : wiz.task, + ), + el("button", { class: "ghost", onclick: attemptClose }, "✕"), + )); + + const reviewedSet = new Set(wiz.reviewedIds); + const items = []; + for (const entry of wiz.pilots) { + for (const p of entry.penalties) { + if (reviewedSet.has(p.id)) items.push(p); + } + } + + modal.appendChild(el("p", { class: "muted" }, + t("apply_overview_explain", { n: items.length }))); + + if (items.length === 0) { + modal.appendChild(el("div", { class: "muted", style: { padding: "1rem 0" } }, t("nothing_to_apply"))); + } else { + const tbl = el("table", { class: "mini-table" }); + tbl.appendChild(el("thead", null, el("tr", null, + el("th", null, t("pilot_number")), + el("th", null, t("pilot_name")), + el("th", null, t("flight")), + el("th", null, t("rule_number_short")), + el("th", null, t("penalty_values")), + el("th", null, t("description")), + ))); + const tb = el("tbody"); + items.sort((a, b) => + naturalCompare(a.pilot_number, b.pilot_number) || + naturalCompare(a.flight, b.flight) || + (a.id - b.id)); + for (const p of items) { + tb.appendChild(el("tr", null, + el("td", null, p.pilot_number || ""), + el("td", null, p.pilot_name || ""), + el("td", null, p.flight || ""), + el("td", null, p.rule_number || ""), + el("td", null, el("span", { class: "badge accent" }, p.penalties_text || "")), + el("td", null, p.description || ""), + )); + } + tbl.appendChild(tb); + modal.appendChild(el("div", { class: "table-wrap" }, tbl)); + } + + modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginTop: "1rem" } }, + el("button", { onclick: () => { + // go back to last pilot step (preview) + if (wiz.pilots.length > 0) { + wiz.cursor = wiz.pilots.length - 1; + renderPilotStep(); + } else { + renderTaskSelect(); + } + } }, "← " + t("back")), + el("div", { class: "row" }, + el("button", { onclick: attemptClose }, t("cancel")), + el("button", { class: "primary", disabled: items.length === 0, onclick: async (e) => { + e.target.disabled = true; + try { + await API.applyPenalties(competitionId, { ids: wiz.reviewedIds, applied: true }); + backdrop.remove(); + await loadPenalties(); + patchPenaltyTable(); + } catch (err) { alert(err.message); e.target.disabled = false; } + } }, t("save")), + ), + )); + } + + renderTaskSelect(); + } + + function openPenaltyModal(penalty) { + const p = penalty || {}; + const backdrop = el("div", { class: "modal-backdrop", + onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); + + const flight = el("input", { type: "text", value: p.flight || "" }); + const date = el("input", { type: "date", value: p.date || new Date().toISOString().slice(0, 10) }); + const pilotSelect = el("select", { onchange: () => refreshPrior() }, + el("option", { value: "" }, "—"), + ...sortPilots(state.pilots).map((pl) => el("option", { value: pl.number, selected: pl.number === p.pilot_number }, + `${pl.number} — ${pl.last_name}, ${pl.first_name}`)) + ); + const existingRule = p.rule_number ? state.rulesByNumber[p.rule_number] : null; + const ruleSearch = el("input", { type: "text", placeholder: t("search_rule"), + value: existingRule ? `${existingRule.number} — ${existingRule.text}` : (p.rule_number || "") }); + const ruleNumber = el("input", { type: "text", placeholder: t("rule_number_short"), value: p.rule_number || "" }); + const suggestionBox = el("div", { class: "rule-card", style: { display: "none" } }); + const searchResults = el("div", { class: "results", style: { display: "none" } }); + const priorBox = el("div", { class: "prior-box", style: { display: "none" } }); + + function escalationViz(r) { + if (r.escalation_mode === "same") return el("span", { class: "muted small" }, t("escalation_same")); + if (r.escalation_mode === "doubled") return el("span", { class: "muted small" }, t("escalation_doubled")); + if (r.escalation_mode === "escalate") { + const wrap = el("div", { class: "tier-row" }); + (r.escalation_tiers || []).forEach((tier, i, arr) => { + wrap.appendChild(el("span", { class: "tier-pill" }, tier)); + if (i < arr.length - 1) wrap.appendChild(el("span", { class: "tier-arrow" }, "→")); + }); + return wrap; + } + return el("span", null, ""); + } + + function refreshSuggestion() { + const rNum = ruleNumber.value.trim(); + const r = state.rulesByNumber[rNum]; + if (!r) { suggestionBox.style.display = "none"; return; } + suggestionBox.style.display = "block"; + suggestionBox.innerHTML = ""; + suggestionBox.appendChild(el("div", { class: "rule-head" }, + el("span", { class: "rule-num" }, r.number), + el("span", { class: "rule-text" }, r.text), + )); + suggestionBox.appendChild(el("div", { class: "kv" }, + el("span", { class: "k" }, t("suggested_penalty")), + el("span", { class: "badge accent" }, r.suggested_penalty), + )); + suggestionBox.appendChild(el("div", { class: "kv" }, + el("span", { class: "k" }, t("escalation")), + escalationViz(r), + )); + } + + function refreshPrior() { + const rNum = ruleNumber.value.trim(); + const pNum = pilotSelect.value; + if (!rNum || !pNum) { priorBox.style.display = "none"; return; } + const prior = state.penalties.filter((x) => + x.pilot_number === pNum && x.rule_number === rNum && x.id !== p.id + ); + priorBox.innerHTML = ""; + if (prior.length === 0) { priorBox.style.display = "none"; return; } + priorBox.style.display = "block"; + priorBox.appendChild(el("div", { class: "prior-title" }, + t("prior_penalties") + " (" + prior.length + ")" + )); + const tbl = el("table", { class: "mini-table" }); + tbl.appendChild(el("thead", null, el("tr", null, + el("th", null, t("flight")), + el("th", null, t("date")), + el("th", null, t("penalty_values")), + el("th", null, t("description")), + ))); + const tb = el("tbody"); + prior.sort((a, b) => naturalCompare(a.date, b.date) || (a.id - b.id)); + for (const pp of prior) { + tb.appendChild(el("tr", null, + el("td", null, pp.flight), + el("td", null, pp.date), + el("td", null, el("span", { class: "badge accent" }, pp.penalties_text)), + el("td", null, pp.description), + )); + } + tbl.appendChild(tb); + priorBox.appendChild(tbl); + } + + refreshSuggestion(); + refreshPrior(); + ruleNumber.addEventListener("input", () => { refreshSuggestion(); refreshPrior(); }); + + function searchRules(q) { + q = q.trim().toLowerCase(); + if (!q) return []; + return state.rules.filter((r) => + r.number.toLowerCase().includes(q) || r.text.toLowerCase().includes(q) + ).slice(0, 30); + } + ruleSearch.addEventListener("input", () => { + const items = searchRules(ruleSearch.value); + searchResults.innerHTML = ""; + if (items.length === 0) { searchResults.style.display = "none"; return; } + searchResults.style.display = "block"; + for (const r of items) { + const it = el("div", { class: "item", + onclick: () => { + ruleNumber.value = r.number; + ruleSearch.value = `${r.number} — ${r.text}`; + searchResults.style.display = "none"; + refreshSuggestion(); + refreshPrior(); + } + }, + el("span", { class: "num" }, r.number), + r.text + ); + searchResults.appendChild(it); + } + }); + ruleSearch.addEventListener("blur", () => setTimeout(() => searchResults.style.display = "none", 200)); + + const task = el("input", { type: "text", value: p.task || "" }); + const penaltiesText = el("input", { type: "text", value: p.penalties_text || "", + placeholder: "e.g. 50 CP, Warning, +50m, No Result" }); + const description = el("textarea", null, p.description || ""); + const transferred = el("input", { type: "checkbox", checked: !!p.transferred }); + + backdrop.appendChild(el("div", { class: "modal" }, + el("h3", null, p.id ? t("edit") : t("add_penalty")), + el("div", { class: "grid" }, + el("div", { class: "field" }, el("label", null, t("flight")), flight), + el("div", { class: "field" }, el("label", null, t("date")), date), + el("div", { class: "field" }, el("label", null, t("pilot_number")), pilotSelect), + ), + el("div", { class: "field", style: { marginTop: "0.5rem" } }, + el("label", null, t("search_rule")), + el("div", { class: "search-box" }, ruleSearch, searchResults), + ), + el("div", { class: "field" }, el("label", null, t("rule_number_short")), ruleNumber), + suggestionBox, + priorBox, + el("div", { class: "grid", style: { marginTop: "0.5rem" } }, + el("div", { class: "field" }, el("label", null, t("task")), task), + el("div", { class: "field" }, el("label", null, t("penalty_values")), penaltiesText), + ), + el("div", { class: "field" }, el("label", null, t("description")), description), + isChief() && el("label", { class: "row", style: { marginTop: "0.5rem" } }, transferred, " " + t("applied")), + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: () => backdrop.remove() }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + const body = { + flight: flight.value, + date: date.value, + pilot_number: pilotSelect.value, + rule_number: ruleNumber.value, + task: task.value, + penalties_text: penaltiesText.value, + description: description.value, + }; + if (isChief()) body.transferred = transferred.checked; + try { + if (p.id) await API.updatePenalty(competitionId, p.id, body); + else await API.createPenalty(competitionId, body); + backdrop.remove(); + } catch (e) { alert(e.message); } + } }, t("save")), + ), + )); + document.body.appendChild(backdrop); + } + + // ---- Pilots tab -------------------------------------------------------- + + const PILOT_COLS = [ + { key: "number", label: "number" }, + { key: "last_name", label: "last_name" }, + { key: "first_name", label: "first_name" }, + { key: "country", label: "country" }, + { key: "balloon_id", label: "balloon_id" }, + ]; + + function sortPilots(list) { + const { col, dir } = state.pilotSort; + const sign = dir === "asc" ? 1 : -1; + return [...list].sort((a, b) => naturalCompare(a[col], b[col]) * sign); + } + + function renderPilotsTab() { + const card = el("div"); + if (isChief()) { + card.appendChild(el("div", { class: "toolbar" }, + el("button", { class: "primary", onclick: openPilotModal }, t("add_pilot")), + el("button", { onclick: openImportModal }, t("import_csv")), + )); + } + if (state.pilots.length === 0) { + card.appendChild(el("div", { class: "muted" }, t("no_pilots"))); + return card; + } + const table = el("table"); + const headRow = el("tr"); + for (const c of PILOT_COLS) { + headRow.appendChild(el("th", { + onclick: () => { + if (state.pilotSort.col === c.key) state.pilotSort.dir = state.pilotSort.dir === "asc" ? "desc" : "asc"; + else { state.pilotSort.col = c.key; state.pilotSort.dir = "asc"; } + render(); + } + }, + t(c.label), + el("span", { class: "sort-ind" }, state.pilotSort.col === c.key ? (state.pilotSort.dir === "asc" ? "▲" : "▼") : ""), + )); + } + if (isChief()) headRow.appendChild(el("th", null, t("actions"))); + table.appendChild(el("thead", null, headRow)); + const tbody = el("tbody"); + for (const pl of sortPilots(state.pilots)) { + tbody.appendChild(el("tr", null, + el("td", null, pl.number), + el("td", null, pl.last_name), + el("td", null, pl.first_name), + el("td", null, pl.country), + el("td", null, pl.balloon_id), + isChief() && el("td", null, + el("button", { class: "action-btn", onclick: () => openPilotModal(pl) }, t("edit")), + el("button", { class: "action-btn danger", onclick: async () => { + if (!confirm(t("confirm_delete"))) return; + await API.deletePilot(competitionId, pl.id); + await loadPilots(); + render(); + } }, t("delete")), + ), + )); + } + table.appendChild(tbody); + card.appendChild(el("div", { class: "table-wrap" }, table)); + return card; + } + + function openPilotModal(pilot) { + const p = pilot || {}; + const backdrop = el("div", { class: "modal-backdrop", + onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); + const number = el("input", { type: "text", value: p.number || "" }); + const lastName = el("input", { type: "text", value: p.last_name || "" }); + const firstName = el("input", { type: "text", value: p.first_name || "" }); + const country = el("input", { type: "text", value: p.country || "" }); + const balloon = el("input", { type: "text", value: p.balloon_id || "" }); + backdrop.appendChild(el("div", { class: "modal" }, + el("h3", null, p.id ? t("edit") : t("add_pilot")), + el("div", { class: "grid" }, + el("div", { class: "field" }, el("label", null, t("number")), number), + el("div", { class: "field" }, el("label", null, t("last_name")), lastName), + el("div", { class: "field" }, el("label", null, t("first_name")), firstName), + el("div", { class: "field" }, el("label", null, t("country")), country), + el("div", { class: "field" }, el("label", null, t("balloon_id")), balloon), + ), + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: () => backdrop.remove() }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + const body = { + number: number.value.trim(), + last_name: lastName.value.trim(), + first_name: firstName.value.trim(), + country: country.value.trim(), + balloon_id: balloon.value.trim(), + }; + try { + if (p.id) await API.updatePilot(competitionId, p.id, body); + else await API.createPilot(competitionId, body); + await loadPilots(); + backdrop.remove(); + render(); + } catch (e) { alert(e.message); } + } }, t("save")), + ), + )); + document.body.appendChild(backdrop); + } + + function openImportModal() { + const backdrop = el("div", { class: "modal-backdrop", + onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); + const textarea = el("textarea", { placeholder: t("csv_paste"), style: { width: "100%", minHeight: "180px" } }); + backdrop.appendChild(el("div", { class: "modal" }, + el("h3", null, t("import_csv")), + textarea, + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: () => backdrop.remove() }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + try { + await API.importPilots(competitionId, textarea.value); + await loadPilots(); + backdrop.remove(); + render(); + } catch (e) { alert(e.message); } + } }, t("import_csv")), + ), + )); + document.body.appendChild(backdrop); + } + + // ---- Rules tab --------------------------------------------------------- + + function ruleEscalationViz(r) { + if (r.escalation_mode === "same") return el("span", { class: "muted small" }, t("escalation_same")); + if (r.escalation_mode === "doubled") return el("span", { class: "muted small" }, t("escalation_doubled")); + if (r.escalation_mode === "escalate") { + const wrap = el("div", { class: "tier-row" }); + (r.escalation_tiers || []).forEach((tier, i, arr) => { + wrap.appendChild(el("span", { class: "tier-pill" }, tier)); + if (i < arr.length - 1) wrap.appendChild(el("span", { class: "tier-arrow" }, "→")); + }); + return wrap; + } + return el("span", null, ""); + } + + function renderRulesTab() { + const card = el("div"); + const search = el("input", { type: "search", placeholder: t("search_rule"), style: { width: "100%" } }); + const count = el("div", { class: "muted", style: { margin: "0.5rem 0" } }); + const list = el("div", { class: "rules-list" }); + function refresh() { + list.innerHTML = ""; + const q = search.value.trim().toLowerCase(); + const filtered = q + ? state.rules.filter((r) => r.number.toLowerCase().includes(q) || r.text.toLowerCase().includes(q)) + : state.rules; + filtered.sort((a, b) => naturalCompare(a.number, b.number)); + count.textContent = t("showing_n_of_m", { n: filtered.length, m: state.rules.length }); + for (const r of filtered) { + list.appendChild(el("div", { class: "rule-card" }, + el("div", { class: "rule-head" }, + el("span", { class: "rule-num" }, r.number), + el("span", { class: "rule-text" }, r.text), + ), + el("div", { class: "kv" }, + el("span", { class: "k" }, t("suggested_penalty")), + el("span", { class: "badge accent" }, r.suggested_penalty), + ), + el("div", { class: "kv" }, + el("span", { class: "k" }, t("escalation")), + ruleEscalationViz(r), + ), + )); + } + if (filtered.length === 0) list.appendChild(el("div", { class: "muted" }, t("none"))); + } + search.addEventListener("input", refresh); + card.appendChild(search); + card.appendChild(count); + card.appendChild(list); + setTimeout(refresh, 0); + return card; + } + + // ---- Members tab ------------------------------------------------------- + + function renderMembersTab() { + const card = el("div"); + card.appendChild(el("div", { class: "toolbar" }, + el("button", { class: "primary", onclick: openMemberModal }, t("add_member")), + user.is_system_admin && el("button", { onclick: openUserModalForCompetition }, t("add_user")), + )); + if (state.members.length === 0) { + card.appendChild(el("div", { class: "muted" }, t("no_members"))); + return card; + } + const table = el("table"); + table.appendChild(el("thead", null, el("tr", null, + el("th", null, t("username")), + el("th", null, t("display_name")), + el("th", null, t("role")), + el("th", null, t("actions")), + ))); + const tbody = el("tbody"); + for (const m of state.members) { + tbody.appendChild(el("tr", null, + el("td", null, m.username), + el("td", null, m.display_name), + el("td", null, t(m.role)), + el("td", null, + el("button", { class: "action-btn danger", onclick: async () => { + if (!confirm(t("confirm_delete"))) return; + await API.removeMember(competitionId, m.user_id); + await loadMembers(); + render(); + } }, t("remove")), + ), + )); + } + table.appendChild(tbody); + card.appendChild(el("div", { class: "table-wrap" }, table)); + return card; + } + + function openMemberModal() { + const backdrop = el("div", { class: "modal-backdrop", + onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); + const username = el("input", { type: "text", placeholder: t("username"), + oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } }); + const roleSelect = el("select", null, + el("option", { value: "scorer" }, t("scorer")), + el("option", { value: "chief_scorer" }, t("chief_scorer")), + ); + backdrop.appendChild(el("div", { class: "modal" }, + el("h3", null, t("add_member")), + el("p", { class: "muted" }, t("username")), + username, + el("div", { class: "field", style: { marginTop: "0.5rem" } }, el("label", null, t("role")), roleSelect), + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: () => backdrop.remove() }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + let users = []; + try { users = await API.listUsers(); } catch (e) { alert(t("forbidden")); return; } + const wanted = username.value.trim().toLowerCase(); + const u = users.find((x) => x.username === wanted); + if (!u) { alert(t("user_not_found")); return; } + await API.addMember(competitionId, { user_id: u.id, role: roleSelect.value }); + await loadMembers(); + backdrop.remove(); + render(); + } }, t("save")), + ), + )); + document.body.appendChild(backdrop); + } + + function openUserModalForCompetition() { + const backdrop = el("div", { class: "modal-backdrop", + onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); + const username = el("input", { type: "text", + oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } }); + const password = el("input", { type: "password" }); + const displayName = el("input", { type: "text" }); + const langSelect = el("select", null, + ...I18N_AVAILABLE.map((l) => el("option", { value: l }, I18N_NAMES[l])) + ); + const roleSelect = el("select", null, + el("option", { value: "scorer" }, t("scorer")), + el("option", { value: "chief_scorer" }, t("chief_scorer")), + ); + backdrop.appendChild(el("div", { class: "modal" }, + el("h3", null, t("add_user")), + el("div", { class: "field" }, el("label", null, t("username")), username), + el("div", { class: "field" }, el("label", null, t("password")), password), + el("div", { class: "field" }, el("label", null, t("display_name")), displayName), + el("div", { class: "field" }, el("label", null, t("language")), langSelect), + el("div", { class: "field" }, el("label", null, t("role")), roleSelect), + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: () => backdrop.remove() }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + if (!username.value.trim() || !password.value) return; + try { + const created = await API.createUser({ + username: username.value.trim().toLowerCase(), + password: password.value, + display_name: displayName.value, + language: langSelect.value, + is_system_admin: false, + }); + await API.addMember(competitionId, { user_id: created.id, role: roleSelect.value }); + await loadMembers(); + backdrop.remove(); + render(); + } catch (err) { alert(err.message); } + } }, t("create")), + ), + )); + document.body.appendChild(backdrop); + } + + // ---- Settings tab ------------------------------------------------------ + + function renderSettingsTab() { + const name = el("input", { type: "text", value: state.competition.name }); + const allow = el("input", { type: "checkbox", checked: !!state.competition.allow_any_scorer_edit }); + const msg = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } }); + return el("div", { class: "card" }, + el("h2", null, t("settings_tab")), + el("div", { class: "field" }, el("label", null, t("competition_name")), name), + el("label", { class: "row", style: { marginTop: "0.5rem" } }, allow, " " + t("allow_any_scorer_edit")), + msg, + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { class: "primary", onclick: async () => { + try { + await API.updateCompetition(competitionId, { + name: name.value, allow_any_scorer_edit: allow.checked, + }); + const c = await API.getCompetition(competitionId); + state.competition = c; + msg.textContent = t("saved"); + msg.style.display = "block"; + } catch (e) { alert(e.message); } + } }, t("save_settings")), + ), + ); + } + + // ---- Main render ------------------------------------------------------- + + function render() { + clearNode(root); + const backBtn = el("button", { class: "ghost", + onclick: () => { if (state.ws) { state.ws.close(); state.ws = null; } navigate("competitions"); } }, + "← " + t("back")); + root.appendChild(renderTopbar(user, { extra: backBtn })); + + const container = el("div", { class: "container" }); + container.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.5rem" } }, + el("h2", { style: { margin: 0 } }, + state.competition.name, + " ", + el("span", { class: "badge accent" }, t(state.competition.role)), + ), + el("div", { class: "row" }, + el("span", { class: "connection-status " + (state.wsOnline ? "online" : "offline") }), + el("span", { class: "muted" }, state.wsOnline ? t("online") : t("offline")), + ), + )); + + const tabs = el("div", { class: "tabs" }); + const tabDefs = [ + ["penalties", t("penalties")], + ["pilots", t("pilots")], + ["rules", t("rules")], + ]; + if (isChief()) { + tabDefs.push(["members", t("members")]); + tabDefs.push(["settings", t("settings_tab")]); + } + for (const [id, label] of tabDefs) { + tabs.appendChild(el("button", { + class: state.tab === id ? "active" : "", + onclick: () => { state.tab = id; render(); } + }, label)); + } + container.appendChild(tabs); + + if (state.tab === "penalties") container.appendChild(renderPenaltiesTab()); + else if (state.tab === "pilots") container.appendChild(renderPilotsTab()); + else if (state.tab === "rules") container.appendChild(renderRulesTab()); + else if (state.tab === "members") container.appendChild(renderMembersTab()); + else if (state.tab === "settings") container.appendChild(renderSettingsTab()); + + root.appendChild(container); + } + + function handleWSMessage(msg) { + if (msg.type === "penalty_created") { + if (!state.penalties.some((p) => p.id === msg.payload.id)) { + state.penalties.unshift(msg.payload); + patchPenaltyTable(); + } + } else if (msg.type === "penalty_updated") { + const idx = state.penalties.findIndex((p) => p.id === msg.payload.id); + if (idx >= 0) { state.penalties[idx] = msg.payload; patchPenaltyTable(); } + } else if (msg.type === "penalty_deleted") { + state.penalties = state.penalties.filter((p) => p.id !== msg.payload.id); + patchPenaltyTable(); + } else if (msg.type === "penalties_applied") { + loadPenalties().then(() => patchPenaltyTable()); + } else if (msg.type === "pilot_changed") { + Promise.all([loadPilots(), loadPenalties()]).then(() => { + if (state.tab === "penalties") patchPenaltyTable(); + else render(); + }); + } else if (msg.type === "competition_updated") { + API.getCompetition(competitionId).then((c) => { state.competition = c; render(); }); + } + } + + await loadAll(); + state.ws = openCompetitionWS(competitionId, { + onopen: () => { state.wsOnline = true; const e = document.querySelector(".connection-status"); if (e) { e.classList.add("online"); e.classList.remove("offline"); } }, + onclose: () => { state.wsOnline = false; const e = document.querySelector(".connection-status"); if (e) { e.classList.remove("online"); e.classList.add("offline"); } }, + onmessage: handleWSMessage, + }); + render(); +})(); diff --git a/web/competitions.html b/web/competitions.html new file mode 100644 index 0000000..75cb546 --- /dev/null +++ b/web/competitions.html @@ -0,0 +1,17 @@ + + + + + +Penalty Tracker — Competitions + + + +
+ + + + + + + diff --git a/web/competitions.js b/web/competitions.js new file mode 100644 index 0000000..ab068c3 --- /dev/null +++ b/web/competitions.js @@ -0,0 +1,202 @@ +(async function () { + const root = document.getElementById("app"); + const user = await bootstrapAuth({ requireAuth: true, forbidIfMustChange: true }); + if (!user) return; + + const state = { competitions: [], users: [] }; + + async function loadCompetitions() { + state.competitions = await API.listCompetitions(); + } + async function loadUsers() { + if (user.is_system_admin) state.users = await API.listUsers(); + } + + function openCompetitionModal() { + const backdrop = el("div", { class: "modal-backdrop", + onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); + const nameInput = el("input", { type: "text" }); + const allowInput = el("input", { type: "checkbox" }); + backdrop.appendChild(el("div", { class: "modal" }, + el("h3", null, t("new_competition")), + el("div", { class: "field" }, el("label", null, t("competition_name")), nameInput), + el("label", { class: "row" }, allowInput, " " + t("allow_any_scorer_edit")), + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: () => backdrop.remove() }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + if (!nameInput.value.trim()) return; + try { + await API.createCompetition({ name: nameInput.value.trim(), allow_any_scorer_edit: allowInput.checked }); + backdrop.remove(); + await loadCompetitions(); + render(); + } catch (e) { alert(e.message); } + } }, t("create")), + ), + )); + document.body.appendChild(backdrop); + } + + function openUserModal() { + const backdrop = el("div", { class: "modal-backdrop", + onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); + const username = el("input", { type: "text", + oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } }); + const password = el("input", { type: "password" }); + const displayName = el("input", { type: "text" }); + const langSelect = el("select", null, + ...I18N_AVAILABLE.map((l) => el("option", { value: l }, I18N_NAMES[l])) + ); + const isAdmin = el("input", { type: "checkbox" }); + backdrop.appendChild(el("div", { class: "modal" }, + el("h3", null, t("add_user")), + el("div", { class: "field" }, el("label", null, t("username")), username), + el("div", { class: "field" }, el("label", null, t("password")), password), + el("div", { class: "field" }, el("label", null, t("display_name")), displayName), + el("div", { class: "field" }, el("label", null, t("language")), langSelect), + el("label", { class: "row" }, isAdmin, " " + t("is_admin")), + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: () => backdrop.remove() }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + if (!username.value.trim() || !password.value) return; + try { + await API.createUser({ + username: username.value.trim().toLowerCase(), + password: password.value, + display_name: displayName.value, + language: langSelect.value, + is_system_admin: isAdmin.checked, + }); + backdrop.remove(); + await loadUsers(); + render(); + } catch (err) { alert(err.message); } + } }, t("create")), + ), + )); + document.body.appendChild(backdrop); + } + + function renderUsersAdmin() { + const card = el("div", { class: "card" }); + card.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.5rem" } }, + el("h2", { style: { margin: 0 } }, t("users")), + el("button", { onclick: openUserModal }, t("add_user")), + )); + if (state.users.length === 0) { + card.appendChild(el("div", { class: "muted" }, "—")); + return card; + } + const table = el("table"); + table.appendChild(el("thead", null, + el("tr", null, + el("th", null, t("username")), + el("th", null, t("display_name")), + el("th", null, t("language")), + el("th", null, t("is_admin")), + el("th", null, t("must_change_password")), + el("th", null, t("actions")), + ) + )); + const tbody = el("tbody"); + for (const u of state.users) { + const row = el("tr", null, + el("td", null, u.username), + el("td", null, u.display_name), + el("td", null, I18N_NAMES[u.language] || u.language), + el("td", null, u.is_system_admin ? t("yes") : t("no")), + el("td", null, u.must_change_password ? el("span", { class: "badge warn" }, t("yes")) : t("no")), + el("td", null, + el("button", { class: "action-btn", onclick: () => openEditUserModal(u) }, t("edit")), + !u.must_change_password && el("button", { class: "action-btn", onclick: async () => { + if (!confirm(t("confirm_force_password"))) return; + await API.updateUser(u.id, { must_change_password: true }); + await loadUsers(); + render(); + } }, t("force_password_change")), + u.id !== user.id && el("button", { class: "action-btn danger", onclick: async () => { + if (!confirm(t("confirm_delete"))) return; + await API.deleteUser(u.id); + await loadUsers(); + render(); + } }, t("delete")), + ), + ); + tbody.appendChild(row); + } + table.appendChild(tbody); + card.appendChild(el("div", { class: "table-wrap" }, table)); + return card; + } + + function openEditUserModal(u) { + const backdrop = el("div", { class: "modal-backdrop", + onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); + const username = el("input", { type: "text", value: u.username, + oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } }); + const displayName = el("input", { type: "text", value: u.display_name || "" }); + const password = el("input", { type: "password", placeholder: t("leave_blank_keep") }); + const isAdmin = el("input", { type: "checkbox", checked: !!u.is_system_admin }); + backdrop.appendChild(el("div", { class: "modal" }, + el("h3", null, t("edit") + ": " + u.username), + el("div", { class: "field" }, el("label", null, t("username")), username), + el("div", { class: "field" }, el("label", null, t("display_name")), displayName), + el("div", { class: "field" }, el("label", null, t("new_password")), password), + u.id !== user.id && el("label", { class: "row" }, isAdmin, " " + t("is_admin")), + el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, + el("button", { onclick: () => backdrop.remove() }, t("cancel")), + el("button", { class: "primary", onclick: async () => { + const body = {}; + const newUsername = username.value.trim().toLowerCase(); + if (newUsername && newUsername !== u.username) body.username = newUsername; + if (displayName.value !== (u.display_name || "")) body.display_name = displayName.value; + if (password.value) body.password = password.value; + if (u.id !== user.id && isAdmin.checked !== !!u.is_system_admin) body.is_system_admin = isAdmin.checked; + if (Object.keys(body).length === 0) { backdrop.remove(); return; } + try { + await API.updateUser(u.id, body); + backdrop.remove(); + await loadUsers(); + render(); + } catch (e) { alert((e.data && e.data.error) || e.message); } + } }, t("save")), + ), + )); + document.body.appendChild(backdrop); + } + + function render() { + clearNode(root); + root.appendChild(renderTopbar(user)); + const container = el("div", { class: "container" }); + container.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.75rem" } }, + el("h2", { style: { margin: 0 } }, t("competitions")), + user.is_system_admin && el("button", { class: "primary", onclick: openCompetitionModal }, t("new_competition")), + )); + + if (state.competitions.length === 0) { + container.appendChild(el("div", { class: "muted" }, t("no_competitions"))); + } else { + const grid = el("div", { class: "grid" }); + for (const c of state.competitions) { + grid.appendChild(el("div", { class: "card" }, + el("h2", null, c.name), + el("div", { class: "muted", style: { marginBottom: "0.5rem" } }, + el("span", { class: "badge accent" }, t(c.role)), + ), + el("button", { class: "primary", onclick: () => navigate("competition", { id: c.id }) }, t("open")), + )); + } + container.appendChild(grid); + } + + if (user.is_system_admin) { + container.appendChild(renderUsersAdmin()); + } + root.appendChild(container); + } + + await loadCompetitions(); + await loadUsers(); + render(); +})(); diff --git a/web/force-password.html b/web/force-password.html new file mode 100644 index 0000000..09d56e5 --- /dev/null +++ b/web/force-password.html @@ -0,0 +1,17 @@ + + + + + +Penalty Tracker — Change password + + + +
+ + + + + + + diff --git a/web/force-password.js b/web/force-password.js new file mode 100644 index 0000000..1691da0 --- /dev/null +++ b/web/force-password.js @@ -0,0 +1,51 @@ +(async function () { + const root = document.getElementById("app"); + const user = await bootstrapAuth({ requireAuth: true, onlyIfMustChange: true }); + if (!user) return; + + const pw1 = el("input", { type: "password", autocomplete: "new-password" }); + const pw2 = el("input", { type: "password", autocomplete: "new-password" }); + const err = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } }); + + const logoutBtn = el("button", { class: "ghost", onclick: async () => { + try { await API.logout(); } catch (_) {} + navigate("login"); + } }, t("logout")); + + async function submit(e) { + e.preventDefault(); + err.style.display = "none"; + if (pw1.value.length < 6) { + err.textContent = t("password_too_short"); + err.style.display = "block"; + return; + } + if (pw1.value !== pw2.value) { + err.textContent = t("passwords_dont_match"); + err.style.display = "block"; + return; + } + try { + const u = await API.updateMe({ password: pw1.value }); + if (u && !u.must_change_password) navigate("competitions"); + } catch (e) { + err.textContent = (e.data && e.data.error) || e.message || "error"; + err.style.display = "block"; + } + } + + const form = el("form", { onsubmit: submit, class: "col" }, + el("h1", null, t("change_password")), + el("p", { class: "muted" }, t("force_password_explain")), + el("div", { class: "field" }, el("label", null, t("new_password")), pw1), + el("div", { class: "field" }, el("label", null, t("repeat_password")), pw2), + err, + el("div", { class: "row", style: { justifyContent: "space-between", marginTop: "0.5rem" } }, + logoutBtn, + el("button", { type: "submit", class: "primary" }, t("save")), + ), + ); + + root.appendChild(el("div", { class: "login-wrap" }, el("div", { class: "login-box" }, form))); + pw1.focus(); +})(); diff --git a/web/i18n.js b/web/i18n.js index e0a6a6c..38291b5 100644 --- a/web/i18n.js +++ b/web/i18n.js @@ -52,6 +52,49 @@ const I18N_DATA = { username_taken: "Username already taken", prior_penalties: "Prior penalties for this pilot and rule", none: "None", + applied: "Applied", + apply_by_task: "Apply by task", + apply_by_task_explain: "Confirm all open penalties for a task at once. Penalties are only marked applied after confirmation.", + apply_n_open: "Apply {n} open", + confirm_apply_task: "Mark all {n} open penalties for task '{task}' as applied?", + summary: "Summary", + penalty_summary: "Penalty summary", + rule: "Rule", + rule_not_found: "rule not found", + close: "Close", + count: "#", + count_hint: "Number of prior penalties for this pilot and rule", + prior_count: "Prior count (this pilot & rule)", + search_penalties: "Search penalties…", + filter_all: "All", + filter_open: "Open only", + filter_applied: "Applied only", + total: "Total", open: "Open", + repeat_password: "Repeat password", + password_too_short: "Password must be at least 6 characters", + passwords_dont_match: "Passwords do not match", + too_many_attempts: "Too many login attempts — please wait a few minutes", + profile_username_readonly: "Username can only be changed by a system administrator", + profile_displayname_readonly: "Display name can only be changed by a system administrator", + must_change_password: "Must change password", + confirm_force_password: "Force this user to change their password on next request?", + force_password_change: "Force password change", + force_password_explain: "An administrator has required you to set a new password before you can continue.", + user_not_found: "User not found", + show_incidents: "Show incidents", + hide_incidents: "Hide incidents", + apply_select_task: "Pick a task to start applying its open penalties.", + no_open_penalties: "No open penalties to apply.", + no_task: "(no task)", + start_apply: "Start", + step_x_of_y: "Pilot {x} of {y}", + pilot: "Pilot", + next: "Next", + to_overview: "To overview", + apply_overview: "Overview", + apply_overview_explain: "{n} penalty/penalties will be marked applied on save.", + nothing_to_apply: "Nothing to apply.", + confirm_save_partial: "Save and apply {n} penalty/penalties reviewed so far?", }, de: { login_title: "Anmelden", @@ -100,6 +143,49 @@ const I18N_DATA = { username_taken: "Benutzername bereits vergeben", prior_penalties: "Frühere Strafen für diesen Piloten und diese Regel", none: "Keine", + applied: "Angewendet", + apply_by_task: "Pro Task anwenden", + apply_by_task_explain: "Bestätige alle offenen Strafen einer Aufgabe gemeinsam. Erst nach Bestätigung gelten die Strafen als angewendet.", + apply_n_open: "{n} offene anwenden", + confirm_apply_task: "Alle {n} offenen Strafen der Aufgabe '{task}' als angewendet markieren?", + summary: "Übersicht", + penalty_summary: "Strafen-Übersicht", + rule: "Regel", + rule_not_found: "Regel nicht gefunden", + close: "Schließen", + count: "#", + count_hint: "Anzahl früherer Strafen für diesen Piloten und diese Regel", + prior_count: "Frühere Anzahl (dieser Pilot & Regel)", + search_penalties: "Strafen durchsuchen…", + filter_all: "Alle", + filter_open: "Nur offene", + filter_applied: "Nur angewendete", + total: "Gesamt", open: "Offen", + repeat_password: "Passwort wiederholen", + password_too_short: "Passwort muss mindestens 6 Zeichen lang sein", + passwords_dont_match: "Passwörter stimmen nicht überein", + too_many_attempts: "Zu viele Anmeldeversuche — bitte ein paar Minuten warten", + profile_username_readonly: "Der Benutzername kann nur vom Systemadministrator geändert werden", + profile_displayname_readonly: "Der Anzeigename kann nur vom Systemadministrator geändert werden", + must_change_password: "Passwortwechsel erforderlich", + confirm_force_password: "Diesen Benutzer beim nächsten Zugriff zum Passwortwechsel zwingen?", + force_password_change: "Passwortwechsel erzwingen", + force_password_explain: "Ein Administrator hat festgelegt, dass du ein neues Passwort vergeben musst, bevor du fortfahren kannst.", + user_not_found: "Benutzer nicht gefunden", + show_incidents: "Vorfälle anzeigen", + hide_incidents: "Vorfälle ausblenden", + apply_select_task: "Aufgabe wählen, deren offene Strafen angewendet werden sollen.", + no_open_penalties: "Keine offenen Strafen zum Anwenden.", + no_task: "(ohne Aufgabe)", + start_apply: "Start", + step_x_of_y: "Pilot {x} von {y}", + pilot: "Pilot", + next: "Weiter", + to_overview: "Zur Übersicht", + apply_overview: "Übersicht", + apply_overview_explain: "Beim Speichern werden {n} Strafe(n) als angewendet markiert.", + nothing_to_apply: "Nichts anzuwenden.", + confirm_save_partial: "{n} bisher überprüfte Strafe(n) jetzt speichern und anwenden?", }, pl: { login_title: "Zaloguj się", diff --git a/web/index.html b/web/index.html index 870049e..827a822 100644 --- a/web/index.html +++ b/web/index.html @@ -7,10 +7,19 @@ -
+
- + + diff --git a/web/login.html b/web/login.html new file mode 100644 index 0000000..c71c534 --- /dev/null +++ b/web/login.html @@ -0,0 +1,17 @@ + + + + + +Penalty Tracker — Sign in + + + +
+ + + + + + + diff --git a/web/login.js b/web/login.js new file mode 100644 index 0000000..ae56b4a --- /dev/null +++ b/web/login.js @@ -0,0 +1,55 @@ +(async function () { + const root = document.getElementById("app"); + + // If already signed in, skip the login form. + try { + const u = await API.me(); + if (u) { + navigate(u.must_change_password ? "forcePassword" : "competitions"); + return; + } + } catch (e) {} + + function render() { + clearNode(root); + const usernameInput = el("input", { type: "text", autocomplete: "username", placeholder: t("username"), + oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } }); + const passwordInput = el("input", { type: "password", autocomplete: "current-password", placeholder: t("password") }); + const errorBox = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } }); + const langSelect = el("select", + { onchange: (e) => { setLang(e.target.value); render(); } }, + ...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === CURRENT_LANG }, I18N_NAMES[l])) + ); + + async function doLogin(e) { + e.preventDefault(); + errorBox.style.display = "none"; + try { + const u = await API.login(usernameInput.value, passwordInput.value); + if (u.must_change_password) navigate("forcePassword"); + else navigate("competitions"); + } catch (err) { + if (err.status === 429) errorBox.textContent = t("too_many_attempts"); + else if (err.status === 401) errorBox.textContent = t("invalid_credentials"); + else errorBox.textContent = err.message || t("invalid_credentials"); + errorBox.style.display = "block"; + } + } + + const form = el("form", { onsubmit: doLogin, class: "col" }, + el("h1", null, t("login_title")), + el("div", { class: "field" }, el("label", null, t("username")), usernameInput), + el("div", { class: "field" }, el("label", null, t("password")), passwordInput), + errorBox, + el("button", { type: "submit", class: "primary" }, t("sign_in")), + el("div", { class: "field", style: { marginTop: "0.5rem" } }, + el("label", null, t("language")), langSelect + ), + ); + + root.appendChild(el("div", { class: "login-wrap" }, el("div", { class: "login-box" }, form))); + usernameInput.focus(); + } + + render(); +})(); diff --git a/web/style.css b/web/style.css index 3bc5fa4..45a80e9 100644 --- a/web/style.css +++ b/web/style.css @@ -397,3 +397,20 @@ textarea { resize: vertical; min-height: 60px; } .topbar { padding: 0.5rem 0.75rem; } .container { padding: 0.75rem; } } + +.loading-wrap { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; +} + +.muted.small, +.small { font-size: 0.75rem; } + +.apply-row { gap: 1rem; } + +h4 { margin: 0.75rem 0 0.25rem 0; font-size: 0.95rem; } + +input[disabled], +button[disabled] { opacity: 0.55; cursor: not-allowed; } diff --git a/ws.go b/ws.go index 1d8abf2..280d94b 100644 --- a/ws.go +++ b/ws.go @@ -10,7 +10,17 @@ import ( ) var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + // Non-browser client without Origin header — allow. + return true + } + if originAllowed(origin) { + return true + } + return sameOriginRequest(r) + }, } type wsMessage struct {