Files
descrybe/apps/api/internal/httpapi/admin_dev_handlers.go
T

504 lines
15 KiB
Go
Raw Normal View History

package httpapi
import (
"context"
"errors"
"net/http"
"sort"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
const defaultDevPassword = "DemoPass123!"
func isLocalDemoEmail(email string) bool {
switch strings.ToLower(strings.TrimSpace(email)) {
case "demo@descrybe.local", "demo@descrybe.test":
return true
default:
return false
}
}
2026-08-14 01:38:34 +02:00
// resolveImpersonationActor returns the privileged actor allowed to drive user
// switching: platform staff_role=admin (any env), plus non-prod full admin / demo
// users, or the stored impersonator when still privileged.
func (s *Server) resolveDevImpersonationActor(ctx context.Context) (actorID uuid.UUID, ok bool, err error) {
uid, hasUID := UserIDFromContext(ctx)
if !hasUID || uid == uuid.Nil {
return uuid.Nil, false, nil
}
if s.Auth == nil {
return uuid.Nil, false, errors.New("auth unavailable")
}
2026-08-14 01:38:34 +02:00
if canImpersonate, err := s.userMayImpersonate(ctx, uid); err != nil {
return uuid.Nil, false, err
2026-08-14 01:38:34 +02:00
} else if canImpersonate {
return uid, true, nil
}
impStr := strings.TrimSpace(s.Sessions.GetString(ctx, auth.SessionImpersonatorIDKey))
if impStr == "" {
return uuid.Nil, false, nil
}
impID, err := uuid.Parse(impStr)
if err != nil || impID == uuid.Nil {
return uuid.Nil, false, nil
}
2026-08-14 01:38:34 +02:00
if canImpersonate, err := s.userMayImpersonate(ctx, impID); err != nil {
return uuid.Nil, false, err
2026-08-14 01:38:34 +02:00
} else if canImpersonate {
return impID, true, nil
}
return uuid.Nil, false, nil
}
2026-08-14 01:38:34 +02:00
// userMayImpersonate is true for platform staff_role=admin in any environment.
// Non-production also allows other full admins and local demo accounts.
func (s *Server) userMayImpersonate(ctx context.Context, userID uuid.UUID) (bool, error) {
access, err := s.checkStaffAccess(ctx, userID)
if err != nil {
return false, err
}
if access.FullAdmin && access.Role == auth.StaffRoleAdmin {
return true, nil
}
if s.Config.IsProduction() {
2026-08-14 01:38:34 +02:00
return false, nil
}
if access.FullAdmin {
return true, nil
}
2026-08-14 01:38:34 +02:00
if s.Auth == nil {
return false, nil
}
user, err := s.Auth.GetUser(ctx, userID)
if err != nil {
return false, nil
}
return isLocalDemoEmail(user.Email), nil
}
// handleAdminDevSetPassword force-sets a password for any active user.
// Platform admin only (route). Production requires an explicit password (no default)
// so ops can unlock @legacy.local / fake-email accounts that cannot receive invites.
func (s *Server) handleAdminDevSetPassword(w http.ResponseWriter, r *http.Request) {
if s.Auth == nil {
Error(w, http.StatusServiceUnavailable, "auth unavailable")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
Password string `json:"password"`
}
_ = DecodeJSONOptional(r, &body)
2026-08-14 01:38:34 +02:00
password := strings.TrimSpace(body.Password)
if password == "" {
if s.Config.IsProduction() {
Error(w, http.StatusBadRequest, "password is required")
return
}
password = defaultDevPassword
}
if len(password) < 8 {
Error(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
user, err := s.Auth.GetUser(r.Context(), id)
if err != nil {
Error(w, http.StatusNotFound, "user not found")
return
}
if !user.IsActive {
Error(w, http.StatusBadRequest, "user is inactive")
return
}
if err := s.Auth.ForceSetPassword(r.Context(), id, password); err != nil {
if errors.Is(err, auth.ErrUserNotFound) {
Error(w, http.StatusNotFound, "user not found")
return
}
LogAndError(w, http.StatusInternalServerError, "could not set password", err)
return
}
2026-08-14 01:38:34 +02:00
hint := "Password set. Sign in with this email and the password you provided."
if !s.Config.IsProduction() {
hint = "Password set for local login. Omit body.password to use the built-in local default."
}
JSON(w, http.StatusOK, map[string]any{
"ok": true,
"user_id": id,
"email": user.Email,
2026-08-14 01:38:34 +02:00
"hint": hint,
})
}
2026-08-14 01:38:34 +02:00
// handleAdminDevImpersonate swaps the current session to the target user so the
// operator sees that user's tenant membership and plan (not platform staff chrome).
func (s *Server) handleAdminDevImpersonate(w http.ResponseWriter, r *http.Request) {
if s.Auth == nil {
Error(w, http.StatusServiceUnavailable, "auth unavailable")
return
}
actorID, allowed, err := s.resolveDevImpersonationActor(r.Context())
if err != nil {
LogAndError(w, http.StatusInternalServerError, "could not authorize user switch", err)
return
}
if !allowed {
Error(w, http.StatusForbidden, "user switch not allowed")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
adminID, ok := UserIDFromContext(r.Context())
if !ok || adminID == uuid.Nil {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
if adminID == id {
Error(w, http.StatusBadRequest, "already signed in as this user")
return
}
user, err := s.Auth.GetUser(r.Context(), id)
if err != nil {
Error(w, http.StatusNotFound, "user not found")
return
}
if !user.IsActive {
Error(w, http.StatusBadRequest, "user is inactive")
return
}
companies, err := s.Auth.ListUserCompanies(r.Context(), id)
if err != nil {
LogAndError(w, http.StatusInternalServerError, "could not list companies", err)
return
}
var companyID uuid.UUID
if len(companies) > 0 {
companyID = companies[0].ID
}
if err := s.beginImpersonatedSession(r.Context(), id, companyID, actorID); err != nil {
Error(w, http.StatusInternalServerError, "session start failed")
return
}
JSON(w, http.StatusOK, map[string]any{
"ok": true,
"user": user,
"company_id": companyID,
"companies": companies,
"hint": "Session switched. Reload the app to view this user's tenant context.",
})
}
// handleAdminDevStopImpersonate restores the session to the original admin/demo actor.
func (s *Server) handleAdminDevStopImpersonate(w http.ResponseWriter, r *http.Request) {
if s.Auth == nil {
Error(w, http.StatusServiceUnavailable, "auth unavailable")
return
}
actorID, allowed, err := s.resolveDevImpersonationActor(r.Context())
if err != nil {
LogAndError(w, http.StatusInternalServerError, "could not authorize stop impersonate", err)
return
}
if !allowed {
Error(w, http.StatusForbidden, "user switch not allowed")
return
}
impStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionImpersonatorIDKey))
if impStr == "" {
Error(w, http.StatusBadRequest, "not impersonating")
return
}
impID, err := uuid.Parse(impStr)
if err != nil || impID == uuid.Nil {
Error(w, http.StatusBadRequest, "invalid impersonator")
return
}
if impID != actorID {
2026-08-14 01:38:34 +02:00
canImp, ierr := s.userMayImpersonate(r.Context(), impID)
if ierr != nil || !canImp {
Error(w, http.StatusForbidden, "user switch not allowed")
return
}
}
user, err := s.Auth.GetUser(r.Context(), impID)
if err != nil {
Error(w, http.StatusNotFound, "impersonator not found")
return
}
if !user.IsActive {
Error(w, http.StatusBadRequest, "impersonator is inactive")
return
}
companies, err := s.Auth.ListUserCompanies(r.Context(), impID)
if err != nil {
LogAndError(w, http.StatusInternalServerError, "could not list companies", err)
return
}
var companyID uuid.UUID
if len(companies) > 0 {
companyID = companies[0].ID
}
// Clear impersonation then start a normal session as the actor.
s.Sessions.Remove(r.Context(), auth.SessionImpersonatorIDKey)
if err := s.beginAuthenticatedSession(r.Context(), impID, companyID); err != nil {
Error(w, http.StatusInternalServerError, "session start failed")
return
}
JSON(w, http.StatusOK, map[string]any{
"ok": true,
"user": user,
"company_id": companyID,
"companies": companies,
"hint": "Returned to original session. Reload the app.",
})
}
// primaryA1LegacyUserID is the Clerk user_id for the A1 contact we care about in local demos
// (migrated as …@legacy.local). Used only for non-prod switcher labels.
const primaryA1LegacyUserID = "user_30AqqJ8uepxvPUzDSqy81U5w6Ll"
type switchableUserRow struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Name *string `json:"name"`
LegacyUserID *string `json:"legacy_user_id,omitempty"`
MembershipRole string `json:"membership_role,omitempty"`
CompanyID uuid.UUID `json:"company_id"`
CompanyName string `json:"company_name"`
CompanyLabel string `json:"company_label"`
Label string `json:"label"`
Subtitle string `json:"subtitle"`
IsDemoAdmin bool `json:"is_demo_admin"`
IsPrimaryA1 bool `json:"is_primary_a1"`
ClerkSuffix string `json:"clerk_suffix,omitempty"`
}
func companyDisplayLabel(companyName, legacyCompanyID string) string {
name := strings.TrimSpace(companyName)
if isA1LegacyCompany(legacyCompanyID, name) {
// Prefer live company name when already A1 Slovenija; never fake "Local Demo Co".
if name != "" && !strings.EqualFold(name, "Local Demo Co") {
return name
}
return "A1 Slovenija"
}
if name == "" {
return "Unknown company"
}
return name
}
func clerkIDFromLegacy(email string, legacyUserID *string) string {
if legacyUserID != nil {
if id := strings.TrimSpace(*legacyUserID); id != "" {
return id
}
}
email = strings.TrimSpace(strings.ToLower(email))
if strings.HasSuffix(email, "@legacy.local") {
return strings.TrimSuffix(email, "@legacy.local")
}
return ""
}
func shortClerkSuffix(clerkID string) string {
id := strings.TrimSpace(clerkID)
if id == "" {
return ""
}
const n = 8
if len(id) <= n {
return id
}
return id[len(id)-n:]
}
func isPrimaryA1User(email, clerkID string) bool {
emailNorm := strings.TrimSpace(strings.ToLower(email))
if emailNorm == "a1-primary@descrybe.local" {
return true
}
if strings.EqualFold(strings.TrimSpace(clerkID), primaryA1LegacyUserID) {
return true
}
target := strings.ToLower(primaryA1LegacyUserID)
local := emailNorm
if i := strings.IndexByte(local, '@'); i > 0 {
local = local[:i]
}
return local == target
}
// isA1LegacyCompany is true when the membership company maps to MySQL A1 Slovenija
// (legacy_company_id 97e1a309-…, dump name, or the old Local Demo Co rename).
// isA1LegacyCompany is true for non-prod switcher labels when the membership
// company maps to migrated A1 (immutable legacy_company_id) OR known dump/demo
// display names. Name matches are UI-only — billing cohort uses IsA1CohortCompany.
func isA1LegacyCompany(legacyCompanyID, companyName string) bool {
if billing.IsA1CohortCompany(legacyCompanyID, companyName) {
return true
}
n := strings.TrimSpace(companyName)
return strings.EqualFold(n, "A1 Slovenija") ||
strings.EqualFold(n, "Local Demo Co") ||
strings.EqualFold(n, "A1")
}
// a1SwitcherLabel builds dump-truth labels. MySQL profiles have no human names/emails —
// only Clerk user_id — so we show "A1 · …<clerkSuffix>".
func a1SwitcherLabel(clerkSuffix string) string {
if strings.TrimSpace(clerkSuffix) != "" {
return "A1 · …" + clerkSuffix
}
return "A1 · A1 Slovenija"
}
func enrichSwitchableUser(u *switchableUserRow, legacyCompanyID string) {
u.CompanyLabel = companyDisplayLabel(u.CompanyName, legacyCompanyID)
clerkID := clerkIDFromLegacy(u.Email, u.LegacyUserID)
u.ClerkSuffix = shortClerkSuffix(clerkID)
u.IsDemoAdmin = isLocalDemoEmail(u.Email)
onA1 := isA1LegacyCompany(legacyCompanyID, u.CompanyName)
u.IsPrimaryA1 = onA1 && isPrimaryA1User(u.Email, clerkID)
switch {
case u.IsDemoAdmin:
u.Label = "Demo admin"
u.Subtitle = u.Email
case onA1 && (u.IsPrimaryA1 || clerkID != ""):
// Dump-confirmed A1 members (no human name in MySQL profiles/admin_users).
u.Label = a1SwitcherLabel(u.ClerkSuffix)
if clerkID != "" {
u.Subtitle = "A1 Slovenija · " + clerkID
} else {
u.Subtitle = "A1 Slovenija · " + u.Email
}
default:
if u.Name != nil && strings.TrimSpace(*u.Name) != "" {
u.Label = strings.TrimSpace(*u.Name)
} else {
u.Label = u.Email
}
u.Subtitle = u.Email
}
}
// handleAdminDevListSwitchableUsers lists active users with a preferred company label
2026-08-14 01:38:34 +02:00
// for the header user-switch dropdown (platform staff_role=admin, or non-prod demo).
func (s *Server) handleAdminDevListSwitchableUsers(w http.ResponseWriter, r *http.Request) {
if s.Pool == nil {
Error(w, http.StatusServiceUnavailable, "database unavailable")
return
}
_, allowed, err := s.resolveDevImpersonationActor(r.Context())
if err != nil {
LogAndError(w, http.StatusInternalServerError, "could not authorize user list", err)
return
}
if !allowed {
Error(w, http.StatusForbidden, "user switch not allowed")
return
}
activeCompanyID := uuid.Nil
if cidStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionCompanyIDKey)); cidStr != "" {
if cid, err := uuid.Parse(cidStr); err == nil {
activeCompanyID = cid
}
}
rows, err := s.Pool.Query(r.Context(), `
SELECT DISTINCT ON (u.id)
u.id, u.email, u.name, u.legacy_user_id, m.role, c.id, c.name, COALESCE(c.legacy_company_id, '')
FROM users u
INNER JOIN memberships m ON m.user_id = u.id AND m.status = 'active'
INNER JOIN companies c ON c.id = m.company_id
WHERE u.is_active = true
ORDER BY u.id,
CASE WHEN c.id = $1 THEN 0 ELSE 1 END,
CASE WHEN COALESCE(c.legacy_company_id, '') = $2 THEN 0
WHEN c.name IN ('A1 Slovenija', 'Local Demo Co') THEN 0
ELSE 1 END,
c.name ASC`, activeCompanyID, billing.A1LegacyCompanyID)
if err != nil {
LogAndError(w, http.StatusInternalServerError, "list failed", err)
return
}
defer rows.Close()
out := make([]switchableUserRow, 0)
for rows.Next() {
var u switchableUserRow
var legacyCompanyID string
if err := rows.Scan(
&u.ID, &u.Email, &u.Name, &u.LegacyUserID, &u.MembershipRole,
&u.CompanyID, &u.CompanyName, &legacyCompanyID,
); err != nil {
Error(w, http.StatusInternalServerError, "scan failed")
return
}
enrichSwitchableUser(&u, legacyCompanyID)
out = append(out, u)
}
if err := rows.Err(); err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
sort.SliceStable(out, func(i, j int) bool {
ai := out[i].CompanyID == activeCompanyID
aj := out[j].CompanyID == activeCompanyID
if ai != aj {
return ai
}
if out[i].CompanyLabel != out[j].CompanyLabel {
return out[i].CompanyLabel < out[j].CompanyLabel
}
// Demo admin + primary A1 first within a company group.
rank := func(u switchableUserRow) int {
if u.IsDemoAdmin {
return 0
}
if u.IsPrimaryA1 {
return 1
}
return 2
}
ri, rj := rank(out[i]), rank(out[j])
if ri != rj {
return ri < rj
}
return strings.ToLower(out[i].Label) < strings.ToLower(out[j].Label)
})
payload := map[string]any{"users": out}
if impStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionImpersonatorIDKey)); impStr != "" {
payload["impersonating"] = true
if impID, err := uuid.Parse(impStr); err == nil {
if impUser, err := s.Auth.GetUser(r.Context(), impID); err == nil {
payload["impersonator"] = map[string]any{
"id": impUser.ID,
"email": impUser.Email,
"name": impUser.Name,
}
}
}
}
JSON(w, http.StatusOK, payload)
}