feat: server-side test role — PUT /api/admin/test-role writes to session, middleware injects into context

This commit is contained in:
James 2026-03-12 03:45:39 -04:00
parent e89b4e5a5c
commit 84f8350700
8 changed files with 72 additions and 9 deletions

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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