diff --git a/apps/api/cats.json b/apps/api/cats.json new file mode 100644 index 0000000..a17098f --- /dev/null +++ b/apps/api/cats.json @@ -0,0 +1 @@ +{"categories":[{"created_at":"2026-08-08T09:52:08.115992+02:00","description":null,"description_template":{"metaDescription":"Write a short Meta Description for the product and spotlight key features, specs, or use cases. Keep between 120-155 characters for optimal display in search results.","metaTitle":"Create a concise and compelling Meta Title that includes the product name and main feature, benefit or use case. Keep it between 50-60 characters for optimal display in search results.","sections":[{"exportId":"heading_1","id":"11003f56-6abb-4c46-8287-5447fd1c4da8","instructions":"Write a compelling main title that includes the product name and its key feature. Example: \"Professional 4K Monitor with Ultra-Wide Color Gamut\"","type":"h1"},{"exportId":"paragraph_1","id":"40403106-faa9-4c76-8693-0ad1456b3b86","instructions":"Write a detailed paragraph about specific features or benefits. Example: \"Describe the monitor's color accuracy and its benefits for professional work\"","type":"p"},{"exportId":"heading_2","id":"8c6c9cab-77fb-4c91-acb7-bb2b67f8d36a","instructions":"Create a section heading focusing on a key aspect. Example: \"Advanced Display Technology\" or \"Professional Color Accuracy\"","type":"h2"},{"exportId":"bullet_points","id":"b6824b02-bbe9-4352-b657-923a2948879e","instructions":"List key features or specifications. Example: \"List the most important technical specifications like resolution, refresh rate, and response time\"","type":"ul"},{"exportId":"Prednost1","id":"f5d3ae52-c7d4-4eed-b026-ca7aed338388","instructions":"Izpostavi eno prednost izdelka v nekaj besedah. Primer: \"Samočistilni kondenzator.\"","type":"p"},{"exportId":"Prednost2","id":"d8e04ff8-e37a-4c8e-9fd9-7657a36c35ac","instructions":"Izpostavi eno prednost izdelka v nekaj besedah. Primer: \"Smart Dry.\"","type":"p"},{"exportId":"Prednost3","id":"87cfa795-1ad8-41ef-ab34-72b4a098c5ab","instructions":"Izpostavi eno prednost izdelka v nekaj besedah. Primer: \"Možnost polovičnega polnjenja.\"","type":"p"}]},"has_description_formula":true,"has_prompt":true,"has_title_formula":true,"id":"faca7a03-7c11-4c05-ba78-5527bd205846","is_active":true,"level":0,"name":"Agregati","parent_unique_id":"1002","path":"","position":0,"title_template":{"elements":[{"description":"Znamka izdelka","example":"Samsung","id":"0-variable-brand","label":"Znamka","type":"variable","value":"brand"}],"separator":" "},"unique_id":"1","updated_at":"2026-08-09T21:45:12.236059+02:00"},{"created_at":"2026-08-08T09:52:08.039871+02:00","description":null,"description_template":{"metaDescription":"Write a short Meta Description for the product and spotlight key features, specs, or use cases. Keep between 120-155 characters for optimal display in search results.","metaTitle":"Create a concise and compelling Meta Title that includes the product name and main feature, benefit or use case. Keep it between 50-60 characters for optimal display in search results.","sections":[{"id":"a7908e85-7b0b-4e92-a347-82601dfec118","instructions":"Write a detailed paragraph about specific features or benefits. Example: \"Describe the monitor's color accuracy and its benefits for professional work\"","type":"p"}]},"has_description_formula":true,"has_prompt":false,"has_title_formula":true,"id":"81b4d01c-76c0-4781-901d-34fb52b4eafe","is_active":true,"level":0,"name":"Audio video","parent_unique_id":null,"path":"","position":0,"title_template":{"elements":[{"description":"Znamka izdelka","example":"Samsung","id":"0-variable-brand","label":"Znamka","type":"variable","value":"brand"}],"separator":" "},"unique_id":"1000","updated_at":"2026-08-08T09:52:08.039871+02:00"},{"created_at":"2026-08-08T09:52:08.287027+02:00","description":null,"description_template":{"metaDescription":"Write a short Meta Description for the product and spotlight key features, specs, or use cases. Keep between 120-155 characters for optimal display in search results.","metaTitle":"Create a concise and compelling Meta Title that includes the product name and main feature, benefit or use case. Keep it between 50-60 characters for optimal display in search results.","sections":[{"id":"a7908e85-7b0b-4e92-a347-82601dfec118","instructions":"Write a detailed paragraph about specific features or benefits. Example: \"Describe the monitor's color accuracy and its benefits for professional work\"","type":"p"}]},"has_description_formula":true,"has_prompt":true,"has_title_formula":true,"id":"2af1581e-1f81-482d-84c7-5dac6dde2f21","is_active":true,"level":0,"name":"Avto oprema","parent_unique_id":"1002","path":"","position":0,"title_template":{"elements":[{"description":"Znamka izdelka","example":"Samsung","id":"0-variable-brand","label":"Znamka","type":"variable","value":"brand"}],"separator":" "},"unique_id":"123","updated_at":"2026-08-09T21:45:12.236059+02:00"}],"limit":3,"offset":0,"total":119} diff --git a/apps/api/internal/httpapi/admin_dev_handlers.go b/apps/api/internal/httpapi/admin_dev_handlers.go index c5d5115..93d5291 100644 --- a/apps/api/internal/httpapi/admin_dev_handlers.go +++ b/apps/api/internal/httpapi/admin_dev_handlers.go @@ -24,12 +24,10 @@ func isLocalDemoEmail(email string) bool { } } -// resolveDevImpersonationActor returns the privileged actor allowed to drive non-prod -// user switching: the current full admin/demo user, or the stored impersonator. +// 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) { - if s.Config.IsProduction() { - return uuid.Nil, false, nil - } uid, hasUID := UserIDFromContext(ctx) if !hasUID || uid == uuid.Nil { return uuid.Nil, false, nil @@ -38,15 +36,9 @@ func (s *Server) resolveDevImpersonationActor(ctx context.Context) (actorID uuid return uuid.Nil, false, errors.New("auth unavailable") } - access, err := s.checkStaffAccess(ctx, uid) - if err != nil { + if canImpersonate, err := s.userMayImpersonate(ctx, uid); 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) { + } else if canImpersonate { return uid, true, nil } @@ -58,27 +50,44 @@ func (s *Server) resolveDevImpersonationActor(ctx context.Context) (actorID uuid if err != nil || impID == uuid.Nil { return uuid.Nil, false, nil } - impAccess, err := s.checkStaffAccess(ctx, impID) - if err != nil { + if canImpersonate, err := s.userMayImpersonate(ctx, impID); 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) { + } else if canImpersonate { 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 +// 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() { + return false, nil + } + if access.FullAdmin { + return true, nil + } + 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 @@ -92,8 +101,12 @@ func (s *Server) handleAdminDevSetPassword(w http.ResponseWriter, r *http.Reques Password string `json:"password"` } _ = DecodeJSONOptional(r, &body) - password := body.Password - if strings.TrimSpace(password) == "" { + 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 { @@ -117,20 +130,21 @@ func (s *Server) handleAdminDevSetPassword(w http.ResponseWriter, r *http.Reques LogAndError(w, http.StatusInternalServerError, "could not set password", err) return } + 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, - "hint": "Password set for local login. Omit body.password to use the built-in local default.", + "hint": hint, }) } -// handleAdminDevImpersonate swaps the current session to the target user (non-production only). +// 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.Config.IsProduction() { - Error(w, http.StatusNotFound, "not found") - return - } if s.Auth == nil { Error(w, http.StatusServiceUnavailable, "auth unavailable") return @@ -191,10 +205,6 @@ func (s *Server) handleAdminDevImpersonate(w http.ResponseWriter, r *http.Reques // 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 @@ -219,14 +229,10 @@ func (s *Server) handleAdminDevStopImpersonate(w http.ResponseWriter, r *http.Re 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 - } + 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) @@ -393,12 +399,8 @@ func enrichSwitchableUser(u *switchableUserRow, legacyCompanyID string) { } // handleAdminDevListSwitchableUsers lists active users with a preferred company label -// for the header user-switch dropdown (non-production only). +// 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.Config.IsProduction() { - Error(w, http.StatusNotFound, "not found") - return - } if s.Pool == nil { Error(w, http.StatusServiceUnavailable, "database unavailable") return diff --git a/apps/api/internal/httpapi/admin_dev_impersonation_test.go b/apps/api/internal/httpapi/admin_dev_impersonation_test.go index 812fdfc..2ea1055 100644 --- a/apps/api/internal/httpapi/admin_dev_impersonation_test.go +++ b/apps/api/internal/httpapi/admin_dev_impersonation_test.go @@ -6,7 +6,7 @@ import ( "testing" ) -func TestRouterProductionOmitsImpersonationRoutes(t *testing.T) { +func TestRouterProductionMountsImpersonationBehindAuth(t *testing.T) { t.Parallel() s := testAPIServer() s.Config.AppEnv = "production" @@ -16,6 +16,7 @@ func TestRouterProductionOmitsImpersonationRoutes(t *testing.T) { "/api/admin/users/00000000-0000-0000-0000-000000000001/impersonate", "/api/admin/dev/stop-impersonate", "/api/admin/dev/switchable-users", + "/api/admin/users/00000000-0000-0000-0000-000000000001/dev-password", } { rec := httptest.NewRecorder() method := http.MethodPost @@ -23,10 +24,9 @@ func TestRouterProductionOmitsImpersonationRoutes(t *testing.T) { method = http.MethodGet } h.ServeHTTP(rec, httptest.NewRequest(method, path, nil)) - // Unauthenticated session yields 401; production must not expose the route as 200/403 from the handler. - // Mounted routes behind RequireSession return 401; unmounted chi paths under /api/admin still hit RequireSession then 404 for unknown — either way not a successful switch. + // Unauthenticated: RequireSession → 401. Must not succeed without a session. if rec.Code == http.StatusOK { - t.Fatalf("%s returned 200 in production", path) + t.Fatalf("%s returned 200 without auth in production", path) } } } diff --git a/apps/api/internal/httpapi/auth_handlers.go b/apps/api/internal/httpapi/auth_handlers.go index 7cea360..f75adae 100644 --- a/apps/api/internal/httpapi/auth_handlers.go +++ b/apps/api/internal/httpapi/auth_handlers.go @@ -379,16 +379,20 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { } } } - if !s.Config.IsProduction() { - canSwitch := impersonating - if !canSwitch { + canUserSwitch := impersonating + if !canUserSwitch { + if accessErr == nil && access.FullAdmin && access.Role == auth.StaffRoleAdmin { + canUserSwitch = true + } else if !s.Config.IsProduction() { if accessErr == nil && access.FullAdmin { - canSwitch = true + canUserSwitch = true } else if isLocalDemoEmail(user.Email) { - canSwitch = true + canUserSwitch = true } } - out["dev_user_switch"] = canSwitch + } + if canUserSwitch { + out["dev_user_switch"] = true } JSON(w, http.StatusOK, out) } diff --git a/apps/api/internal/httpapi/company_member_role_test.go b/apps/api/internal/httpapi/company_member_role_test.go index 38b5721..4d40a84 100644 --- a/apps/api/internal/httpapi/company_member_role_test.go +++ b/apps/api/internal/httpapi/company_member_role_test.go @@ -152,7 +152,7 @@ func TestAllowCompanyAdminOrPlatform(t *testing.T) { } }) - t.Run("dev_impersonator_retains_admin", func(t *testing.T) { + t.Run("impersonated_member_denied", func(t *testing.T) { t.Parallel() actor := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") sm := scs.New() @@ -185,28 +185,16 @@ func TestAllowCompanyAdminOrPlatform(t *testing.T) { LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := context.WithValue(r.Context(), ctxUserID, uid) ctx = context.WithValue(ctx, ctxRole, "member") - req := r.WithContext(ctx) - if !s.allowCompanyAdminOrPlatform(w, req) { - t.Fatal("impersonating privileged actor must retain company-admin powers") + if s.allowCompanyAdminOrPlatform(w, r.WithContext(ctx)) { + t.Fatal("impersonated member must not retain company-admin powers") } - w.WriteHeader(http.StatusNoContent) })).ServeHTTP(rec, func() *http.Request { req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token}) return req }()) - if rec.Code != http.StatusNoContent { - t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) - } - }) - - t.Run("dev_impersonator_helper_empty_session", func(t *testing.T) { - t.Parallel() - s := &Server{Config: config.Config{AppEnv: "development"}} - req := httptest.NewRequest(http.MethodGet, "/", nil) - req = req.WithContext(context.WithValue(req.Context(), ctxUserID, uid)) - if s.devImpersonatorRetainsCompanyAdmin(req) { - t.Fatal("nil Sessions must not retain admin") + if rec.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s, want 403", rec.Code, rec.Body.String()) } }) } diff --git a/apps/api/internal/httpapi/middleware.go b/apps/api/internal/httpapi/middleware.go index b916486..8580d5b 100644 --- a/apps/api/internal/httpapi/middleware.go +++ b/apps/api/internal/httpapi/middleware.go @@ -68,8 +68,6 @@ func requireCompanyAdmin(w http.ResponseWriter, r *http.Request) bool { // allowCompanyAdminOrPlatform allows company admins, API keys, or platform admins. // Platform admins can manage team after migration when all memberships are still "member". -// Non-prod: while a privileged demo/platform actor is impersonating, retain company-admin powers -// so local user-switch can still create API keys and manage the tenant. func (s *Server) allowCompanyAdminOrPlatform(w http.ResponseWriter, r *http.Request) bool { if CompanyAdminAllowed(r.Context()) { return true @@ -87,38 +85,10 @@ func (s *Server) allowCompanyAdminOrPlatform(w http.ResponseWriter, r *http.Requ if isAdmin { return true } - if s.devImpersonatorRetainsCompanyAdmin(r) { - return true - } Error(w, http.StatusForbidden, "admin required") return false } -// devImpersonatorRetainsCompanyAdmin is true in non-production when the session is -// impersonating and the stored actor is still a privileged demo/platform admin. -func (s *Server) devImpersonatorRetainsCompanyAdmin(r *http.Request) bool { - if s.Config.IsProduction() || s.Sessions == nil { - return false - } - impStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionImpersonatorIDKey)) - if impStr == "" { - return false - } - impID, err := uuid.Parse(impStr) - if err != nil || impID == uuid.Nil { - return false - } - access, err := s.checkStaffAccess(r.Context(), impID) - if err == nil && access.FullAdmin { - return true - } - if s.Auth == nil { - return false - } - impUser, err := s.Auth.GetUser(r.Context(), impID) - return err == nil && isLocalDemoEmail(impUser.Email) -} - func (s *Server) RequireSession(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { uidStr := s.Sessions.GetString(r.Context(), auth.SessionUserIDKey) diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index e8f3f58..2447372 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -343,12 +343,10 @@ func (s *Server) Router() http.Handler { r.Route("/api/admin", func(r chi.Router) { r.Use(s.RequireSession) - // Non-prod only: user switch / impersonation (handlers also fail closed). - if !s.Config.IsProduction() { - r.Get("/dev/switchable-users", s.handleAdminDevListSwitchableUsers) - r.Post("/dev/stop-impersonate", s.handleAdminDevStopImpersonate) - r.Post("/users/{id}/impersonate", s.handleAdminDevImpersonate) - } + // User switch / impersonation: platform staff_role=admin (handlers enforce). + r.Get("/dev/switchable-users", s.handleAdminDevListSwitchableUsers) + r.Post("/dev/stop-impersonate", s.handleAdminDevStopImpersonate) + r.Post("/users/{id}/impersonate", s.handleAdminDevImpersonate) // Support desk: full admin OR support_staff (least privilege). r.Group(func(r chi.Router) { @@ -389,11 +387,9 @@ func (s *Server) Router() http.Handler { r.Get("/users", s.handleAdminListUsers) r.Patch("/users/{id}/staff-role", s.handleAdminSetStaffRole) r.Put("/support/agents/{id}", s.handleAdminSetSupportAgent) - r.Get("/staff", s.handleAdminListStaff) - if !s.Config.IsProduction() { - r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword) - } - r.Get("/companies", s.handleAdminListCompanies) + r.Get("/staff", s.handleAdminListStaff) + r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword) + r.Get("/companies", s.handleAdminListCompanies) r.Get("/readiness", s.handleAdminReadiness) r.Get("/diagnostics", s.handleAdminDiagnostics) r.Get("/analytics", s.handleAdminAnalytics) diff --git a/apps/api/login.json b/apps/api/login.json new file mode 100644 index 0000000..efae8e5 --- /dev/null +++ b/apps/api/login.json @@ -0,0 +1 @@ +{"user":{"id":"6bf00877-a693-4d77-b28e-8c8292adac98","email":"a1-primary@descrybe.local","name":"A1 user","must_set_password":false,"is_platform_admin":false,"is_active":true},"company_id":"604f23a8-b66e-4b21-8b45-0d72b68f4790","companies":[{"id":"604f23a8-b66e-4b21-8b45-0d72b68f4790","name":"A1 Slovenija"}]} diff --git a/apps/api/me.json b/apps/api/me.json new file mode 100644 index 0000000..bd7eac7 --- /dev/null +++ b/apps/api/me.json @@ -0,0 +1 @@ +{"active_company_id":"604f23a8-b66e-4b21-8b45-0d72b68f4790","companies":[{"id":"604f23a8-b66e-4b21-8b45-0d72b68f4790","name":"A1 Slovenija"}],"company":{"id":"604f23a8-b66e-4b21-8b45-0d72b68f4790","name":"A1 Slovenija"},"credits":{"total_credits":1000,"used_credits":4,"remaining":996,"remaining_credits":996,"low_credits":false,"low_credits_threshold":100,"product_count":0,"at_product_limit":false,"plan":{"is_custom":true,"is_trial":false,"max_products":null,"monthly_credits":0,"name":"A1","next_billing_date":"2026-08-30T17:55:37+02:00"},"has_active_plan":true,"can_use_ai":true,"can_use_eprel":true,"is_free_plan":false,"is_paid_plan":true,"features":{"billing.checkout":true,"billing.customer_portal":true,"billing.overview":true,"billing.plans_compare":true,"billing.quick_upgrade":true,"capability.ai_credits":true,"capability.ai_processing":true,"capability.api_access":true,"capability.brand_ai_apply":true,"capability.byok":true,"capability.campaign_ai":true,"capability.email_live_send":true,"capability.eprel":true,"capability.export_feed_limit":true,"capability.feed_source_limit":true,"capability.normalize_specs_fill":true,"capability.seo_ai_rewrite":true,"capability.sku_cap":true,"capability.storage_limit":false,"catalog.attributes":true,"catalog.attributes.bulk_import":true,"catalog.categories":true,"catalog.categories.description_formula":true,"catalog.categories.title_formula":true,"catalog.products":true,"catalog.products.enrichment_review":true,"catalog.products.export_selection":true,"catalog.products.process_ai_descriptions":true,"catalog.products.process_ai_titles":true,"catalog.products.process_attributes":true,"catalog.products.process_categories":true,"catalog.products.tab_error":true,"catalog.products.tab_needs_review":true,"catalog.products.tab_processed":true,"catalog.products.tab_processing":true,"catalog.products.tab_unprocessed":true,"catalog.products.upgrade_prompt":true,"catalog.standard_fields":true,"catalog.standard_fields.groups":true,"catalog.structured_descriptions":true,"catalog.vector_categories":true,"dashboard.activation_checklist":true,"dashboard.etl_gaps":false,"dashboard.migrated_checklist":false,"dashboard.news_feed":true,"dashboard.overview":true,"dashboard.quick_links":true,"dashboard.recent_jobs":true,"dashboard.stats":true,"dashboard.store_reconnect":false,"dashboard.upgrade_banners":true,"feeds.add_csv":true,"feeds.add_url":true,"feeds.export_feeds":true,"feeds.export_feeds.create":true,"feeds.export_feeds.generate":true,"feeds.list":true,"feeds.mapping":true,"feeds.mapping.map_fields":true,"feeds.mapping.select_item":true,"feeds.sync":true,"feeds.uploads":true,"integrations.ai":false,"integrations.ai.byok":false,"integrations.email":false,"integrations.email.blast":false,"integrations.email.test":false,"marketing.brand_ai_apply":false,"marketing.brand_kit":false,"marketing.campaigns":false,"marketing.campaigns.create":false,"marketing.campaigns.generate_ai":false,"marketing.campaigns.send":false,"marketing.content_calendar":false,"marketing.reviews":false,"marketing.seo":false,"marketing.seo.ai_rewrite":false,"marketing.seo.template_fill":false,"processing.monitor":true,"settings.alerts":true,"settings.api_keys":true,"settings.company":true,"settings.profile":true,"settings.team":true,"settings.team_invite":true,"shell.account_menu":true,"shell.billing_recovery_banner":true,"shell.command_palette":true,"shell.company_switcher":true,"shell.navigation":true,"shell.support_notifications":true,"shell.tutorial":true,"stores.hub":false,"stores.shopify":false,"stores.shopify.connection":false,"stores.shopify.orders":false,"stores.shopify.settings":false,"stores.woocommerce":false,"stores.woocommerce.attributes":false,"stores.woocommerce.categories":false,"stores.woocommerce.connection":false,"stores.woocommerce.orders":false,"stores.woocommerce.reviews":false,"stores.woocommerce.settings":false,"support.center":true,"support.ticket_create":true,"support.ticket_thread":true},"sections":{"billing":true,"capabilities":true,"catalog":true,"dashboard":true,"feeds":true,"integrations":false,"marketing":false,"processing":true,"settings":true,"shell":true,"stores":true,"support":true},"disabled_features":["capability.storage_limit","dashboard.etl_gaps","dashboard.migrated_checklist","dashboard.store_reconnect","integrations.ai","integrations.ai.byok","integrations.email","integrations.email.blast","integrations.email.test","marketing.brand_ai_apply","marketing.brand_kit","marketing.campaigns","marketing.campaigns.create","marketing.campaigns.generate_ai","marketing.campaigns.send","marketing.content_calendar","marketing.reviews","marketing.seo","marketing.seo.ai_rewrite","marketing.seo.template_fill","stores.hub","stores.shopify","stores.shopify.connection","stores.shopify.orders","stores.shopify.settings","stores.woocommerce","stores.woocommerce.attributes","stores.woocommerce.categories","stores.woocommerce.connection","stores.woocommerce.orders","stores.woocommerce.reviews","stores.woocommerce.settings"],"feature_etag":"sha256:04d99efdef0674948916dfd3ad1de384c0f6867325d0a9c0a1212412c2012191"},"dev_user_switch":false,"membership":{"role":"member","status":"active"},"user":{"id":"6bf00877-a693-4d77-b28e-8c8292adac98","email":"a1-primary@descrybe.local","name":"A1 user","must_set_password":false,"is_platform_admin":false,"is_active":true}} diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 553747e..15a822e 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -104,7 +104,7 @@ export type MeResponse = { /** Membership company to restore via company switcher revert. */ staff_home_company_id?: string; staff_home_company?: Company | null; - /** Non-prod only: show header user-switch for platform admin/demo or while impersonating. */ + /** Platform staff_role=admin (any env) or non-prod demo: show header user-switch. */ dev_user_switch?: boolean; impersonating?: boolean; impersonator?: ImpersonatorRef | null; diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 1330c6e..6d7f254 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -183,11 +183,17 @@ }); const showAdminNav = $derived( - Boolean(me?.staff_access?.support_desk || shouldUnlockAllFeatures(me)) + Boolean( + !me?.impersonating && + !me?.staff_tenant_acting && + (me?.staff_access?.support_desk || shouldUnlockAllFeatures(me)) + ) ); - /** Demo + full platform staff only — never unlock via legacy is_platform_admin when staff_access denies full_admin (A1/support). */ - const unlockAllFeatures = $derived(shouldUnlockAllFeatures(me)); + /** Demo + full platform staff only — never unlock while impersonating or staff-acting as a tenant. */ + const unlockAllFeatures = $derived( + shouldUnlockAllFeatures(me) && !me?.impersonating && !me?.staff_tenant_acting + ); /** * P1-15: platform-admin readiness strip on /admin only (API fail-closed). @@ -222,7 +228,11 @@ }); const showHypercareAdminTriage = $derived( - Boolean(me?.staff_access?.support_desk || shouldUnlockAllFeatures(me)) + Boolean( + !me?.impersonating && + !me?.staff_tenant_acting && + (me?.staff_access?.support_desk || shouldUnlockAllFeatures(me)) + ) ); diff --git a/apps/web/src/routes/admin/users/+page.svelte b/apps/web/src/routes/admin/users/+page.svelte index 56523b4..09e1ddb 100644 --- a/apps/web/src/routes/admin/users/+page.svelte +++ b/apps/web/src/routes/admin/users/+page.svelte @@ -74,9 +74,12 @@ let companiesTotal = $state(0); let companiesOffset = $state(0); let plans = $state([]); - let devTools = $state(false); + let userOps = $state(false); let staffRoleApiOk = $state(true); + let passwordOpen = $state(false); + let passwordUser = $state(null); + let passwordValue = $state(""); let roleOpen = $state(false); let roleUser = $state(null); let roleValue = $state<"" | PlatformStaffRole>(""); @@ -118,8 +121,8 @@ } try { await Promise.all([reloadUsers(), reloadCompanies(), loadPlans()]); - // Local-only password/impersonation helpers — never show in production builds. - devTools = !import.meta.env.PROD; + // Platform admins can set passwords + impersonate (API enforces staff_role=admin for switch). + userOps = true; } catch (err) { error = failureMessage(err, "Failed to load directory"); } finally { @@ -372,23 +375,41 @@ } } - async function setDevPassword(userId: string) { - busyUserId = userId; + function openPasswordDialog(user: AdminOrgUser) { + passwordUser = user; + passwordValue = ""; + passwordOpen = true; + } + + async function saveForcedPassword(event: Event) { + event.preventDefault(); + if (!passwordUser) return; + const pwd = passwordValue.trim(); + if (pwd.length < 8) { + error = "Password must be at least 8 characters"; + return; + } + busyUserId = passwordUser.id; error = ""; success = ""; try { - const res = await api<{ email?: string }>(`/api/admin/users/${userId}/dev-password`, { + const res = await api<{ email?: string }>(`/api/admin/users/${passwordUser.id}/dev-password`, { method: "POST", - body: {} + body: { password: pwd } }); - success = i18n.t("flash.admin.localPasswordSet", { email: res.email ?? "user" }); - users = users.map((u) => (u.id === userId ? { ...u, must_set_password: false } : u)); + success = i18n.t("flash.admin.localPasswordSet", { email: res.email ?? passwordUser.email }); + users = users.map((u) => + u.id === passwordUser!.id ? { ...u, must_set_password: false } : u + ); + passwordOpen = false; + passwordUser = null; + passwordValue = ""; } catch (err) { if (err instanceof ApiError && err.status === 404) { - devTools = false; + userOps = false; error = i18n.t("flash.admin.localPasswordUnavailable"); } else { - error = failureMessage(err, "Could not set local password"); + error = failureMessage(err, "Could not set password"); } } finally { busyUserId = null; @@ -405,7 +426,7 @@ window.location.assign("/dashboard"); } catch (err) { if (err instanceof ApiError && err.status === 404) { - devTools = false; + userOps = false; error = i18n.t("flash.admin.switchUnavailable"); } else { error = failureMessage(err, "Could not switch user"); @@ -579,13 +600,13 @@ {/if} - {#if devTools} + {#if userOps} + + +