Add display in sidebar for impersonation

This commit is contained in:
Eli Ribble 2026-04-02 17:39:16 +00:00
parent 51811132a4
commit 76c395d613
No known key found for this signature in database
11 changed files with 259 additions and 234 deletions

View file

@ -27,6 +27,8 @@ func AddRoutes(r *mux.Router) {
r.Handle("/image/{uuid}", auth.NewEnsureAuth(apiImagePost)).Methods("POST") r.Handle("/image/{uuid}", auth.NewEnsureAuth(apiImagePost)).Methods("POST")
r.Handle("/image/{uuid}/content", auth.NewEnsureAuth(apiImageContentGet)).Methods("GET") r.Handle("/image/{uuid}/content", auth.NewEnsureAuth(apiImageContentGet)).Methods("GET")
r.Handle("/image/{uuid}/content", auth.NewEnsureAuth(apiImageContentPost)).Methods("POST") r.Handle("/image/{uuid}/content", auth.NewEnsureAuth(apiImageContentPost)).Methods("POST")
impersonation := resource.Impersonation(router)
r.Handle("/impersonation", authenticatedHandlerJSONPost(impersonation.Create)).Methods("POST")
lead := resource.Lead(r) lead := resource.Lead(r)
r.Handle("/leads", authenticatedHandlerJSON(lead.List)).Methods("GET") r.Handle("/leads", authenticatedHandlerJSON(lead.List)).Methods("GET")
r.Handle("/leads", authenticatedHandlerJSONPost(lead.Create)).Methods("POST") r.Handle("/leads", authenticatedHandlerJSONPost(lead.Create)).Methods("POST")

View file

@ -31,7 +31,7 @@ func postSignup(ctx context.Context, r *http.Request, signup reqSignup) (string,
return "", nhttp.NewError("Failed to signup user", err) return "", nhttp.NewError("Failed to signup user", err)
} }
auth.AddUserSession(r, user) auth.AddUserSession(ctx, user)
return "/", nil return "/", nil
} }

View file

@ -30,16 +30,37 @@ type EnsureAuth struct {
handler AuthenticatedHandler handler AuthenticatedHandler
} }
func AddUserSession(r *http.Request, user *platform.User) { func AddUserSession(ctx context.Context, user *platform.User) {
id := strconv.Itoa(int(user.ID)) id_str := strconv.Itoa(int(user.ID))
sessionManager.Put(r.Context(), "user_id", id) sessionManager.Put(ctx, "user_id", id_str)
sessionManager.Put(r.Context(), "username", user.Username) sessionManager.Put(ctx, "username", user.Username)
log.Debug().Str("id", id).Str("username", user.Username).Msg("added user session") log.Debug().Str("id", id_str).Str("username", user.Username).Msg("added user session")
}
func ImpersonateUser(ctx context.Context, target_user_id int) {
target_user_id_str := strconv.Itoa(int(target_user_id))
sessionManager.Put(ctx, "impersonated_user_id", target_user_id_str)
}
func ImpersonatedUser(ctx context.Context) *int32 {
i_str := sessionManager.GetString(ctx, "impersonated_user_id")
if i_str == "" {
return nil
}
i, err := strconv.Atoi(i_str)
if err != nil {
log.Error().Err(err).Str("impersonated_user_id", i_str).Msg("failed to parse impersonated_user_id")
return nil
}
result := int32(i)
return &result
} }
func GetAuthenticatedUser(r *http.Request) (*platform.User, error) { func GetAuthenticatedUser(r *http.Request) (*platform.User, error) {
ctx := r.Context() ctx := r.Context()
user_id_str := sessionManager.GetString(ctx, "user_id") user_id_str := sessionManager.GetString(ctx, "user_id")
impersonated_user_id_str := sessionManager.GetString(ctx, "impersonated_user_id")
if impersonated_user_id_str != "" {
user_id_str = impersonated_user_id_str
}
if user_id_str != "" { if user_id_str != "" {
user_id, err := strconv.Atoi(user_id_str) user_id, err := strconv.Atoi(user_id_str)
if err != nil { if err != nil {
@ -59,7 +80,7 @@ func GetAuthenticatedUser(r *http.Request) (*platform.User, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
AddUserSession(r, user) AddUserSession(ctx, user)
return user, nil return user, nil
} }
@ -108,7 +129,7 @@ func SigninUser(r *http.Request, username string, password string) (*platform.Us
if user == nil { if user == nil {
return nil, errors.New("No matching user") return nil, errors.New("No matching user")
} }
AddUserSession(r, user) AddUserSession(r.Context(), user)
return user, nil return user, nil
} }

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"net/http" "net/http"
"github.com/Gleipnir-Technology/nidus-sync/auth"
"github.com/Gleipnir-Technology/nidus-sync/config" "github.com/Gleipnir-Technology/nidus-sync/config"
"github.com/Gleipnir-Technology/nidus-sync/html" "github.com/Gleipnir-Technology/nidus-sync/html"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http" nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
@ -47,6 +48,7 @@ type sessionURL struct {
type sessionURLAPI struct { type sessionURLAPI struct {
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
Communication string `json:"communication"` Communication string `json:"communication"`
Impersonation string `json:"impersonation"`
PublicreportMessage string `json:"publicreport_message"` PublicreportMessage string `json:"publicreport_message"`
ReviewTask string `json:"review_task"` ReviewTask string `json:"review_task"`
Signal string `json:"signal"` Signal string `json:"signal"`
@ -65,8 +67,17 @@ func (res *sessionR) Get(ctx context.Context, r *http.Request, user platform.Use
if err != nil { if err != nil {
return nil, nhttp.NewError("create user: %w", err) return nil, nhttp.NewError("create user: %w", err)
} }
var impersonating *string
impersonating_id := auth.ImpersonatedUser(ctx)
if impersonating_id != nil {
i, err := res.router.IDToURI("user.ByIDGet", int(*impersonating_id))
if err != nil {
return nil, nhttp.NewError("create impersonating uri: %w", err)
}
impersonating = &i
}
return &session{ return &session{
Impersonating: nil, Impersonating: impersonating,
NotificationCounts: sessionNotificationCounts{ NotificationCounts: sessionNotificationCounts{
Communications: counts.Communications, Communications: counts.Communications,
Home: counts.Home, Home: counts.Home,
@ -81,6 +92,7 @@ func (res *sessionR) Get(ctx context.Context, r *http.Request, user platform.Use
API: sessionURLAPI{ API: sessionURLAPI{
Avatar: config.MakeURLNidus("/api/avatar"), Avatar: config.MakeURLNidus("/api/avatar"),
Communication: urls.API.Communication, Communication: urls.API.Communication,
Impersonation: config.MakeURLNidus("/api/impersonation"),
PublicreportMessage: urls.API.Publicreport.Message, PublicreportMessage: urls.API.Publicreport.Message,
ReviewTask: config.MakeURLNidus("/api/review-task"), ReviewTask: config.MakeURLNidus("/api/review-task"),
Signal: config.MakeURLNidus("/api/signal"), Signal: config.MakeURLNidus("/api/signal"),

View file

@ -14,7 +14,7 @@ import (
"github.com/aarondl/opt/omitnull" "github.com/aarondl/opt/omitnull"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/rs/zerolog/log" //"github.com/rs/zerolog/log"
) )
type user struct { type user struct {
@ -159,9 +159,9 @@ func (res *userR) List(ctx context.Context, r *http.Request, u platform.User, qu
return nil, nhttp.NewError("list users: %w", err) return nil, nhttp.NewError("list users: %w", err)
} }
results := make([]*user, len(users)) results := make([]*user, len(users))
log.Debug().Int("len", len(users)).Msg("building response") //log.Debug().Int("len", len(users)).Msg("building response")
for i, v := range users { for i, v := range users {
log.Debug().Int("i", i).Msg("making results") //log.Debug().Int("i", i).Msg("making results")
resp, err := res.response(v) resp, err := res.response(v)
if err != nil { if err != nil {
return nil, nhttp.NewError("create response: %w", err) return nil, nhttp.NewError("create response: %w", err)

View file

@ -1,140 +0,0 @@
.logo-container {
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s ease;
}
.logo {
max-width: 100%;
height: auto;
transition: all 0.3s ease;
}
#sidebar {
background-color: $off-white;
min-height: 100vh;
transition: all 0.3s;
width: 250px;
position: fixed;
z-index: 1000;
padding: 20px;
}
#sidebar.collapsed {
width: 70px;
padding: 20px 10px;
}
/* Logo style when sidebar is collapsed */
#sidebar.collapsed .logo-container {
width: 100%;
}
#sidebar.collapsed .logo-img {
max-width: 40px; /* smaller size for collapsed state */
}
#content {
transition: all 0.3s;
margin-left: 250px;
padding: 10px;
width: calc(100% - 250px);
}
#content.expanded {
margin-left: 70px;
width: calc(100% - 70px);
}
.sidebar-header {
padding-bottom: 20px;
border-bottom: 1px solid $off-black;
margin-bottom: 20px;
overflow: hidden;
white-space: nowrap;
display: flex;
justify-content: center; /* Center for the logo */
}
.sidebar-menu {
list-style: none;
padding: 0;
}
.sidebar-menu li {
padding: 10px 0;
}
.sidebar-menu li a {
text-decoration: none;
color: $off-black;
display: flex;
align-items: center;
overflow: hidden;
white-space: nowrap;
}
.sidebar-menu li a:hover {
color: $primary;
}
.sidebar-menu .menu-icon {
font-size: 1.2rem;
min-width: 30px;
display: flex;
justify-content: center;
}
.sidebar-menu .menu-icon svg {
width: 1.5em;
height: 1.5em;
}
.sidebar-menu .menu-text {
transition: opacity 0.3s;
}
#sidebar.collapsed .menu-text {
opacity: 0;
visibility: hidden;
width: 0;
}
#sidebar.collapsed .sidebar-header h4 {
opacity: 0;
visibility: hidden;
}
#sidebar.collapsed .sidebar-menu .menu-icon {
min-width: 100%;
font-size: 1.5rem;
}
#sidebarToggle {
position: absolute;
left: calc(250px - 15px);
top: 50%;
transform: translateY(-50%);
z-index: 1050;
width: 30px;
height: 30px;
border-radius: 50%;
border: 1px solid #dee2e6;
display: flex;
align-items: center;
transition: left 0.3s;
padding: 0;
}
#sidebarToggle i {
transition: transform 0.3s;
}
#sidebar.collapsed > #sidebarToggle {
left: calc(70px - 15px);
}
#sidebar > #sidebarToggle i {
position: relative;
left: 5px;
}
#sidebar.collapsed > #sidebarToggle i {
transform: rotate(180deg);
}

View file

@ -12,6 +12,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from "vue"; import { onMounted } from "vue";
import { useSessionStore } from "@/store/session"; import { useSessionStore } from "@/store/session";
import { Session } from "@/types";
import Sidebar from "./components/layout/Sidebar.vue"; import Sidebar from "./components/layout/Sidebar.vue";
import MainContent from "./components/layout/MainContent.vue"; import MainContent from "./components/layout/MainContent.vue";
@ -19,7 +20,9 @@ import NavigationLink from "@/components/common/NavigationLink.vue";
const session = useSessionStore(); const session = useSessionStore();
onMounted(() => { onMounted(() => {
session.fetchSession(); session.get().then((session: Session) => {
console.log("session loaded", session);
});
}); });
</script> </script>

View file

@ -49,24 +49,25 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted, watch } from "vue";
import Avatar from "@/components/Avatar.vue"; import Avatar from "@/components/Avatar.vue";
import { useUserStore } from "@/store/user"; import { useUserStore } from "@/store/user";
import type { User } from "@/types"; import type { User } from "@/types";
interface Props { interface Props {
modelValue?: User | null;
placeholder?: string; placeholder?: string;
minChars?: number; minChars?: number;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
modelValue: null,
placeholder: "Search users...", placeholder: "Search users...",
minChars: 3, minChars: 3,
}); });
const emit = defineEmits<{ const emit = defineEmits<{
select: [user: User]; "update:modelValue": [user: User | null];
input: [query: string];
}>(); }>();
const usersStore = useUserStore(); const usersStore = useUserStore();
@ -78,8 +79,25 @@ onMounted(async () => {
if (!usersStore.all) { if (!usersStore.all) {
await usersStore.fetchAll(); await usersStore.fetchAll();
} }
// Initialize search query with selected user's name if provided
if (props.modelValue) {
searchQuery.value = props.modelValue.display_name;
}
}); });
// Watch for external changes to modelValue
watch(
() => props.modelValue,
(newValue) => {
if (newValue) {
searchQuery.value = newValue.display_name;
} else {
searchQuery.value = "";
}
},
);
const filteredUsers = computed(() => { const filteredUsers = computed(() => {
if (searchQuery.value.length < props.minChars || !usersStore.all) { if (searchQuery.value.length < props.minChars || !usersStore.all) {
return []; return [];
@ -98,7 +116,11 @@ const filteredUsers = computed(() => {
function onInput() { function onInput() {
showDropdown.value = searchQuery.value.length >= props.minChars; showDropdown.value = searchQuery.value.length >= props.minChars;
emit("input", searchQuery.value);
// Clear selection if user is typing
if (props.modelValue && searchQuery.value !== props.modelValue.display_name) {
emit("update:modelValue", null);
}
} }
function onFocus() { function onFocus() {
@ -117,7 +139,7 @@ function onBlur() {
function selectUser(user: User) { function selectUser(user: User) {
searchQuery.value = user.display_name; searchQuery.value = user.display_name;
showDropdown.value = false; showDropdown.value = false;
emit("select", user); emit("update:modelValue", user);
} }
function highlightMatch(text: string): string { function highlightMatch(text: string): string {

View file

@ -1,5 +1,152 @@
<style scoped lang="scss">
#content {
transition: all 0.3s;
margin-left: 250px;
padding: 10px;
width: calc(100% - 250px);
}
#content.expanded {
margin-left: 70px;
width: calc(100% - 70px);
}
.logo-container {
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s ease;
}
.logo {
max-width: 100%;
height: auto;
transition: all 0.3s ease;
}
#sidebar {
background-color: $off-white;
min-height: 100vh;
transition: all 0.3s;
width: 250px;
position: fixed;
z-index: 1000;
padding: 20px;
}
#sidebar.collapsed {
width: 70px;
padding: 20px 10px;
}
/* Logo style when sidebar is collapsed */
#sidebar.collapsed .logo-container {
width: 100%;
}
#sidebar.collapsed .logo-img {
max-width: 40px; /* smaller size for collapsed state */
}
#sidebar.impersonating {
background-color: $danger;
}
#sidebar.collapsed .menu-text {
opacity: 0;
visibility: hidden;
width: 0;
}
#sidebar.collapsed .sidebar-header h4 {
opacity: 0;
visibility: hidden;
}
#sidebar.collapsed .sidebar-menu .menu-icon {
min-width: 100%;
font-size: 1.5rem;
}
#sidebarToggle {
position: absolute;
left: calc(250px - 15px);
top: 50%;
transform: translateY(-50%);
z-index: 1050;
width: 30px;
height: 30px;
border-radius: 50%;
border: 1px solid #dee2e6;
display: flex;
align-items: center;
transition: left 0.3s;
padding: 0;
}
#sidebarToggle i {
transition: transform 0.3s;
}
#sidebar.collapsed > #sidebarToggle {
left: calc(70px - 15px);
}
#sidebar > #sidebarToggle i {
position: relative;
left: 5px;
}
#sidebar.collapsed > #sidebarToggle i {
transform: rotate(180deg);
}
.sidebar-header {
padding-bottom: 20px;
border-bottom: 1px solid $off-black;
margin-bottom: 20px;
overflow: hidden;
white-space: nowrap;
display: flex;
justify-content: center; /* Center for the logo */
}
.sidebar-menu {
list-style: none;
padding: 0;
}
.sidebar-menu li {
padding: 10px 0;
}
.sidebar-menu li a {
text-decoration: none;
color: $off-black;
display: flex;
align-items: center;
overflow: hidden;
white-space: nowrap;
}
.sidebar-menu li a:hover {
color: $primary;
}
.sidebar-menu .menu-icon {
font-size: 1.2rem;
min-width: 30px;
display: flex;
justify-content: center;
}
.sidebar-menu .menu-icon svg {
width: 1.5em;
height: 1.5em;
}
.sidebar-menu .menu-text {
transition: opacity 0.3s;
}
</style>
<template> <template>
<div id="sidebar" :class="{ collapsed: isCollapsed }"> <div
id="sidebar"
:class="{ collapsed: isCollapsed, impersonating: isImpersonating }"
>
<div class="sidebar-header"> <div class="sidebar-header">
<div class="logo-container"> <div class="logo-container">
<img class="logo" src="/static/img/nidus-logo-256-transparent.png" /> <img class="logo" src="/static/img/nidus-logo-256-transparent.png" />
@ -66,6 +213,7 @@ import { useSessionStore } from "@/store/session";
// Reactive state // Reactive state
const isCollapsed = ref(false); const isCollapsed = ref(false);
const isImpersonating = ref(false);
const session = useSessionStore(); const session = useSessionStore();
@ -135,6 +283,8 @@ onMounted(async () => {
await nextTick(); await nextTick();
const s = await session.get();
isImpersonating.value = !!s.impersonating;
initializeBootstrap(); initializeBootstrap();
setTooltipsForSidebar(); setTooltipsForSidebar();
}); });

View file

@ -12,10 +12,9 @@
<div class="col-md-6"> <div class="col-md-6">
<label for="userSearch" class="form-label">Search Users</label> <label for="userSearch" class="form-label">Search Users</label>
<UserSelector <UserSelector
v-model="selectedUserId" v-model="selectedUser"
label="Choose a user" label="Choose a user"
placeholder="Select a user..." placeholder="Select a user..."
@change="onUserChange"
/> />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
@ -29,76 +28,9 @@
</select> </select>
</div> </div>
</div> </div>
<div class="row mb-3">
<div class="table-responsive"> <button class="btn btn-danger" @click="doImpersonation" type="submit">
<table class="table table-hover"> Impersonate
<thead>
<tr>
<th>User ID</th>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td>1001</td>
<td>John Doe</td>
<td>john.doe@example.com</td>
<td><span class="badge bg-primary">Admin</span></td>
<td>
<button class="btn btn-sm btn-primary">
Impersonate <i class="bi bi-box-arrow-in-right"></i>
</button>
</td>
</tr>
<tr>
<td>1002</td>
<td>Jane Smith</td>
<td>jane.smith@example.com</td>
<td><span class="badge bg-info">Support</span></td>
<td>
<button class="btn btn-sm btn-primary">
Impersonate <i class="bi bi-box-arrow-in-right"></i>
</button>
</td>
</tr>
<tr>
<td>1003</td>
<td>Robert Johnson</td>
<td>robert@example.com</td>
<td><span class="badge bg-success">Premium User</span></td>
<td>
<button class="btn btn-sm btn-primary">
Impersonate <i class="bi bi-box-arrow-in-right"></i>
</button>
</td>
</tr>
<tr>
<td>1004</td>
<td>Maria Garcia</td>
<td>maria@example.com</td>
<td><span class="badge bg-secondary">Standard User</span></td>
<td>
<button class="btn btn-sm btn-primary">
Impersonate <i class="bi bi-box-arrow-in-right"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div
class="alert alert-warning mt-3 mb-0"
id="impersonationStatus"
style="display: none"
>
<i class="bi bi-exclamation-triangle"></i> You are currently
impersonating <strong>John Doe</strong>
<button class="btn btn-sm btn-warning float-end">
Exit Impersonation <i class="bi bi-box-arrow-left"></i>
</button> </button>
</div> </div>
</div> </div>
@ -113,9 +45,31 @@ import type { User } from "@/types";
const session = useSessionStore(); const session = useSessionStore();
const selectedUserId = ref<number | null>(null); const selectedUser = ref<User | null>(null);
const onUserChange = (user: User | null) => { const doImpersonation = async () => {
console.log("Selected user:", user); if (!selectedUser.value) {
console.log("Can't impersonate, null user");
return;
}
console.log("doing impersonation of user", selectedUser.value);
const body = {
id: selectedUser.value.id,
};
const url = session.urls!.api.impersonation;
const response = await fetch(url, {
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`);
}
const result = await response.json();
console.log("impersonation", result);
const new_session = await session.fetchSession();
console.log("session is now", new_session);
}; };
</script> </script>

View file

@ -229,6 +229,7 @@ export interface URLs {
interface URLsAPI { interface URLsAPI {
avatar: string; avatar: string;
communication: string; communication: string;
impersonation: string;
publicreport_message: string; publicreport_message: string;
review_task: string; review_task: string;
signal: string; signal: string;