Add initial user selector for impersonation page

This commit is contained in:
Eli Ribble 2026-03-20 05:20:37 +00:00
parent 68e0da1133
commit 42d9d2372d
No known key found for this signature in database
9 changed files with 370 additions and 31 deletions

View file

@ -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"`
}

View file

@ -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)

View file

@ -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
}

View file

@ -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 = `
<link href="/static/css/bootstrap.css" rel="stylesheet">
<style>
:host {
display: block;
position: relative;
}
.suggestions-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
max-height: 300px;
overflow-y: auto;
display: none;
}
.suggestions-dropdown.show {
display: block;
}
.suggestion-item {
cursor: pointer;
border-bottom: 1px solid #dee2e6;
}
.suggestion-item:last-child {
border-bottom: none;
}
.suggestion-item:hover {
background-color: #f8f9fa;
}
.user-display-name {
font-weight: 500;
color: #212529;
}
.user-username {
font-size: 0.875rem;
color: #6c757d;
}
.user-org {
font-size: 0.875rem;
color: #6c757d;
}
.loading {
text-align: center;
padding: 0.75rem;
color: #6c757d;
}
</style>
<div class="user-selector-container">
<input
type="text"
class="form-control"
placeholder="Type to search users (min. 4 characters)..."
id="userInput"
autocomplete="off"
/>
<div class="suggestions-dropdown card shadow-sm" id="suggestionsDropdown">
<div class="list-group list-group-flush" id="suggestionsList">
<!-- Suggestions will be inserted here -->
</div>
</div>
</div>
`;
}
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 = '<div class="loading">Loading...</div>';
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 = `
<div class="alert alert-danger m-2" role="alert">
Error loading suggestions. Please try again.
</div>
`;
}
}
displaySuggestions(users) {
const suggestionsList = this.shadowRoot.getElementById("suggestionsList");
const dropdown = this.shadowRoot.getElementById("suggestionsDropdown");
if (!users || users.length === 0) {
suggestionsList.innerHTML = `
<div class="loading">No users found</div>
`;
return;
}
suggestionsList.innerHTML = users
.map(
(user) => `
<div class="list-group-item list-group-item-action suggestion-item" data-user='${JSON.stringify(user)}'>
<div class="d-flex w-100 justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="user-display-name">${this.escapeHtml(user.display_name)}</div>
<div class="user-username">@${this.escapeHtml(user.username)}</div>
</div>
<div class="text-end">
<span class="badge bg-secondary user-org">${this.escapeHtml(user.organization.name)}</span>
</div>
</div>
</div>
`,
)
.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 = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
};
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);

View file

@ -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");

View file

@ -2,6 +2,7 @@
{{ define "title" }}Sudo{{ end }}
{{ define "extraheader" }}
<script src="/static/js/user-selector.js"></script>
{{ end }}
{{ define "content" }}
<div class="container mt-4">
@ -336,17 +337,7 @@
<div class="row mb-3">
<div class="col-md-6">
<label for="userSearch" class="form-label">Search Users</label>
<div class="input-group">
<input
type="text"
class="form-control"
id="userSearch"
placeholder="Search by name, email, ID..."
/>
<button class="btn btn-outline-secondary" type="button">
<i class="bi bi-search"></i>
</button>
</div>
<user-selector></user-selector>
</div>
<div class="col-md-6">
<label for="userRole" class="form-label">Filter by Role</label>

View file

@ -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
}

View file

@ -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
}

View file

@ -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,
}
}