Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user