feat: server-side test role — PUT /api/admin/test-role writes to session, middleware injects into context
This commit is contained in:
parent
e89b4e5a5c
commit
84f8350700
|
|
@ -2852,3 +2852,35 @@ func containsAny(s string, subs ...string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateOrg handles PUT /api/orgs/{orgId} — update org name, domains, role, website
|
// 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})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ type contextKey string
|
||||||
|
|
||||||
const (
|
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.
|
// UserIDFromContext extracts the authenticated user ID from the request context.
|
||||||
|
|
@ -27,6 +29,18 @@ func UserIDFromContext(ctx context.Context) string {
|
||||||
return v
|
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.
|
// AuthMiddleware validates JWT tokens and sets user context.
|
||||||
func AuthMiddleware(db *lib.DB, jwtSecret []byte) func(http.Handler) http.Handler {
|
func AuthMiddleware(db *lib.DB, jwtSecret []byte) func(http.Handler) http.Handler {
|
||||||
return func(next 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(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))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.
|
||||||
r.Get("/orgs", h.ListOrgs)
|
r.Get("/orgs", h.ListOrgs)
|
||||||
r.Post("/orgs", h.CreateOrg)
|
r.Post("/orgs", h.CreateOrg)
|
||||||
r.Put("/orgs/{orgID}", h.UpdateOrg)
|
r.Put("/orgs/{orgID}", h.UpdateOrg)
|
||||||
|
r.Put("/admin/test-role", h.SetTestRole)
|
||||||
r.Get("/orgs/{orgID}", h.GetOrg)
|
r.Get("/orgs/{orgID}", h.GetOrg)
|
||||||
r.Patch("/orgs/{orgID}", h.UpdateOrg)
|
r.Patch("/orgs/{orgID}", h.UpdateOrg)
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -1100,3 +1100,9 @@ func OAuthTokenRevoke(db *DB, tokenStr string) error {
|
||||||
_, err := db.Conn.Exec(`UPDATE oauth_tokens SET revoked = 1 WHERE token = ?`, tokenStr)
|
_, err := db.Conn.Exec(`UPDATE oauth_tokens SET revoked = 1 WHERE token = ?`, tokenStr)
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,7 @@ type Session struct {
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
ExpiresAt int64 `json:"expires_at"`
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
Revoked bool `json:"revoked"`
|
Revoked bool `json:"revoked"`
|
||||||
|
TestRole string `json:"test_role,omitempty"` // super-admin test impersonation
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme is a CSS custom properties bundle.
|
// Theme is a CSS custom properties bundle.
|
||||||
|
|
|
||||||
|
|
@ -83,20 +83,27 @@
|
||||||
function escHtml(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
function escHtml(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||||
|
|
||||||
// Theme switcher
|
// Theme switcher
|
||||||
function setTestRole(role) {
|
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);
|
localStorage.setItem('ds_test_role', role);
|
||||||
document.getElementById('testRoleSelect').value = role;
|
|
||||||
// Show banner when impersonating
|
|
||||||
const banner = document.getElementById('testRoleBanner');
|
const banner = document.getElementById('testRoleBanner');
|
||||||
if (banner) { banner.textContent = role ? '⚠ Viewing as: ' + role : ''; banner.style.display = role ? 'block' : 'none'; }
|
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(){
|
(function(){
|
||||||
const r = localStorage.getItem('ds_test_role') || '';
|
const r = localStorage.getItem('ds_test_role') || '';
|
||||||
const sel = document.getElementById('testRoleSelect');
|
const sel = document.getElementById('testRoleSelect');
|
||||||
if (sel) sel.value = r;
|
if (sel) sel.value = r;
|
||||||
const banner = document.getElementById('testRoleBanner');
|
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))}
|
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))}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue