Browse Source

feat: added user creation/update

production
nic 4 months ago
parent
commit
f5160a95b6
  1. 3
      apps/web/main.go
  2. BIN
      apps/web/web-app
  3. 9
      go.mod
  4. 19
      go.sum
  5. 481
      internal/api/users.go
  6. 783
      internal/handlers/web/invoice_clock_report.go
  7. 862
      internal/handlers/web/users.go
  8. 36
      internal/middleware/csrf_simple.go
  9. 31
      templates/generic.html
  10. 3
      templates/layout.html
  11. 130
      templates/users.html
  12. 152
      templates/users_update.html

3
apps/web/main.go

@ -145,6 +145,9 @@ func main() {
protected.HandleFunc("/services", web.ServicesHandler).Methods("GET") protected.HandleFunc("/services", web.ServicesHandler).Methods("GET")
protected.HandleFunc("/tags", web.TagsHandler).Methods("GET") protected.HandleFunc("/tags", web.TagsHandler).Methods("GET")
protected.HandleFunc("/users", web.UsersHandler).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 // Document upload routes
protected.HandleFunc("/documents", web.DocumentsHandler).Methods("GET") protected.HandleFunc("/documents", web.DocumentsHandler).Methods("GET")

BIN
apps/web/web-app

Binary file not shown.

9
go.mod

@ -11,4 +11,13 @@ require (
require ( require (
github.com/felixge/httpsnoop v1.0.3 // indirect github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/gorilla/securecookie v1.1.2 // 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
) )

19
go.sum

@ -10,3 +10,22 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 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=

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
}

783
internal/handlers/web/invoice_clock_report.go

@ -4,30 +4,28 @@ import (
"bytes" "bytes"
"encoding/csv" "encoding/csv"
"fmt" "fmt"
"log"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/xuri/excelize/v2"
"marmic/servicetrade-toolbox/internal/api" "marmic/servicetrade-toolbox/internal/api"
"marmic/servicetrade-toolbox/internal/middleware" "marmic/servicetrade-toolbox/internal/middleware"
) )
var invoiceClockReportHeader = []string{ var reportHeader = []string{
"invoice_number", "Customer PO",
"invoice_id", "id",
"job_id", "Link",
"job_number", "Clock In",
"assignment_type", "Clock Out",
"vendor_name",
"tech_names",
"enroute_events",
"onsite_events",
"error",
} }
// InvoiceClockReportHandler processes uploaded CSVs of invoice numbers and returns a clock-event report. // InvoiceClockReportHandler processes a CSV upload and returns a CSV matching the requested template.
func InvoiceClockReportHandler(w http.ResponseWriter, r *http.Request) { func InvoiceClockReportHandler(w http.ResponseWriter, r *http.Request) {
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
if !ok { if !ok {
@ -59,272 +57,719 @@ func InvoiceClockReportHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
if len(records) == 0 { if len(records) < 2 {
http.Error(w, "CSV file is empty", http.StatusBadRequest) http.Error(w, "CSV must include a header row and at least one data row", http.StatusBadRequest)
return return
} }
invoiceNumbers := extractInvoiceNumbers(records) header := records[0]
if len(invoiceNumbers) == 0 { columns := detectInputColumns(header)
http.Error(w, "No invoice numbers found in CSV", http.StatusBadRequest)
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 return
} }
var buffer bytes.Buffer log.Printf("Invoice clock report: detected columns jobID=%d invoice=%d customerPO=%d",
writer := csv.NewWriter(&buffer) 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 err := writer.Write(invoiceClockReportHeader); err != nil { if len(tasks) == 0 {
http.Error(w, fmt.Sprintf("Error writing CSV header: %v", err), http.StatusInternalServerError) var buffer bytes.Buffer
if err := f.Write(&buffer); err != nil {
http.Error(w, fmt.Sprintf("Error finalizing XLSX: %v", err), http.StatusInternalServerError)
return return
} }
if err := f.Close(); err != nil {
log.Printf("Warning: error closing workbook: %v", err)
}
for _, invoiceNumber := range invoiceNumbers { filename := fmt.Sprintf("invoice-clock-report-%s.xlsx", time.Now().Format("20060102-150405"))
row := buildInvoiceClockReportRow(session, invoiceNumber) w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
if err := writer.Write(row); err != nil { w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
http.Error(w, fmt.Sprintf("Error writing CSV row: %v", err), http.StatusInternalServerError) w.Header().Set("Cache-Control", "no-store")
w.Write(buffer.Bytes())
return 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
} }
writer.Flush() jobsCh := make(chan rowTask)
if err := writer.Error(); err != nil { resultsCh := make(chan rowResult, workerCount)
http.Error(w, fmt.Sprintf("Error finalizing CSV: %v", err), http.StatusInternalServerError)
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 return
} }
filename := fmt.Sprintf("invoice-clock-report-%s.csv", time.Now().Format("20060102-150405")) var buffer bytes.Buffer
w.Header().Set("Content-Type", "text/csv") 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("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
w.Write(buffer.Bytes()) w.Write(buffer.Bytes())
} }
func extractInvoiceNumbers(records [][]string) []string { type inputColumns struct {
if len(records) == 0 { jobID int
return nil 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))
} }
headers := make([]string, len(records[0])) jobPriority := []string{"id", "jobid", "job_id"}
for i, header := range records[0] { for _, candidate := range jobPriority {
headers[i] = strings.ToLower(strings.TrimSpace(header)) if cols.jobID != -1 {
break
}
for i, name := range normalized {
if name == candidate {
cols.jobID = i
break
}
}
} }
columnIndex := 0 if cols.jobID == -1 {
hasHeader := false for i, name := range normalized {
for i, header := range headers { if strings.Contains(name, "job") && strings.Contains(name, "id") {
if header == "invoice_number" || header == "invoicenumber" || header == "invoice" { cols.jobID = i
columnIndex = i
hasHeader = true
break break
} }
} }
}
var numbers []string invoiceCandidates := []string{"invoicenumber", "invoice", "refnumber", "ref"}
startRow := 0 for _, candidate := range invoiceCandidates {
if hasHeader { if cols.invoice != -1 {
startRow = 1 break
} }
for _, row := range records[startRow:] { for i, name := range normalized {
if columnIndex >= len(row) { if name == candidate {
continue cols.invoice = i
break
} }
number := strings.TrimSpace(row[columnIndex])
if number != "" {
numbers = append(numbers, number)
} }
} }
return numbers 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
}
}
}
func buildInvoiceClockReportRow(session *api.Session, invoiceIdentifier string) []string { if cols.customerPO == -1 {
row := make([]string, len(invoiceClockReportHeader)) for i, name := range normalized {
row[0] = invoiceIdentifier if strings.Contains(name, "customerpo") && !strings.Contains(name, "postal") {
cols.customerPO = i
break
}
}
}
invoice, err := session.GetInvoice(invoiceIdentifier) if cols.customerPO == -1 && cols.invoice != -1 {
if err != nil { invoiceHeader := normalized[cols.invoice]
row[len(row)-1] = fmt.Sprintf("invoice lookup error: %v", err) if strings.Contains(invoiceHeader, "customerpo") {
return row cols.customerPO = cols.invoice
} }
if invoice == nil {
row[len(row)-1] = "invoice not found"
return row
} }
invoiceID := valueToString(invoice["id"]) return cols
row[1] = invoiceID }
jobInfo, ok := invoice["job"].(map[string]interface{}) func normalizeHeaderValue(header string) string {
if !ok { h := strings.ToLower(strings.TrimSpace(header))
row[len(row)-1] = "invoice missing job information" replacements := []string{" ", "_", "-", "."}
return row for _, repl := range replacements {
h = strings.ReplaceAll(h, repl, "")
} }
return h
}
jobID := valueToString(jobInfo["id"]) func ensureRowLength(row []string, length int) []string {
jobNumber := valueToString(jobInfo["number"]) if len(row) >= length {
row[2] = jobID return row
row[3] = jobNumber }
res := make([]string, length)
var errors []string copy(res, row)
return res
}
jobDetails, err := session.GetJobDetails(jobID) func getRowValue(row []string, index int) string {
if err != nil { if index < 0 || index >= len(row) {
errors = append(errors, fmt.Sprintf("job details error: %v", err)) return ""
} }
return row[index]
}
clockData, err := session.GetJobClockEvents(jobID) func cleanIdentifier(value string) string {
if err != nil { value = strings.TrimSpace(value)
errors = append(errors, fmt.Sprintf("clock events error: %v", err)) 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
}
techNames := collectTechNames(jobDetails, clockData) type rowTask struct {
index int
rawID string
invoiceNumber string
customerPO string
}
vendorName := extractVendorName(jobDetails, invoice) type rowResult struct {
assignmentType := determineAssignmentType(techNames, vendorName) index int
customerPO string
jobID string
link string
clockIn string
clockOut string
}
row[4] = assignmentType type workerContext struct {
row[5] = vendorName session *api.Session
row[6] = strings.Join(techNames, ", ") caches *sharedCaches
limiter *rateLimiter
lookup *lookupState
}
if clockData != nil { type sharedCaches struct {
row[7] = formatActivityEvents(clockData.PairedEvents, "enroute") mu sync.RWMutex
row[8] = formatActivityEvents(clockData.PairedEvents, "onsite") invoices map[string]map[string]interface{}
} clocks map[string]clockSummary
}
if len(errors) > 0 { func newSharedCaches() *sharedCaches {
row[len(row)-1] = strings.Join(errors, "; ") return &sharedCaches{
invoices: make(map[string]map[string]interface{}),
clocks: make(map[string]clockSummary),
} }
}
return row 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 collectTechNames(jobDetails map[string]interface{}, clockData *api.ClockEventData) []string { func (c *sharedCaches) setInvoice(key string, invoice map[string]interface{}) {
seen := map[string]struct{}{} c.mu.Lock()
defer c.mu.Unlock()
c.invoices[key] = invoice
}
if jobDetails != nil { func (c *sharedCaches) getClock(jobID string) (clockSummary, bool) {
if appointments, ok := jobDetails["appointments"].([]interface{}); ok { c.mu.RLock()
for _, appt := range appointments { defer c.mu.RUnlock()
apptMap, ok := appt.(map[string]interface{}) summary, ok := c.clocks[jobID]
if !ok { return summary, ok
continue }
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,
} }
if techs, ok := apptMap["techs"].([]interface{}); ok { }
for _, tech := range techs {
if techMap, ok := tech.(map[string]interface{}); ok { func (s *lookupState) shouldTryJobID() bool {
name := strings.TrimSpace(valueToString(techMap["name"])) s.mu.Lock()
if name != "" { defer s.mu.Unlock()
seen[name] = struct{}{} 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
} }
if current, ok := jobDetails["currentAppointment"].(map[string]interface{}); ok { return
if techs, ok := current["techs"].([]interface{}); ok {
for _, tech := range techs {
if techMap, ok := tech.(map[string]interface{}); ok {
name := strings.TrimSpace(valueToString(techMap["name"]))
if name != "" {
seen[name] = struct{}{}
} }
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
}
if clockData != nil { func (rl *rateLimiter) Stop() {
for _, event := range clockData.PairedEvents { if rl == nil || rl.ticker == nil {
name := strings.TrimSpace(event.Start.User.Name) return
if name != "" {
seen[name] = struct{}{}
} }
name = strings.TrimSpace(event.End.User.Name) rl.ticker.Stop()
if name != "" { }
seen[name] = struct{}{}
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 len(seen) == 0 { if invoiceData == nil && invoiceNumber != "" {
return nil 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
}
} }
var names []string if invoiceData != nil {
for name := range seen { applyInvoiceFields(&result, invoiceData)
names = append(names, name) 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
} }
sort.Strings(names) result.jobID = jobFromInvoice
return names 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 extractVendorName(jobDetails map[string]interface{}, invoice map[string]interface{}) string { func applyInvoiceFields(result *rowResult, invoice map[string]interface{}) {
if jobDetails != nil { if invoice == nil {
if vendor, ok := jobDetails["vendor"].(map[string]interface{}); ok { return
if name := strings.TrimSpace(valueToString(vendor["name"])); name != "" {
return name
} }
if result.customerPO == "" {
if po := strings.TrimSpace(valueToString(invoice["customerPo"])); po != "" {
result.customerPO = po
} }
} }
}
if vendor, ok := invoice["vendor"].(map[string]interface{}); ok { func finalizeCustomerPO(result *rowResult, invoiceNumber, rawID string) {
if name := strings.TrimSpace(valueToString(vendor["name"])); name != "" { if result.customerPO != "" {
return name return
} }
if invoiceNumber != "" {
result.customerPO = invoiceNumber
return
}
if rawID != "" {
result.customerPO = rawID
} }
}
func buildJobLink(jobID string) string {
if jobID == "" {
return "" return ""
}
return fmt.Sprintf("https://app.servicetrade.com/jobs/%s", jobID)
} }
func determineAssignmentType(techNames []string, vendorName string) string { func writeExcelRow(f *excelize.File, sheet string, result rowResult, index int, textStyle, bodyStyle int) error {
if len(techNames) > 0 { excelRow := index + 2
return "Technician" 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)
} }
if vendorName != "" {
return "Vendor"
} }
return "Unknown" }
return nil
} }
func formatActivityEvents(events []api.ClockPairedEvent, activity string) string { func fetchInvoice(session *api.Session, identifier string, caches *sharedCaches, limiter *rateLimiter) (map[string]interface{}, error) {
var parts []string if identifier == "" {
activityLower := strings.ToLower(activity) return nil, nil
}
for _, e := range events { if invoice, ok := caches.getInvoice(identifier); ok {
if strings.ToLower(e.Start.Activity) != activityLower { return invoice, nil
continue
} }
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
}
tech := strings.TrimSpace(e.Start.User.Name) func extractJobIDFromInvoice(invoice map[string]interface{}) string {
if tech == "" { if invoice == nil {
tech = "Unknown Tech" return ""
}
jobInfo, ok := invoice["job"].(map[string]interface{})
if !ok {
return ""
} }
return cleanIdentifier(valueToString(jobInfo["id"]))
}
start := formatTimestamp(e.Start.EventTime) type clockSummary struct {
end := formatTimestamp(e.End.EventTime) ClockIn string
var durationText string ClockOut string
if e.ElapsedTime > 0 { }
duration := time.Duration(e.ElapsedTime) * time.Second
durationText = duration.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
} }
detail := fmt.Sprintf("%s: %s -> %s", tech, start, end) if limiter != nil {
if durationText != "" { limiter.Wait()
detail = fmt.Sprintf("%s (%s)", detail, durationText)
} }
data, err := session.GetJobClockEvents(jobID)
if err != nil {
return clockSummary{}, err
}
var earliestStart int64
var latestEnd int64
parts = append(parts, detail) 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
}
}
} }
return strings.Join(parts, " | ") summary := clockSummary{
ClockIn: formatTimestampPlain(earliestStart),
ClockOut: formatTimestampPlain(latestEnd),
}
caches.setClock(jobID, summary)
return summary, nil
} }
func formatTimestamp(epoch int64) string { func formatTimestampPlain(epoch int64) string {
if epoch == 0 { if epoch == 0 {
return "" return ""
} }
return time.Unix(epoch, 0).UTC().Format(time.RFC3339) local := time.Unix(epoch, 0).In(time.Local)
return local.Format("2006-01-02 15:04:05 MST")
} }
func valueToString(value interface{}) string { func valueToString(value interface{}) string {

862
internal/handlers/web/users.go

@ -1,18 +1,872 @@
package web package web
import ( import (
root "marmic/servicetrade-toolbox" "bytes"
"encoding/csv"
"fmt"
"html/template"
"log"
"net/http" "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) { 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 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{}{ 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
}
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
}
err := tmpl.Execute(w, data) 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 { 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
} }

36
internal/middleware/csrf_simple.go

@ -20,16 +20,26 @@ func CSRFSimple(next http.Handler) http.Handler {
// Ensure token cookie exists for safe methods // Ensure token cookie exists for safe methods
if method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions || method == http.MethodTrace { if method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions || method == http.MethodTrace {
if _, err := r.Cookie("XSRF-TOKEN"); err != nil { token := ""
// Generate a random 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) buf := make([]byte, 32)
if _, err := rand.Read(buf); err == nil { if _, err := rand.Read(buf); err == nil {
token := base64.RawURLEncoding.EncodeToString(buf) 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{ http.SetCookie(w, &http.Cookie{
Name: "XSRF-TOKEN", Name: name,
Value: token, Value: token,
Path: "/", Path: "/",
HttpOnly: false, // must be readable by client script HttpOnly: false,
Secure: isHTTPS, Secure: isHTTPS,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(12 * time.Hour), Expires: time.Now().Add(12 * time.Hour),
@ -53,16 +63,24 @@ func CSRFSimple(next http.Handler) http.Handler {
} }
} }
cookie, err := r.Cookie("XSRF-TOKEN") tokenCookie, err := r.Cookie("XSRF-TOKEN")
if err != nil || token == "" || cookie == nil || cookie.Value == "" || cookie.Value != 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 token == "" {
if headerToken := r.Header.Get("X-CSRF-Token"); headerToken != "" { if headerToken := r.Header.Get("X-CSRF-Token"); headerToken != "" {
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 { 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 cookie != nil { if tokenCookie != nil {
return cookie.Value return tokenCookie.Value
} }
return "" return ""
}(), err) }(), err)

31
templates/generic.html

@ -11,12 +11,14 @@
<section class="card"> <section class="card">
<h4>Invoice Clock Events Report (Proof of Concept)</h4> <h4>Invoice Clock Events Report (Proof of Concept)</h4>
<p> <p>
Upload a CSV containing invoice numbers to generate a downloadable report that aggregates Upload a CSV export that contains either job ids or invoice numbers. The tool automatically detects
related job information, assigned technicians, and enroute/onsite clock events. 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> </p>
<form id="invoice-clock-form" class="form" action="/reports/invoice-clock" method="POST" <form id="invoice-clock-form" class="form" action="/reports/invoice-clock" method="POST"
enctype="multipart/form-data"> enctype="multipart/form-data" hx-target="#invoice-clock-status" hx-indicator=".htmx-indicator">
{{if .CSRFField}} {{if .CSRFField}}
{{.CSRFField}} {{.CSRFField}}
{{end}} {{end}}
@ -26,12 +28,22 @@
<input class="card-input" type="file" id="invoiceCsv" name="invoiceCsv" accept=".csv" required> <input class="card-input" type="file" id="invoiceCsv" name="invoiceCsv" accept=".csv" required>
</div> </div>
<p class="help-text"> <p class="help-text">
The CSV may include a header named <code>invoice_number</code>; if no header is present, the first If a <code>job id</code> column is present we use it directly; otherwise we fall back to columns such as
column <code>invoice_number</code> or <code>customer po</code> to discover the job before pulling clock events.
is used. Each invoice number is processed individually.
</p> </p>
<button class="btn-primary" type="submit">Generate Report</button> <button class="btn-primary" type="submit">Generate XLSX Report</button>
</form> </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> </section>
</div> </div>
</div> </div>
@ -69,11 +81,6 @@
document.addEventListener('submit', ensureTokenBeforeSubmit, true); document.addEventListener('submit', ensureTokenBeforeSubmit, true);
if (document.body && document.body.addEventListener) {
document.body.addEventListener('htmx:load', function (evt) {
applyToken(evt.target || document);
});
}
})(); })();
</script> </script>
{{end}} {{end}}

3
templates/layout.html

@ -45,6 +45,7 @@
<li><a href="/services">Services</a></li> <li><a href="/services">Services</a></li>
<li><a href="/tags">Tags</a></li> <li><a href="/tags">Tags</a></li>
<li><a href="/users">Users</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> <li><a href="/admin">Admin</a></li>
</ul> </ul>
</nav> </nav>
@ -87,6 +88,8 @@
{{template "tags_content" .}} {{template "tags_content" .}}
{{else if eq .Title "Users"}} {{else if eq .Title "Users"}}
{{template "users_content" .}} {{template "users_content" .}}
{{else if eq .Title "User Updates"}}
{{template "users_update_content" .}}
{{else if eq .Title "Admin"}} {{else if eq .Title "Admin"}}
{{template "admin_content" .}} {{template "admin_content" .}}
{{else}} {{else}}

130
templates/users.html

@ -1,18 +1,132 @@
{{define "users_content"}} {{define "users_content"}}
<div class="page-header"> <div class="page-header">
<h2>Users Management</h2> <h2>User Imports</h2>
<p>Manage user accounts and permissions.</p> <p>Upload a CSV to create ServiceTrade users and assign roles in bulk.</p>
</div> </div>
<div class="page-content"> <div class="page-content">
<div class="content"> <div class="content">
<h3 class="submenu-header">User Search & Management</h3> <h3 class="submenu-header">Upload CSV</h3>
<p>User management functionality will be implemented here.</p> {{if .FlashError}}
<div class="placeholder-content"> <div class="error-message">{{.FlashError}}</div>
<div class="placeholder-icon">👤</div> {{end}}
<h4>Coming Soon</h4> {{if .FlashSuccess}}
<p>User management features are under development.</p> <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>
</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> </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