Make impersonation ending work, fix frontend events

This commit is contained in:
Eli Ribble 2026-04-02 21:31:31 +00:00
parent 522c5785a2
commit 4b87c74f41
No known key found for this signature in database
18 changed files with 255 additions and 106 deletions

View file

@ -17,7 +17,7 @@ type ConnectionSSE struct {
chanEvent chan platform.Event
id uuid.UUID
organizationID int32
userID int
userID int32
}
type Message struct {
@ -50,6 +50,9 @@ func SetEventChannel(chan_envelopes <-chan platform.Envelope) {
if conn.organizationID == envelope.OrganizationID {
log.Debug().Int("type", int(envelope.Event.Type)).Int32("env-org", envelope.OrganizationID).Msg("pushed event to client")
conn.chanEvent <- envelope.Event
} else if conn.userID == envelope.UserID {
log.Debug().Int("type", int(envelope.Event.Type)).Int32("env-user", envelope.UserID).Msg("pushed event to user")
conn.chanEvent <- envelope.Event
} else {
log.Debug().Int("type", int(envelope.Event.Type)).Int32("env-org", envelope.OrganizationID).Int32("conn-org", conn.organizationID).Msg("skipped event, bad org")
}
@ -87,7 +90,7 @@ func streamEvents(w http.ResponseWriter, r *http.Request, u platform.User) {
chanEvent: make(chan platform.Event),
id: uid,
organizationID: u.Organization.ID,
userID: u.ID,
userID: int32(u.ID),
}
connectionsSSE[&connection] = true
log.Debug().Int32("org", u.Organization.ID).Int("user", u.ID).Str("id", uid.String()).Msg("connected SSE client")

View file

@ -20,6 +20,28 @@ import (
var decoder = schema.NewDecoder()
type handlerFunctionDelete func(context.Context, *http.Request, platform.User) *nhttp.ErrorWithStatus
func authenticatedHandlerDelete(f handlerFunctionDelete) http.Handler {
return auth.NewEnsureAuth(func(w http.ResponseWriter, r *http.Request, u platform.User) {
ctx := r.Context()
e := f(ctx, r, u)
if e != nil {
log.Warn().Int("status", e.Status).Err(e).Str("user message", e.Message).Msg("Responding with an error from api")
body, err := json.Marshal(ErrorAPI{Message: e.Error()})
if err != nil {
log.Error().Err(err).Msg("failed to marshal error")
http.Error(w, "{\"message\": \"boom. I can't even tell you what went wrong\"}", http.StatusInternalServerError)
return
}
http.Error(w, string(body), e.Status)
return
}
http.Error(w, "", http.StatusNoContent)
return
})
}
type handlerFunctionGetImage func(context.Context, *http.Request, platform.User) (file.Collection, uuid.UUID, *nhttp.ErrorWithStatus)
func authenticatedHandlerGetImage(f handlerFunctionGetImage) http.Handler {

View file

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

View file

@ -36,6 +36,9 @@ func AddUserSession(ctx context.Context, user *platform.User) {
sessionManager.Put(ctx, "username", user.Username)
log.Debug().Str("id", id_str).Str("username", user.Username).Msg("added user session")
}
func ImpersonateEnd(ctx context.Context) {
sessionManager.Put(ctx, "impersonated_user_id", "")
}
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)
@ -53,7 +56,17 @@ func ImpersonatedUser(ctx context.Context) *int32 {
result := int32(i)
return &result
}
func ImpersonatorID(ctx context.Context) *int32 {
user_id_str := sessionManager.GetString(ctx, "user_id")
user_id, err := strconv.Atoi(user_id_str)
if err != nil {
log.Error().Err(err).Str("user_id", user_id_str).Msg("failed to parse user_id")
return nil
}
result := int32(user_id)
return &result
}
func GetAuthenticatedUser(r *http.Request) (*platform.User, error) {
ctx := r.Context()
user_id_str := sessionManager.GetString(ctx, "user_id")

View file

@ -26,8 +26,9 @@ func (e Event) MarshalJSON() ([]byte, error) {
}
type Envelope struct {
OrganizationID int32
Event Event
OrganizationID int32
UserID int32
}
func SetEventChannel(chan_events chan<- Envelope) {
@ -90,6 +91,7 @@ const (
TypeRMONuisance
TypeRMOReport
TypeRMOWater
TypeSession
TypeSignal
)
@ -115,6 +117,17 @@ func Updated(t ResourceType, organization_id int32, uri_id string) {
OrganizationID: organization_id,
})
}
func UpdatedUser(t ResourceType, user_id int32, uri_id string) {
go Send(Envelope{
Event: Event{
Resource: resourceString(t),
Time: time.Now(),
Type: EventTypeUpdated,
URI: makeURI(t, uri_id),
},
UserID: user_id,
})
}
func Send(env Envelope) {
chanEvents <- env
}
@ -127,13 +140,15 @@ func resourceString(t ResourceType) string {
case TypeNoteImage:
return "sync:note:image"
case TypeReviewTask:
return "sync:review_task"
return "sync:review-task"
case TypeRMONuisance:
return "rmo:nuisance"
case TypeRMOReport:
return "rmo:report"
case TypeRMOWater:
return "rmo:water"
case TypeSession:
return "sync:session"
case TypeSignal:
return "sync:signal"
default:
@ -143,19 +158,21 @@ func resourceString(t ResourceType) string {
func makeURI(t ResourceType, id string) string {
switch t {
case TypeFileCSV:
return config.MakeURLNidus("/upload/%s", id)
return config.MakeURLNidus("/api/upload/%s", id)
case TypeNoteAudio:
return config.MakeURLNidus("/note/%s", id)
return config.MakeURLNidus("/api/note/%s", id)
case TypeNoteImage:
return config.MakeURLNidus("/note/%s", id)
return config.MakeURLNidus("/api/note/%s", id)
case TypeReviewTask:
return config.MakeURLNidus("/review/%s", id)
return config.MakeURLNidus("/api/review/%s", id)
case TypeRMONuisance:
return config.MakeURLReport("/report/%s", id)
return config.MakeURLReport("/api/report/%s", id)
case TypeRMOWater:
return config.MakeURLReport("/report/%s", id)
return config.MakeURLReport("/api/report/%s", id)
case TypeSession:
return config.MakeURLReport("/api/session")
case TypeSignal:
return config.MakeURLReport("/signal/%s", id)
return config.MakeURLReport("/api/signal/%s", id)
default:
return config.MakeURLReport("/unknown")
}

53
platform/impersonation.go Normal file
View file

@ -0,0 +1,53 @@
package platform
import (
"context"
"fmt"
"time"
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/Gleipnir-Technology/nidus-sync/platform/event"
"github.com/aarondl/opt/omit"
"github.com/aarondl/opt/omitnull"
"github.com/rs/zerolog/log"
)
func ImpersonationCreate(ctx context.Context, user User, target int) (*models.LogImpersonation, error) {
if !user.HasRoot() {
return nil, fmt.Errorf("user %d is not root, and therefore can't impersonate user %d", user.ID, target)
}
setter := models.LogImpersonationSetter{
BeginAt: omit.From(time.Now()),
EndAt: omitnull.FromPtr[time.Time](nil),
//ID: ,
ImpersonatorID: omit.From(int32(user.ID)),
TargetID: omit.From(int32(target)),
}
log, err := models.LogImpersonations.Insert(&setter).One(ctx, db.PGInstance.BobDB)
if err != nil {
return nil, fmt.Errorf("insert log: %w", err)
}
event.UpdatedUser(event.TypeSession, user.model.ID, "")
event.UpdatedUser(event.TypeSession, int32(target), "")
return log, nil
}
func ImpersonationEnd(ctx context.Context, user User, impersonator_id int32) error {
l, err := models.LogImpersonations.Query(
models.SelectWhere.LogImpersonations.EndAt.IsNull(),
models.SelectWhere.LogImpersonations.ImpersonatorID.EQ(impersonator_id),
).One(ctx, db.PGInstance.BobDB)
if err != nil {
return fmt.Errorf("query impersonations: %w", err)
}
err = l.Update(ctx, db.PGInstance.BobDB, &models.LogImpersonationSetter{
EndAt: omitnull.From(time.Now()),
})
if err != nil {
return fmt.Errorf("update impersonation log: %w", err)
}
log.Info().Int32("impersonator", l.ImpersonatorID).Int32("target", l.TargetID).Msg("Stopped impersonating")
event.UpdatedUser(event.TypeSession, user.model.ID, "")
event.UpdatedUser(event.TypeSession, impersonator_id, "")
return nil
}

56
resource/impersonation.go Normal file
View file

@ -0,0 +1,56 @@
package resource
import (
"context"
"net/http"
"github.com/Gleipnir-Technology/nidus-sync/auth"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
"github.com/Gleipnir-Technology/nidus-sync/platform"
"github.com/aarondl/opt/omit"
"github.com/rs/zerolog/log"
)
func Impersonation(r *router) *impersonationR {
return &impersonationR{
router: r,
}
}
type impersonationR struct {
router *router
}
type impersonation struct {
UserID omit.Val[int] `json:"id"`
}
func (res *impersonationR) Create(ctx context.Context, r *http.Request, u platform.User, i impersonation) (*impersonation, *nhttp.ErrorWithStatus) {
if i.UserID.IsUnset() {
return nil, nhttp.NewBadRequest("you must provide an 'id'")
}
target_id := i.UserID.MustGet()
l, err := platform.ImpersonationCreate(ctx, u, target_id)
if err != nil {
return nil, nhttp.NewError("create impersonation: %w", err)
}
auth.ImpersonateUser(ctx, target_id)
log.Info().Int("user.id", u.ID).Str("username", u.Username).Int("target.id", target_id).Int32("log.id", l.ID).Msg("Impersonation begins")
return &impersonation{
UserID: i.UserID,
}, nil
}
func (res *impersonationR) Delete(ctx context.Context, r *http.Request, u platform.User) *nhttp.ErrorWithStatus {
if auth.ImpersonatedUser == nil {
return nhttp.NewBadRequest("not impersonating")
}
real_user_id := auth.ImpersonatorID(ctx)
if real_user_id == nil {
return nhttp.NewError("No impersonator ID")
}
err := platform.ImpersonationEnd(ctx, u, *real_user_id)
if err != nil {
return nhttp.NewError("end impersonation: %w", err)
}
auth.ImpersonateEnd(ctx)
return nil
}

View file

@ -1,8 +1,9 @@
// Define types for the SSE data structure
interface SSEMessage {
export interface SSEMessage {
resource: string;
time: string;
type: string;
count?: number;
[key: string]: any; // Allow additional properties
uri: string;
}
type SSEHandler = (data: SSEMessage) => void;
@ -10,48 +11,25 @@ type SSEHandler = (data: SSEMessage) => void;
interface SSEManagerType {
connect: (url: string) => Promise<EventSource>;
disconnect: () => void;
subscribe: (eventType: string, handler: SSEHandler) => void;
unsubscribe: (eventType: string, handler: SSEHandler) => void;
subscribe: (handler: SSEHandler) => string;
unsubscribe: (uuid: string) => void;
ready: (callback: (eventSource: EventSource) => void) => void;
}
/*
declare global {
interface Window {
SSEManager: SSEManagerType;
}
}
*/
export const SSEManager: SSEManagerType = (function (): SSEManagerType {
let eventSource: EventSource | null = null;
let subscribers: Map<string, SSEHandler[]> = new Map();
let subscribers: Map<string, SSEHandler> = new Map();
let isConnected: boolean = false;
let connectionPromise: Promise<EventSource> | null = null;
function subscribe(eventType: string, handler: SSEHandler): void {
if (!subscribers.has(eventType)) {
subscribers.set(eventType, []);
}
subscribers.get(eventType)!.push(handler);
// If already connected, attach the listener immediately
if (isConnected && eventSource) {
eventSource.addEventListener(eventType, handler as EventListener);
}
}
function unsubscribe(eventType: string, handler: SSEHandler): void {
if (subscribers.has(eventType)) {
const handlers = subscribers.get(eventType)!;
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
}
if (eventSource) {
eventSource.removeEventListener(eventType, handler as EventListener);
}
}
function connect(url: string): Promise<EventSource> {
if (connectionPromise) {
return connectionPromise;
@ -63,19 +41,9 @@ export const SSEManager: SSEManagerType = (function (): SSEManagerType {
eventSource.onopen = function (): void {
isConnected = true;
// Attach all pre-registered handlers
subscribers.forEach((handlers: SSEHandler[], eventType: string) => {
handlers.forEach((handler: SSEHandler) => {
eventSource!.addEventListener(
"message",
(message: MessageEvent) => {
const data: SSEMessage = JSON.parse(message.data);
if (eventType === "*" || eventType === data.type) {
handler(data);
}
},
);
});
eventSource!.addEventListener("message", (message: MessageEvent) => {
const data: SSEMessage = JSON.parse(message.data);
handleMessage(data);
});
console.log("SSE connected");
@ -115,6 +83,15 @@ export const SSEManager: SSEManagerType = (function (): SSEManagerType {
}
}
function handleMessage(msg: SSEMessage) {
if (msg.type == "heartbeat") {
return;
}
subscribers.forEach((handler: SSEHandler, _: string) => {
handler(msg);
});
}
function ready(callback: (eventSource: EventSource) => void): void {
if (connectionPromise) {
connectionPromise.then(callback);
@ -129,6 +106,18 @@ export const SSEManager: SSEManagerType = (function (): SSEManagerType {
}
}
function subscribe(handler: SSEHandler): string {
const uuid = crypto.randomUUID();
subscribers.set(uuid.toString(), handler);
return uuid;
}
function unsubscribe(uuid: string): void {
if (subscribers.has(uuid)) {
subscribers.delete(uuid);
}
}
return {
connect,
disconnect,
@ -137,11 +126,3 @@ export const SSEManager: SSEManagerType = (function (): SSEManagerType {
ready,
};
})();
function updateNotificationBadge(data: SSEMessage): void {
const badge = document.querySelector<HTMLElement>(".notification-badge");
if (badge) {
badge.textContent = String(data.count || 0);
badge.style.display = (data.count || 0) > 0 ? "block" : "none";
}
}

View file

@ -208,8 +208,9 @@
import { ref, reactive, onMounted, onBeforeUnmount, nextTick } from "vue";
import { Tooltip, Popover } from "bootstrap";
import NavigationLink from "@/components/common/NavigationLink.vue";
import { SSEManager } from "@/SSEManager";
import { SSEManager, type SSEMessage } from "@/SSEManager";
import { useSessionStore } from "@/store/session";
import type { Session } from "@/types";
// Reactive state
const isCollapsed = ref(false);
@ -279,6 +280,14 @@ const setTooltipsForSidebar = () => {
// Lifecycle hooks
onMounted(async () => {
const sub = SSEManager.subscribe((msg: SSEMessage) => {
if (msg.resource != "sync:session") {
return;
}
session.fetchSession().then((s: Session) => {
isImpersonating.value = !!s.impersonating;
});
});
restoreLocalStorage();
await nextTick();

View file

@ -8,9 +8,9 @@
<i class="bi bi-people"></i> Impersonate User
</div>
<div class="card-body">
<template v-if="isImpersonating && impersonatedUser">
<template v-if="session.impersonating">
<h1>You're impersonating</h1>
<p>{{ impersonatedUser.username }}</p>
<p>{{ session.impersonating }}</p>
<button class="btn btn-primary" @click="doImpersonationEnd">
End Impersonation
</button>
@ -96,14 +96,13 @@ const doImpersonationStart = async () => {
const new_session = await session.fetchSession();
console.log("session is now", new_session);
};
/*
onMounted(() => {
session.get().then((session: Session) => {
if (session.impersonating) {
isImpersonating.value = true;
console.log("is impersonating, but who?");
user.byURI(session.impersonating).then((user: User | null) => {
impersonatedUser.value = user;
console.log("is impersonating", user);
});
} else {
isImpersonating.value = false;
@ -111,4 +110,5 @@ onMounted(() => {
}
});
});
*/
</script>

View file

@ -2,7 +2,7 @@ import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import { SSEManager } from "./SSEManager";
import { SSEManager, type SSEMessage } from "./SSEManager";
//import { SetupSidebar } from "./sidebar";
import "maplibre-gl/dist/maplibre-gl.css";
@ -22,9 +22,9 @@ window.SSEManager = SSEManager;
document.addEventListener("DOMContentLoaded", () => {
SSEManager.connect("/api/events");
SSEManager.subscribe("*", (e) => {
if (e.type != "heartbeat") {
console.log("SSE", e);
SSEManager.subscribe((msg: SSEMessage) => {
if (msg.type != "heartbeat") {
console.log("SSE", msg);
}
});
});

View file

@ -1,7 +1,7 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { Communication } from "../types";
import { SSEManager } from "../SSEManager";
import { SSEManager, SSEMessage } from "../SSEManager";
import { useSessionStore } from "./session";
export const useCommunicationStore = defineStore("communication", () => {
@ -11,8 +11,8 @@ export const useCommunicationStore = defineStore("communication", () => {
const error = ref(null);
// Subscription
SSEManager.subscribe("*", (e) => {
if (e.resource.startsWith("rmo")) {
SSEManager.subscribe((msg: SSEMessage) => {
if (msg.resource.startsWith("rmo:")) {
fetchAll();
}
});

View file

@ -1,6 +1,6 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { SSEManager } from "@/SSEManager";
import { SSEManager, SSEMessage } from "@/SSEManager";
import { ReviewTask } from "@/types";
import { useSessionStore } from "@/store/session";
@ -11,8 +11,8 @@ export const useReviewTaskStore = defineStore("review-task", () => {
const error = ref<string | null>(null);
// Subscription
SSEManager.subscribe("*", (e) => {
if (e.resource.startsWith("review-task")) {
SSEManager.subscribe((msg: SSEMessage) => {
if (msg.resource.startsWith("sync:review-task")) {
fetchAll();
}
});

View file

@ -1,6 +1,6 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { SSEManager } from "@/SSEManager";
import { SSEManager, type SSEMessage } from "@/SSEManager";
import {
Organization,
Session,
@ -11,6 +11,7 @@ import {
export const useSessionStore = defineStore("session", () => {
// State
const impersonating = ref<string | null>(null);
const error = ref<string | null>(null);
const loading = ref(false);
const current = ref<Session | null>(null);
@ -21,8 +22,8 @@ export const useSessionStore = defineStore("session", () => {
const urls = ref<URLs | null>(null);
// Subscription
SSEManager.subscribe("*", (e) => {
if (e.type !== "heartbeat") {
SSEManager.subscribe((msg: SSEMessage) => {
if (msg.type !== "sync:session") {
fetchSession();
}
});
@ -37,6 +38,7 @@ export const useSessionStore = defineStore("session", () => {
if (!response.ok) throw new Error("Failed to fetch user");
const data: Session = await response.json();
impersonating.value = data.impersonating || null;
notification_counts.value = data.notification_counts;
organization.value = data.organization;
self.value = data.self;
@ -65,15 +67,15 @@ export const useSessionStore = defineStore("session", () => {
return ongoingFetch.value;
}
ongoingFetch.value = fetchSession().finally(() => {
ongoingFetch.value = null;
});
return ongoingFetch.value;
const s = await fetchSession();
current.value = s;
ongoingFetch.value = null;
return s;
}
return {
// State
current,
error,
impersonating,
loading,
notification_counts,
organization,

View file

@ -1,7 +1,7 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { Signal } from "../types";
import { SSEManager } from "../SSEManager";
import { SSEManager, type SSEMessage } from "../SSEManager";
import { useSessionStore } from "@/store/session";
export const useSignalStore = defineStore("signal", () => {
@ -11,8 +11,8 @@ export const useSignalStore = defineStore("signal", () => {
const error = ref(null);
// Subscription
SSEManager.subscribe("*", (e) => {
if (e.resource.startsWith("signal")) {
SSEManager.subscribe((msg: SSEMessage) => {
if (msg.resource.startsWith("sync:signal")) {
fetchAll();
}
});

View file

@ -1,7 +1,7 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { Upload } from "@/types";
import { SSEManager } from "@/SSEManager";
import { SSEManager, type SSEMessage } from "@/SSEManager";
import { useSessionStore } from "@/store/session";
export const useUploadStore = defineStore("upload", () => {
@ -12,8 +12,8 @@ export const useUploadStore = defineStore("upload", () => {
const error = ref(null);
// Subscription
SSEManager.subscribe("*", (e) => {
if (e.resource.startsWith("upload")) {
SSEManager.subscribe((msg: SSEMessage) => {
if (msg.resource.startsWith("sync:upload")) {
fetchAll();
}
});

View file

@ -1,7 +1,7 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { Session, User } from "@/types";
import { SSEManager } from "@/SSEManager";
import { SSEManager, type SSEMessage } from "@/SSEManager";
import { useSessionStore } from "@/store/session";
export const useUserStore = defineStore("users", () => {
@ -13,8 +13,8 @@ export const useUserStore = defineStore("users", () => {
const ongoingFetch = ref<Promise<User[]> | null>(null);
// Subscription
SSEManager.subscribe("*", (e) => {
if (e.resource.startsWith("users")) {
SSEManager.subscribe((msg: SSEMessage) => {
if (msg.resource.startsWith("sync:user")) {
fetchAll();
}
});

View file

@ -59,14 +59,15 @@
import { computed, onMounted, ref } from "vue";
import maplibregl from "maplibre-gl";
import { useCommunicationStore } from "@/store/communication";
import { useSessionStore } from "@/store/session";
import CommunicationColumnAction from "@/components/CommunicationColumnAction.vue";
import CommunicationColumnDetail from "@/components/CommunicationColumnDetail.vue";
import CommunicationColumnList from "@/components/CommunicationColumnList.vue";
import ImageViewerModal from "@/components/ImageViewerModal.vue";
import ThreeColumn from "@/components/layout/ThreeColumn.vue";
import ToastNotification from "@/components/ToastNotification.vue";
import { SSEManager } from "@/SSEManager";
import { useCommunicationStore } from "@/store/communication";
import { useSessionStore } from "@/store/session";
import { Bounds, Communication, Marker } from "@/types";
const communication = useCommunicationStore();
@ -307,15 +308,6 @@ function updateMap() {
onMounted(async () => {
await loadFromAPI();
// Subscribe to SSE events
if (window.SSEManager) {
window.SSEManager.subscribe("*", (e) => {
if (e.resource.startsWith("rmo:")) {
fetchCommunications();
}
});
}
// Setup map layer after next tick to ensure map is mounted
/*
if (mapRef.value) {