12 changed files with 2320 additions and 215 deletions
Binary file not shown.
@ -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 |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
|
||||
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 { |
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 |
||||
} |
} |
||||
|
|||||
@ -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}} — {{.Message}} |
||||
|
</li> |
||||
|
{{end}} |
||||
|
</ul> |
||||
|
{{else if .Error}} |
||||
|
— |
||||
|
{{else}} |
||||
|
No roles provided |
||||
|
{{end}} |
||||
|
</td> |
||||
|
<td>{{.ProcessingTime}}</td> |
||||
|
</tr> |
||||
|
{{end}} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
{{end}} |
||||
</div> |
</div> |
||||
{{end}} |
{{end}} |
||||
|
|||||
@ -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}} |
||||
|
— |
||||
|
{{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}} — {{.Message}} |
||||
|
</li> |
||||
|
{{end}} |
||||
|
</ul> |
||||
|
{{else if .Error}} |
||||
|
— |
||||
|
{{else}} |
||||
|
No roles provided |
||||
|
{{end}} |
||||
|
</td> |
||||
|
<td>{{.ProcessingTime}}</td> |
||||
|
</tr> |
||||
|
{{end}} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
{{end}} |
||||
|
|
||||
Loading…
Reference in new issue