From 42d9d2372d723a08b4de82d8f0c22ecf181b20e6 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Fri, 20 Mar 2026 05:20:37 +0000 Subject: [PATCH] Add initial user selector for impersonation page --- api/query_params.go | 1 + api/routes.go | 4 +- api/user.go | 27 +++ html/static/js/user-selector.js | 243 +++++++++++++++++++ html/template/sync/layout/authenticated.html | 2 +- html/template/sync/sudo.html | 13 +- platform/communication.go | 16 -- platform/organization.go | 6 + platform/user.go | 89 ++++++- 9 files changed, 370 insertions(+), 31 deletions(-) create mode 100644 html/static/js/user-selector.js diff --git a/api/query_params.go b/api/query_params.go index 2b929247..84138f45 100644 --- a/api/query_params.go +++ b/api/query_params.go @@ -2,6 +2,7 @@ package api type queryParams struct { Limit *int `schema:"limit"` + Query *string `schema:"query"` Sort *string `schema:"sort"` Type *string `schema:"type"` } diff --git a/api/routes.go b/api/routes.go index 25c9b43f..c4b944ef 100644 --- a/api/routes.go +++ b/api/routes.go @@ -30,7 +30,9 @@ func AddRoutes(r chi.Router) { r.Method("GET", "/signal", authenticatedHandlerJSON(listSignal)) r.Method("GET", "/trap-data", auth.NewEnsureAuth(apiTrapData)) r.Method("GET", "/tile/{z}/{y}/{x}", auth.NewEnsureAuth(getTile)) - r.Method("GET", "/user", authenticatedHandlerJSON(getUser)) + r.Method("GET", "/user/self", authenticatedHandlerJSON(getUser)) + r.Method("GET", "/user/suggestion", authenticatedHandlerJSON(listUserSuggestion)) + r.Method("GET", "/user", authenticatedHandlerJSON(listUser)) // Unauthenticated endpoints r.Get("/district", apiGetDistrict) diff --git a/api/user.go b/api/user.go index 0326de19..7f3cb244 100644 --- a/api/user.go +++ b/api/user.go @@ -16,3 +16,30 @@ func getUser(ctx context.Context, r *http.Request, user platform.User, query que user.NotificationCounts = *counts return &user, nil } + +type responseListUser struct { + Users []platform.User `json:"users"` +} + +func listUser(ctx context.Context, r *http.Request, user platform.User, query queryParams) (*responseListUser, *nhttp.ErrorWithStatus) { + return &responseListUser{ + Users: []platform.User{}, + }, nil +} + +type responseListUserSuggestion struct { + Users []platform.User `json:"users"` +} + +func listUserSuggestion(ctx context.Context, r *http.Request, user platform.User, query queryParams) (*responseListUser, *nhttp.ErrorWithStatus) { + if query.Query == nil { + return nil, nhttp.NewErrorStatus(http.StatusBadRequest, "you need to include a query") + } + users, err := platform.UserSuggestion(ctx, user, *query.Query) + if err != nil { + return nil, nhttp.NewError("query suggestions: %w", err) + } + return &responseListUser{ + Users: users, + }, nil +} diff --git a/html/static/js/user-selector.js b/html/static/js/user-selector.js new file mode 100644 index 00000000..63b3d8cb --- /dev/null +++ b/html/static/js/user-selector.js @@ -0,0 +1,243 @@ +class UserSelector extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.selectedUser = null; + this.debounceTimer = null; + } + + connectedCallback() { + this.render(); + this.setupEventListeners(); + } + + render() { + this.shadowRoot.innerHTML = ` + + + +
+ + +
+
+ +
+
+
+ `; + } + + setupEventListeners() { + const input = this.shadowRoot.getElementById("userInput"); + const dropdown = this.shadowRoot.getElementById("suggestionsDropdown"); + + input.addEventListener("input", (e) => this.handleInput(e)); + input.addEventListener("focus", (e) => { + if (e.target.value.length >= 4) { + this.handleInput(e); + } + }); + + // Close dropdown when clicking outside + document.addEventListener("click", (e) => { + if (!this.contains(e.target)) { + this.hideSuggestions(); + } + }); + } + + handleInput(e) { + const query = e.target.value; + + // Clear previous timer + clearTimeout(this.debounceTimer); + + if (query.length < 4) { + this.hideSuggestions(); + return; + } + + // Debounce API calls + this.debounceTimer = setTimeout(() => { + this.fetchSuggestions(query); + }, 300); + } + + async fetchSuggestions(query) { + const suggestionsList = this.shadowRoot.getElementById("suggestionsList"); + const dropdown = this.shadowRoot.getElementById("suggestionsDropdown"); + + // Show loading state + suggestionsList.innerHTML = '
Loading...
'; + dropdown.classList.add("show"); + + try { + const response = await fetch( + `/api/user/suggestion?query=${encodeURIComponent(query)}`, + ); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + const data = await response.json(); + this.displaySuggestions(data.users); + } catch (error) { + console.error("Error fetching suggestions:", error); + suggestionsList.innerHTML = ` + + `; + } + } + + displaySuggestions(users) { + const suggestionsList = this.shadowRoot.getElementById("suggestionsList"); + const dropdown = this.shadowRoot.getElementById("suggestionsDropdown"); + + if (!users || users.length === 0) { + suggestionsList.innerHTML = ` +
No users found
+ `; + return; + } + + suggestionsList.innerHTML = users + .map( + (user) => ` +
+
+
+
${this.escapeHtml(user.display_name)}
+
@${this.escapeHtml(user.username)}
+
+
+ ${this.escapeHtml(user.organization.name)} +
+
+
+ `, + ) + .join(""); + + // Add click handlers to suggestion items + suggestionsList.querySelectorAll(".suggestion-item").forEach((item) => { + item.addEventListener("click", (e) => { + const userData = JSON.parse(e.currentTarget.getAttribute("data-user")); + this.selectUser(userData); + }); + }); + + dropdown.classList.add("show"); + } + + selectUser(user) { + this.selectedUser = user; + const input = this.shadowRoot.getElementById("userInput"); + input.value = user.displayName || user.display_name; + this.hideSuggestions(); + + // Dispatch custom event + this.dispatchEvent( + new CustomEvent("user-selected", { + detail: { user }, + bubbles: true, + composed: true, + }), + ); + } + + hideSuggestions() { + const dropdown = this.shadowRoot.getElementById("suggestionsDropdown"); + dropdown.classList.remove("show"); + } + + escapeHtml(text) { + const map = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return text.replace(/[&<>"']/g, (m) => map[m]); + } + + // Public method to get selected user + getSelectedUser() { + return this.selectedUser; + } + + // Public method to clear selection + clear() { + this.selectedUser = null; + const input = this.shadowRoot.getElementById("userInput"); + input.value = ""; + this.hideSuggestions(); + } +} + +// Register the custom element +customElements.define("user-selector", UserSelector); diff --git a/html/template/sync/layout/authenticated.html b/html/template/sync/layout/authenticated.html index d67bffd1..fc76fd0e 100644 --- a/html/template/sync/layout/authenticated.html +++ b/html/template/sync/layout/authenticated.html @@ -51,7 +51,7 @@ }); } async function updateUserState() { - const response = await fetch("/api/user"); + const response = await fetch("/api/user/self"); const data = await response.json(); // Update properties instead of replacing the whole store which leverages Alpine's reactivity const store_user = Alpine.store("user"); diff --git a/html/template/sync/sudo.html b/html/template/sync/sudo.html index 3b765268..668d8ebd 100644 --- a/html/template/sync/sudo.html +++ b/html/template/sync/sudo.html @@ -2,6 +2,7 @@ {{ define "title" }}Sudo{{ end }} {{ define "extraheader" }} + {{ end }} {{ define "content" }}
@@ -336,17 +337,7 @@
-
- - -
+
diff --git a/platform/communication.go b/platform/communication.go index c277923e..0d3b65ce 100644 --- a/platform/communication.go +++ b/platform/communication.go @@ -1,17 +1 @@ package platform - -import ( - "context" - "fmt" - - "github.com/Gleipnir-Technology/nidus-sync/db/models" - "github.com/Gleipnir-Technology/nidus-sync/platform/publicreport" -) - -func NotificationCount(ctx context.Context, org *models.Organization, user *models.User) (result uint, err error) { - count_reports, err := publicreport.ReportsForOrganizationCount(ctx, org.ID) - if err != nil { - return 0, fmt.Errorf("report query: %w", err) - } - return uint(count_reports), nil -} diff --git a/platform/organization.go b/platform/organization.go index 14e91283..f94b33b8 100644 --- a/platform/organization.go +++ b/platform/organization.go @@ -2,6 +2,7 @@ package platform import ( "context" + "encoding/json" "fmt" "github.com/Gleipnir-Technology/bob/dialect/psql/sm" @@ -52,6 +53,11 @@ func (o Organization) HasServiceArea() bool { func (o Organization) IsCatchall() bool { return o.model.IsCatchall } +func (o Organization) MarshalJSON() ([]byte, error) { + to_marshal := map[string]any{} + to_marshal["name"] = o.Name() + return json.Marshal(to_marshal) +} func (o Organization) Name() string { return o.model.Name } diff --git a/platform/user.go b/platform/user.go index 86889473..2d6afd7a 100644 --- a/platform/user.go +++ b/platform/user.go @@ -6,14 +6,15 @@ import ( "fmt" "strings" - "github.com/aarondl/opt/omit" - //"github.com/Gleipnir-Technology/bob" + "github.com/Gleipnir-Technology/bob/dialect/psql" "github.com/Gleipnir-Technology/bob/dialect/psql/dialect" + "github.com/Gleipnir-Technology/bob/dialect/psql/sm" "github.com/Gleipnir-Technology/bob/mods" "github.com/Gleipnir-Technology/nidus-sync/db" "github.com/Gleipnir-Technology/nidus-sync/db/enums" "github.com/Gleipnir-Technology/nidus-sync/db/models" "github.com/Gleipnir-Technology/nidus-sync/debug" + "github.com/aarondl/opt/omit" "github.com/rs/zerolog/log" ) @@ -112,6 +113,74 @@ func UsersByOrg(ctx context.Context, org Organization) (map[int32]*User, error) } return results, nil } +func UserSuggestion(ctx context.Context, user User, query string) ([]User, error) { + query_arg := "%" + query + "%" + if user.HasRoot() { + return userSuggestionRoot(ctx, user, query_arg) + } else { + return userSuggestionNonRoot(ctx, user, query_arg) + } +} +func userSuggestionNonRoot(ctx context.Context, user User, query_arg string) ([]User, error) { + users, err := models.Users.Query( + sm.Where( + psql.Or( + psql.Quote("username").ILike(psql.Arg(query_arg)), + psql.Quote("display_name").ILike(psql.Arg(query_arg)), + ), + ), + sm.Where( + psql.Quote("organization_id").EQ(psql.Arg(user.Organization.ID())), + ), + ).All(ctx, db.PGInstance.BobDB) + if err != nil { + return nil, fmt.Errorf("query users: %w", err) + } + results := make([]User, len(users)) + for i, user := range users { + results[i] = toUser(user) + } + return results, nil +} +func userSuggestionRoot(ctx context.Context, user User, query_arg string) ([]User, error) { + users, err := models.Users.Query( + sm.Where( + psql.Or( + psql.Quote("username").ILike(psql.Arg(query_arg)), + psql.Quote("display_name").ILike(psql.Arg(query_arg)), + ), + ), + ).All(ctx, db.PGInstance.BobDB) + if err != nil { + return nil, fmt.Errorf("query users: %w", err) + } + organization_ids := make([]int32, 0) + for _, user := range users { + organization_ids = append(organization_ids, user.OrganizationID) + } + orgs, err := models.Organizations.Query( + sm.Where( + psql.Quote("id").EQ(psql.Any(organization_ids)), + ), + ).All(ctx, db.PGInstance.BobDB) + if err != nil { + return nil, fmt.Errorf("query orgs: %w", err) + } + org_map := make(map[int32]*models.Organization, len(orgs)) + for _, org := range orgs { + org_map[org.ID] = org + } + results := make([]User, len(users)) + for i, user := range users { + u := toUser(user) + org := org_map[user.OrganizationID] + u.Organization = Organization{ + model: org, + } + results[i] = u + } + return results, nil +} func getUser(ctx context.Context, where mods.Where[*dialect.SelectQuery]) (*User, error) { user, err := models.Users.Query( models.Preload.User.Organization(), @@ -144,3 +213,19 @@ func extractInitials(name string) string { return initials.String() } +func toUser(user *models.User) User { + return User{ + DisplayName: user.DisplayName, + ID: int(user.ID), + Initials: extractInitials(user.DisplayName), + Notifications: []Notification{}, + NotificationCounts: UserNotificationCounts{}, + Organization: Organization{}, + PasswordHash: user.PasswordHash, + PasswordHashType: string(user.PasswordHashType), + Role: user.Role.String(), + Username: user.Username, + + model: user, + } +}