diff --git a/api/handlers.go b/api/handlers.go index 665f4e0..3754749 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -2852,3 +2852,35 @@ func containsAny(s string, subs ...string) bool { } // UpdateOrg handles PUT /api/orgs/{orgId} — update org name, domains, role, website + +// SetTestRole handles PUT /api/admin/test-role — super admin sets their test role for the current session. +func (h *Handlers) SetTestRole(w http.ResponseWriter, r *http.Request) { + actorID := UserIDFromContext(r.Context()) + isSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID) + if !isSuperAdmin { + ErrorResponse(w, http.StatusForbidden, "access_denied", "Super admin only") + return + } + sessionID := SessionIDFromContext(r.Context()) + if sessionID == "" { + ErrorResponse(w, http.StatusBadRequest, "no_session", "No session found") + return + } + var req struct { + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") + return + } + validRoles := map[string]bool{"": true, "ib": true, "buyer": true, "seller": true, "advisor": true} + if !validRoles[req.Role] { + ErrorResponse(w, http.StatusBadRequest, "invalid_role", "Role must be one of: ib, buyer, seller, advisor (or empty to reset)") + return + } + if err := lib.SetSessionTestRole(h.DB, sessionID, req.Role); err != nil { + ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to set test role") + return + } + JSONResponse(w, http.StatusOK, map[string]any{"ok": true, "test_role": req.Role}) +} diff --git a/api/middleware.go b/api/middleware.go index 84a024c..75a704d 100644 --- a/api/middleware.go +++ b/api/middleware.go @@ -18,7 +18,9 @@ import ( type contextKey string const ( - ctxUserID contextKey = "user_id" + ctxUserID contextKey = "user_id" + ctxTestRole contextKey = "test_role" + ctxSessID contextKey = "session_id" ) // UserIDFromContext extracts the authenticated user ID from the request context. @@ -27,6 +29,18 @@ func UserIDFromContext(ctx context.Context) string { return v } +// TestRoleFromContext returns the active test role (empty = none). +func TestRoleFromContext(ctx context.Context) string { + v, _ := ctx.Value(ctxTestRole).(string) + return v +} + +// SessionIDFromContext returns the session ID from context. +func SessionIDFromContext(ctx context.Context) string { + v, _ := ctx.Value(ctxSessID).(string) + return v +} + // AuthMiddleware validates JWT tokens and sets user context. func AuthMiddleware(db *lib.DB, jwtSecret []byte) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { @@ -55,6 +69,8 @@ func AuthMiddleware(db *lib.DB, jwtSecret []byte) func(http.Handler) http.Handle } ctx := context.WithValue(r.Context(), ctxUserID, claims.UserID) + ctx = context.WithValue(ctx, ctxTestRole, session.TestRole) + ctx = context.WithValue(ctx, ctxSessID, claims.SessionID) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/api/routes.go b/api/routes.go index 56a2fcf..1e05c51 100644 --- a/api/routes.go +++ b/api/routes.go @@ -99,6 +99,7 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs. r.Get("/orgs", h.ListOrgs) r.Post("/orgs", h.CreateOrg) r.Put("/orgs/{orgID}", h.UpdateOrg) + r.Put("/admin/test-role", h.SetTestRole) r.Get("/orgs/{orgID}", h.GetOrg) r.Patch("/orgs/{orgID}", h.UpdateOrg) diff --git a/data/dealspace.db-shm b/data/dealspace.db-shm index 4a0d389..e393e42 100644 Binary files a/data/dealspace.db-shm and b/data/dealspace.db-shm differ diff --git a/data/dealspace.db-wal b/data/dealspace.db-wal index 78e88c6..fcbd94b 100644 Binary files a/data/dealspace.db-wal and b/data/dealspace.db-wal differ diff --git a/lib/dbcore.go b/lib/dbcore.go index b1358ba..49fc1e0 100644 --- a/lib/dbcore.go +++ b/lib/dbcore.go @@ -1100,3 +1100,9 @@ func OAuthTokenRevoke(db *DB, tokenStr string) error { _, err := db.Conn.Exec(`UPDATE oauth_tokens SET revoked = 1 WHERE token = ?`, tokenStr) return err } + +// SetSessionTestRole updates the test_role on a session (super-admin only). +func SetSessionTestRole(db *DB, sessionID, role string) error { + _, err := db.Conn.Exec(`UPDATE sessions SET test_role = ? WHERE id = ?`, role, sessionID) + return err +} diff --git a/lib/types.go b/lib/types.go index a9e2ce5..2db3480 100644 --- a/lib/types.go +++ b/lib/types.go @@ -202,6 +202,7 @@ type Session struct { CreatedAt int64 `json:"created_at"` ExpiresAt int64 `json:"expires_at"` Revoked bool `json:"revoked"` + TestRole string `json:"test_role,omitempty"` // super-admin test impersonation } // Theme is a CSS custom properties bundle. diff --git a/portal/templates/layouts/app.html b/portal/templates/layouts/app.html index d1a0d1b..96798e1 100644 --- a/portal/templates/layouts/app.html +++ b/portal/templates/layouts/app.html @@ -83,20 +83,27 @@ function escHtml(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } // Theme switcher - function setTestRole(role) { - localStorage.setItem('ds_test_role', role); - document.getElementById('testRoleSelect').value = role; - // Show banner when impersonating - const banner = document.getElementById('testRoleBanner'); - if (banner) { banner.textContent = role ? '⚠ Viewing as: ' + role : ''; banner.style.display = role ? 'block' : 'none'; } + async function setTestRole(role) { + const sel = document.getElementById('testRoleSelect'); + sel.disabled = true; + try { + const res = await fetchAPI('/api/admin/test-role', { method: 'PUT', body: JSON.stringify({ role }) }); + if (!res.ok) { const e = await res.json(); alert(e.error || 'Failed'); sel.value = localStorage.getItem('ds_test_role')||''; return; } + localStorage.setItem('ds_test_role', role); + const banner = document.getElementById('testRoleBanner'); + if (banner) { banner.textContent = role ? '⚠ Test mode: viewing as ' + role.toUpperCase() : ''; banner.style.display = role ? 'block' : 'none'; } + // Reload to reflect new role + window.location.reload(); + } catch(e) { alert('Error: ' + e.message); } + finally { sel.disabled = false; } } - // Restore test role select on load + // Restore test role banner on load (role is server-side now) (function(){ const r = localStorage.getItem('ds_test_role') || ''; const sel = document.getElementById('testRoleSelect'); if (sel) sel.value = r; const banner = document.getElementById('testRoleBanner'); - if (banner && r) { banner.textContent = '⚠ Viewing as: ' + r; banner.style.display = 'block'; } + if (banner && r) { banner.textContent = '⚠ Test mode: viewing as ' + r.toUpperCase(); banner.style.display = 'block'; } })(); function setTheme(t){document.documentElement.setAttribute('data-theme',t);localStorage.setItem('ds_theme',t);document.querySelectorAll('#ds-theme-bar button').forEach(b=>b.classList.toggle('active',b.getAttribute('data-t')===t))}