fix
This commit is contained in:
@@ -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}
|
||||||
@@ -24,12 +24,10 @@ func isLocalDemoEmail(email string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveDevImpersonationActor returns the privileged actor allowed to drive non-prod
|
// resolveImpersonationActor returns the privileged actor allowed to drive user
|
||||||
// user switching: the current full admin/demo user, or the stored impersonator.
|
// 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) {
|
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)
|
uid, hasUID := UserIDFromContext(ctx)
|
||||||
if !hasUID || uid == uuid.Nil {
|
if !hasUID || uid == uuid.Nil {
|
||||||
return uuid.Nil, false, 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")
|
return uuid.Nil, false, errors.New("auth unavailable")
|
||||||
}
|
}
|
||||||
|
|
||||||
access, err := s.checkStaffAccess(ctx, uid)
|
if canImpersonate, err := s.userMayImpersonate(ctx, uid); err != nil {
|
||||||
if err != nil {
|
|
||||||
return uuid.Nil, false, err
|
return uuid.Nil, false, err
|
||||||
}
|
} else if canImpersonate {
|
||||||
if access.FullAdmin {
|
|
||||||
return uid, true, nil
|
|
||||||
}
|
|
||||||
user, err := s.Auth.GetUser(ctx, uid)
|
|
||||||
if err == nil && isLocalDemoEmail(user.Email) {
|
|
||||||
return uid, true, nil
|
return uid, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,27 +50,44 @@ func (s *Server) resolveDevImpersonationActor(ctx context.Context) (actorID uuid
|
|||||||
if err != nil || impID == uuid.Nil {
|
if err != nil || impID == uuid.Nil {
|
||||||
return uuid.Nil, false, nil
|
return uuid.Nil, false, nil
|
||||||
}
|
}
|
||||||
impAccess, err := s.checkStaffAccess(ctx, impID)
|
if canImpersonate, err := s.userMayImpersonate(ctx, impID); err != nil {
|
||||||
if err != nil {
|
|
||||||
return uuid.Nil, false, err
|
return uuid.Nil, false, err
|
||||||
}
|
} else if canImpersonate {
|
||||||
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 impID, true, nil
|
||||||
}
|
}
|
||||||
return uuid.Nil, false, nil
|
return uuid.Nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAdminDevSetPassword sets a known local password for any active user.
|
// userMayImpersonate is true for platform staff_role=admin in any environment.
|
||||||
// Blocked in production. Intended for @legacy.local migrated accounts (invite emails skip those).
|
// Non-production also allows other full admins and local demo accounts.
|
||||||
func (s *Server) handleAdminDevSetPassword(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) userMayImpersonate(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||||
if s.Config.IsProduction() {
|
access, err := s.checkStaffAccess(ctx, userID)
|
||||||
Error(w, http.StatusNotFound, "not found")
|
if err != nil {
|
||||||
return
|
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 {
|
if s.Auth == nil {
|
||||||
Error(w, http.StatusServiceUnavailable, "auth unavailable")
|
Error(w, http.StatusServiceUnavailable, "auth unavailable")
|
||||||
return
|
return
|
||||||
@@ -92,8 +101,12 @@ func (s *Server) handleAdminDevSetPassword(w http.ResponseWriter, r *http.Reques
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
_ = DecodeJSONOptional(r, &body)
|
_ = DecodeJSONOptional(r, &body)
|
||||||
password := body.Password
|
password := strings.TrimSpace(body.Password)
|
||||||
if strings.TrimSpace(password) == "" {
|
if password == "" {
|
||||||
|
if s.Config.IsProduction() {
|
||||||
|
Error(w, http.StatusBadRequest, "password is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
password = defaultDevPassword
|
password = defaultDevPassword
|
||||||
}
|
}
|
||||||
if len(password) < 8 {
|
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)
|
LogAndError(w, http.StatusInternalServerError, "could not set password", err)
|
||||||
return
|
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{
|
JSON(w, http.StatusOK, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"user_id": id,
|
"user_id": id,
|
||||||
"email": user.Email,
|
"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) {
|
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 {
|
if s.Auth == nil {
|
||||||
Error(w, http.StatusServiceUnavailable, "auth unavailable")
|
Error(w, http.StatusServiceUnavailable, "auth unavailable")
|
||||||
return
|
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.
|
// handleAdminDevStopImpersonate restores the session to the original admin/demo actor.
|
||||||
func (s *Server) handleAdminDevStopImpersonate(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if s.Auth == nil {
|
||||||
Error(w, http.StatusServiceUnavailable, "auth unavailable")
|
Error(w, http.StatusServiceUnavailable, "auth unavailable")
|
||||||
return
|
return
|
||||||
@@ -219,14 +229,10 @@ func (s *Server) handleAdminDevStopImpersonate(w http.ResponseWriter, r *http.Re
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if impID != actorID {
|
if impID != actorID {
|
||||||
// Prefer the stored impersonator when it is still the privileged actor.
|
canImp, ierr := s.userMayImpersonate(r.Context(), impID)
|
||||||
impAccess, aerr := s.checkStaffAccess(r.Context(), impID)
|
if ierr != nil || !canImp {
|
||||||
if aerr != nil || !impAccess.FullAdmin {
|
Error(w, http.StatusForbidden, "user switch not allowed")
|
||||||
impUser, uerr := s.Auth.GetUser(r.Context(), impID)
|
return
|
||||||
if uerr != nil || !isLocalDemoEmail(impUser.Email) {
|
|
||||||
Error(w, http.StatusForbidden, "user switch not allowed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
user, err := s.Auth.GetUser(r.Context(), impID)
|
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
|
// 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) {
|
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 {
|
if s.Pool == nil {
|
||||||
Error(w, http.StatusServiceUnavailable, "database unavailable")
|
Error(w, http.StatusServiceUnavailable, "database unavailable")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRouterProductionOmitsImpersonationRoutes(t *testing.T) {
|
func TestRouterProductionMountsImpersonationBehindAuth(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
s := testAPIServer()
|
s := testAPIServer()
|
||||||
s.Config.AppEnv = "production"
|
s.Config.AppEnv = "production"
|
||||||
@@ -16,6 +16,7 @@ func TestRouterProductionOmitsImpersonationRoutes(t *testing.T) {
|
|||||||
"/api/admin/users/00000000-0000-0000-0000-000000000001/impersonate",
|
"/api/admin/users/00000000-0000-0000-0000-000000000001/impersonate",
|
||||||
"/api/admin/dev/stop-impersonate",
|
"/api/admin/dev/stop-impersonate",
|
||||||
"/api/admin/dev/switchable-users",
|
"/api/admin/dev/switchable-users",
|
||||||
|
"/api/admin/users/00000000-0000-0000-0000-000000000001/dev-password",
|
||||||
} {
|
} {
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
method := http.MethodPost
|
method := http.MethodPost
|
||||||
@@ -23,10 +24,9 @@ func TestRouterProductionOmitsImpersonationRoutes(t *testing.T) {
|
|||||||
method = http.MethodGet
|
method = http.MethodGet
|
||||||
}
|
}
|
||||||
h.ServeHTTP(rec, httptest.NewRequest(method, path, nil))
|
h.ServeHTTP(rec, httptest.NewRequest(method, path, nil))
|
||||||
// Unauthenticated session yields 401; production must not expose the route as 200/403 from the handler.
|
// Unauthenticated: RequireSession → 401. Must not succeed without a session.
|
||||||
// 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.
|
|
||||||
if rec.Code == http.StatusOK {
|
if rec.Code == http.StatusOK {
|
||||||
t.Fatalf("%s returned 200 in production", path)
|
t.Fatalf("%s returned 200 without auth in production", path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -379,16 +379,20 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !s.Config.IsProduction() {
|
canUserSwitch := impersonating
|
||||||
canSwitch := impersonating
|
if !canUserSwitch {
|
||||||
if !canSwitch {
|
if accessErr == nil && access.FullAdmin && access.Role == auth.StaffRoleAdmin {
|
||||||
|
canUserSwitch = true
|
||||||
|
} else if !s.Config.IsProduction() {
|
||||||
if accessErr == nil && access.FullAdmin {
|
if accessErr == nil && access.FullAdmin {
|
||||||
canSwitch = true
|
canUserSwitch = true
|
||||||
} else if isLocalDemoEmail(user.Email) {
|
} 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)
|
JSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
t.Parallel()
|
||||||
actor := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
actor := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||||
sm := scs.New()
|
sm := scs.New()
|
||||||
@@ -185,28 +185,16 @@ func TestAllowCompanyAdminOrPlatform(t *testing.T) {
|
|||||||
LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := context.WithValue(r.Context(), ctxUserID, uid)
|
ctx := context.WithValue(r.Context(), ctxUserID, uid)
|
||||||
ctx = context.WithValue(ctx, ctxRole, "member")
|
ctx = context.WithValue(ctx, ctxRole, "member")
|
||||||
req := r.WithContext(ctx)
|
if s.allowCompanyAdminOrPlatform(w, r.WithContext(ctx)) {
|
||||||
if !s.allowCompanyAdminOrPlatform(w, req) {
|
t.Fatal("impersonated member must not retain company-admin powers")
|
||||||
t.Fatal("impersonating privileged actor must retain company-admin powers")
|
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
})).ServeHTTP(rec, func() *http.Request {
|
})).ServeHTTP(rec, func() *http.Request {
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
|
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
|
||||||
return req
|
return req
|
||||||
}())
|
}())
|
||||||
if rec.Code != http.StatusNoContent {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("status=%d body=%s, want 403", 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")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,8 +68,6 @@ func requireCompanyAdmin(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
|
|
||||||
// allowCompanyAdminOrPlatform allows company admins, API keys, or platform admins.
|
// allowCompanyAdminOrPlatform allows company admins, API keys, or platform admins.
|
||||||
// Platform admins can manage team after migration when all memberships are still "member".
|
// 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 {
|
func (s *Server) allowCompanyAdminOrPlatform(w http.ResponseWriter, r *http.Request) bool {
|
||||||
if CompanyAdminAllowed(r.Context()) {
|
if CompanyAdminAllowed(r.Context()) {
|
||||||
return true
|
return true
|
||||||
@@ -87,38 +85,10 @@ func (s *Server) allowCompanyAdminOrPlatform(w http.ResponseWriter, r *http.Requ
|
|||||||
if isAdmin {
|
if isAdmin {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if s.devImpersonatorRetainsCompanyAdmin(r) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
Error(w, http.StatusForbidden, "admin required")
|
Error(w, http.StatusForbidden, "admin required")
|
||||||
return false
|
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 {
|
func (s *Server) RequireSession(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
uidStr := s.Sessions.GetString(r.Context(), auth.SessionUserIDKey)
|
uidStr := s.Sessions.GetString(r.Context(), auth.SessionUserIDKey)
|
||||||
|
|||||||
@@ -343,12 +343,10 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Route("/api/admin", func(r chi.Router) {
|
r.Route("/api/admin", func(r chi.Router) {
|
||||||
r.Use(s.RequireSession)
|
r.Use(s.RequireSession)
|
||||||
|
|
||||||
// Non-prod only: user switch / impersonation (handlers also fail closed).
|
// User switch / impersonation: platform staff_role=admin (handlers enforce).
|
||||||
if !s.Config.IsProduction() {
|
r.Get("/dev/switchable-users", s.handleAdminDevListSwitchableUsers)
|
||||||
r.Get("/dev/switchable-users", s.handleAdminDevListSwitchableUsers)
|
r.Post("/dev/stop-impersonate", s.handleAdminDevStopImpersonate)
|
||||||
r.Post("/dev/stop-impersonate", s.handleAdminDevStopImpersonate)
|
r.Post("/users/{id}/impersonate", s.handleAdminDevImpersonate)
|
||||||
r.Post("/users/{id}/impersonate", s.handleAdminDevImpersonate)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Support desk: full admin OR support_staff (least privilege).
|
// Support desk: full admin OR support_staff (least privilege).
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
@@ -389,11 +387,9 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Get("/users", s.handleAdminListUsers)
|
r.Get("/users", s.handleAdminListUsers)
|
||||||
r.Patch("/users/{id}/staff-role", s.handleAdminSetStaffRole)
|
r.Patch("/users/{id}/staff-role", s.handleAdminSetStaffRole)
|
||||||
r.Put("/support/agents/{id}", s.handleAdminSetSupportAgent)
|
r.Put("/support/agents/{id}", s.handleAdminSetSupportAgent)
|
||||||
r.Get("/staff", s.handleAdminListStaff)
|
r.Get("/staff", s.handleAdminListStaff)
|
||||||
if !s.Config.IsProduction() {
|
r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword)
|
||||||
r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword)
|
r.Get("/companies", s.handleAdminListCompanies)
|
||||||
}
|
|
||||||
r.Get("/companies", s.handleAdminListCompanies)
|
|
||||||
r.Get("/readiness", s.handleAdminReadiness)
|
r.Get("/readiness", s.handleAdminReadiness)
|
||||||
r.Get("/diagnostics", s.handleAdminDiagnostics)
|
r.Get("/diagnostics", s.handleAdminDiagnostics)
|
||||||
r.Get("/analytics", s.handleAdminAnalytics)
|
r.Get("/analytics", s.handleAdminAnalytics)
|
||||||
|
|||||||
@@ -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"}]}
|
||||||
File diff suppressed because one or more lines are too long
@@ -104,7 +104,7 @@ export type MeResponse = {
|
|||||||
/** Membership company to restore via company switcher revert. */
|
/** Membership company to restore via company switcher revert. */
|
||||||
staff_home_company_id?: string;
|
staff_home_company_id?: string;
|
||||||
staff_home_company?: Company | null;
|
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;
|
dev_user_switch?: boolean;
|
||||||
impersonating?: boolean;
|
impersonating?: boolean;
|
||||||
impersonator?: ImpersonatorRef | null;
|
impersonator?: ImpersonatorRef | null;
|
||||||
|
|||||||
@@ -183,11 +183,17 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const showAdminNav = $derived(
|
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). */
|
/** Demo + full platform staff only — never unlock while impersonating or staff-acting as a tenant. */
|
||||||
const unlockAllFeatures = $derived(shouldUnlockAllFeatures(me));
|
const unlockAllFeatures = $derived(
|
||||||
|
shouldUnlockAllFeatures(me) && !me?.impersonating && !me?.staff_tenant_acting
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* P1-15: platform-admin readiness strip on /admin only (API fail-closed).
|
* P1-15: platform-admin readiness strip on /admin only (API fail-closed).
|
||||||
@@ -222,7 +228,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const showHypercareAdminTriage = $derived(
|
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))
|
||||||
|
)
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -74,9 +74,12 @@
|
|||||||
let companiesTotal = $state(0);
|
let companiesTotal = $state(0);
|
||||||
let companiesOffset = $state(0);
|
let companiesOffset = $state(0);
|
||||||
let plans = $state<AdminBillingPlan[]>([]);
|
let plans = $state<AdminBillingPlan[]>([]);
|
||||||
let devTools = $state(false);
|
let userOps = $state(false);
|
||||||
let staffRoleApiOk = $state(true);
|
let staffRoleApiOk = $state(true);
|
||||||
|
|
||||||
|
let passwordOpen = $state(false);
|
||||||
|
let passwordUser = $state<AdminOrgUser | null>(null);
|
||||||
|
let passwordValue = $state("");
|
||||||
let roleOpen = $state(false);
|
let roleOpen = $state(false);
|
||||||
let roleUser = $state<AdminOrgUser | null>(null);
|
let roleUser = $state<AdminOrgUser | null>(null);
|
||||||
let roleValue = $state<"" | PlatformStaffRole>("");
|
let roleValue = $state<"" | PlatformStaffRole>("");
|
||||||
@@ -118,8 +121,8 @@
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await Promise.all([reloadUsers(), reloadCompanies(), loadPlans()]);
|
await Promise.all([reloadUsers(), reloadCompanies(), loadPlans()]);
|
||||||
// Local-only password/impersonation helpers — never show in production builds.
|
// Platform admins can set passwords + impersonate (API enforces staff_role=admin for switch).
|
||||||
devTools = !import.meta.env.PROD;
|
userOps = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = failureMessage(err, "Failed to load directory");
|
error = failureMessage(err, "Failed to load directory");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -372,23 +375,41 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setDevPassword(userId: string) {
|
function openPasswordDialog(user: AdminOrgUser) {
|
||||||
busyUserId = userId;
|
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 = "";
|
error = "";
|
||||||
success = "";
|
success = "";
|
||||||
try {
|
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",
|
method: "POST",
|
||||||
body: {}
|
body: { password: pwd }
|
||||||
});
|
});
|
||||||
success = i18n.t("flash.admin.localPasswordSet", { email: res.email ?? "user" });
|
success = i18n.t("flash.admin.localPasswordSet", { email: res.email ?? passwordUser.email });
|
||||||
users = users.map((u) => (u.id === userId ? { ...u, must_set_password: false } : u));
|
users = users.map((u) =>
|
||||||
|
u.id === passwordUser!.id ? { ...u, must_set_password: false } : u
|
||||||
|
);
|
||||||
|
passwordOpen = false;
|
||||||
|
passwordUser = null;
|
||||||
|
passwordValue = "";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.status === 404) {
|
if (err instanceof ApiError && err.status === 404) {
|
||||||
devTools = false;
|
userOps = false;
|
||||||
error = i18n.t("flash.admin.localPasswordUnavailable");
|
error = i18n.t("flash.admin.localPasswordUnavailable");
|
||||||
} else {
|
} else {
|
||||||
error = failureMessage(err, "Could not set local password");
|
error = failureMessage(err, "Could not set password");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
busyUserId = null;
|
busyUserId = null;
|
||||||
@@ -405,7 +426,7 @@
|
|||||||
window.location.assign("/dashboard");
|
window.location.assign("/dashboard");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.status === 404) {
|
if (err instanceof ApiError && err.status === 404) {
|
||||||
devTools = false;
|
userOps = false;
|
||||||
error = i18n.t("flash.admin.switchUnavailable");
|
error = i18n.t("flash.admin.switchUnavailable");
|
||||||
} else {
|
} else {
|
||||||
error = failureMessage(err, "Could not switch user");
|
error = failureMessage(err, "Could not switch user");
|
||||||
@@ -579,13 +600,13 @@
|
|||||||
<span class="hidden lg:inline">{i18n.t("admin.users.reissueInvite")}</span>
|
<span class="hidden lg:inline">{i18n.t("admin.users.reissueInvite")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if devTools}
|
{#if userOps}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
loading={busyUserId === user.id}
|
loading={busyUserId === user.id}
|
||||||
onclick={() => setDevPassword(user.id)}
|
onclick={() => openPasswordDialog(user)}
|
||||||
aria-label={`Set local password for ${user.email}`}
|
aria-label={`Set password for ${user.email}`}
|
||||||
>
|
>
|
||||||
<KeyRound class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
|
<KeyRound class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
|
||||||
<span class="hidden lg:inline">{i18n.t("admin.users.setLocalPassword")}</span>
|
<span class="hidden lg:inline">{i18n.t("admin.users.setLocalPassword")}</span>
|
||||||
@@ -735,6 +756,35 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
bind:open={passwordOpen}
|
||||||
|
title="Set password"
|
||||||
|
description="Force-set a login password for this user (works for fake/legacy emails that cannot receive invites)."
|
||||||
|
>
|
||||||
|
<form class="space-y-4" onsubmit={saveForcedPassword}>
|
||||||
|
{#if error}
|
||||||
|
<p class="text-sm text-destructive" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
{#if passwordUser}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{passwordUser.name || "—"} · {passwordUser.email}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="forced-password">New password</Label>
|
||||||
|
<Input
|
||||||
|
id="forced-password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
minlength={8}
|
||||||
|
required
|
||||||
|
bind:value={passwordValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" loading={busyUserId === passwordUser?.id}>Set password</Button>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
bind:open={roleOpen}
|
bind:open={roleOpen}
|
||||||
title={i18n.t("admin.users.assignRoleTitle")}
|
title={i18n.t("admin.users.assignRoleTitle")}
|
||||||
|
|||||||
Reference in New Issue
Block a user