Add initial user selector for impersonation page
This commit is contained in:
parent
68e0da1133
commit
42d9d2372d
9 changed files with 370 additions and 31 deletions
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
27
api/user.go
27
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
|
||||
}
|
||||
|
|
|
|||
243
html/static/js/user-selector.js
Normal file
243
html/static/js/user-selector.js
Normal 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 = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
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);
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue