Compare commits

...

7 Commits

  1. 96
      apps/web/main.go
  2. BIN
      apps/web/web-app
  3. 20
      go.mod
  4. 29
      go.sum
  5. 79
      internal/api/clock.go
  6. 481
      internal/api/users.go
  7. 4
      internal/handlers/web/documents.go
  8. 44
      internal/handlers/web/generic.go
  9. 793
      internal/handlers/web/invoice_clock_report.go
  10. 4
      internal/handlers/web/login.go
  11. 862
      internal/handlers/web/users.go
  12. 30
      internal/middleware/csrf_expose.go
  13. 93
      internal/middleware/csrf_simple.go
  14. 18
      internal/middleware/security_headers.go
  15. 80
      templates/generic.html
  16. 17
      templates/layout.html
  17. 132
      templates/users.html
  18. 152
      templates/users_update.html

96
apps/web/main.go

@ -4,12 +4,15 @@ import (
"log"
"net/http"
"os"
"strings"
"time"
root "marmic/servicetrade-toolbox"
"marmic/servicetrade-toolbox/internal/handlers/web"
"marmic/servicetrade-toolbox/internal/middleware"
"github.com/gorilla/csrf"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
)
@ -20,6 +23,12 @@ func main() {
}
r := mux.NewRouter()
// Make app proxy-aware (X-Forwarded-Proto/Host) so downstream middlewares see correct scheme/host
r.Use(handlers.ProxyHeaders)
// Global security headers middleware
r.Use(middleware.SecurityHeaders)
// Serve embedded static files
staticFS, err := root.GetStaticFS()
if err != nil {
@ -30,15 +39,92 @@ func main() {
// Serve static files
// r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
// Auth routes
// Auth routes (login remains outside CSRF)
r.HandleFunc("/login", web.LoginHandler).Methods("GET", "POST")
r.HandleFunc("/logout", web.LogoutHandler).Methods("GET", "POST")
// CSRF protection for state-changing routes
csrfKey := os.Getenv("CSRF_AUTH_KEY")
if len(csrfKey) < 32 {
log.Println("WARNING: CSRF_AUTH_KEY is not set or too short; using insecure default for development only")
csrfKey = "this-is-a-very-insecure-dev-key-please-set-env"
}
secureCookies := os.Getenv("APP_SECURE_COOKIES") != "false"
disableCSRF := os.Getenv("CSRF_DISABLE") == "true"
// Trusted origins for reverse-proxy/HTTPS setups (comma-separated env)
var trustedOrigins []string
if envTO := os.Getenv("CSRF_TRUSTED_ORIGINS"); envTO != "" {
for _, part := range strings.Split(envTO, ",") {
o := strings.TrimSpace(part)
if o != "" {
trustedOrigins = append(trustedOrigins, o)
}
}
} else {
// Sensible defaults for your known hosts; override via CSRF_TRUSTED_ORIGINS in prod
trustedOrigins = []string{
"https://dev.toolbox.nicpatterson.info",
"https://toolbox.nicpatterson.info",
"http://localhost:8080",
"http://127.0.0.1:8080",
}
}
csrfOpts := []csrf.Option{
csrf.Secure(secureCookies),
csrf.SameSite(csrf.SameSiteLaxMode),
csrf.CookieName("XSRF-TOKEN"),
csrf.HttpOnly(false), // allow htmx script to read and send the token
csrf.Path("/"),
csrf.ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Log the specific CSRF failure reason to help debugging
origin := r.Header.Get("Origin")
referer := r.Header.Get("Referer")
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if xfProto := r.Header.Get("X-Forwarded-Proto"); xfProto != "" {
scheme = xfProto
}
if reason := csrf.FailureReason(r); reason != nil {
log.Printf("CSRF validation failed: %v | host=%s scheme=%s origin=%s referer=%s", reason, r.Host, scheme, origin, referer)
} else {
log.Printf("CSRF validation failed: unknown reason | host=%s scheme=%s origin=%s referer=%s", r.Host, scheme, origin, referer)
}
http.Error(w, "Forbidden - CSRF", http.StatusForbidden)
})),
}
if len(trustedOrigins) > 0 {
log.Printf("CSRF trusted origins: %v", trustedOrigins)
csrfOpts = append(csrfOpts, csrf.TrustedOrigins(trustedOrigins))
}
csrfMw := csrf.Protect([]byte(csrfKey), csrfOpts...)
// Protected routes
protected := r.PathPrefix("/").Subrouter()
protected.Use(middleware.AuthMiddleware)
if disableCSRF {
log.Println("WARNING: CSRF is DISABLED by environment (CSRF_DISABLE=true) - DO NOT USE IN PRODUCTION")
} else {
// Environment-guarded CSRF mode selection
appEnv := os.Getenv("APP_ENV") // expected: production, staging, dev, local
useSimple := os.Getenv("CSRF_MODE") == "simple"
if appEnv == "production" && useSimple {
log.Fatal("CSRF_MODE=simple is not allowed in production")
}
if useSimple && appEnv != "production" {
log.Println("INFO: Using simple CSRF mode (double-submit cookie) for non-production environment")
protected.Use(middleware.CSRFSimple)
} else {
protected.Use(csrfMw)
protected.Use(middleware.CSRFExposeToken)
}
}
protected.HandleFunc("/", web.DashboardHandler).Methods("GET")
protected.HandleFunc("/logout", web.LogoutHandler).Methods("POST")
protected.HandleFunc("/jobs", web.JobsHandler).Methods("GET", "POST")
protected.HandleFunc("/invoices", web.InvoicesHandler).Methods("GET", "POST")
protected.HandleFunc("/ok-invoice/{id}", web.UpdateInvoiceStatusHandler).Methods("PUT")
@ -59,6 +145,9 @@ func main() {
protected.HandleFunc("/services", web.ServicesHandler).Methods("GET")
protected.HandleFunc("/tags", web.TagsHandler).Methods("GET")
protected.HandleFunc("/users", web.UsersHandler).Methods("GET")
protected.HandleFunc("/users/upload", web.UsersUploadHandler).Methods("POST")
protected.HandleFunc("/users/update", web.UsersUpdateHandler).Methods("GET")
protected.HandleFunc("/users/update/upload", web.UsersUpdateUploadHandler).Methods("POST")
// Document upload routes
protected.HandleFunc("/documents", web.DocumentsHandler).Methods("GET")
@ -77,6 +166,9 @@ func main() {
protected.HandleFunc("/documents/remove/bulk", web.BulkRemoveDocumentsHandler).Methods("POST")
protected.HandleFunc("/documents/remove/results", web.RemovalResultsHandler).Methods("GET")
// Reports & utilities
protected.HandleFunc("/reports/invoice-clock", web.InvoiceClockReportHandler).Methods("POST")
port := os.Getenv("PORT")
if port == "" {
port = "8080"

BIN
apps/web/web-app

Binary file not shown.

20
go.mod

@ -2,4 +2,22 @@ module marmic/servicetrade-toolbox
go 1.22.1
require github.com/gorilla/mux v1.8.1
require (
github.com/gorilla/csrf v1.7.3
github.com/gorilla/handlers v1.5.2
github.com/gorilla/mux v1.8.1
)
require (
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.3 // indirect
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 // indirect
github.com/xuri/excelize/v2 v2.8.1 // indirect
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 // indirect
golang.org/x/crypto v0.19.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/text v0.14.0 // indirect
)

29
go.sum

@ -1,2 +1,31 @@
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM=
github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 h1:Chd9DkqERQQuHpXjR/HSV1jLZA6uaoiwwH3vSuF3IW0=
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.8.1 h1:pZLMEwK8ep+CLIUWpWmvW8IWE/yxqG0I1xcN6cVMGuQ=
github.com/xuri/excelize/v2 v2.8.1/go.mod h1:oli1E4C3Pa5RXg1TBXn4ENCXDV5JUMlBluUhG7c+CEE=
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 h1:qhbILQo1K3mphbwKh1vNm4oGezE1eF9fQWmNiIpSfI4=
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=

79
internal/api/clock.go

@ -0,0 +1,79 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
)
// ClockEventData represents the response data returned from the ServiceTrade clock endpoint.
type ClockEventData struct {
TotalPages int `json:"totalPages"`
Page int `json:"page"`
ActivityTime *ClockActivityMap `json:"activityTime"`
PairedEvents []ClockPairedEvent `json:"pairedEvents"`
UnpairedEvents []ClockEvent `json:"unpairedEvents"`
}
// ClockActivityMap captures total elapsed seconds for each activity type.
type ClockActivityMap struct {
Onsite int64 `json:"onsite"`
Offsite int64 `json:"offsite"`
Enroute int64 `json:"enroute"`
Onbreak int64 `json:"onbreak"`
}
// ClockPairedEvent represents a paired start/end clock event.
type ClockPairedEvent struct {
ElapsedTime int64 `json:"elapsedTime"`
Start ClockEvent `json:"start"`
End ClockEvent `json:"end"`
}
// ClockEvent represents an individual clock event.
type ClockEvent struct {
EventTime int64 `json:"eventTime"`
EventType string `json:"eventType"`
Activity string `json:"activity"`
Source string `json:"source"`
User ClockUser `json:"user"`
}
// ClockUser represents a technician associated with a clock event.
type ClockUser struct {
Name string `json:"name"`
Email string `json:"email"`
}
type clockResponse struct {
Data ClockEventData `json:"data"`
}
// GetJobClockEvents retrieves paired clock events for a given job identifier.
func (s *Session) GetJobClockEvents(jobID string) (*ClockEventData, error) {
endpoint := fmt.Sprintf("/clock?jobId=%s", jobID)
resp, err := s.DoRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned error: %s - %s", resp.Status, string(body))
}
var result clockResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("error unmarshalling clock response: %w", err)
}
return &result.Data, nil
}

481
internal/api/users.go

@ -0,0 +1,481 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
// CreateUserPayload represents the payload required to create a ServiceTrade user.
type CreateUserPayload struct {
Username string `json:"username"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Password string `json:"password"`
Email string `json:"email"`
Phone string `json:"phone,omitempty"`
CompanyID int `json:"companyId"`
LocationID int `json:"locationId"`
Details string `json:"details,omitempty"`
Status string `json:"status,omitempty"`
IsSales *bool `json:"isSales,omitempty"`
Timezone string `json:"timezone,omitempty"`
ServiceLineIDs []int `json:"serviceLineIds,omitempty"`
ManagerID *int `json:"managerId,omitempty"`
MFARequired *bool `json:"mfaRequired,omitempty"`
}
// UserRecord represents a simplified ServiceTrade user returned from the API.
type UserRecord struct {
ID int `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Status string `json:"status"`
Roles []Role `json:"roles,omitempty"`
Raw []byte `json:"-"`
}
// Role represents a ServiceTrade role.
type Role struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Activities []string `json:"activities,omitempty"`
Global bool `json:"global"`
}
// UpdateUserPayload represents the fields that can be updated on an existing ServiceTrade user.
type UpdateUserPayload struct {
Username *string `json:"username,omitempty"`
FirstName *string `json:"firstName,omitempty"`
LastName *string `json:"lastName,omitempty"`
Password *string `json:"password,omitempty"`
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
CompanyID *int `json:"companyId,omitempty"`
LocationID *int `json:"locationId,omitempty"`
Details *string `json:"details,omitempty"`
Status *string `json:"status,omitempty"`
IsSales *bool `json:"isSales,omitempty"`
Timezone *string `json:"timezone,omitempty"`
ServiceLineIDs *[]int `json:"serviceLineIds,omitempty"`
ManagerID *int `json:"managerId,omitempty"`
MFARequired *bool `json:"mfaRequired,omitempty"`
}
// CreateUser creates a new ServiceTrade user and returns the created user record.
func (s *Session) CreateUser(payload CreateUserPayload) (*UserRecord, error) {
bodyBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("error marshalling user payload: %w", err)
}
resp, err := s.DoRequest(http.MethodPost, "/user", bytes.NewBuffer(bodyBytes))
if err != nil {
return nil, fmt.Errorf("error sending create user request: %w", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading create user response: %w", err)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("create user failed: %s - %s", resp.Status, string(responseBody))
}
user, err := parseUserFromResponse(responseBody)
if err != nil {
return nil, fmt.Errorf("unable to parse create user response: %w", err)
}
user.Raw = responseBody
return user, nil
}
// AssignRoleToUser assigns a role to a ServiceTrade user.
func (s *Session) AssignRoleToUser(userID, roleID int) error {
endpoint := fmt.Sprintf("/user/%d/role/%d", userID, roleID)
resp, err := s.DoRequest(http.MethodPost, endpoint, nil)
if err != nil {
return fmt.Errorf("error assigning role %d to user %d: %w", roleID, userID, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("assign role failed: %s - %s", resp.Status, string(body))
}
return nil
}
// ListRoles retrieves all ServiceTrade roles visible to the authenticated account.
func (s *Session) ListRoles() ([]Role, error) {
resp, err := s.DoRequest(http.MethodGet, "/role", nil)
if err != nil {
return nil, fmt.Errorf("error requesting roles: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading roles response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("list roles failed: %s - %s", resp.Status, string(body))
}
roles, err := parseRolesFromResponse(body)
if err != nil {
return nil, err
}
return roles, nil
}
// UpdateUser updates an existing ServiceTrade user with the provided fields.
func (s *Session) UpdateUser(userID int, payload UpdateUserPayload) (*UserRecord, error) {
bodyBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("error marshalling user update payload: %w", err)
}
endpoint := fmt.Sprintf("/user/%d", userID)
resp, err := s.DoRequest(http.MethodPut, endpoint, bytes.NewBuffer(bodyBytes))
if err != nil {
return nil, fmt.Errorf("error sending update user request: %w", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading update user response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("update user failed: %s - %s", resp.Status, string(responseBody))
}
user, err := parseUserFromResponse(responseBody)
if err != nil {
return nil, fmt.Errorf("unable to parse update user response: %w", err)
}
user.Raw = responseBody
return user, nil
}
// FindUserByUsername searches for a user by username. Returns an error if not exactly one match is found.
func (s *Session) FindUserByUsername(username string) (*UserRecord, error) {
if strings.TrimSpace(username) == "" {
return nil, fmt.Errorf("username is required")
}
query := url.Values{}
query.Set("search", username)
query.Set("searchFields", "username")
query.Set("status", "active,inactive,pending")
endpoint := fmt.Sprintf("/user?%s", query.Encode())
resp, err := s.DoRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("error searching for user %q: %w", username, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading user search response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("user search failed: %s - %s", resp.Status, string(body))
}
users, err := parseUsersFromResponse(body)
if err != nil {
return nil, fmt.Errorf("error parsing user search response: %w", err)
}
var matches []*UserRecord
for i := range users {
if strings.EqualFold(users[i].Username, username) {
matches = append(matches, &users[i])
}
}
if len(matches) == 0 {
return nil, fmt.Errorf("no user found with username %q", username)
}
if len(matches) > 1 {
return nil, fmt.Errorf("multiple users found with username %q", username)
}
return matches[0], nil
}
func parseUserFromResponse(body []byte) (*UserRecord, error) {
var root map[string]interface{}
if err := json.Unmarshal(body, &root); err != nil {
return nil, fmt.Errorf("error decoding user response: %w", err)
}
candidate := extractMap(root, "data", "user")
if candidate == nil {
candidate = extractFirstMapFromArray(root, "data", "users")
}
if candidate == nil {
if userVal, ok := root["user"].(map[string]interface{}); ok {
candidate = userVal
}
}
if candidate == nil {
if dataVal, ok := root["data"].(map[string]interface{}); ok {
candidate = dataVal
}
}
if candidate == nil {
candidate = root
}
return buildUserRecord(candidate)
}
func parseUsersFromResponse(body []byte) ([]UserRecord, error) {
var root map[string]interface{}
if err := json.Unmarshal(body, &root); err != nil {
return nil, fmt.Errorf("error decoding users response: %w", err)
}
var records []interface{}
if dataMap, ok := root["data"].(map[string]interface{}); ok {
if list, ok := dataMap["users"].([]interface{}); ok {
records = list
} else if list, ok := dataMap["records"].([]interface{}); ok {
records = list
}
}
if records == nil {
if list, ok := root["users"].([]interface{}); ok {
records = list
}
}
if records == nil {
return []UserRecord{}, nil
}
results := make([]UserRecord, 0, len(records))
for _, item := range records {
userMap, ok := item.(map[string]interface{})
if !ok {
continue
}
user, err := buildUserRecord(userMap)
if err != nil {
return nil, err
}
results = append(results, *user)
}
return results, nil
}
func parseRolesFromResponse(body []byte) ([]Role, error) {
var root map[string]interface{}
if err := json.Unmarshal(body, &root); err != nil {
return nil, fmt.Errorf("error decoding roles response: %w", err)
}
var rolesList []interface{}
if dataMap, ok := root["data"].(map[string]interface{}); ok {
if list, ok := dataMap["roles"].([]interface{}); ok {
rolesList = list
} else if list, ok := dataMap["records"].([]interface{}); ok {
rolesList = list
}
}
if rolesList == nil {
if list, ok := root["roles"].([]interface{}); ok {
rolesList = list
}
}
if rolesList == nil {
return nil, fmt.Errorf("roles not found in response")
}
return convertRoles(rolesList), nil
}
func extractMap(root map[string]interface{}, keys ...string) map[string]interface{} {
current := root
for _, key := range keys {
val, ok := current[key]
if !ok {
return nil
}
if nextMap, ok := val.(map[string]interface{}); ok {
current = nextMap
} else {
return nil
}
}
return current
}
func extractFirstMapFromArray(root map[string]interface{}, keys ...string) map[string]interface{} {
if len(keys) == 0 {
return nil
}
lastKey := keys[len(keys)-1]
parent := extractMap(root, keys[:len(keys)-1]...)
if parent == nil {
return nil
}
val, ok := parent[lastKey]
if !ok {
return nil
}
arr, ok := val.([]interface{})
if !ok || len(arr) == 0 {
return nil
}
first, ok := arr[0].(map[string]interface{})
if !ok {
return nil
}
return first
}
func convertRoles(items []interface{}) []Role {
var roles []Role
for _, item := range items {
roleMap, ok := item.(map[string]interface{})
if !ok {
continue
}
role := Role{
ID: toInt(roleMap["id"]),
Name: toString(roleMap["name"]),
Description: toString(roleMap["description"]),
Global: toBool(roleMap["global"]),
Activities: toStringSlice(roleMap["activities"]),
}
roles = append(roles, role)
}
return roles
}
func toInt(value interface{}) int {
switch v := value.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case json.Number:
i, _ := v.Int64()
return int(i)
case string:
if v == "" {
return 0
}
i, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0
}
return i
default:
return 0
}
}
func toString(value interface{}) string {
switch v := value.(type) {
case string:
return v
case json.Number:
return v.String()
case fmt.Stringer:
return v.String()
case int:
return strconv.Itoa(v)
case int64:
return strconv.FormatInt(v, 10)
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
default:
return ""
}
}
func toBool(value interface{}) bool {
switch v := value.(type) {
case bool:
return v
case string:
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes", "y":
return true
case "false", "0", "no", "n":
return false
}
case float64:
return v != 0
case int:
return v != 0
case json.Number:
i, err := v.Int64()
if err == nil {
return i != 0
}
}
return false
}
func toStringSlice(value interface{}) []string {
switch v := value.(type) {
case []string:
return v
case []interface{}:
result := make([]string, 0, len(v))
for _, item := range v {
result = append(result, toString(item))
}
return result
default:
return nil
}
}
func buildUserRecord(candidate map[string]interface{}) (*UserRecord, error) {
if candidate == nil {
return nil, fmt.Errorf("user data missing in response")
}
user := &UserRecord{
ID: toInt(candidate["id"]),
Username: toString(candidate["username"]),
Email: toString(candidate["email"]),
FirstName: toString(candidate["firstName"]),
LastName: toString(candidate["lastName"]),
Status: toString(candidate["status"]),
}
if user.ID == 0 {
return nil, fmt.Errorf("user ID missing in response")
}
if rolesVal, ok := candidate["roles"].([]interface{}); ok {
user.Roles = convertRoles(rolesVal)
}
return user, nil
}

4
internal/handlers/web/documents.go

@ -18,6 +18,8 @@ import (
"marmic/servicetrade-toolbox/internal/api"
"marmic/servicetrade-toolbox/internal/middleware"
"marmic/servicetrade-toolbox/internal/utils"
"github.com/gorilla/csrf"
)
// UploadResult represents the result of a single file upload
@ -70,6 +72,8 @@ func DocumentsHandler(w http.ResponseWriter, r *http.Request) {
"Title": "Document Uploads",
"Session": session,
}
// Include CSRF token for htmx headers in partials
data["CSRFToken"] = csrf.Token(r)
if r.Header.Get("HX-Request") == "true" {
// For HTMX requests, just send the document_upload partial

44
internal/handlers/web/generic.go

@ -1,18 +1,54 @@
package web
import (
root "marmic/servicetrade-toolbox"
"bytes"
"html/template"
"net/http"
root "marmic/servicetrade-toolbox"
"marmic/servicetrade-toolbox/internal/api"
"marmic/servicetrade-toolbox/internal/middleware"
"github.com/gorilla/csrf"
)
func GenericHandler(w http.ResponseWriter, r *http.Request) {
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tmpl := root.WebTemplates
var csrfCookie string
if c, err := r.Cookie("XSRF-TOKEN"); err == nil {
csrfCookie = c.Value
} else if c, err := r.Cookie("XSRF-TOKEN-VALUE"); err == nil {
csrfCookie = c.Value
}
data := map[string]interface{}{
"Title": "Generic",
"Title": "Generic",
"Session": session,
"CSRFField": csrf.TemplateField(r),
"CSRFToken": csrf.Token(r),
"CSRFCookie": csrfCookie,
}
if r.Header.Get("HX-Request") == "true" {
if err := tmpl.ExecuteTemplate(w, "generic_content", data); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
var contentBuf bytes.Buffer
if err := tmpl.ExecuteTemplate(&contentBuf, "generic_content", data); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data["BodyContent"] = template.HTML(contentBuf.String())
err := tmpl.Execute(w, data)
if err != nil {
if err := tmpl.ExecuteTemplate(w, "layout.html", data); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

793
internal/handlers/web/invoice_clock_report.go

@ -0,0 +1,793 @@
package web
import (
"bytes"
"encoding/csv"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/xuri/excelize/v2"
"marmic/servicetrade-toolbox/internal/api"
"marmic/servicetrade-toolbox/internal/middleware"
)
var reportHeader = []string{
"Customer PO",
"id",
"Link",
"Clock In",
"Clock Out",
}
// InvoiceClockReportHandler processes a CSV upload and returns a CSV matching the requested template.
func InvoiceClockReportHandler(w http.ResponseWriter, r *http.Request) {
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, fmt.Sprintf("Unable to parse form: %v", err), http.StatusBadRequest)
return
}
file, _, err := r.FormFile("invoiceCsv")
if err != nil {
http.Error(w, fmt.Sprintf("Error retrieving file: %v", err), http.StatusBadRequest)
return
}
defer file.Close()
csvReader := csv.NewReader(file)
records, err := csvReader.ReadAll()
if err != nil {
http.Error(w, fmt.Sprintf("Error reading CSV file: %v", err), http.StatusBadRequest)
return
}
if len(records) < 2 {
http.Error(w, "CSV must include a header row and at least one data row", http.StatusBadRequest)
return
}
header := records[0]
columns := detectInputColumns(header)
if columns.jobID == -1 && columns.invoice == -1 {
http.Error(w, "Input must include a job id column or an invoice number column", http.StatusBadRequest)
return
}
log.Printf("Invoice clock report: detected columns jobID=%d invoice=%d customerPO=%d",
columns.jobID, columns.invoice, columns.customerPO)
tasks := make([]rowTask, 0, len(records)-1)
for rowIdx := 1; rowIdx < len(records); rowIdx++ {
row := ensureRowLength(records[rowIdx], len(header))
task := rowTask{
index: rowIdx - 1,
rawID: cleanIdentifier(getRowValue(row, columns.jobID)),
invoiceNumber: strings.TrimSpace(getRowValue(row, columns.invoice)),
customerPO: strings.TrimSpace(getRowValue(row, columns.customerPO)),
}
tasks = append(tasks, task)
}
f := excelize.NewFile()
sheet := f.GetSheetName(0)
f.SetSheetName(sheet, "Report")
sheet = "Report"
headerStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 12},
Alignment: &excelize.Alignment{
Horizontal: "center",
Vertical: "center",
WrapText: true,
},
})
textStyle, _ := f.NewStyle(&excelize.Style{
NumFmt: 49, // treat as text
Alignment: &excelize.Alignment{
Vertical: "top",
WrapText: true,
},
})
bodyWrapStyle, _ := f.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Vertical: "top",
WrapText: true,
},
})
for idx, title := range reportHeader {
cell, _ := excelize.CoordinatesToCellName(idx+1, 1)
if err := f.SetCellStr(sheet, cell, title); err != nil {
http.Error(w, fmt.Sprintf("Error writing header cell: %v", err), http.StatusInternalServerError)
return
}
if err := f.SetCellStyle(sheet, cell, cell, headerStyle); err != nil {
http.Error(w, fmt.Sprintf("Error styling header cell: %v", err), http.StatusInternalServerError)
return
}
}
if err := f.SetPanes(sheet, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
}); err != nil {
log.Printf("Warning: unable to freeze header row: %v", err)
}
if err := f.SetColWidth(sheet, "A", "A", 25); err != nil {
log.Printf("Warning: unable to set column width: %v", err)
}
if err := f.SetColWidth(sheet, "B", "C", 40); err != nil {
log.Printf("Warning: unable to set column width: %v", err)
}
if err := f.SetColWidth(sheet, "D", "E", 28); err != nil {
log.Printf("Warning: unable to set column width: %v", err)
}
if len(tasks) == 0 {
var buffer bytes.Buffer
if err := f.Write(&buffer); err != nil {
http.Error(w, fmt.Sprintf("Error finalizing XLSX: %v", err), http.StatusInternalServerError)
return
}
if err := f.Close(); err != nil {
log.Printf("Warning: error closing workbook: %v", err)
}
filename := fmt.Sprintf("invoice-clock-report-%s.xlsx", time.Now().Format("20060102-150405"))
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
w.Header().Set("Cache-Control", "no-store")
w.Write(buffer.Bytes())
return
}
limiter := newRateLimiter(5)
defer limiter.Stop()
ctx := &workerContext{
session: session,
caches: newSharedCaches(),
limiter: limiter,
lookup: newLookupState(5),
}
workerCount := 6
if len(tasks) < workerCount {
workerCount = len(tasks)
}
if workerCount < 1 {
workerCount = 1
}
jobsCh := make(chan rowTask)
resultsCh := make(chan rowResult, workerCount)
var wg sync.WaitGroup
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for task := range jobsCh {
resultsCh <- processRow(task, ctx)
}
}()
}
go func() {
for _, task := range tasks {
jobsCh <- task
}
close(jobsCh)
}()
go func() {
wg.Wait()
close(resultsCh)
}()
pending := make(map[int]rowResult)
nextIndex := 0
var writeErr error
for res := range resultsCh {
if writeErr != nil {
continue
}
pending[res.index] = res
for {
data, ok := pending[nextIndex]
if !ok {
break
}
if err := writeExcelRow(f, sheet, data, nextIndex, textStyle, bodyWrapStyle); err != nil {
writeErr = err
break
}
delete(pending, nextIndex)
nextIndex++
}
}
if writeErr != nil {
http.Error(w, fmt.Sprintf("Error writing XLSX: %v", writeErr), http.StatusInternalServerError)
return
}
var buffer bytes.Buffer
if err := f.Write(&buffer); err != nil {
http.Error(w, fmt.Sprintf("Error finalizing XLSX: %v", err), http.StatusInternalServerError)
return
}
if err := f.Close(); err != nil {
log.Printf("Warning: error closing workbook: %v", err)
}
filename := fmt.Sprintf("invoice-clock-report-%s.xlsx", time.Now().Format("20060102-150405"))
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
w.Header().Set("Cache-Control", "no-store")
w.Write(buffer.Bytes())
}
type inputColumns struct {
jobID int
invoice int
customerPO int
}
func detectInputColumns(headers []string) inputColumns {
var cols inputColumns
cols.jobID = -1
cols.invoice = -1
cols.customerPO = -1
normalized := make([]string, len(headers))
original := make([]string, len(headers))
for i, header := range headers {
normalized[i] = normalizeHeaderValue(header)
original[i] = strings.ToLower(strings.TrimSpace(header))
}
jobPriority := []string{"id", "jobid", "job_id"}
for _, candidate := range jobPriority {
if cols.jobID != -1 {
break
}
for i, name := range normalized {
if name == candidate {
cols.jobID = i
break
}
}
}
if cols.jobID == -1 {
for i, name := range normalized {
if strings.Contains(name, "job") && strings.Contains(name, "id") {
cols.jobID = i
break
}
}
}
invoiceCandidates := []string{"invoicenumber", "invoice", "refnumber", "ref"}
for _, candidate := range invoiceCandidates {
if cols.invoice != -1 {
break
}
for i, name := range normalized {
if name == candidate {
cols.invoice = i
break
}
}
}
customerExact := []string{"customer_po", "customerpo", "customer purchase order", "customer_po_number"}
for _, candidate := range customerExact {
if cols.customerPO != -1 {
break
}
for i, name := range original {
if name == candidate {
cols.customerPO = i
break
}
}
}
if cols.customerPO == -1 {
for i, name := range normalized {
if strings.Contains(name, "customerpo") && !strings.Contains(name, "postal") {
cols.customerPO = i
break
}
}
}
if cols.customerPO == -1 && cols.invoice != -1 {
invoiceHeader := normalized[cols.invoice]
if strings.Contains(invoiceHeader, "customerpo") {
cols.customerPO = cols.invoice
}
}
return cols
}
func normalizeHeaderValue(header string) string {
h := strings.ToLower(strings.TrimSpace(header))
replacements := []string{" ", "_", "-", "."}
for _, repl := range replacements {
h = strings.ReplaceAll(h, repl, "")
}
return h
}
func ensureRowLength(row []string, length int) []string {
if len(row) >= length {
return row
}
res := make([]string, length)
copy(res, row)
return res
}
func getRowValue(row []string, index int) string {
if index < 0 || index >= len(row) {
return ""
}
return row[index]
}
func cleanIdentifier(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if strings.HasPrefix(value, "'") {
value = strings.TrimPrefix(value, "'")
}
if dot := strings.IndexRune(value, '.'); dot > 0 {
if decimalPart := value[dot+1:]; len(decimalPart) == 0 || strings.Trim(decimalPart, "0") == "" {
return value[:dot]
}
}
return value
}
type rowTask struct {
index int
rawID string
invoiceNumber string
customerPO string
}
type rowResult struct {
index int
customerPO string
jobID string
link string
clockIn string
clockOut string
}
type workerContext struct {
session *api.Session
caches *sharedCaches
limiter *rateLimiter
lookup *lookupState
}
type sharedCaches struct {
mu sync.RWMutex
invoices map[string]map[string]interface{}
clocks map[string]clockSummary
}
func newSharedCaches() *sharedCaches {
return &sharedCaches{
invoices: make(map[string]map[string]interface{}),
clocks: make(map[string]clockSummary),
}
}
func (c *sharedCaches) getInvoice(key string) (map[string]interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
inv, ok := c.invoices[key]
return inv, ok
}
func (c *sharedCaches) setInvoice(key string, invoice map[string]interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.invoices[key] = invoice
}
func (c *sharedCaches) getClock(jobID string) (clockSummary, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
summary, ok := c.clocks[jobID]
return summary, ok
}
func (c *sharedCaches) setClock(jobID string, summary clockSummary) {
c.mu.Lock()
defer c.mu.Unlock()
c.clocks[jobID] = summary
}
type lookupState struct {
mu sync.Mutex
jobIDEnabled bool
invoiceIDEnabled bool
jobIDFailures int
invoiceIDFailures int
threshold int
jobIDDisabledLogged bool
invoiceIDDisabledLogged bool
}
func newLookupState(threshold int) *lookupState {
return &lookupState{
jobIDEnabled: true,
invoiceIDEnabled: true,
threshold: threshold,
}
}
func (s *lookupState) shouldTryJobID() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.jobIDEnabled
}
func (s *lookupState) recordJobIDResult(success bool) {
s.mu.Lock()
defer s.mu.Unlock()
if success {
s.jobIDFailures = 0
if !s.jobIDEnabled {
s.jobIDEnabled = true
s.jobIDDisabledLogged = false
}
return
}
if !s.jobIDEnabled {
return
}
s.jobIDFailures++
if s.jobIDFailures >= s.threshold {
s.jobIDEnabled = false
if !s.jobIDDisabledLogged {
log.Printf("Disabling job ID lookups after %d consecutive failures", s.jobIDFailures)
s.jobIDDisabledLogged = true
}
}
}
func (s *lookupState) shouldTryInvoiceID() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.invoiceIDEnabled
}
func (s *lookupState) recordInvoiceIDResult(success bool) {
s.mu.Lock()
defer s.mu.Unlock()
if success {
s.invoiceIDFailures = 0
if !s.invoiceIDEnabled {
s.invoiceIDEnabled = true
s.invoiceIDDisabledLogged = false
}
return
}
if !s.invoiceIDEnabled {
return
}
s.invoiceIDFailures++
if s.invoiceIDFailures >= s.threshold {
s.invoiceIDEnabled = false
if !s.invoiceIDDisabledLogged {
log.Printf("Disabling invoice ID lookups after %d consecutive failures", s.invoiceIDFailures)
s.invoiceIDDisabledLogged = true
}
}
}
type rateLimiter struct {
ticker *time.Ticker
}
func newRateLimiter(rps int) *rateLimiter {
if rps <= 0 {
rps = 5
}
interval := time.Second / time.Duration(rps)
return &rateLimiter{ticker: time.NewTicker(interval)}
}
func (rl *rateLimiter) Wait() {
if rl == nil || rl.ticker == nil {
return
}
<-rl.ticker.C
}
func (rl *rateLimiter) Stop() {
if rl == nil || rl.ticker == nil {
return
}
rl.ticker.Stop()
}
func processRow(task rowTask, ctx *workerContext) rowResult {
result := rowResult{
index: task.index,
customerPO: strings.TrimSpace(task.customerPO),
}
rawID := strings.TrimSpace(task.rawID)
invoiceNumber := strings.TrimSpace(task.invoiceNumber)
displayRow := task.index + 1
if rawID != "" {
if ctx.lookup.shouldTryJobID() {
log.Printf("Row %d: attempting job clock lookup for id=%s", displayRow, rawID)
summary, err := fetchClockSummary(ctx.session, rawID, ctx.caches, ctx.limiter)
if err == nil {
ctx.lookup.recordJobIDResult(true)
result.jobID = rawID
result.link = buildJobLink(rawID)
result.clockIn = summary.ClockIn
result.clockOut = summary.ClockOut
finalizeCustomerPO(&result, invoiceNumber, rawID)
return result
}
log.Printf("Row %d: clock lookup failed for job %s: %v", displayRow, rawID, err)
ctx.lookup.recordJobIDResult(false)
} else {
log.Printf("Row %d: skipping job id lookup (disabled)", displayRow)
}
}
var invoiceData map[string]interface{}
if rawID != "" {
if ctx.lookup.shouldTryInvoiceID() {
log.Printf("Row %d: falling back to invoice id %s", displayRow, rawID)
inv, err := fetchInvoice(ctx.session, rawID, ctx.caches, ctx.limiter)
if err == nil && inv != nil {
ctx.lookup.recordInvoiceIDResult(true)
invoiceData = inv
} else {
if err != nil {
log.Printf("Invoice lookup failed for id %s (row %d): %v", rawID, displayRow, err)
}
ctx.lookup.recordInvoiceIDResult(false)
}
} else {
log.Printf("Row %d: skipping invoice id lookup (disabled)", displayRow)
}
}
if invoiceData == nil && invoiceNumber != "" {
log.Printf("Row %d: falling back to invoice number %s", displayRow, invoiceNumber)
inv, err := fetchInvoice(ctx.session, invoiceNumber, ctx.caches, ctx.limiter)
if err != nil {
log.Printf("Invoice lookup failed for number %s (row %d): %v", invoiceNumber, displayRow, err)
} else if inv != nil {
invoiceData = inv
}
}
if invoiceData != nil {
applyInvoiceFields(&result, invoiceData)
jobFromInvoice := extractJobIDFromInvoice(invoiceData)
if jobFromInvoice != "" {
summary, err := fetchClockSummary(ctx.session, jobFromInvoice, ctx.caches, ctx.limiter)
if err != nil {
log.Printf("Row %d: clock lookup failed for invoice-derived job %s: %v", displayRow, jobFromInvoice, err)
} else {
result.clockIn = summary.ClockIn
result.clockOut = summary.ClockOut
}
result.jobID = jobFromInvoice
result.link = buildJobLink(jobFromInvoice)
}
}
if result.jobID == "" {
log.Printf("Row %d: no job id could be determined; leaving link blank", displayRow)
}
finalizeCustomerPO(&result, invoiceNumber, rawID)
return result
}
func applyInvoiceFields(result *rowResult, invoice map[string]interface{}) {
if invoice == nil {
return
}
if result.customerPO == "" {
if po := strings.TrimSpace(valueToString(invoice["customerPo"])); po != "" {
result.customerPO = po
}
}
}
func finalizeCustomerPO(result *rowResult, invoiceNumber, rawID string) {
if result.customerPO != "" {
return
}
if invoiceNumber != "" {
result.customerPO = invoiceNumber
return
}
if rawID != "" {
result.customerPO = rawID
}
}
func buildJobLink(jobID string) string {
if jobID == "" {
return ""
}
return fmt.Sprintf("https://app.servicetrade.com/jobs/%s", jobID)
}
func writeExcelRow(f *excelize.File, sheet string, result rowResult, index int, textStyle, bodyStyle int) error {
excelRow := index + 2
values := []string{
result.customerPO,
result.jobID,
result.link,
result.clockIn,
result.clockOut,
}
for colIdx, value := range values {
cell, _ := excelize.CoordinatesToCellName(colIdx+1, excelRow)
style := bodyStyle
if colIdx == 1 {
style = textStyle
}
if err := f.SetCellStr(sheet, cell, value); err != nil {
return err
}
if err := f.SetCellStyle(sheet, cell, cell, style); err != nil {
return err
}
if colIdx == 2 && value != "" {
if err := f.SetCellHyperLink(sheet, cell, value, "External"); err != nil {
log.Printf("Warning: unable to set hyperlink for cell %s: %v", cell, err)
}
}
}
return nil
}
func fetchInvoice(session *api.Session, identifier string, caches *sharedCaches, limiter *rateLimiter) (map[string]interface{}, error) {
if identifier == "" {
return nil, nil
}
if invoice, ok := caches.getInvoice(identifier); ok {
return invoice, nil
}
if limiter != nil {
limiter.Wait()
}
invoice, err := session.GetInvoice(identifier)
if err != nil {
return nil, err
}
if invoice != nil {
caches.setInvoice(identifier, invoice)
}
return invoice, nil
}
func extractJobIDFromInvoice(invoice map[string]interface{}) string {
if invoice == nil {
return ""
}
jobInfo, ok := invoice["job"].(map[string]interface{})
if !ok {
return ""
}
return cleanIdentifier(valueToString(jobInfo["id"]))
}
type clockSummary struct {
ClockIn string
ClockOut string
}
func fetchClockSummary(session *api.Session, jobID string, caches *sharedCaches, limiter *rateLimiter) (clockSummary, error) {
if jobID == "" {
return clockSummary{}, nil
}
if summary, ok := caches.getClock(jobID); ok {
return summary, nil
}
if limiter != nil {
limiter.Wait()
}
data, err := session.GetJobClockEvents(jobID)
if err != nil {
return clockSummary{}, err
}
var earliestStart int64
var latestEnd int64
for _, event := range data.PairedEvents {
if event.Start.EventTime != 0 {
if earliestStart == 0 || event.Start.EventTime < earliestStart {
earliestStart = event.Start.EventTime
}
}
if event.End.EventTime != 0 {
if event.End.EventTime > latestEnd {
latestEnd = event.End.EventTime
}
}
}
summary := clockSummary{
ClockIn: formatTimestampPlain(earliestStart),
ClockOut: formatTimestampPlain(latestEnd),
}
caches.setClock(jobID, summary)
return summary, nil
}
func formatTimestampPlain(epoch int64) string {
if epoch == 0 {
return ""
}
local := time.Unix(epoch, 0).In(time.Local)
return local.Format("2006-01-02 15:04:05 MST")
}
func valueToString(value interface{}) string {
switch v := value.(type) {
case string:
return v
case fmt.Stringer:
return v.String()
case float64:
return strconv.FormatInt(int64(v), 10)
case int64:
return strconv.FormatInt(v, 10)
case int:
return strconv.Itoa(v)
default:
if v == nil {
return ""
}
return fmt.Sprintf("%v", v)
}
}

4
internal/handlers/web/login.go

@ -1,6 +1,7 @@
package web
import (
"html"
"log"
root "marmic/servicetrade-toolbox"
"marmic/servicetrade-toolbox/internal/api"
@ -23,7 +24,8 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
err := session.Login(email, password)
if err != nil {
if r.Header.Get("HX-Request") == "true" {
w.Write([]byte("<div class='error'>Login failed: " + err.Error() + "</div>"))
safeMsg := html.EscapeString(err.Error())
w.Write([]byte("<div class='error'>Login failed: " + safeMsg + "</div>"))
} else {
http.Error(w, "Login failed", http.StatusUnauthorized)
}

862
internal/handlers/web/users.go

@ -1,18 +1,872 @@
package web
import (
root "marmic/servicetrade-toolbox"
"bytes"
"encoding/csv"
"fmt"
"html/template"
"log"
"net/http"
"strconv"
"strings"
"time"
root "marmic/servicetrade-toolbox"
"marmic/servicetrade-toolbox/internal/api"
"marmic/servicetrade-toolbox/internal/middleware"
"github.com/gorilla/csrf"
)
// RoleAssignmentResult captures the outcome of assigning a role to a user.
type RoleAssignmentResult struct {
Token string
RoleID int
Role string
Success bool
Message string
}
// UserImportResult represents the processing result for a single CSV row.
type UserImportResult struct {
Row int
Username string
Email string
UserID int
Created bool
Error string
RoleAssignments []RoleAssignmentResult
ProcessingTime time.Duration
ServiceLineIDs []int
AdditionalFields map[string]string
}
// UserImportSummary aggregates statistics about a CSV import run.
type UserImportSummary struct {
TotalRows int
UsersCreated int
RowsFailed int
RoleAssignments int
RoleAssignmentErrors int
ProcessedFilename string
ProcessedAt time.Time
}
// UserUpdateResult represents the processing result for a single update row.
type UserUpdateResult struct {
Row int
Username string
Email string
UserID int
Updated bool
Error string
UpdatedFields []string
LookupMethod string
RoleAssignments []RoleAssignmentResult
ProcessingTime time.Duration
}
// UserUpdateSummary aggregates statistics about a CSV update run.
type UserUpdateSummary struct {
TotalRows int
UsersUpdated int
RowsFailed int
RoleAssignments int
RoleAssignmentErrors int
LookupsByUsername int
LookupsByID int
ProcessedFilename string
ProcessedAt time.Time
}
// UsersHandler renders the user management page.
func UsersHandler(w http.ResponseWriter, r *http.Request) {
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
data := baseUsersPageData(r)
data["Title"] = "Users"
data["Session"] = session
if roles, err := session.ListRoles(); err != nil {
log.Printf("UsersHandler: unable to load roles: %v", err)
data["RolesError"] = err.Error()
} else {
data["Roles"] = roles
}
renderUsersPage(w, r, data)
}
// UsersUploadHandler processes uploaded CSV files to create users and assign roles.
func UsersUploadHandler(w http.ResponseWriter, r *http.Request) {
start := time.Now()
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, fmt.Sprintf("Unable to parse upload: %v", err), http.StatusBadRequest)
return
}
file, header, err := r.FormFile("csvFile")
if err != nil {
http.Error(w, fmt.Sprintf("Unable to read file: %v", err), http.StatusBadRequest)
return
}
defer file.Close()
reader := csv.NewReader(file)
reader.FieldsPerRecord = -1
reader.TrimLeadingSpace = true
rows, err := reader.ReadAll()
if err != nil {
http.Error(w, fmt.Sprintf("Unable to read CSV: %v", err), http.StatusBadRequest)
return
}
data := baseUsersPageData(r)
data["Title"] = "Users"
data["Session"] = session
if len(rows) < 2 {
data["FlashError"] = "CSV must include a header row and at least one data row."
renderUsersPage(w, r, data)
return
}
headerMap := buildHeaderIndex(rows[0])
required := []string{"username", "firstname", "lastname", "email", "password", "companyid", "locationid"}
if missing := missingHeaders(headerMap, required); len(missing) > 0 {
data["FlashError"] = fmt.Sprintf("Missing required column headers: %s", strings.Join(missing, ", "))
renderUsersPage(w, r, data)
return
}
roles, rolesErr := session.ListRoles()
if rolesErr != nil {
log.Printf("UsersUploadHandler: unable to load roles: %v", rolesErr)
data["RolesError"] = fmt.Sprintf("Unable to load roles: %v. Role names in the CSV will not resolve.", rolesErr)
} else {
data["Roles"] = roles
}
roleIndexByName := buildRoleNameIndex(roles)
results := make([]UserImportResult, 0, len(rows)-1)
summary := UserImportSummary{
TotalRows: len(rows) - 1,
ProcessedAt: time.Now(),
ProcessedFilename: header.Filename,
}
for rowIdx, row := range rows[1:] {
rowStart := time.Now()
result := UserImportResult{
Row: rowIdx + 2,
Username: getValue(row, headerMap, "username"),
Email: getValue(row, headerMap, "email"),
AdditionalFields: map[string]string{},
}
if rowIsEmpty(row) {
continue
}
if err := validateRequiredRowFields(row, headerMap, required); err != nil {
result.Error = err.Error()
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
companyID, err := strconv.Atoi(getValue(row, headerMap, "companyid"))
if err != nil {
result.Error = fmt.Sprintf("invalid companyId: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
locationID, err := strconv.Atoi(getValue(row, headerMap, "locationid"))
if err != nil {
result.Error = fmt.Sprintf("invalid locationId: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
isSales, err := parseOptionalBool(getValue(row, headerMap, "issales"))
if err != nil {
result.Error = fmt.Sprintf("invalid isSales value: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
mfaRequired, err := parseOptionalBool(getValue(row, headerMap, "mfarequired"))
if err != nil {
result.Error = fmt.Sprintf("invalid mfaRequired value: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
managerID, err := parseOptionalInt(getValue(row, headerMap, "managerid"))
if err != nil {
result.Error = fmt.Sprintf("invalid managerId value: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
serviceLineIDs, err := parseIntList(getValue(row, headerMap, "servicelineids"))
if err != nil {
result.Error = fmt.Sprintf("invalid serviceLineIds value: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
result.ServiceLineIDs = serviceLineIDs
payload := api.CreateUserPayload{
Username: result.Username,
FirstName: getValue(row, headerMap, "firstname"),
LastName: getValue(row, headerMap, "lastname"),
Password: getValue(row, headerMap, "password"),
Email: result.Email,
Phone: getValue(row, headerMap, "phone"),
CompanyID: companyID,
LocationID: locationID,
Details: getValue(row, headerMap, "details"),
Status: getValue(row, headerMap, "status"),
Timezone: getValue(row, headerMap, "timezone"),
ServiceLineIDs: serviceLineIDs,
}
if isSales != nil {
payload.IsSales = isSales
}
if mfaRequired != nil {
payload.MFARequired = mfaRequired
}
if managerID != nil {
payload.ManagerID = managerID
}
userRecord, err := session.CreateUser(payload)
if err != nil {
result.Error = fmt.Sprintf("failed to create user: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
result.Created = true
result.UserID = userRecord.ID
summary.UsersCreated++
roleTokens := parseRoleTokens(getValue(row, headerMap, "roles"))
if len(roleTokens) > 0 {
assignments := make([]RoleAssignmentResult, 0, len(roleTokens))
for _, token := range roleTokens {
assignResult := RoleAssignmentResult{Token: token}
role, resolved, message := resolveRoleToken(token, roleIndexByName)
if !resolved {
assignResult.Message = message
assignments = append(assignments, assignResult)
summary.RoleAssignmentErrors++
continue
}
assignResult.RoleID = role.ID
assignResult.Role = role.Name
if err := session.AssignRoleToUser(userRecord.ID, role.ID); err != nil {
assignResult.Message = fmt.Sprintf("failed to assign role: %v", err)
summary.RoleAssignmentErrors++
} else {
assignResult.Success = true
assignResult.Message = "assigned"
summary.RoleAssignments++
}
assignments = append(assignments, assignResult)
}
result.RoleAssignments = assignments
}
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
}
data["ImportResults"] = results
data["ImportSummary"] = summary
data["FlashSuccess"] = fmt.Sprintf("Processed %d row(s) in %s.", summary.TotalRows, time.Since(start).Round(time.Millisecond))
renderUsersPage(w, r, data)
}
// UsersUpdateHandler renders the user update page.
func UsersUpdateHandler(w http.ResponseWriter, r *http.Request) {
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
data := baseUsersPageData(r)
data["Title"] = "User Updates"
data["Session"] = session
if roles, err := session.ListRoles(); err != nil {
log.Printf("UsersUpdateHandler: unable to load roles: %v", err)
data["RolesError"] = err.Error()
} else {
data["Roles"] = roles
}
renderUsersUpdatePage(w, r, data)
}
// UsersUpdateUploadHandler processes CSV uploads to update existing users.
func UsersUpdateUploadHandler(w http.ResponseWriter, r *http.Request) {
start := time.Now()
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, fmt.Sprintf("Unable to parse upload: %v", err), http.StatusBadRequest)
return
}
file, header, err := r.FormFile("csvFile")
if err != nil {
http.Error(w, fmt.Sprintf("Unable to read file: %v", err), http.StatusBadRequest)
return
}
defer file.Close()
reader := csv.NewReader(file)
reader.FieldsPerRecord = -1
reader.TrimLeadingSpace = true
rows, err := reader.ReadAll()
if err != nil {
http.Error(w, fmt.Sprintf("Unable to read CSV: %v", err), http.StatusBadRequest)
return
}
data := baseUsersPageData(r)
data["Title"] = "User Updates"
data["Session"] = session
if len(rows) < 2 {
data["FlashError"] = "CSV must include a header row and at least one data row."
renderUsersUpdatePage(w, r, data)
return
}
headerMap := buildHeaderIndex(rows[0])
if _, ok := headerMap["username"]; !ok {
if _, ok := headerMap["userid"]; !ok {
data["FlashError"] = "CSV must include either a username or userId column to locate users."
renderUsersUpdatePage(w, r, data)
return
}
}
roles, rolesErr := session.ListRoles()
if rolesErr != nil {
log.Printf("UsersUpdateUploadHandler: unable to load roles: %v", rolesErr)
data["RolesError"] = fmt.Sprintf("Unable to load roles: %v. Role names in the CSV will not resolve.", rolesErr)
} else {
data["Roles"] = roles
}
roleIndexByName := buildRoleNameIndex(roles)
results := make([]UserUpdateResult, 0, len(rows)-1)
summary := UserUpdateSummary{
TotalRows: len(rows) - 1,
ProcessedAt: time.Now(),
ProcessedFilename: header.Filename,
}
for rowIdx, row := range rows[1:] {
rowStart := time.Now()
if rowIsEmpty(row) {
continue
}
result := UserUpdateResult{
Row: rowIdx + 2,
Username: getValue(row, headerMap, "username"),
Email: getValue(row, headerMap, "email"),
}
userIDValue := getValue(row, headerMap, "userid")
var userID int
if userIDValue != "" {
parsedID, err := strconv.Atoi(userIDValue)
if err != nil || parsedID <= 0 {
result.Error = fmt.Sprintf("invalid userId: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
userID = parsedID
result.UserID = userID
result.LookupMethod = "userId"
summary.LookupsByID++
} else if result.Username != "" {
userRecord, err := session.FindUserByUsername(result.Username)
if err != nil {
result.Error = fmt.Sprintf("failed to locate user by username: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
userID = userRecord.ID
result.UserID = userID
result.LookupMethod = "username"
summary.LookupsByUsername++
} else {
result.Error = "row missing username and userId"
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
payload := api.UpdateUserPayload{}
var updatedFields []string
if v := getValue(row, headerMap, "firstname"); v != "" {
payload.FirstName = stringPtr(v)
updatedFields = append(updatedFields, "firstName")
}
if v := getValue(row, headerMap, "new_username"); v != "" {
payload.Username = stringPtr(v)
updatedFields = append(updatedFields, "username")
}
if v := getValue(row, headerMap, "lastname"); v != "" {
payload.LastName = stringPtr(v)
updatedFields = append(updatedFields, "lastName")
}
if v := getValue(row, headerMap, "email"); v != "" {
payload.Email = stringPtr(v)
updatedFields = append(updatedFields, "email")
}
if v := getValue(row, headerMap, "password"); v != "" {
payload.Password = stringPtr(v)
updatedFields = append(updatedFields, "password")
}
if v := getValue(row, headerMap, "phone"); v != "" {
payload.Phone = stringPtr(v)
updatedFields = append(updatedFields, "phone")
}
if v := getValue(row, headerMap, "details"); v != "" {
payload.Details = stringPtr(v)
updatedFields = append(updatedFields, "details")
}
if v := getValue(row, headerMap, "status"); v != "" {
payload.Status = stringPtr(v)
updatedFields = append(updatedFields, "status")
}
if v := getValue(row, headerMap, "timezone"); v != "" {
payload.Timezone = stringPtr(v)
updatedFields = append(updatedFields, "timezone")
}
if companyVal := getValue(row, headerMap, "companyid"); companyVal != "" {
companyID, err := strconv.Atoi(companyVal)
if err != nil {
result.Error = fmt.Sprintf("invalid companyId: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
payload.CompanyID = intPtr(companyID)
updatedFields = append(updatedFields, "companyId")
}
if locationVal := getValue(row, headerMap, "locationid"); locationVal != "" {
locationID, err := strconv.Atoi(locationVal)
if err != nil {
result.Error = fmt.Sprintf("invalid locationId: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
payload.LocationID = intPtr(locationID)
updatedFields = append(updatedFields, "locationId")
}
if managerVal := getValue(row, headerMap, "managerid"); managerVal != "" {
managerID, err := strconv.Atoi(managerVal)
if err != nil {
result.Error = fmt.Sprintf("invalid managerId: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
payload.ManagerID = intPtr(managerID)
updatedFields = append(updatedFields, "managerId")
}
if isSales, err := parseOptionalBool(getValue(row, headerMap, "issales")); err != nil {
result.Error = fmt.Sprintf("invalid isSales value: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
} else if isSales != nil {
payload.IsSales = isSales
updatedFields = append(updatedFields, "isSales")
}
if mfaRequired, err := parseOptionalBool(getValue(row, headerMap, "mfarequired")); err != nil {
result.Error = fmt.Sprintf("invalid mfaRequired value: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
} else if mfaRequired != nil {
payload.MFARequired = mfaRequired
updatedFields = append(updatedFields, "mfaRequired")
}
if serviceLineIDs, err := parseIntList(getValue(row, headerMap, "servicelineids")); err != nil {
result.Error = fmt.Sprintf("invalid serviceLineIds value: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
} else if serviceLineIDs != nil {
payload.ServiceLineIDs = &serviceLineIDs
updatedFields = append(updatedFields, "serviceLineIds")
}
if len(updatedFields) == 0 {
result.Error = "no updatable fields provided in row"
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
if _, err := session.UpdateUser(userID, payload); err != nil {
result.Error = fmt.Sprintf("failed to update user: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
result.Updated = true
result.UpdatedFields = updatedFields
summary.UsersUpdated++
roleTokens := parseRoleTokens(getValue(row, headerMap, "roles"))
if len(roleTokens) > 0 {
assignments := make([]RoleAssignmentResult, 0, len(roleTokens))
for _, token := range roleTokens {
assignResult := RoleAssignmentResult{Token: token}
role, resolved, message := resolveRoleToken(token, roleIndexByName)
if !resolved {
assignResult.Message = message
assignments = append(assignments, assignResult)
summary.RoleAssignmentErrors++
continue
}
assignResult.RoleID = role.ID
assignResult.Role = role.Name
if err := session.AssignRoleToUser(userID, role.ID); err != nil {
assignResult.Message = fmt.Sprintf("failed to assign role: %v", err)
summary.RoleAssignmentErrors++
} else {
assignResult.Success = true
assignResult.Message = "assigned"
summary.RoleAssignments++
}
assignments = append(assignments, assignResult)
}
result.RoleAssignments = assignments
}
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
}
data["UpdateResults"] = results
data["UpdateSummary"] = summary
if summary.UsersUpdated > 0 {
data["FlashSuccess"] = fmt.Sprintf("Updated %d user(s) (processed %d rows in %s).", summary.UsersUpdated, summary.TotalRows, time.Since(start).Round(time.Millisecond))
} else {
data["FlashError"] = "No users were updated. Review the row errors below."
}
renderUsersUpdatePage(w, r, data)
}
func renderUsersPage(w http.ResponseWriter, r *http.Request, data map[string]interface{}) {
renderUsersTemplatePage(w, r, data, "users_content")
}
func renderUsersUpdatePage(w http.ResponseWriter, r *http.Request, data map[string]interface{}) {
renderUsersTemplatePage(w, r, data, "users_update_content")
}
func renderUsersTemplatePage(w http.ResponseWriter, r *http.Request, data map[string]interface{}, templateName string) {
tmpl := root.WebTemplates
if r.Header.Get("HX-Request") == "true" {
if err := tmpl.ExecuteTemplate(w, templateName, data); err != nil {
log.Printf("UsersHandler: template error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
var contentBuf bytes.Buffer
if err := tmpl.ExecuteTemplate(&contentBuf, templateName, data); err != nil {
log.Printf("UsersHandler: template error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data["BodyContent"] = template.HTML(contentBuf.String())
if err := tmpl.ExecuteTemplate(w, "layout.html", data); err != nil {
log.Printf("UsersHandler: layout template error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func baseUsersPageData(r *http.Request) map[string]interface{} {
data := map[string]interface{}{
"Title": "Users",
"CSRFField": csrf.TemplateField(r),
"CSRFToken": csrf.Token(r),
}
if c, err := r.Cookie("XSRF-TOKEN"); err == nil {
data["CSRFCookie"] = c.Value
} else if c, err := r.Cookie("XSRF-TOKEN-VALUE"); err == nil {
data["CSRFCookie"] = c.Value
}
return data
}
func buildHeaderIndex(headers []string) map[string]int {
index := make(map[string]int, len(headers))
for idx, header := range headers {
if header == "" {
continue
}
normalized := normalizeHeader(header)
if canonical, ok := headerCanonicalMap[normalized]; ok {
index[canonical] = idx
} else {
index[normalized] = idx
}
}
return index
}
err := tmpl.Execute(w, data)
func missingHeaders(headerIndex map[string]int, required []string) []string {
var missing []string
for _, key := range required {
if _, ok := headerIndex[key]; !ok {
missing = append(missing, key)
}
}
return missing
}
func rowIsEmpty(row []string) bool {
for _, value := range row {
if strings.TrimSpace(value) != "" {
return false
}
}
return true
}
func validateRequiredRowFields(row []string, index map[string]int, required []string) error {
var missing []string
for _, key := range required {
if strings.TrimSpace(getValue(row, index, key)) == "" {
missing = append(missing, key)
}
}
if len(missing) > 0 {
return fmt.Errorf("missing required value(s): %s", strings.Join(missing, ", "))
}
return nil
}
func getValue(row []string, index map[string]int, key string) string {
if idx, ok := index[key]; ok && idx < len(row) {
return strings.TrimSpace(row[idx])
}
return ""
}
func parseOptionalBool(value string) (*bool, error) {
if value == "" {
return nil, nil
}
switch strings.ToLower(strings.TrimSpace(value)) {
case "true", "1", "yes", "y":
result := true
return &result, nil
case "false", "0", "no", "n":
result := false
return &result, nil
default:
return nil, fmt.Errorf("expected boolean value, got %q", value)
}
}
func parseOptionalInt(value string) (*int, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
intVal, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return nil, err
}
return &intVal, nil
}
func parseIntList(raw string) ([]int, error) {
if strings.TrimSpace(raw) == "" {
return nil, nil
}
parts := splitTokens(raw)
ints := make([]int, 0, len(parts))
for _, part := range parts {
id, err := strconv.Atoi(part)
if err != nil {
return nil, fmt.Errorf("invalid integer %q", part)
}
ints = append(ints, id)
}
return ints, nil
}
func parseRoleTokens(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
return splitTokens(raw)
}
func splitTokens(raw string) []string {
cleaned := strings.NewReplacer(";", ",", "|", ",").Replace(raw)
parts := strings.Split(cleaned, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
token := strings.TrimSpace(part)
if token != "" {
result = append(result, token)
}
}
return result
}
func resolveRoleToken(token string, nameIndex map[string]api.Role) (api.Role, bool, string) {
clean := strings.TrimSpace(token)
if clean == "" {
return api.Role{}, false, "empty role token"
}
normalized := normalizeRoleName(clean)
if role, ok := nameIndex[normalized]; ok {
return role, true, ""
}
return api.Role{}, false, fmt.Sprintf("role %q not found; use the role name exactly as listed", token)
}
func buildRoleNameIndex(roles []api.Role) map[string]api.Role {
nameIndex := make(map[string]api.Role, len(roles))
for _, role := range roles {
nameKey := normalizeRoleName(role.Name)
if nameKey != "" {
nameIndex[nameKey] = role
}
}
return nameIndex
}
func normalizeHeader(value string) string {
v := strings.TrimSpace(strings.ToLower(value))
v = strings.ReplaceAll(v, " ", "")
v = strings.ReplaceAll(v, "_", "")
v = strings.ReplaceAll(v, "-", "")
return v
}
func normalizeRoleName(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
var headerCanonicalMap = map[string]string{
"username": "username",
"user": "username",
"firstname": "firstname",
"first": "firstname",
"lastname": "lastname",
"last": "lastname",
"email": "email",
"mail": "email",
"password": "password",
"pass": "password",
"companyid": "companyid",
"company": "companyid",
"locationid": "locationid",
"location": "locationid",
"userid": "userid",
"id": "userid",
"newusername": "new_username",
"username_new": "new_username",
"usernameupdate": "new_username",
"phone": "phone",
"phonenumber": "phone",
"roles": "roles",
"roleids": "roles",
"rolenames": "roles",
"status": "status",
"timezone": "timezone",
"details": "details",
"issales": "issales",
"sales": "issales",
"managerid": "managerid",
"manager": "managerid",
"mfarequired": "mfarequired",
"mfa": "mfarequired",
"servicelineids": "servicelineids",
}
func stringPtr(value string) *string {
v := value
return &v
}
func intPtr(value int) *int {
v := value
return &v
}

30
internal/middleware/csrf_expose.go

@ -0,0 +1,30 @@
package middleware
import (
"net/http"
"strings"
"github.com/gorilla/csrf"
)
// CSRFExposeToken sets a readable cookie with the per-request masked CSRF token on safe methods.
func CSRFExposeToken(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
token := csrf.Token(r)
if token != "" {
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(w, &http.Cookie{
Name: "XSRF-TOKEN-VALUE",
Value: token,
Path: "/",
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
}
next.ServeHTTP(w, r)
})
}

93
internal/middleware/csrf_simple.go

@ -0,0 +1,93 @@
package middleware
import (
"crypto/rand"
"encoding/base64"
"log"
"net/http"
"strings"
"time"
)
// CSRFSimple is a lightweight double-submit-cookie CSRF middleware suitable for HTMX
// - Sets a readable XSRF-TOKEN cookie on safe requests if missing
// - Requires unsafe requests to include X-CSRF-Token header matching the cookie
func CSRFSimple(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method := strings.ToUpper(r.Method)
// Determine if connection is effectively HTTPS (behind proxy aware)
isHTTPS := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
// Ensure token cookie exists for safe methods
if method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions || method == http.MethodTrace {
token := ""
if c, err := r.Cookie("XSRF-TOKEN"); err == nil {
if v := strings.TrimSpace(c.Value); v != "" && !strings.Contains(v, "|") {
token = v
}
}
if token == "" {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err == nil {
token = base64.RawURLEncoding.EncodeToString(buf)
}
}
if token != "" {
// Refresh both legacy and simple-mode cookie names so the frontend reads the correct value.
for _, name := range []string{"XSRF-TOKEN", "XSRF-TOKEN-VALUE"} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: token,
Path: "/",
HttpOnly: false,
Secure: isHTTPS,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(12 * time.Hour),
})
}
}
next.ServeHTTP(w, r)
return
}
// For unsafe methods, require header matches cookie
token := r.Header.Get("X-CSRF-Token")
if token == "" {
contentType := r.Header.Get("Content-Type")
if strings.Contains(contentType, "multipart/form-data") {
if err := r.ParseMultipartForm(10 << 20); err == nil {
token = r.PostFormValue("csrfToken")
}
} else if err := r.ParseForm(); err == nil {
token = r.PostFormValue("csrfToken")
}
}
tokenCookie, err := r.Cookie("XSRF-TOKEN")
if err != nil || tokenCookie == nil || strings.Contains(tokenCookie.Value, "|") || tokenCookie.Value == "" {
if legacyCookie, legacyErr := r.Cookie("XSRF-TOKEN-VALUE"); legacyErr == nil {
tokenCookie = legacyCookie
} else {
err = legacyErr
}
}
if token == "" || tokenCookie == nil || tokenCookie.Value == "" || tokenCookie.Value != token {
if token == "" {
if headerToken := r.Header.Get("X-CSRF-Token"); headerToken != "" {
token = headerToken
}
}
log.Printf("CSRF validation failed (simple mode): header=%q form=%q cookie=%q err=%v", r.Header.Get("X-CSRF-Token"), token, func() string {
if tokenCookie != nil {
return tokenCookie.Value
}
return ""
}(), err)
http.Error(w, "Forbidden - CSRF", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}

18
internal/middleware/security_headers.go

@ -0,0 +1,18 @@
package middleware
import (
"net/http"
)
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://unpkg.com 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
if r.TLS != nil {
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
}
next.ServeHTTP(w, r)
})
}

80
templates/generic.html

@ -7,12 +7,80 @@
<div class="page-content">
<div class="content">
<h3 class="submenu-header">Generic Tools & Utilities</h3>
<p>Generic tools and utilities will be implemented here.</p>
<div class="placeholder-content">
<div class="placeholder-icon">🔧</div>
<h4>Coming Soon</h4>
<p>Generic tools and utilities are under development.</p>
</div>
<section class="card">
<h4>Invoice Clock Events Report (Proof of Concept)</h4>
<p>
Upload a CSV export that contains either job ids or invoice numbers. The tool automatically detects
which identifier is usable, gathers the related clock activity, and returns an Excel workbook formatted
like the shared audit template (Customer PO, id, Link, Clock In, Clock Out) with the header row frozen
and columns expanded.
</p>
<form id="invoice-clock-form" class="form" action="/reports/invoice-clock" method="POST"
enctype="multipart/form-data" hx-target="#invoice-clock-status" hx-indicator=".htmx-indicator">
{{if .CSRFField}}
{{.CSRFField}}
{{end}}
<input type="hidden" name="csrfToken" id="generic-csrf-token" value="{{.CSRFCookie}}">
<div class="form-group">
<label for="invoiceCsv">Select CSV file</label>
<input class="card-input" type="file" id="invoiceCsv" name="invoiceCsv" accept=".csv" required>
</div>
<p class="help-text">
If a <code>job id</code> column is present we use it directly; otherwise we fall back to columns such as
<code>invoice_number</code> or <code>customer po</code> to discover the job before pulling clock events.
</p>
<button class="btn-primary" type="submit">Generate XLSX Report</button>
</form>
<div class="status-message subtle-tip">
Generating very large reports can take a few minutes after you submit. Please keep the tab open while the download prepares.
</div>
<div id="invoice-clock-indicator" class="htmx-indicator status-indicator" style="display:none;">
<div class="spinner"></div>
<div>
<strong>Building report…</strong>
<p>Hang tight while we fetch clock events and assemble the workbook.</p>
</div>
</div>
<div id="invoice-clock-status" class="status-message"></div>
</section>
</div>
</div>
<script>
(function () {
function readCookie(name) {
var re = new RegExp('(?:^|; )' + name.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') + '=([^;]+)');
var m = document.cookie.match(re);
return m ? decodeURIComponent(m[1]) : null;
}
function applyToken(root) {
var form = root.querySelector ? root.querySelector('#invoice-clock-form') : document.getElementById('invoice-clock-form');
if (!form) { return; }
var field = form.querySelector('#generic-csrf-token');
if (!field) { return; }
var token = readCookie('XSRF-TOKEN') || readCookie('XSRF-TOKEN-VALUE');
if (token) {
field.value = token;
}
}
function ensureTokenBeforeSubmit(evt) {
if (!evt.target || evt.target.id !== 'invoice-clock-form') {
return;
}
applyToken(evt.target);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { applyToken(document); });
} else {
applyToken(document);
}
document.addEventListener('submit', ensureTokenBeforeSubmit, true);
})();
</script>
{{end}}

17
templates/layout.html

@ -6,6 +6,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ServiceTrade Tools</title>
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
<script>
// Send CSRF token on all htmx requests: prefer masked token cookie (XSRF-TOKEN-VALUE), fallback to storage token (XSRF-TOKEN)
document.addEventListener('DOMContentLoaded', function () {
function readCookie(name) {
var re = new RegExp('(?:^|; )' + name.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') + '=([^;]+)');
var m = document.cookie.match(re);
return m ? decodeURIComponent(m[1]) : null;
}
document.body.addEventListener('htmx:configRequest', function (evt) {
var token = readCookie('XSRF-TOKEN-VALUE') || readCookie('XSRF-TOKEN');
if (token) { evt.detail.headers['X-CSRF-Token'] = token; }
});
});
</script>
<link rel="stylesheet" href="/static/css/styles.css" />
<link rel="stylesheet" href="/static/css/upload.css" />
<script src="/static/js/dashboard-drag.js"></script>
@ -31,6 +45,7 @@
<li><a href="/services">Services</a></li>
<li><a href="/tags">Tags</a></li>
<li><a href="/users">Users</a></li>
<li><a href="/users/update">User Updates</a></li>
<li><a href="/admin">Admin</a></li>
</ul>
</nav>
@ -73,6 +88,8 @@
{{template "tags_content" .}}
{{else if eq .Title "Users"}}
{{template "users_content" .}}
{{else if eq .Title "User Updates"}}
{{template "users_update_content" .}}
{{else if eq .Title "Admin"}}
{{template "admin_content" .}}
{{else}}

132
templates/users.html

@ -1,18 +1,132 @@
{{define "users_content"}}
<div class="page-header">
<h2>Users Management</h2>
<p>Manage user accounts and permissions.</p>
<h2>User Imports</h2>
<p>Upload a CSV to create ServiceTrade users and assign roles in bulk.</p>
</div>
<div class="page-content">
<div class="content">
<h3 class="submenu-header">User Search & Management</h3>
<p>User management functionality will be implemented here.</p>
<div class="placeholder-content">
<div class="placeholder-icon">👤</div>
<h4>Coming Soon</h4>
<p>User management features are under development.</p>
<h3 class="submenu-header">Upload CSV</h3>
{{if .FlashError}}
<div class="error-message">{{.FlashError}}</div>
{{end}}
{{if .FlashSuccess}}
<div class="info-message">{{.FlashSuccess}}</div>
{{end}}
<form action="/users/upload" method="POST" enctype="multipart/form-data">
{{if .CSRFField}}{{.CSRFField}}{{end}}
{{if .CSRFCookie}}<input type="hidden" name="csrfToken" value="{{.CSRFCookie}}">{{end}}
<label for="users-csv">CSV file</label>
<input id="users-csv" name="csvFile" type="file" accept=".csv" class="card-input" required>
<button type="submit" class="btn-primary">Process CSV</button>
</form>
<div class="info-message" style="margin-top: 1rem;">
<strong>Required headers:</strong> username, firstName, lastName, email, password, companyId, locationId.
Optional headers include phone, status, timezone, details, isSales, managerId, mfaRequired, serviceLineIds, and roles.
In the roles column list the human-readable role names (comma, semicolon, or pipe separated); names are matched case-insensitively.
</div>
</div>
{{if .RolesError}}
<div class="content" style="margin-top:1rem;">
<div class="error-message">{{.RolesError}}</div>
</div>
{{end}}
{{if .Roles}}
<div class="content" style="margin-top:1rem;">
<h3 class="submenu-header">Available Roles</h3>
<p>Use the role names from this list in the CSV roles column.</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Description</th>
<th>Global</th>
</tr>
</thead>
<tbody>
{{range .Roles}}
<tr>
<td>{{.ID}}</td>
<td>{{.Name}}</td>
<td>{{.Description}}</td>
<td>{{if .Global}}Yes{{else}}No{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{if .ImportSummary}}
<div class="content" style="margin-top:1rem;">
<h3 class="submenu-header">Import Summary</h3>
<p>
Processed file: <strong>{{.ImportSummary.ProcessedFilename}}</strong> at {{.ImportSummary.ProcessedAt.Format "2006-01-02 15:04 MST"}}.
</p>
<ul>
<li>Total rows: {{.ImportSummary.TotalRows}}</li>
<li>Users created: {{.ImportSummary.UsersCreated}}</li>
<li>Rows failed: {{.ImportSummary.RowsFailed}}</li>
<li>Role assignments succeeded: {{.ImportSummary.RoleAssignments}}</li>
<li>Role assignments failed: {{.ImportSummary.RoleAssignmentErrors}}</li>
</ul>
</div>
{{end}}
{{if .ImportResults}}
<div class="content" style="margin-top:1rem;">
<h3 class="submenu-header">Import Details</h3>
<table>
<thead>
<tr>
<th>Row</th>
<th>Username</th>
<th>Email</th>
<th>User Result</th>
<th>Role Assignments</th>
<th>Processing Time</th>
</tr>
</thead>
<tbody>
{{range .ImportResults}}
<tr>
<td>{{.Row}}</td>
<td>{{.Username}}</td>
<td>{{.Email}}</td>
<td>
{{if .Error}}
<div class="error-message">{{.Error}}</div>
{{else if .Created}}
Created (#{{.UserID}})
{{else}}
Skipped
{{end}}
</td>
<td>
{{if .RoleAssignments}}
<ul>
{{range .RoleAssignments}}
<li>
{{if .Success}}✅{{else}}⚠️{{end}}
{{if .Role}}{{.Role}} ({{.RoleID}}){{else}}{{.Token}}{{end}} &mdash; {{.Message}}
</li>
{{end}}
</ul>
{{else if .Error}}
&mdash;
{{else}}
No roles provided
{{end}}
</td>
<td>{{.ProcessingTime}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
</div>
{{end}}
{{end}}

152
templates/users_update.html

@ -0,0 +1,152 @@
{{define "users_update_content"}}
<div class="page-header">
<h2>User Updates</h2>
<p>Upload a CSV to update existing ServiceTrade users and assign additional roles.</p>
</div>
<div class="page-content">
<div class="content">
<h3 class="submenu-header">Upload Update CSV</h3>
{{if .FlashError}}
<div class="error-message">{{.FlashError}}</div>
{{end}}
{{if .FlashSuccess}}
<div class="info-message">{{.FlashSuccess}}</div>
{{end}}
<form action="/users/update/upload" method="POST" enctype="multipart/form-data">
{{if .CSRFField}}{{.CSRFField}}{{end}}
{{if .CSRFCookie}}<input type="hidden" name="csrfToken" value="{{.CSRFCookie}}">{{end}}
<label for="users-update-csv">CSV file</label>
<input id="users-update-csv" name="csvFile" type="file" accept=".csv" class="card-input" required>
<button type="submit" class="btn-primary">Process Updates</button>
</form>
<div class="info-message" style="margin-top: 1rem;">
Provide either <strong>userId</strong> or <strong>username</strong> for each row. Use <strong>new_username</strong> to change an account's username. All other columns match the create template; only non-empty values are applied. Roles column should list human-readable role names (comma, semicolon, or pipe separated).
</div>
</div>
{{if .RolesError}}
<div class="content" style="margin-top:1rem;">
<div class="error-message">{{.RolesError}}</div>
</div>
{{end}}
{{if .Roles}}
<div class="content" style="margin-top:1rem;">
<h3 class="submenu-header">Available Roles</h3>
<p>Use the role names from this list in the CSV roles column.</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Description</th>
<th>Global</th>
</tr>
</thead>
<tbody>
{{range .Roles}}
<tr>
<td>{{.ID}}</td>
<td>{{.Name}}</td>
<td>{{.Description}}</td>
<td>{{if .Global}}Yes{{else}}No{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{if .UpdateSummary}}
<div class="content" style="margin-top:1rem;">
<h3 class="submenu-header">Update Summary</h3>
<p>
Processed file: <strong>{{.UpdateSummary.ProcessedFilename}}</strong> at {{.UpdateSummary.ProcessedAt.Format "2006-01-02 15:04 MST"}}.
</p>
<ul>
<li>Total rows: {{.UpdateSummary.TotalRows}}</li>
<li>Users updated: {{.UpdateSummary.UsersUpdated}}</li>
<li>Rows failed: {{.UpdateSummary.RowsFailed}}</li>
<li>Role assignments succeeded: {{.UpdateSummary.RoleAssignments}}</li>
<li>Role assignments failed: {{.UpdateSummary.RoleAssignmentErrors}}</li>
<li>Lookups by username: {{.UpdateSummary.LookupsByUsername}}</li>
<li>Lookups by userId: {{.UpdateSummary.LookupsByID}}</li>
</ul>
</div>
{{end}}
{{if .UpdateResults}}
<div class="content" style="margin-top:1rem;">
<h3 class="submenu-header">Update Details</h3>
<table>
<thead>
<tr>
<th>Row</th>
<th>User</th>
<th>Lookup</th>
<th>Updated Fields</th>
<th>Result</th>
<th>Role Assignments</th>
<th>Processing Time</th>
</tr>
</thead>
<tbody>
{{range .UpdateResults}}
<tr>
<td>{{.Row}}</td>
<td>
{{if .UserID}}
#{{.UserID}}
{{end}}
{{if .Username}}
({{.Username}})
{{end}}
</td>
<td>{{.LookupMethod}}</td>
<td>
{{if .UpdatedFields}}
<ul>
{{range .UpdatedFields}}
<li>{{.}}</li>
{{end}}
</ul>
{{else}}
&mdash;
{{end}}
</td>
<td>
{{if .Error}}
<div class="error-message">{{.Error}}</div>
{{else if .Updated}}
Updated
{{else}}
Skipped
{{end}}
</td>
<td>
{{if .RoleAssignments}}
<ul>
{{range .RoleAssignments}}
<li>
{{if .Success}}✅{{else}}⚠️{{end}}
{{if .Role}}{{.Role}} ({{.RoleID}}){{else}}{{.Token}}{{end}} &mdash; {{.Message}}
</li>
{{end}}
</ul>
{{else if .Error}}
&mdash;
{{else}}
No roles provided
{{end}}
</td>
<td>{{.ProcessingTime}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
</div>
{{end}}
Loading…
Cancel
Save