diff --git a/apps/api/internal/auth/errors.go b/apps/api/internal/auth/errors.go index 35ab4d0..e774f4a 100644 --- a/apps/api/internal/auth/errors.go +++ b/apps/api/internal/auth/errors.go @@ -7,6 +7,7 @@ var ( ErrPasswordTooShort = errors.New("password must be at least 8 characters") ErrUserNotFound = errors.New("user not found") ErrNotCompanyMember = errors.New("not a member of company") + ErrCompanyNotFound = errors.New("company not found") ErrInviteNotFound = errors.New("invite not found") ErrEmailRequired = errors.New("email is required") ErrSyntheticEmail = errors.New("synthetic migration email cannot receive invites") diff --git a/apps/api/internal/auth/service.go b/apps/api/internal/auth/service.go index 1362e14..0f9939b 100644 --- a/apps/api/internal/auth/service.go +++ b/apps/api/internal/auth/service.go @@ -469,7 +469,61 @@ func (s *Service) ListUserCompanies(ctx context.Context, userID uuid.UUID) ([]Co return out, rows.Err() } +// ListCompanies returns tenants for platform staff company switching (id + name). +// Ordered with A1 / demo sandboxes first, then name. Caps at limit (default 500). +func (s *Service) ListCompanies(ctx context.Context, limit int) ([]Company, error) { + if s == nil || s.Pool == nil { + return nil, errors.New("auth service unavailable") + } + if limit <= 0 || limit > 2000 { + limit = 500 + } + const a1LegacyCompanyID = "97e1a309-3d23-4aa2-b518-8e8d7afdfec7" + rows, err := s.Pool.Query(ctx, ` + SELECT c.id, c.name + FROM companies c + ORDER BY + CASE + WHEN lower(c.name) = 'a1 slovenija' THEN 0 + WHEN lower(c.name) = 'local demo co' THEN 0 + WHEN lower(COALESCE(c.legacy_company_id, '')) = lower($1) THEN 0 + WHEN lower(c.name) IN ('platform demo', 'demo') THEN 1 + ELSE 2 + END, + c.name + LIMIT $2`, a1LegacyCompanyID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]Company, 0) + for rows.Next() { + var c Company + if err := rows.Scan(&c.ID, &c.Name); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// CompanyByID loads a single company or ErrNotCompanyMember-style miss via pgx.ErrNoRows. +func (s *Service) CompanyByID(ctx context.Context, companyID uuid.UUID) (Company, error) { + if s == nil || s.Pool == nil { + return Company{}, errors.New("auth service unavailable") + } + var c Company + err := s.Pool.QueryRow(ctx, `SELECT id, name FROM companies WHERE id = $1`, companyID).Scan(&c.ID, &c.Name) + if errors.Is(err, pgx.ErrNoRows) { + return Company{}, ErrCompanyNotFound + } + return c, err +} + func (s *Service) EnsureMembership(ctx context.Context, userID, companyID uuid.UUID) (Membership, error) { + if s == nil || s.Pool == nil { + return Membership{}, ErrNotCompanyMember + } var m Membership err := s.Pool.QueryRow(ctx, ` SELECT id, company_id, user_id, role, status diff --git a/apps/api/internal/auth/session.go b/apps/api/internal/auth/session.go index 44913d9..9bc0cdb 100644 --- a/apps/api/internal/auth/session.go +++ b/apps/api/internal/auth/session.go @@ -26,8 +26,9 @@ func NewSessionManager(pool *pgxpool.Pool, cookieName string, secure bool, idleH } const ( - SessionUserIDKey = "user_id" - SessionCompanyIDKey = "company_id" - SessionImpersonatorIDKey = "impersonator_id" // non-prod user switch: original admin/demo - SessionVersionKey = "session_version" // must match users.session_version + SessionUserIDKey = "user_id" + SessionCompanyIDKey = "company_id" + SessionImpersonatorIDKey = "impersonator_id" // non-prod user switch: original admin/demo + SessionStaffHomeCompanyKey = "staff_home_company_id" // platform admin tenant switch: home company to revert to + SessionVersionKey = "session_version" // must match users.session_version ) diff --git a/apps/api/internal/httpapi/auth_handlers.go b/apps/api/internal/httpapi/auth_handlers.go index c2daac3..7cea360 100644 --- a/apps/api/internal/httpapi/auth_handlers.go +++ b/apps/api/internal/httpapi/auth_handlers.go @@ -140,8 +140,9 @@ func (s *Server) beginAuthenticatedSession(ctx context.Context, userID, companyI } s.Sessions.Put(ctx, auth.SessionUserIDKey, userID.String()) s.putSessionVersion(ctx, userID) - // Fresh login/register clears any prior impersonation chain. + // Fresh login/register clears any prior impersonation chain / staff tenant act-as. s.Sessions.Remove(ctx, auth.SessionImpersonatorIDKey) + s.Sessions.Remove(ctx, auth.SessionStaffHomeCompanyKey) if companyID == uuid.Nil { s.Sessions.Put(ctx, auth.SessionCompanyIDKey, "") return nil @@ -302,16 +303,32 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { Error(w, http.StatusInternalServerError, "failed to load companies") return } + staffTenantSwitch := false + access, accessErr := s.Auth.GetStaffAccess(r.Context(), uid) + if accessErr == nil && access.FullAdmin && access.Role == auth.StaffRoleAdmin { + staffTenantSwitch = true + all, listErr := s.Auth.ListCompanies(r.Context(), 500) + if listErr != nil { + Error(w, http.StatusInternalServerError, "failed to load companies") + return + } + companies = all + } cidStr := s.Sessions.GetString(r.Context(), auth.SessionCompanyIDKey) + homeStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionStaffHomeCompanyKey)) out := map[string]any{ "user": user, "companies": companies, "active_company_id": cidStr, } - if access, err := s.Auth.GetStaffAccess(r.Context(), uid); err == nil && (access.FullAdmin || access.SupportDesk) { + if accessErr == nil && (access.FullAdmin || access.SupportDesk) { out["staff_access"] = access out["staff_capabilities"] = auth.StaffCapabilities(access.Role) } + if staffTenantSwitch { + out["staff_tenant_switch"] = true + } + staffOverride := false if cid, err := uuid.Parse(cidStr); err == nil { for _, c := range companies { if c.ID == cid { @@ -319,13 +336,33 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { break } } + // Staff may have selected a company not in the capped list — resolve by id. + if _, ok := out["company"]; !ok && staffTenantSwitch { + if c, cerr := s.Auth.CompanyByID(r.Context(), cid); cerr == nil { + out["company"] = c + } + } if credits, err := s.Billing.CreditsOverview(r.Context(), cid, s.Config.LowCreditsThreshold); err == nil { out["credits"] = credits } if m, err := s.Auth.EnsureMembership(r.Context(), uid, cid); err == nil { out["membership"] = map[string]string{"role": m.Role, "status": m.Status} + } else if staffTenantSwitch && errors.Is(err, auth.ErrNotCompanyMember) { + staffOverride = true + out["membership"] = map[string]any{"role": "admin", "status": "active", "staff_override": true} } } + if staffTenantSwitch && homeStr != "" { + out["staff_home_company_id"] = homeStr + if hid, err := uuid.Parse(homeStr); err == nil { + if c, cerr := s.Auth.CompanyByID(r.Context(), hid); cerr == nil { + out["staff_home_company"] = c + } + } + } + if staffOverride { + out["staff_tenant_acting"] = true + } impersonating := false if impStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionImpersonatorIDKey)); impStr != "" { if impID, err := uuid.Parse(impStr); err == nil && impID != uuid.Nil { @@ -345,8 +382,7 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { if !s.Config.IsProduction() { canSwitch := impersonating if !canSwitch { - access, err := s.checkStaffAccess(r.Context(), uid) - if err == nil && access.FullAdmin { + if accessErr == nil && access.FullAdmin { canSwitch = true } else if isLocalDemoEmail(user.Email) { canSwitch = true @@ -418,10 +454,48 @@ func (s *Server) handleSelectCompany(w http.ResponseWriter, r *http.Request) { Error(w, http.StatusBadRequest, "invalid company_id") return } - if _, err := s.Auth.EnsureMembership(r.Context(), uid, cid); err != nil { - Error(w, http.StatusForbidden, "forbidden") - return + _, memErr := s.Auth.EnsureMembership(r.Context(), uid, cid) + staffOK := s.staffMayActAsCompany(r.Context(), uid) + if memErr != nil { + if !(errors.Is(memErr, auth.ErrNotCompanyMember) && staffOK) { + Error(w, http.StatusForbidden, "forbidden") + return + } + if _, cerr := s.Auth.CompanyByID(r.Context(), cid); cerr != nil { + if errors.Is(cerr, auth.ErrCompanyNotFound) { + Error(w, http.StatusNotFound, "company not found") + return + } + Error(w, http.StatusInternalServerError, "company lookup failed") + return + } + // First hop into a foreign tenant: remember home company for revert. + if strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionStaffHomeCompanyKey)) == "" { + if home := s.resolveStaffHomeCompany(r.Context(), uid); home != uuid.Nil { + s.Sessions.Put(r.Context(), auth.SessionStaffHomeCompanyKey, home.String()) + } + } + } else { + // Back on a membership company — clear act-as home. + s.Sessions.Remove(r.Context(), auth.SessionStaffHomeCompanyKey) } s.Sessions.Put(r.Context(), auth.SessionCompanyIDKey, cid.String()) JSON(w, http.StatusOK, map[string]string{"company_id": cid.String()}) } + +// resolveStaffHomeCompany picks the company to restore on revert: current session +// company when the admin is a member, else their first membership company. +func (s *Server) resolveStaffHomeCompany(ctx context.Context, userID uuid.UUID) uuid.UUID { + if cur := strings.TrimSpace(s.Sessions.GetString(ctx, auth.SessionCompanyIDKey)); cur != "" { + if cid, err := uuid.Parse(cur); err == nil { + if _, err := s.Auth.EnsureMembership(ctx, userID, cid); err == nil { + return cid + } + } + } + companies, err := s.Auth.ListUserCompanies(ctx, userID) + if err != nil || len(companies) == 0 { + return uuid.Nil + } + return companies[0].ID +} diff --git a/apps/api/internal/httpapi/middleware.go b/apps/api/internal/httpapi/middleware.go index dee96cd..b916486 100644 --- a/apps/api/internal/httpapi/middleware.go +++ b/apps/api/internal/httpapi/middleware.go @@ -192,6 +192,12 @@ func (s *Server) RequireCompany(next http.Handler) http.Handler { } m, err := s.Auth.EnsureMembership(r.Context(), uid, cid) if err != nil { + if errors.Is(err, auth.ErrNotCompanyMember) && s.staffMayActAsCompany(r.Context(), uid) { + ctx := context.WithValue(r.Context(), ctxCompanyID, cid) + ctx = context.WithValue(ctx, ctxRole, "admin") + next.ServeHTTP(w, r.WithContext(ctx)) + return + } Error(w, http.StatusForbidden, "forbidden") return } @@ -201,6 +207,16 @@ func (s *Server) RequireCompany(next http.Handler) http.Handler { }) } +// staffMayActAsCompany is true only for platform staff_role=admin (or legacy +// is_platform_admin → admin). Developers and support_staff cannot tenant-switch. +func (s *Server) staffMayActAsCompany(ctx context.Context, userID uuid.UUID) bool { + access, err := s.checkStaffAccess(ctx, userID) + if err != nil || !access.FullAdmin { + return false + } + return access.Role == auth.StaffRoleAdmin +} + func (s *Server) CSRF(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Public API-key and token export routes do not use cookie CSRF. diff --git a/apps/api/internal/httpapi/tenant_test.go b/apps/api/internal/httpapi/tenant_test.go index 35b83f6..c22d27a 100644 --- a/apps/api/internal/httpapi/tenant_test.go +++ b/apps/api/internal/httpapi/tenant_test.go @@ -150,6 +150,105 @@ func TestRequireSessionRejectsStaleSessionVersion(t *testing.T) { } } +func TestRequireCompanyAllowsFullStaffWithoutMembership(t *testing.T) { + t.Parallel() + sm := scs.New() + uid := uuid.New() + cid := uuid.New() + s := &Server{ + Sessions: sm, + Config: config.Config{}, + Auth: &auth.Service{}, + testStaffAccess: func(ctx context.Context, userID uuid.UUID) (auth.StaffAccess, error) { + if userID != uid { + t.Fatalf("unexpected user %s", userID) + } + return auth.ResolveStaffAccess(true, auth.StaffRoleAdmin), nil + }, + } + + var capturedToken string + seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sm.Put(r.Context(), auth.SessionUserIDKey, uid.String()) + sm.Put(r.Context(), auth.SessionCompanyIDKey, cid.String()) + w.WriteHeader(http.StatusNoContent) + })) + seedRec := httptest.NewRecorder() + seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil)) + for _, c := range seedRec.Result().Cookies() { + if c.Name == sm.Cookie.Name { + capturedToken = c.Value + } + } + if capturedToken == "" { + t.Fatal("expected session cookie") + } + + var gotCompany uuid.UUID + var gotRole string + h := LoadSession(sm)(s.RequireSession(s.RequireCompany(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCompany, _ = CompanyIDFromContext(r.Context()) + gotRole, _ = RoleFromContext(r.Context()) + w.WriteHeader(http.StatusNoContent) + })))) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/company", nil) + req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: capturedToken}) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204 staff override", rec.Code) + } + if gotCompany != cid { + t.Fatalf("company = %s, want %s", gotCompany, cid) + } + if gotRole != "admin" { + t.Fatalf("role = %q, want admin", gotRole) + } +} + +func TestRequireCompanyDeniesDeveloperStaffWithoutMembership(t *testing.T) { + t.Parallel() + sm := scs.New() + uid := uuid.New() + cid := uuid.New() + s := &Server{ + Sessions: sm, + Config: config.Config{}, + Auth: &auth.Service{}, + testStaffAccess: func(ctx context.Context, userID uuid.UUID) (auth.StaffAccess, error) { + return auth.ResolveStaffAccess(true, auth.StaffRoleDeveloper), nil + }, + } + + var capturedToken string + seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sm.Put(r.Context(), auth.SessionUserIDKey, uid.String()) + sm.Put(r.Context(), auth.SessionCompanyIDKey, cid.String()) + w.WriteHeader(http.StatusNoContent) + })) + seedRec := httptest.NewRecorder() + seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil)) + for _, c := range seedRec.Result().Cookies() { + if c.Name == sm.Cookie.Name { + capturedToken = c.Value + } + } + if capturedToken == "" { + t.Fatal("expected session cookie") + } + + h := LoadSession(sm)(s.RequireSession(s.RequireCompany(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })))) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/company", nil) + req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: capturedToken}) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 for developer without membership", rec.Code) + } +} + func TestRequireCompanyRequiresSelection(t *testing.T) { t.Parallel() sm := scs.New() diff --git a/apps/web/src/app.html b/apps/web/src/app.html index 93d1b4c..e5102df 100644 --- a/apps/web/src/app.html +++ b/apps/web/src/app.html @@ -4,6 +4,8 @@ + + %sveltekit.head% -{#if list.length > 0} +{#if list.length > 0 || canRevert} {#if canSwitch} {#snippet trigger({ open, toggle })} @@ -69,11 +81,22 @@ data-tour="company-switcher" title={label} > - + {#if staffTenantActing || canRevert} + + {:else} + + {/if} {switching ? i18n.t("switcher.switching") : label} {/snippet} + {#if canRevert} + void selectCompany(homeId)}> + + {revertLabel} + + + {/if} {i18n.t("companySwitcher.companies")} {#each list as company (company.id)} @@ -93,7 +116,7 @@ {:else}
diff --git a/apps/web/src/lib/i18n/messages/de.ts b/apps/web/src/lib/i18n/messages/de.ts index 2cedb2a..6cad7b5 100644 --- a/apps/web/src/lib/i18n/messages/de.ts +++ b/apps/web/src/lib/i18n/messages/de.ts @@ -5501,6 +5501,8 @@ export const de: MessageDict = { "companySwitcher.select": "Unternehmen auswählen", "companySwitcher.switchAria": "Unternehmen wechseln: {label}", "companySwitcher.companies": "Unternehmen", + "companySwitcher.returnTo": "Zurück zu {name}", + "companySwitcher.returnHome": "Zurück zum Heimatunternehmen", "storeReconnect.primaryLabel": "Shop erneut verbinden", "storeReconnect.titlePlural": "Shops nach Migration erneut verbinden", "storeReconnect.titleChannel": "{channel} nach Migration erneut verbinden", diff --git a/apps/web/src/lib/i18n/messages/en.ts b/apps/web/src/lib/i18n/messages/en.ts index f88c73e..d1dfa80 100644 --- a/apps/web/src/lib/i18n/messages/en.ts +++ b/apps/web/src/lib/i18n/messages/en.ts @@ -5536,6 +5536,8 @@ export const en: MessageDict = { "companySwitcher.select": "Select company", "companySwitcher.switchAria": "Switch company: {label}", "companySwitcher.companies": "Companies", + "companySwitcher.returnTo": "Return to {name}", + "companySwitcher.returnHome": "Return to home company", "storeReconnect.primaryLabel": "Reconnect store", "storeReconnect.titlePlural": "Reconnect stores after migration", "storeReconnect.titleChannel": "Reconnect {channel} after migration", diff --git a/apps/web/src/lib/i18n/messages/es.ts b/apps/web/src/lib/i18n/messages/es.ts index 09b73dd..ee806d7 100644 --- a/apps/web/src/lib/i18n/messages/es.ts +++ b/apps/web/src/lib/i18n/messages/es.ts @@ -5501,6 +5501,8 @@ export const es: MessageDict = { "companySwitcher.select": "Seleccionar empresa", "companySwitcher.switchAria": "Cambiar de empresa: {label}", "companySwitcher.companies": "Empresas", + "companySwitcher.returnTo": "Volver a {name}", + "companySwitcher.returnHome": "Volver a la empresa de origen", "storeReconnect.primaryLabel": "Reconectar tienda", "storeReconnect.titlePlural": "Reconectar tiendas tras la migración", "storeReconnect.titleChannel": "Reconectar {channel} tras la migración", diff --git a/apps/web/src/lib/i18n/messages/fr.ts b/apps/web/src/lib/i18n/messages/fr.ts index 76e2e8b..9e777d2 100644 --- a/apps/web/src/lib/i18n/messages/fr.ts +++ b/apps/web/src/lib/i18n/messages/fr.ts @@ -5501,6 +5501,8 @@ export const fr: MessageDict = { "companySwitcher.select": "Sélectionner une entreprise", "companySwitcher.switchAria": "Changer d’entreprise : {label}", "companySwitcher.companies": "Entreprises", + "companySwitcher.returnTo": "Revenir à {name}", + "companySwitcher.returnHome": "Revenir à l’entreprise d’origine", "storeReconnect.primaryLabel": "Reconnecter la boutique", "storeReconnect.titlePlural": "Reconnecter les boutiques après la migration", "storeReconnect.titleChannel": "Reconnecter {channel} après la migration", diff --git a/apps/web/src/lib/i18n/messages/it.ts b/apps/web/src/lib/i18n/messages/it.ts index 04903d1..7ff7b50 100644 --- a/apps/web/src/lib/i18n/messages/it.ts +++ b/apps/web/src/lib/i18n/messages/it.ts @@ -5501,6 +5501,8 @@ export const it: MessageDict = { "companySwitcher.select": "Seleziona azienda", "companySwitcher.switchAria": "Cambia azienda: {label}", "companySwitcher.companies": "Aziende", + "companySwitcher.returnTo": "Torna a {name}", + "companySwitcher.returnHome": "Torna all’azienda di origine", "storeReconnect.primaryLabel": "Riconnetti negozio", "storeReconnect.titlePlural": "Riconnetti i negozi dopo la migrazione", "storeReconnect.titleChannel": "Riconnetti {channel} dopo la migrazione", diff --git a/apps/web/src/lib/i18n/messages/ja.ts b/apps/web/src/lib/i18n/messages/ja.ts index 779e3c3..dac949c 100644 --- a/apps/web/src/lib/i18n/messages/ja.ts +++ b/apps/web/src/lib/i18n/messages/ja.ts @@ -5501,6 +5501,8 @@ export const ja: MessageDict = { "companySwitcher.select": "会社を選択", "companySwitcher.switchAria": "会社を切替: {label}", "companySwitcher.companies": "会社", + "companySwitcher.returnTo": "{name} に戻る", + "companySwitcher.returnHome": "ホーム会社に戻る", "storeReconnect.primaryLabel": "ストアを再接続", "storeReconnect.titlePlural": "移行後にストアを再接続", "storeReconnect.titleChannel": "移行後に {channel} を再接続", diff --git a/apps/web/src/lib/i18n/messages/nl.ts b/apps/web/src/lib/i18n/messages/nl.ts index 2e6bbd4..5430448 100644 --- a/apps/web/src/lib/i18n/messages/nl.ts +++ b/apps/web/src/lib/i18n/messages/nl.ts @@ -5501,6 +5501,8 @@ export const nl: MessageDict = { "companySwitcher.select": "Bedrijf selecteren", "companySwitcher.switchAria": "Bedrijf wisselen: {label}", "companySwitcher.companies": "Bedrijven", + "companySwitcher.returnTo": "Terug naar {name}", + "companySwitcher.returnHome": "Terug naar thuisbedrijf", "storeReconnect.primaryLabel": "Winkel opnieuw verbinden", "storeReconnect.titlePlural": "Winkels opnieuw verbinden na migratie", "storeReconnect.titleChannel": "{channel} opnieuw verbinden na migratie", diff --git a/apps/web/src/lib/i18n/messages/pl.ts b/apps/web/src/lib/i18n/messages/pl.ts index 69c65bd..56e8638 100644 --- a/apps/web/src/lib/i18n/messages/pl.ts +++ b/apps/web/src/lib/i18n/messages/pl.ts @@ -5501,6 +5501,8 @@ export const pl: MessageDict = { "companySwitcher.select": "Wybierz firmę", "companySwitcher.switchAria": "Przełącz firmę: {label}", "companySwitcher.companies": "Firmy", + "companySwitcher.returnTo": "Wróć do {name}", + "companySwitcher.returnHome": "Wróć do firmy domowej", "storeReconnect.primaryLabel": "Połącz sklep ponownie", "storeReconnect.titlePlural": "Połącz sklepy ponownie po migracji", "storeReconnect.titleChannel": "Połącz {channel} ponownie po migracji", diff --git a/apps/web/src/lib/i18n/messages/pt.ts b/apps/web/src/lib/i18n/messages/pt.ts index 7a7c46a..54bc694 100644 --- a/apps/web/src/lib/i18n/messages/pt.ts +++ b/apps/web/src/lib/i18n/messages/pt.ts @@ -5501,6 +5501,8 @@ export const pt: MessageDict = { "companySwitcher.select": "Selecionar empresa", "companySwitcher.switchAria": "Mudar de empresa: {label}", "companySwitcher.companies": "Empresas", + "companySwitcher.returnTo": "Voltar para {name}", + "companySwitcher.returnHome": "Voltar para a empresa de origem", "storeReconnect.primaryLabel": "Reconectar loja", "storeReconnect.titlePlural": "Reconectar lojas após a migração", "storeReconnect.titleChannel": "Reconectar {channel} após a migração", diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index a04203c..553747e 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -93,10 +93,17 @@ export type MeResponse = { company?: Company | null; companies?: Company[]; active_company_id?: string; - membership?: { role: string; status: string } | null; + membership?: { role: string; status: string; staff_override?: boolean } | null; credits?: CreditBalance | null; staff_access?: StaffAccess | null; staff_capabilities?: string[]; + /** Platform staff_role=admin only: company switcher lists all tenants (no membership required). */ + staff_tenant_switch?: boolean; + /** True while admin is acting in a company without membership. */ + staff_tenant_acting?: boolean; + /** 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. */ dev_user_switch?: boolean; impersonating?: boolean; diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 0394754..1330c6e 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -1,6 +1,5 @@ - + + {#if !isMarketingPage} Descrybe {/if} @@ -431,6 +431,9 @@ companies={me.companies ?? (me.company ? [me.company] : [])} activeCompanyId={me.active_company_id ?? me.company?.id ?? ""} activeCompanyName={me.company?.name ?? ""} + homeCompanyId={me.staff_home_company_id ?? ""} + homeCompanyName={me.staff_home_company?.name ?? ""} + staffTenantActing={Boolean(me.staff_tenant_acting)} /> {/if}
diff --git a/apps/web/static/favicon.ico b/apps/web/static/favicon.ico index 89b4a7a..ca516c1 100644 Binary files a/apps/web/static/favicon.ico and b/apps/web/static/favicon.ico differ diff --git a/apps/web/static/favicon.svg b/apps/web/static/favicon.svg index cc5dc66..6f13d5b 100644 --- a/apps/web/static/favicon.svg +++ b/apps/web/static/favicon.svg @@ -1 +1,4 @@ -svelte-logo \ No newline at end of file + + + +