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 |
|||
|
|||
import ( |
|||
root "marmic/servicetrade-toolbox" |
|||
"bytes" |
|||
"encoding/csv" |
|||
"fmt" |
|||
"html/template" |
|||
"log" |
|||
"net/http" |
|||
"strconv" |
|||
"strings" |
|||
"time" |
|||
|
|||
root "marmic/servicetrade-toolbox" |
|||
"marmic/servicetrade-toolbox/internal/api" |
|||
"marmic/servicetrade-toolbox/internal/middleware" |
|||
|
|||
"github.com/gorilla/csrf" |
|||
) |
|||
|
|||
// RoleAssignmentResult captures the outcome of assigning a role to a user.
|
|||
type RoleAssignmentResult struct { |
|||
Token string |
|||
RoleID int |
|||
Role string |
|||
Success bool |
|||
Message string |
|||
} |
|||
|
|||
// UserImportResult represents the processing result for a single CSV row.
|
|||
type UserImportResult struct { |
|||
Row int |
|||
Username string |
|||
Email string |
|||
UserID int |
|||
Created bool |
|||
Error string |
|||
RoleAssignments []RoleAssignmentResult |
|||
ProcessingTime time.Duration |
|||
ServiceLineIDs []int |
|||
AdditionalFields map[string]string |
|||
} |
|||
|
|||
// UserImportSummary aggregates statistics about a CSV import run.
|
|||
type UserImportSummary struct { |
|||
TotalRows int |
|||
UsersCreated int |
|||
RowsFailed int |
|||
RoleAssignments int |
|||
RoleAssignmentErrors int |
|||
ProcessedFilename string |
|||
ProcessedAt time.Time |
|||
} |
|||
|
|||
// UserUpdateResult represents the processing result for a single update row.
|
|||
type UserUpdateResult struct { |
|||
Row int |
|||
Username string |
|||
Email string |
|||
UserID int |
|||
Updated bool |
|||
Error string |
|||
UpdatedFields []string |
|||
LookupMethod string |
|||
RoleAssignments []RoleAssignmentResult |
|||
ProcessingTime time.Duration |
|||
} |
|||
|
|||
// UserUpdateSummary aggregates statistics about a CSV update run.
|
|||
type UserUpdateSummary struct { |
|||
TotalRows int |
|||
UsersUpdated int |
|||
RowsFailed int |
|||
RoleAssignments int |
|||
RoleAssignmentErrors int |
|||
LookupsByUsername int |
|||
LookupsByID int |
|||
ProcessedFilename string |
|||
ProcessedAt time.Time |
|||
} |
|||
|
|||
// UsersHandler renders the user management page.
|
|||
func UsersHandler(w http.ResponseWriter, r *http.Request) { |
|||
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) |
|||
if !ok { |
|||
http.Error(w, "Unauthorized", http.StatusUnauthorized) |
|||
return |
|||
} |
|||
|
|||
data := baseUsersPageData(r) |
|||
data["Title"] = "Users" |
|||
data["Session"] = session |
|||
|
|||
if roles, err := session.ListRoles(); err != nil { |
|||
log.Printf("UsersHandler: unable to load roles: %v", err) |
|||
data["RolesError"] = err.Error() |
|||
} else { |
|||
data["Roles"] = roles |
|||
} |
|||
|
|||
renderUsersPage(w, r, data) |
|||
} |
|||
|
|||
// UsersUploadHandler processes uploaded CSV files to create users and assign roles.
|
|||
func UsersUploadHandler(w http.ResponseWriter, r *http.Request) { |
|||
start := time.Now() |
|||
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) |
|||
if !ok { |
|||
http.Error(w, "Unauthorized", http.StatusUnauthorized) |
|||
return |
|||
} |
|||
|
|||
if err := r.ParseMultipartForm(10 << 20); err != nil { |
|||
http.Error(w, fmt.Sprintf("Unable to parse upload: %v", err), http.StatusBadRequest) |
|||
return |
|||
} |
|||
|
|||
file, header, err := r.FormFile("csvFile") |
|||
if err != nil { |
|||
http.Error(w, fmt.Sprintf("Unable to read file: %v", err), http.StatusBadRequest) |
|||
return |
|||
} |
|||
defer file.Close() |
|||
|
|||
reader := csv.NewReader(file) |
|||
reader.FieldsPerRecord = -1 |
|||
reader.TrimLeadingSpace = true |
|||
rows, err := reader.ReadAll() |
|||
if err != nil { |
|||
http.Error(w, fmt.Sprintf("Unable to read CSV: %v", err), http.StatusBadRequest) |
|||
return |
|||
} |
|||
|
|||
data := baseUsersPageData(r) |
|||
data["Title"] = "Users" |
|||
data["Session"] = session |
|||
|
|||
if len(rows) < 2 { |
|||
data["FlashError"] = "CSV must include a header row and at least one data row." |
|||
renderUsersPage(w, r, data) |
|||
return |
|||
} |
|||
|
|||
headerMap := buildHeaderIndex(rows[0]) |
|||
required := []string{"username", "firstname", "lastname", "email", "password", "companyid", "locationid"} |
|||
if missing := missingHeaders(headerMap, required); len(missing) > 0 { |
|||
data["FlashError"] = fmt.Sprintf("Missing required column headers: %s", strings.Join(missing, ", ")) |
|||
renderUsersPage(w, r, data) |
|||
return |
|||
} |
|||
|
|||
roles, rolesErr := session.ListRoles() |
|||
if rolesErr != nil { |
|||
log.Printf("UsersUploadHandler: unable to load roles: %v", rolesErr) |
|||
data["RolesError"] = fmt.Sprintf("Unable to load roles: %v. Role names in the CSV will not resolve.", rolesErr) |
|||
} else { |
|||
data["Roles"] = roles |
|||
} |
|||
roleIndexByName := buildRoleNameIndex(roles) |
|||
|
|||
results := make([]UserImportResult, 0, len(rows)-1) |
|||
summary := UserImportSummary{ |
|||
TotalRows: len(rows) - 1, |
|||
ProcessedAt: time.Now(), |
|||
ProcessedFilename: header.Filename, |
|||
} |
|||
|
|||
for rowIdx, row := range rows[1:] { |
|||
rowStart := time.Now() |
|||
result := UserImportResult{ |
|||
Row: rowIdx + 2, |
|||
Username: getValue(row, headerMap, "username"), |
|||
Email: getValue(row, headerMap, "email"), |
|||
AdditionalFields: map[string]string{}, |
|||
} |
|||
|
|||
if rowIsEmpty(row) { |
|||
continue |
|||
} |
|||
|
|||
if err := validateRequiredRowFields(row, headerMap, required); err != nil { |
|||
result.Error = err.Error() |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} |
|||
|
|||
companyID, err := strconv.Atoi(getValue(row, headerMap, "companyid")) |
|||
if err != nil { |
|||
result.Error = fmt.Sprintf("invalid companyId: %v", err) |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} |
|||
|
|||
locationID, err := strconv.Atoi(getValue(row, headerMap, "locationid")) |
|||
if err != nil { |
|||
result.Error = fmt.Sprintf("invalid locationId: %v", err) |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} |
|||
|
|||
isSales, err := parseOptionalBool(getValue(row, headerMap, "issales")) |
|||
if err != nil { |
|||
result.Error = fmt.Sprintf("invalid isSales value: %v", err) |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} |
|||
|
|||
mfaRequired, err := parseOptionalBool(getValue(row, headerMap, "mfarequired")) |
|||
if err != nil { |
|||
result.Error = fmt.Sprintf("invalid mfaRequired value: %v", err) |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} |
|||
|
|||
managerID, err := parseOptionalInt(getValue(row, headerMap, "managerid")) |
|||
if err != nil { |
|||
result.Error = fmt.Sprintf("invalid managerId value: %v", err) |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} |
|||
|
|||
serviceLineIDs, err := parseIntList(getValue(row, headerMap, "servicelineids")) |
|||
if err != nil { |
|||
result.Error = fmt.Sprintf("invalid serviceLineIds value: %v", err) |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} |
|||
result.ServiceLineIDs = serviceLineIDs |
|||
|
|||
payload := api.CreateUserPayload{ |
|||
Username: result.Username, |
|||
FirstName: getValue(row, headerMap, "firstname"), |
|||
LastName: getValue(row, headerMap, "lastname"), |
|||
Password: getValue(row, headerMap, "password"), |
|||
Email: result.Email, |
|||
Phone: getValue(row, headerMap, "phone"), |
|||
CompanyID: companyID, |
|||
LocationID: locationID, |
|||
Details: getValue(row, headerMap, "details"), |
|||
Status: getValue(row, headerMap, "status"), |
|||
Timezone: getValue(row, headerMap, "timezone"), |
|||
ServiceLineIDs: serviceLineIDs, |
|||
} |
|||
|
|||
if isSales != nil { |
|||
payload.IsSales = isSales |
|||
} |
|||
if mfaRequired != nil { |
|||
payload.MFARequired = mfaRequired |
|||
} |
|||
if managerID != nil { |
|||
payload.ManagerID = managerID |
|||
} |
|||
|
|||
userRecord, err := session.CreateUser(payload) |
|||
if err != nil { |
|||
result.Error = fmt.Sprintf("failed to create user: %v", err) |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} |
|||
|
|||
result.Created = true |
|||
result.UserID = userRecord.ID |
|||
summary.UsersCreated++ |
|||
|
|||
roleTokens := parseRoleTokens(getValue(row, headerMap, "roles")) |
|||
if len(roleTokens) > 0 { |
|||
assignments := make([]RoleAssignmentResult, 0, len(roleTokens)) |
|||
for _, token := range roleTokens { |
|||
assignResult := RoleAssignmentResult{Token: token} |
|||
role, resolved, message := resolveRoleToken(token, roleIndexByName) |
|||
if !resolved { |
|||
assignResult.Message = message |
|||
assignments = append(assignments, assignResult) |
|||
summary.RoleAssignmentErrors++ |
|||
continue |
|||
} |
|||
assignResult.RoleID = role.ID |
|||
assignResult.Role = role.Name |
|||
|
|||
if err := session.AssignRoleToUser(userRecord.ID, role.ID); err != nil { |
|||
assignResult.Message = fmt.Sprintf("failed to assign role: %v", err) |
|||
summary.RoleAssignmentErrors++ |
|||
} else { |
|||
assignResult.Success = true |
|||
assignResult.Message = "assigned" |
|||
summary.RoleAssignments++ |
|||
} |
|||
assignments = append(assignments, assignResult) |
|||
} |
|||
result.RoleAssignments = assignments |
|||
} |
|||
|
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
} |
|||
|
|||
data["ImportResults"] = results |
|||
data["ImportSummary"] = summary |
|||
data["FlashSuccess"] = fmt.Sprintf("Processed %d row(s) in %s.", summary.TotalRows, time.Since(start).Round(time.Millisecond)) |
|||
|
|||
renderUsersPage(w, r, data) |
|||
} |
|||
|
|||
// UsersUpdateHandler renders the user update page.
|
|||
func UsersUpdateHandler(w http.ResponseWriter, r *http.Request) { |
|||
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) |
|||
if !ok { |
|||
http.Error(w, "Unauthorized", http.StatusUnauthorized) |
|||
return |
|||
} |
|||
|
|||
data := baseUsersPageData(r) |
|||
data["Title"] = "User Updates" |
|||
data["Session"] = session |
|||
|
|||
if roles, err := session.ListRoles(); err != nil { |
|||
log.Printf("UsersUpdateHandler: unable to load roles: %v", err) |
|||
data["RolesError"] = err.Error() |
|||
} else { |
|||
data["Roles"] = roles |
|||
} |
|||
|
|||
renderUsersUpdatePage(w, r, data) |
|||
} |
|||
|
|||
// UsersUpdateUploadHandler processes CSV uploads to update existing users.
|
|||
func UsersUpdateUploadHandler(w http.ResponseWriter, r *http.Request) { |
|||
start := time.Now() |
|||
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) |
|||
if !ok { |
|||
http.Error(w, "Unauthorized", http.StatusUnauthorized) |
|||
return |
|||
} |
|||
|
|||
if err := r.ParseMultipartForm(10 << 20); err != nil { |
|||
http.Error(w, fmt.Sprintf("Unable to parse upload: %v", err), http.StatusBadRequest) |
|||
return |
|||
} |
|||
|
|||
file, header, err := r.FormFile("csvFile") |
|||
if err != nil { |
|||
http.Error(w, fmt.Sprintf("Unable to read file: %v", err), http.StatusBadRequest) |
|||
return |
|||
} |
|||
defer file.Close() |
|||
|
|||
reader := csv.NewReader(file) |
|||
reader.FieldsPerRecord = -1 |
|||
reader.TrimLeadingSpace = true |
|||
rows, err := reader.ReadAll() |
|||
if err != nil { |
|||
http.Error(w, fmt.Sprintf("Unable to read CSV: %v", err), http.StatusBadRequest) |
|||
return |
|||
} |
|||
|
|||
data := baseUsersPageData(r) |
|||
data["Title"] = "User Updates" |
|||
data["Session"] = session |
|||
|
|||
if len(rows) < 2 { |
|||
data["FlashError"] = "CSV must include a header row and at least one data row." |
|||
renderUsersUpdatePage(w, r, data) |
|||
return |
|||
} |
|||
|
|||
headerMap := buildHeaderIndex(rows[0]) |
|||
if _, ok := headerMap["username"]; !ok { |
|||
if _, ok := headerMap["userid"]; !ok { |
|||
data["FlashError"] = "CSV must include either a username or userId column to locate users." |
|||
renderUsersUpdatePage(w, r, data) |
|||
return |
|||
} |
|||
} |
|||
|
|||
roles, rolesErr := session.ListRoles() |
|||
if rolesErr != nil { |
|||
log.Printf("UsersUpdateUploadHandler: unable to load roles: %v", rolesErr) |
|||
data["RolesError"] = fmt.Sprintf("Unable to load roles: %v. Role names in the CSV will not resolve.", rolesErr) |
|||
} else { |
|||
data["Roles"] = roles |
|||
} |
|||
roleIndexByName := buildRoleNameIndex(roles) |
|||
|
|||
results := make([]UserUpdateResult, 0, len(rows)-1) |
|||
summary := UserUpdateSummary{ |
|||
TotalRows: len(rows) - 1, |
|||
ProcessedAt: time.Now(), |
|||
ProcessedFilename: header.Filename, |
|||
} |
|||
|
|||
for rowIdx, row := range rows[1:] { |
|||
rowStart := time.Now() |
|||
if rowIsEmpty(row) { |
|||
continue |
|||
} |
|||
|
|||
result := UserUpdateResult{ |
|||
Row: rowIdx + 2, |
|||
Username: getValue(row, headerMap, "username"), |
|||
Email: getValue(row, headerMap, "email"), |
|||
} |
|||
|
|||
userIDValue := getValue(row, headerMap, "userid") |
|||
var userID int |
|||
if userIDValue != "" { |
|||
parsedID, err := strconv.Atoi(userIDValue) |
|||
if err != nil || parsedID <= 0 { |
|||
result.Error = fmt.Sprintf("invalid userId: %v", err) |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} |
|||
userID = parsedID |
|||
result.UserID = userID |
|||
result.LookupMethod = "userId" |
|||
summary.LookupsByID++ |
|||
} else if result.Username != "" { |
|||
userRecord, err := session.FindUserByUsername(result.Username) |
|||
if err != nil { |
|||
result.Error = fmt.Sprintf("failed to locate user by username: %v", err) |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} |
|||
userID = userRecord.ID |
|||
result.UserID = userID |
|||
result.LookupMethod = "username" |
|||
summary.LookupsByUsername++ |
|||
} else { |
|||
result.Error = "row missing username and userId" |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} |
|||
|
|||
payload := api.UpdateUserPayload{} |
|||
var updatedFields []string |
|||
|
|||
if v := getValue(row, headerMap, "firstname"); v != "" { |
|||
payload.FirstName = stringPtr(v) |
|||
updatedFields = append(updatedFields, "firstName") |
|||
} |
|||
if v := getValue(row, headerMap, "new_username"); v != "" { |
|||
payload.Username = stringPtr(v) |
|||
updatedFields = append(updatedFields, "username") |
|||
} |
|||
if v := getValue(row, headerMap, "lastname"); v != "" { |
|||
payload.LastName = stringPtr(v) |
|||
updatedFields = append(updatedFields, "lastName") |
|||
} |
|||
if v := getValue(row, headerMap, "email"); v != "" { |
|||
payload.Email = stringPtr(v) |
|||
updatedFields = append(updatedFields, "email") |
|||
} |
|||
if v := getValue(row, headerMap, "password"); v != "" { |
|||
payload.Password = stringPtr(v) |
|||
updatedFields = append(updatedFields, "password") |
|||
} |
|||
if v := getValue(row, headerMap, "phone"); v != "" { |
|||
payload.Phone = stringPtr(v) |
|||
updatedFields = append(updatedFields, "phone") |
|||
} |
|||
if v := getValue(row, headerMap, "details"); v != "" { |
|||
payload.Details = stringPtr(v) |
|||
updatedFields = append(updatedFields, "details") |
|||
} |
|||
if v := getValue(row, headerMap, "status"); v != "" { |
|||
payload.Status = stringPtr(v) |
|||
updatedFields = append(updatedFields, "status") |
|||
} |
|||
if v := getValue(row, headerMap, "timezone"); v != "" { |
|||
payload.Timezone = stringPtr(v) |
|||
updatedFields = append(updatedFields, "timezone") |
|||
} |
|||
if companyVal := getValue(row, headerMap, "companyid"); companyVal != "" { |
|||
companyID, err := strconv.Atoi(companyVal) |
|||
if err != nil { |
|||
result.Error = fmt.Sprintf("invalid companyId: %v", err) |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} |
|||
payload.CompanyID = intPtr(companyID) |
|||
updatedFields = append(updatedFields, "companyId") |
|||
} |
|||
if locationVal := getValue(row, headerMap, "locationid"); locationVal != "" { |
|||
locationID, err := strconv.Atoi(locationVal) |
|||
if err != nil { |
|||
result.Error = fmt.Sprintf("invalid locationId: %v", err) |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} |
|||
payload.LocationID = intPtr(locationID) |
|||
updatedFields = append(updatedFields, "locationId") |
|||
} |
|||
if managerVal := getValue(row, headerMap, "managerid"); managerVal != "" { |
|||
managerID, err := strconv.Atoi(managerVal) |
|||
if err != nil { |
|||
result.Error = fmt.Sprintf("invalid managerId: %v", err) |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} |
|||
payload.ManagerID = intPtr(managerID) |
|||
updatedFields = append(updatedFields, "managerId") |
|||
} |
|||
|
|||
if isSales, err := parseOptionalBool(getValue(row, headerMap, "issales")); err != nil { |
|||
result.Error = fmt.Sprintf("invalid isSales value: %v", err) |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} else if isSales != nil { |
|||
payload.IsSales = isSales |
|||
updatedFields = append(updatedFields, "isSales") |
|||
} |
|||
|
|||
if mfaRequired, err := parseOptionalBool(getValue(row, headerMap, "mfarequired")); err != nil { |
|||
result.Error = fmt.Sprintf("invalid mfaRequired value: %v", err) |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} else if mfaRequired != nil { |
|||
payload.MFARequired = mfaRequired |
|||
updatedFields = append(updatedFields, "mfaRequired") |
|||
} |
|||
|
|||
if serviceLineIDs, err := parseIntList(getValue(row, headerMap, "servicelineids")); err != nil { |
|||
result.Error = fmt.Sprintf("invalid serviceLineIds value: %v", err) |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} else if serviceLineIDs != nil { |
|||
payload.ServiceLineIDs = &serviceLineIDs |
|||
updatedFields = append(updatedFields, "serviceLineIds") |
|||
} |
|||
|
|||
if len(updatedFields) == 0 { |
|||
result.Error = "no updatable fields provided in row" |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} |
|||
|
|||
if _, err := session.UpdateUser(userID, payload); err != nil { |
|||
result.Error = fmt.Sprintf("failed to update user: %v", err) |
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
summary.RowsFailed++ |
|||
continue |
|||
} |
|||
|
|||
result.Updated = true |
|||
result.UpdatedFields = updatedFields |
|||
summary.UsersUpdated++ |
|||
|
|||
roleTokens := parseRoleTokens(getValue(row, headerMap, "roles")) |
|||
if len(roleTokens) > 0 { |
|||
assignments := make([]RoleAssignmentResult, 0, len(roleTokens)) |
|||
for _, token := range roleTokens { |
|||
assignResult := RoleAssignmentResult{Token: token} |
|||
role, resolved, message := resolveRoleToken(token, roleIndexByName) |
|||
if !resolved { |
|||
assignResult.Message = message |
|||
assignments = append(assignments, assignResult) |
|||
summary.RoleAssignmentErrors++ |
|||
continue |
|||
} |
|||
assignResult.RoleID = role.ID |
|||
assignResult.Role = role.Name |
|||
|
|||
if err := session.AssignRoleToUser(userID, role.ID); err != nil { |
|||
assignResult.Message = fmt.Sprintf("failed to assign role: %v", err) |
|||
summary.RoleAssignmentErrors++ |
|||
} else { |
|||
assignResult.Success = true |
|||
assignResult.Message = "assigned" |
|||
summary.RoleAssignments++ |
|||
} |
|||
assignments = append(assignments, assignResult) |
|||
} |
|||
result.RoleAssignments = assignments |
|||
} |
|||
|
|||
result.ProcessingTime = time.Since(rowStart) |
|||
results = append(results, result) |
|||
} |
|||
|
|||
data["UpdateResults"] = results |
|||
data["UpdateSummary"] = summary |
|||
if summary.UsersUpdated > 0 { |
|||
data["FlashSuccess"] = fmt.Sprintf("Updated %d user(s) (processed %d rows in %s).", summary.UsersUpdated, summary.TotalRows, time.Since(start).Round(time.Millisecond)) |
|||
} else { |
|||
data["FlashError"] = "No users were updated. Review the row errors below." |
|||
} |
|||
|
|||
renderUsersUpdatePage(w, r, data) |
|||
} |
|||
|
|||
func renderUsersPage(w http.ResponseWriter, r *http.Request, data map[string]interface{}) { |
|||
renderUsersTemplatePage(w, r, data, "users_content") |
|||
} |
|||
|
|||
func renderUsersUpdatePage(w http.ResponseWriter, r *http.Request, data map[string]interface{}) { |
|||
renderUsersTemplatePage(w, r, data, "users_update_content") |
|||
} |
|||
|
|||
func renderUsersTemplatePage(w http.ResponseWriter, r *http.Request, data map[string]interface{}, templateName string) { |
|||
tmpl := root.WebTemplates |
|||
|
|||
if r.Header.Get("HX-Request") == "true" { |
|||
if err := tmpl.ExecuteTemplate(w, templateName, data); err != nil { |
|||
log.Printf("UsersHandler: template error: %v", err) |
|||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
|||
} |
|||
return |
|||
} |
|||
|
|||
var contentBuf bytes.Buffer |
|||
if err := tmpl.ExecuteTemplate(&contentBuf, templateName, data); err != nil { |
|||
log.Printf("UsersHandler: template error: %v", err) |
|||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
|||
return |
|||
} |
|||
data["BodyContent"] = template.HTML(contentBuf.String()) |
|||
|
|||
if err := tmpl.ExecuteTemplate(w, "layout.html", data); err != nil { |
|||
log.Printf("UsersHandler: layout template error: %v", err) |
|||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
|||
} |
|||
} |
|||
|
|||
func baseUsersPageData(r *http.Request) map[string]interface{} { |
|||
data := map[string]interface{}{ |
|||
"Title": "Users", |
|||
"CSRFField": csrf.TemplateField(r), |
|||
"CSRFToken": csrf.Token(r), |
|||
} |
|||
if c, err := r.Cookie("XSRF-TOKEN"); err == nil { |
|||
data["CSRFCookie"] = c.Value |
|||
} else if c, err := r.Cookie("XSRF-TOKEN-VALUE"); err == nil { |
|||
data["CSRFCookie"] = c.Value |
|||
} |
|||
return data |
|||
} |
|||
|
|||
func buildHeaderIndex(headers []string) map[string]int { |
|||
index := make(map[string]int, len(headers)) |
|||
for idx, header := range headers { |
|||
if header == "" { |
|||
continue |
|||
} |
|||
normalized := normalizeHeader(header) |
|||
if canonical, ok := headerCanonicalMap[normalized]; ok { |
|||
index[canonical] = idx |
|||
} else { |
|||
index[normalized] = idx |
|||
} |
|||
} |
|||
return index |
|||
} |
|||
|
|||
err := tmpl.Execute(w, data) |
|||
func missingHeaders(headerIndex map[string]int, required []string) []string { |
|||
var missing []string |
|||
for _, key := range required { |
|||
if _, ok := headerIndex[key]; !ok { |
|||
missing = append(missing, key) |
|||
} |
|||
} |
|||
return missing |
|||
} |
|||
|
|||
func rowIsEmpty(row []string) bool { |
|||
for _, value := range row { |
|||
if strings.TrimSpace(value) != "" { |
|||
return false |
|||
} |
|||
} |
|||
return true |
|||
} |
|||
|
|||
func validateRequiredRowFields(row []string, index map[string]int, required []string) error { |
|||
var missing []string |
|||
for _, key := range required { |
|||
if strings.TrimSpace(getValue(row, index, key)) == "" { |
|||
missing = append(missing, key) |
|||
} |
|||
} |
|||
if len(missing) > 0 { |
|||
return fmt.Errorf("missing required value(s): %s", strings.Join(missing, ", ")) |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
func getValue(row []string, index map[string]int, key string) string { |
|||
if idx, ok := index[key]; ok && idx < len(row) { |
|||
return strings.TrimSpace(row[idx]) |
|||
} |
|||
return "" |
|||
} |
|||
|
|||
func parseOptionalBool(value string) (*bool, error) { |
|||
if value == "" { |
|||
return nil, nil |
|||
} |
|||
switch strings.ToLower(strings.TrimSpace(value)) { |
|||
case "true", "1", "yes", "y": |
|||
result := true |
|||
return &result, nil |
|||
case "false", "0", "no", "n": |
|||
result := false |
|||
return &result, nil |
|||
default: |
|||
return nil, fmt.Errorf("expected boolean value, got %q", value) |
|||
} |
|||
} |
|||
|
|||
func parseOptionalInt(value string) (*int, error) { |
|||
if strings.TrimSpace(value) == "" { |
|||
return nil, nil |
|||
} |
|||
intVal, err := strconv.Atoi(strings.TrimSpace(value)) |
|||
if err != nil { |
|||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
|||
return nil, err |
|||
} |
|||
return &intVal, nil |
|||
} |
|||
|
|||
func parseIntList(raw string) ([]int, error) { |
|||
if strings.TrimSpace(raw) == "" { |
|||
return nil, nil |
|||
} |
|||
parts := splitTokens(raw) |
|||
ints := make([]int, 0, len(parts)) |
|||
for _, part := range parts { |
|||
id, err := strconv.Atoi(part) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("invalid integer %q", part) |
|||
} |
|||
ints = append(ints, id) |
|||
} |
|||
return ints, nil |
|||
} |
|||
|
|||
func parseRoleTokens(raw string) []string { |
|||
if strings.TrimSpace(raw) == "" { |
|||
return nil |
|||
} |
|||
return splitTokens(raw) |
|||
} |
|||
|
|||
func splitTokens(raw string) []string { |
|||
cleaned := strings.NewReplacer(";", ",", "|", ",").Replace(raw) |
|||
parts := strings.Split(cleaned, ",") |
|||
result := make([]string, 0, len(parts)) |
|||
for _, part := range parts { |
|||
token := strings.TrimSpace(part) |
|||
if token != "" { |
|||
result = append(result, token) |
|||
} |
|||
} |
|||
return result |
|||
} |
|||
|
|||
func resolveRoleToken(token string, nameIndex map[string]api.Role) (api.Role, bool, string) { |
|||
clean := strings.TrimSpace(token) |
|||
if clean == "" { |
|||
return api.Role{}, false, "empty role token" |
|||
} |
|||
normalized := normalizeRoleName(clean) |
|||
if role, ok := nameIndex[normalized]; ok { |
|||
return role, true, "" |
|||
} |
|||
return api.Role{}, false, fmt.Sprintf("role %q not found; use the role name exactly as listed", token) |
|||
} |
|||
|
|||
func buildRoleNameIndex(roles []api.Role) map[string]api.Role { |
|||
nameIndex := make(map[string]api.Role, len(roles)) |
|||
for _, role := range roles { |
|||
nameKey := normalizeRoleName(role.Name) |
|||
if nameKey != "" { |
|||
nameIndex[nameKey] = role |
|||
} |
|||
} |
|||
return nameIndex |
|||
} |
|||
|
|||
func normalizeHeader(value string) string { |
|||
v := strings.TrimSpace(strings.ToLower(value)) |
|||
v = strings.ReplaceAll(v, " ", "") |
|||
v = strings.ReplaceAll(v, "_", "") |
|||
v = strings.ReplaceAll(v, "-", "") |
|||
return v |
|||
} |
|||
|
|||
func normalizeRoleName(value string) string { |
|||
return strings.ToLower(strings.TrimSpace(value)) |
|||
} |
|||
|
|||
var headerCanonicalMap = map[string]string{ |
|||
"username": "username", |
|||
"user": "username", |
|||
"firstname": "firstname", |
|||
"first": "firstname", |
|||
"lastname": "lastname", |
|||
"last": "lastname", |
|||
"email": "email", |
|||
"mail": "email", |
|||
"password": "password", |
|||
"pass": "password", |
|||
"companyid": "companyid", |
|||
"company": "companyid", |
|||
"locationid": "locationid", |
|||
"location": "locationid", |
|||
"userid": "userid", |
|||
"id": "userid", |
|||
"newusername": "new_username", |
|||
"username_new": "new_username", |
|||
"usernameupdate": "new_username", |
|||
"phone": "phone", |
|||
"phonenumber": "phone", |
|||
"roles": "roles", |
|||
"roleids": "roles", |
|||
"rolenames": "roles", |
|||
"status": "status", |
|||
"timezone": "timezone", |
|||
"details": "details", |
|||
"issales": "issales", |
|||
"sales": "issales", |
|||
"managerid": "managerid", |
|||
"manager": "managerid", |
|||
"mfarequired": "mfarequired", |
|||
"mfa": "mfarequired", |
|||
"servicelineids": "servicelineids", |
|||
} |
|||
|
|||
func stringPtr(value string) *string { |
|||
v := value |
|||
return &v |
|||
} |
|||
|
|||
func intPtr(value int) *int { |
|||
v := value |
|||
return &v |
|||
} |
|||
|
|||
@ -1,18 +1,132 @@ |
|||
{{define "users_content"}} |
|||
<div class="page-header"> |
|||
<h2>Users Management</h2> |
|||
<p>Manage user accounts and permissions.</p> |
|||
<h2>User Imports</h2> |
|||
<p>Upload a CSV to create ServiceTrade users and assign roles in bulk.</p> |
|||
</div> |
|||
|
|||
<div class="page-content"> |
|||
<div class="content"> |
|||
<h3 class="submenu-header">User Search & Management</h3> |
|||
<p>User management functionality will be implemented here.</p> |
|||
<div class="placeholder-content"> |
|||
<div class="placeholder-icon">👤</div> |
|||
<h4>Coming Soon</h4> |
|||
<p>User management features are under development.</p> |
|||
<h3 class="submenu-header">Upload CSV</h3> |
|||
{{if .FlashError}} |
|||
<div class="error-message">{{.FlashError}}</div> |
|||
{{end}} |
|||
{{if .FlashSuccess}} |
|||
<div class="info-message">{{.FlashSuccess}}</div> |
|||
{{end}} |
|||
<form action="/users/upload" method="POST" enctype="multipart/form-data"> |
|||
{{if .CSRFField}}{{.CSRFField}}{{end}} |
|||
{{if .CSRFCookie}}<input type="hidden" name="csrfToken" value="{{.CSRFCookie}}">{{end}} |
|||
<label for="users-csv">CSV file</label> |
|||
<input id="users-csv" name="csvFile" type="file" accept=".csv" class="card-input" required> |
|||
<button type="submit" class="btn-primary">Process CSV</button> |
|||
</form> |
|||
<div class="info-message" style="margin-top: 1rem;"> |
|||
<strong>Required headers:</strong> username, firstName, lastName, email, password, companyId, locationId. |
|||
Optional headers include phone, status, timezone, details, isSales, managerId, mfaRequired, serviceLineIds, and roles. |
|||
In the roles column list the human-readable role names (comma, semicolon, or pipe separated); names are matched case-insensitively. |
|||
</div> |
|||
</div> |
|||
|
|||
{{if .RolesError}} |
|||
<div class="content" style="margin-top:1rem;"> |
|||
<div class="error-message">{{.RolesError}}</div> |
|||
</div> |
|||
{{end}} |
|||
|
|||
{{if .Roles}} |
|||
<div class="content" style="margin-top:1rem;"> |
|||
<h3 class="submenu-header">Available Roles</h3> |
|||
<p>Use the role names from this list in the CSV roles column.</p> |
|||
<table> |
|||
<thead> |
|||
<tr> |
|||
<th>ID</th> |
|||
<th>Name</th> |
|||
<th>Description</th> |
|||
<th>Global</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
{{range .Roles}} |
|||
<tr> |
|||
<td>{{.ID}}</td> |
|||
<td>{{.Name}}</td> |
|||
<td>{{.Description}}</td> |
|||
<td>{{if .Global}}Yes{{else}}No{{end}}</td> |
|||
</tr> |
|||
{{end}} |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
{{end}} |
|||
|
|||
{{if .ImportSummary}} |
|||
<div class="content" style="margin-top:1rem;"> |
|||
<h3 class="submenu-header">Import Summary</h3> |
|||
<p> |
|||
Processed file: <strong>{{.ImportSummary.ProcessedFilename}}</strong> at {{.ImportSummary.ProcessedAt.Format "2006-01-02 15:04 MST"}}. |
|||
</p> |
|||
<ul> |
|||
<li>Total rows: {{.ImportSummary.TotalRows}}</li> |
|||
<li>Users created: {{.ImportSummary.UsersCreated}}</li> |
|||
<li>Rows failed: {{.ImportSummary.RowsFailed}}</li> |
|||
<li>Role assignments succeeded: {{.ImportSummary.RoleAssignments}}</li> |
|||
<li>Role assignments failed: {{.ImportSummary.RoleAssignmentErrors}}</li> |
|||
</ul> |
|||
</div> |
|||
{{end}} |
|||
|
|||
{{if .ImportResults}} |
|||
<div class="content" style="margin-top:1rem;"> |
|||
<h3 class="submenu-header">Import Details</h3> |
|||
<table> |
|||
<thead> |
|||
<tr> |
|||
<th>Row</th> |
|||
<th>Username</th> |
|||
<th>Email</th> |
|||
<th>User Result</th> |
|||
<th>Role Assignments</th> |
|||
<th>Processing Time</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
{{range .ImportResults}} |
|||
<tr> |
|||
<td>{{.Row}}</td> |
|||
<td>{{.Username}}</td> |
|||
<td>{{.Email}}</td> |
|||
<td> |
|||
{{if .Error}} |
|||
<div class="error-message">{{.Error}}</div> |
|||
{{else if .Created}} |
|||
Created (#{{.UserID}}) |
|||
{{else}} |
|||
Skipped |
|||
{{end}} |
|||
</td> |
|||
<td> |
|||
{{if .RoleAssignments}} |
|||
<ul> |
|||
{{range .RoleAssignments}} |
|||
<li> |
|||
{{if .Success}}✅{{else}}⚠️{{end}} |
|||
{{if .Role}}{{.Role}} ({{.RoleID}}){{else}}{{.Token}}{{end}} — {{.Message}} |
|||
</li> |
|||
{{end}} |
|||
</ul> |
|||
{{else if .Error}} |
|||
— |
|||
{{else}} |
|||
No roles provided |
|||
{{end}} |
|||
</td> |
|||
<td>{{.ProcessingTime}}</td> |
|||
</tr> |
|||
{{end}} |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
{{end}} |
|||
</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