Replaced one-pager with multiple pages and fixed security bugs
This commit is contained in:
@@ -2,3 +2,4 @@
|
|||||||
/penaltytracker.db
|
/penaltytracker.db
|
||||||
/penaltytracker.db-shm
|
/penaltytracker.db-shm
|
||||||
/penaltytracker.db-wal
|
/penaltytracker.db-wal
|
||||||
|
/config.json
|
||||||
|
|||||||
Generated
+15
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GitToolBoxProjectSettings">
|
||||||
|
<option name="commitMessageIssueKeyValidationOverride">
|
||||||
|
<BoolValueOverride>
|
||||||
|
<option name="enabled" value="true" />
|
||||||
|
</BoolValueOverride>
|
||||||
|
</option>
|
||||||
|
<option name="commitMessageValidationEnabledOverride">
|
||||||
|
<BoolValueOverride>
|
||||||
|
<option name="enabled" value="true" />
|
||||||
|
</BoolValueOverride>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -6,8 +6,10 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -20,8 +22,13 @@ const userCtxKey ctxKey = "user"
|
|||||||
const sessionCookie = "pt_session"
|
const sessionCookie = "pt_session"
|
||||||
const sessionDuration = 24 * time.Hour * 14
|
const sessionDuration = 24 * time.Hour * 14
|
||||||
|
|
||||||
|
const maxUsernameLen = 64
|
||||||
|
const maxDisplayNameLen = 128
|
||||||
|
const maxPasswordLen = 256
|
||||||
|
const maxFieldLen = 2000
|
||||||
|
|
||||||
func registerAuthRoutes(mux *http.ServeMux) {
|
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("POST /api/logout", handleLogout)
|
||||||
mux.HandleFunc("GET /api/me", requireAuth(handleMe))
|
mux.HandleFunc("GET /api/me", requireAuth(handleMe))
|
||||||
mux.HandleFunc("PATCH /api/me", requireAuth(handleUpdateMe))
|
mux.HandleFunc("PATCH /api/me", requireAuth(handleUpdateMe))
|
||||||
@@ -40,7 +47,7 @@ func ensureDefaultAdmin() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = db.Exec(
|
_, 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",
|
"admin", string(hash), "System Admin", "en",
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
@@ -54,6 +61,79 @@ func newToken() (string, error) {
|
|||||||
return hex.EncodeToString(b), nil
|
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) {
|
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -63,19 +143,25 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid_body")
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Username = strings.TrimSpace(req.Username)
|
req.Username = normalizeUsername(req.Username)
|
||||||
if req.Username == "" || req.Password == "" {
|
if req.Username == "" || req.Password == "" {
|
||||||
writeError(w, http.StatusBadRequest, "missing_credentials")
|
writeError(w, http.StatusBadRequest, "missing_credentials")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(req.Username) > maxUsernameLen || len(req.Password) > maxPasswordLen {
|
||||||
|
writeError(w, http.StatusBadRequest, "too_long")
|
||||||
|
return
|
||||||
|
}
|
||||||
var id int64
|
var id int64
|
||||||
var hash string
|
var hash string
|
||||||
err := db.QueryRow("SELECT id,password_hash FROM users WHERE username=?", req.Username).Scan(&id, &hash)
|
err := db.QueryRow("SELECT id,password_hash FROM users WHERE username=?", req.Username).Scan(&id, &hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
loginRecord(clientIP(r))
|
||||||
writeError(w, http.StatusUnauthorized, "invalid_credentials")
|
writeError(w, http.StatusUnauthorized, "invalid_credentials")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil {
|
||||||
|
loginRecord(clientIP(r))
|
||||||
writeError(w, http.StatusUnauthorized, "invalid_credentials")
|
writeError(w, http.StatusUnauthorized, "invalid_credentials")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -96,6 +182,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
Expires: expires,
|
Expires: expires,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Secure: r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https"),
|
||||||
}
|
}
|
||||||
if crossSiteCookies {
|
if crossSiteCookies {
|
||||||
cookie.SameSite = http.SameSiteNoneMode
|
cookie.SameSite = http.SameSiteNoneMode
|
||||||
@@ -118,6 +205,7 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Secure: r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https"),
|
||||||
}
|
}
|
||||||
if crossSiteCookies {
|
if crossSiteCookies {
|
||||||
clear.SameSite = http.SameSiteNoneMode
|
clear.SameSite = http.SameSiteNoneMode
|
||||||
@@ -132,48 +220,41 @@ func handleMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, u)
|
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) {
|
func handleUpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||||
u := userFromCtx(r)
|
u := userFromCtx(r)
|
||||||
var req struct {
|
var req struct {
|
||||||
Username *string `json:"username"`
|
Language *string `json:"language"`
|
||||||
Language *string `json:"language"`
|
Password *string `json:"password"`
|
||||||
DisplayName *string `json:"display_name"`
|
|
||||||
Password *string `json:"password"`
|
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid_body")
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||||
return
|
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 req.Language != nil {
|
||||||
if _, err := db.Exec("UPDATE users SET language=? WHERE id=?", *req.Language, u.ID); err != nil {
|
lang := strings.TrimSpace(*req.Language)
|
||||||
writeError(w, http.StatusInternalServerError, "db_error")
|
if len(lang) > 8 {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_language")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
if _, err := db.Exec("UPDATE users SET language=? WHERE id=?", lang, u.ID); err != nil {
|
||||||
if req.DisplayName != nil {
|
|
||||||
if _, err := db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, u.ID); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "db_error")
|
writeError(w, http.StatusInternalServerError, "db_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.Password != nil && *req.Password != "" {
|
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)
|
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "hash_error")
|
writeError(w, http.StatusInternalServerError, "hash_error")
|
||||||
return
|
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")
|
writeError(w, http.StatusInternalServerError, "db_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -184,13 +265,14 @@ func handleUpdateMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func loadUser(id int64) (*User, error) {
|
func loadUser(id int64) (*User, error) {
|
||||||
u := &User{}
|
u := &User{}
|
||||||
var admin int
|
var admin, mustChange int
|
||||||
err := db.QueryRow("SELECT id,username,display_name,language,is_system_admin FROM users WHERE id=?", id).
|
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)
|
Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin, &mustChange)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
u.IsSystemAdmin = admin == 1
|
u.IsSystemAdmin = admin == 1
|
||||||
|
u.MustChangePassword = mustChange == 1
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +294,18 @@ func authUser(r *http.Request) (*User, error) {
|
|||||||
return loadUser(userID)
|
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 {
|
func requireAuth(h http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
u, err := authUser(r)
|
u, err := authUser(r)
|
||||||
@@ -219,6 +313,10 @@ func requireAuth(h http.HandlerFunc) http.HandlerFunc {
|
|||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if u.MustChangePassword && !passwordChangeExempt(r) {
|
||||||
|
writeError(w, http.StatusForbidden, "password_change_required")
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx := context.WithValue(r.Context(), userCtxKey, u)
|
ctx := context.WithValue(r.Context(), userCtxKey, u)
|
||||||
h.ServeHTTP(w, r.WithContext(ctx))
|
h.ServeHTTP(w, r.WithContext(ctx))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ func handleCreateCompetition(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "missing_name")
|
writeError(w, http.StatusBadRequest, "missing_name")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(req.Name) > 200 {
|
||||||
|
writeError(w, http.StatusBadRequest, "too_long")
|
||||||
|
return
|
||||||
|
}
|
||||||
allow := 0
|
allow := 0
|
||||||
if req.AllowAnyScorerEdit {
|
if req.AllowAnyScorerEdit {
|
||||||
allow = 1
|
allow = 1
|
||||||
|
|||||||
@@ -88,5 +88,13 @@ func migrate() error {
|
|||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -142,9 +144,11 @@ func main() {
|
|||||||
registerRuleRoutes(mux)
|
registerRuleRoutes(mux)
|
||||||
registerWSRoutes(mux)
|
registerWSRoutes(mux)
|
||||||
|
|
||||||
|
handler := withSecurityHeaders(withLog(withCSRF(withCORS(mux))))
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: cfg.Addr,
|
Addr: cfg.Addr,
|
||||||
Handler: withLog(withCORS(mux)),
|
Handler: handler,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
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 {
|
func originAllowed(origin string) bool {
|
||||||
if origin == "" {
|
if origin == "" {
|
||||||
return false
|
return false
|
||||||
@@ -186,6 +206,65 @@ func originAllowed(origin string) bool {
|
|||||||
return false
|
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 {
|
func withCORS(next http.Handler) http.Handler {
|
||||||
if len(corsOrigins) == 0 {
|
if len(corsOrigins) == 0 {
|
||||||
return next
|
return next
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
IsSystemAdmin bool `json:"is_system_admin"`
|
IsSystemAdmin bool `json:"is_system_admin"`
|
||||||
|
MustChangePassword bool `json:"must_change_password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Competition struct {
|
type Competition struct {
|
||||||
|
|||||||
+99
-8
@@ -9,12 +9,91 @@ import (
|
|||||||
"strings"
|
"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) {
|
func registerPenaltyRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/competitions/{id}/penalties", requireAuth(handleListPenalties))
|
mux.HandleFunc("GET /api/competitions/{id}/penalties", requireAuth(handleListPenalties))
|
||||||
mux.HandleFunc("POST /api/competitions/{id}/penalties", requireAuth(handleCreatePenalty))
|
mux.HandleFunc("POST /api/competitions/{id}/penalties", requireAuth(handleCreatePenalty))
|
||||||
mux.HandleFunc("PATCH /api/competitions/{id}/penalties/{pid}", requireAuth(handleUpdatePenalty))
|
mux.HandleFunc("PATCH /api/competitions/{id}/penalties/{pid}", requireAuth(handleUpdatePenalty))
|
||||||
mux.HandleFunc("DELETE /api/competitions/{id}/penalties/{pid}", requireAuth(handleDeletePenalty))
|
mux.HandleFunc("DELETE /api/competitions/{id}/penalties/{pid}", requireAuth(handleDeletePenalty))
|
||||||
mux.HandleFunc("GET /api/competitions/{id}/penalties.csv", requireAuth(handleExportPenalties))
|
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) {
|
func loadPenalty(id int64) (*Penalty, error) {
|
||||||
@@ -92,6 +171,13 @@ func handleCreatePenalty(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid_body")
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||||
return
|
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
|
transferred := 0
|
||||||
if pen.Transferred {
|
if pen.Transferred {
|
||||||
transferred = 1
|
transferred = 1
|
||||||
@@ -171,31 +257,31 @@ func handleUpdatePenalty(w http.ResponseWriter, r *http.Request) {
|
|||||||
args := []any{}
|
args := []any{}
|
||||||
if req.Flight != nil {
|
if req.Flight != nil {
|
||||||
sets = append(sets, "flight=?")
|
sets = append(sets, "flight=?")
|
||||||
args = append(args, *req.Flight)
|
args = append(args, clip(*req.Flight, 64))
|
||||||
}
|
}
|
||||||
if req.Date != nil {
|
if req.Date != nil {
|
||||||
sets = append(sets, "date=?")
|
sets = append(sets, "date=?")
|
||||||
args = append(args, *req.Date)
|
args = append(args, clip(*req.Date, 32))
|
||||||
}
|
}
|
||||||
if req.PilotNumber != nil {
|
if req.PilotNumber != nil {
|
||||||
sets = append(sets, "pilot_number=?")
|
sets = append(sets, "pilot_number=?")
|
||||||
args = append(args, *req.PilotNumber)
|
args = append(args, clip(*req.PilotNumber, 32))
|
||||||
}
|
}
|
||||||
if req.RuleNumber != nil {
|
if req.RuleNumber != nil {
|
||||||
sets = append(sets, "rule_number=?")
|
sets = append(sets, "rule_number=?")
|
||||||
args = append(args, *req.RuleNumber)
|
args = append(args, clip(*req.RuleNumber, 64))
|
||||||
}
|
}
|
||||||
if req.Task != nil {
|
if req.Task != nil {
|
||||||
sets = append(sets, "task=?")
|
sets = append(sets, "task=?")
|
||||||
args = append(args, *req.Task)
|
args = append(args, clip(*req.Task, 64))
|
||||||
}
|
}
|
||||||
if req.PenaltiesText != nil {
|
if req.PenaltiesText != nil {
|
||||||
sets = append(sets, "penalties_text=?")
|
sets = append(sets, "penalties_text=?")
|
||||||
args = append(args, *req.PenaltiesText)
|
args = append(args, clip(*req.PenaltiesText, 256))
|
||||||
}
|
}
|
||||||
if req.Description != nil {
|
if req.Description != nil {
|
||||||
sets = append(sets, "description=?")
|
sets = append(sets, "description=?")
|
||||||
args = append(args, *req.Description)
|
args = append(args, clip(*req.Description, maxFieldLen))
|
||||||
}
|
}
|
||||||
if req.Transferred != nil {
|
if req.Transferred != nil {
|
||||||
v := 0
|
v := 0
|
||||||
@@ -287,7 +373,12 @@ func handleExportPenalties(w http.ResponseWriter, r *http.Request) {
|
|||||||
if transferred == 1 {
|
if transferred == 1 {
|
||||||
t = "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()
|
cw.Flush()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ func handleCreatePilot(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "missing_fields")
|
writeError(w, http.StatusBadRequest, "missing_fields")
|
||||||
return
|
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(
|
res, err := db.Exec(
|
||||||
"INSERT INTO pilots(competition_id,number,last_name,first_name,country,balloon_id) VALUES(?,?,?,?,?,?)",
|
"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,
|
id, p.Number, p.LastName, p.FirstName, p.Country, p.BalloonID,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
@@ -17,7 +16,7 @@ func registerUserRoutes(mux *http.ServeMux) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleListUsers(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "db_error")
|
writeError(w, http.StatusInternalServerError, "db_error")
|
||||||
return
|
return
|
||||||
@@ -26,9 +25,10 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
out := []User{}
|
out := []User{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var u User
|
var u User
|
||||||
var admin int
|
var admin, mustChange int
|
||||||
rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin)
|
rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin, &mustChange)
|
||||||
u.IsSystemAdmin = admin == 1
|
u.IsSystemAdmin = admin == 1
|
||||||
|
u.MustChangePassword = mustChange == 1
|
||||||
out = append(out, u)
|
out = append(out, u)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
@@ -47,11 +47,16 @@ func handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid_body")
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Username = strings.TrimSpace(req.Username)
|
req.Username = normalizeUsername(req.Username)
|
||||||
if req.Username == "" || req.Password == "" {
|
if req.Username == "" || req.Password == "" {
|
||||||
writeError(w, http.StatusBadRequest, "missing_fields")
|
writeError(w, http.StatusBadRequest, "missing_fields")
|
||||||
return
|
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 {
|
if req.IsSystemAdmin && !actor.IsSystemAdmin {
|
||||||
writeError(w, http.StatusForbidden, "forbidden")
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
return
|
return
|
||||||
@@ -114,18 +119,39 @@ func handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
DisplayName *string `json:"display_name"`
|
Username *string `json:"username"`
|
||||||
Password *string `json:"password"`
|
DisplayName *string `json:"display_name"`
|
||||||
IsSystemAdmin *bool `json:"is_system_admin"`
|
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 {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid_body")
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||||
return
|
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 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)
|
db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, id)
|
||||||
}
|
}
|
||||||
if req.Password != nil && *req.Password != "" {
|
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)
|
hash, _ := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||||
db.Exec("UPDATE users SET password_hash=? WHERE id=?", string(hash), id)
|
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)
|
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)
|
u, _ := loadUser(id)
|
||||||
writeJSON(w, http.StatusOK, u)
|
writeJSON(w, http.StatusOK, u)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-2
@@ -22,13 +22,14 @@ async function api(method, path, body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const API = {
|
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"),
|
logout: () => api("POST", "/api/logout"),
|
||||||
me: () => api("GET", "/api/me"),
|
me: () => api("GET", "/api/me"),
|
||||||
updateMe: (b) => api("PATCH", "/api/me", b),
|
updateMe: (b) => api("PATCH", "/api/me", b),
|
||||||
|
|
||||||
listUsers: () => api("GET", "/api/users"),
|
listUsers: () => api("GET", "/api/users"),
|
||||||
createUser: (b) => api("POST", "/api/users", b),
|
createUser: (b) => api("POST", "/api/users", b),
|
||||||
|
updateUser: (id, b) => api("PATCH", `/api/users/${id}`, b),
|
||||||
deleteUser: (id) => api("DELETE", `/api/users/${id}`),
|
deleteUser: (id) => api("DELETE", `/api/users/${id}`),
|
||||||
|
|
||||||
listCompetitions: () => api("GET", "/api/competitions"),
|
listCompetitions: () => api("GET", "/api/competitions"),
|
||||||
@@ -60,7 +61,12 @@ const API = {
|
|||||||
createPenalty: (id, b) => api("POST", `/api/competitions/${id}/penalties`, b),
|
createPenalty: (id, b) => api("POST", `/api/competitions/${id}/penalties`, b),
|
||||||
updatePenalty: (id, pid, b) => api("PATCH", `/api/competitions/${id}/penalties/${pid}`, b),
|
updatePenalty: (id, pid, b) => api("PATCH", `/api/competitions/${id}/penalties/${pid}`, b),
|
||||||
deletePenalty: (id, pid) => api("DELETE", `/api/competitions/${id}/penalties/${pid}`),
|
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) : ""}`),
|
listRules: (lang) => api("GET", `/api/rules${lang ? "?lang=" + encodeURIComponent(lang) : ""}`),
|
||||||
};
|
};
|
||||||
|
|||||||
-1163
File diff suppressed because it is too large
Load Diff
+186
@@ -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");
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Penalty Tracker — Competition</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script src="/config.js"></script>
|
||||||
|
<script src="/i18n.js"></script>
|
||||||
|
<script src="/api.js"></script>
|
||||||
|
<script src="/common.js"></script>
|
||||||
|
<script src="/competition.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1232
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Penalty Tracker — Competitions</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script src="/config.js"></script>
|
||||||
|
<script src="/i18n.js"></script>
|
||||||
|
<script src="/api.js"></script>
|
||||||
|
<script src="/common.js"></script>
|
||||||
|
<script src="/competitions.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Penalty Tracker — Change password</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script src="/config.js"></script>
|
||||||
|
<script src="/i18n.js"></script>
|
||||||
|
<script src="/api.js"></script>
|
||||||
|
<script src="/common.js"></script>
|
||||||
|
<script src="/force-password.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
+86
@@ -52,6 +52,49 @@ const I18N_DATA = {
|
|||||||
username_taken: "Username already taken",
|
username_taken: "Username already taken",
|
||||||
prior_penalties: "Prior penalties for this pilot and rule",
|
prior_penalties: "Prior penalties for this pilot and rule",
|
||||||
none: "None",
|
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: {
|
de: {
|
||||||
login_title: "Anmelden",
|
login_title: "Anmelden",
|
||||||
@@ -100,6 +143,49 @@ const I18N_DATA = {
|
|||||||
username_taken: "Benutzername bereits vergeben",
|
username_taken: "Benutzername bereits vergeben",
|
||||||
prior_penalties: "Frühere Strafen für diesen Piloten und diese Regel",
|
prior_penalties: "Frühere Strafen für diesen Piloten und diese Regel",
|
||||||
none: "Keine",
|
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: {
|
pl: {
|
||||||
login_title: "Zaloguj się",
|
login_title: "Zaloguj się",
|
||||||
|
|||||||
+11
-2
@@ -7,10 +7,19 @@
|
|||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div class="loading-wrap"><div class="muted">…</div></div>
|
||||||
<script src="/config.js"></script>
|
<script src="/config.js"></script>
|
||||||
<script src="/i18n.js"></script>
|
<script src="/i18n.js"></script>
|
||||||
<script src="/api.js"></script>
|
<script src="/api.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/common.js"></script>
|
||||||
|
<script>
|
||||||
|
(async () => {
|
||||||
|
let user = null;
|
||||||
|
try { user = await API.me(); } catch (e) {}
|
||||||
|
if (!user) navigate("login");
|
||||||
|
else if (user.must_change_password) navigate("forcePassword");
|
||||||
|
else navigate("competitions");
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Penalty Tracker — Sign in</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script src="/config.js"></script>
|
||||||
|
<script src="/i18n.js"></script>
|
||||||
|
<script src="/api.js"></script>
|
||||||
|
<script src="/common.js"></script>
|
||||||
|
<script src="/login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -397,3 +397,20 @@ textarea { resize: vertical; min-height: 60px; }
|
|||||||
.topbar { padding: 0.5rem 0.75rem; }
|
.topbar { padding: 0.5rem 0.75rem; }
|
||||||
.container { padding: 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; }
|
||||||
|
|||||||
@@ -10,7 +10,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
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 {
|
type wsMessage struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user