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 } } // resolveDevImpersonationActor returns the privileged actor allowed to drive non-prod // user switching: the current full admin/demo user, or the stored impersonator. func (s *Server) resolveDevImpersonationActor(ctx context.Context) (actorID uuid.UUID, ok bool, err error) { if s.Config.IsProduction() { return uuid.Nil, false, nil } 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") } access, err := s.checkStaffAccess(ctx, uid) if err != nil { return uuid.Nil, false, err } if access.FullAdmin { return uid, true, nil } user, err := s.Auth.GetUser(ctx, uid) if err == nil && isLocalDemoEmail(user.Email) { 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 } impAccess, err := s.checkStaffAccess(ctx, impID) if err != nil { return uuid.Nil, false, err } if impAccess.FullAdmin { return impID, true, nil } impUser, err := s.Auth.GetUser(ctx, impID) if err == nil && isLocalDemoEmail(impUser.Email) { return impID, true, nil } return uuid.Nil, false, nil } // handleAdminDevSetPassword sets a known local password for any active user. // Blocked in production. Intended for @legacy.local migrated accounts (invite emails skip those). func (s *Server) handleAdminDevSetPassword(w http.ResponseWriter, r *http.Request) { if s.Config.IsProduction() { Error(w, http.StatusNotFound, "not found") return } 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) password := body.Password if strings.TrimSpace(password) == "" { 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 } JSON(w, http.StatusOK, map[string]any{ "ok": true, "user_id": id, "email": user.Email, "hint": "Password set for local login. Omit body.password to use the built-in local default.", }) } // handleAdminDevImpersonate swaps the current session to the target user (non-production only). func (s *Server) handleAdminDevImpersonate(w http.ResponseWriter, r *http.Request) { if s.Config.IsProduction() { Error(w, http.StatusNotFound, "not found") return } 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.Config.IsProduction() { Error(w, http.StatusNotFound, "not found") return } 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 { // Prefer the stored impersonator when it is still the privileged actor. impAccess, aerr := s.checkStaffAccess(r.Context(), impID) if aerr != nil || !impAccess.FullAdmin { impUser, uerr := s.Auth.GetUser(r.Context(), impID) if uerr != nil || !isLocalDemoEmail(impUser.Email) { 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 · …". 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 // for the header user-switch dropdown (non-production only). func (s *Server) handleAdminDevListSwitchableUsers(w http.ResponseWriter, r *http.Request) { if s.Config.IsProduction() { Error(w, http.StatusNotFound, "not found") return } 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) }