Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
502 lines
15 KiB
Go
502 lines
15 KiB
Go
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 · …<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
|
|
// 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)
|
|
}
|