fix
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
<link rel="icon" href="/descrybe_logo.png" type="image/png" sizes="any" />
|
||||
<link rel="shortcut icon" href="/descrybe_logo.png" />
|
||||
%sveltekit.head%
|
||||
<!-- App-wide theme FOUC guard: keep in sync with $lib/theme.svelte.ts -->
|
||||
<script>
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Descrybe">
|
||||
<rect width="32" height="32" rx="8" fill="#634FFC"/>
|
||||
<path fill="#FFFFFF" d="M10 7.5h7.1c5.05 0 8.9 3.55 8.9 8.5s-3.85 8.5-8.9 8.5H10V7.5zm4.1 3.35v10.3h3c2.95 0 5.05-2.15 5.05-5.15S20.05 10.85 17.1 10.85h-3z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 317 B |
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { i18n } from "$lib/i18n";
|
||||
|
||||
import { Building2, Check, ChevronDown } from "@lucide/svelte";
|
||||
import { Building2, Check, ChevronDown, Undo2 } from "@lucide/svelte";
|
||||
import { api } from "$lib/api";
|
||||
import { notifyApiError } from "$lib/notify";
|
||||
import type { Company } from "$lib/types";
|
||||
@@ -15,11 +15,17 @@
|
||||
let {
|
||||
companies = [],
|
||||
activeCompanyId = "",
|
||||
activeCompanyName = ""
|
||||
activeCompanyName = "",
|
||||
homeCompanyId = "",
|
||||
homeCompanyName = "",
|
||||
staffTenantActing = false
|
||||
}: {
|
||||
companies?: Company[];
|
||||
activeCompanyId?: string;
|
||||
activeCompanyName?: string;
|
||||
homeCompanyId?: string;
|
||||
homeCompanyName?: string;
|
||||
staffTenantActing?: boolean;
|
||||
} = $props();
|
||||
|
||||
let switching = $state(false);
|
||||
@@ -35,7 +41,13 @@
|
||||
return match?.name?.trim() || i18n.t("companySwitcher.select");
|
||||
});
|
||||
|
||||
const canSwitch = $derived(list.length > 1);
|
||||
const homeId = $derived(homeCompanyId.trim());
|
||||
const canRevert = $derived(Boolean(homeId && homeId !== activeCompanyId));
|
||||
const canSwitch = $derived(list.length > 1 || canRevert);
|
||||
const revertLabel = $derived.by(() => {
|
||||
const name = homeCompanyName.trim() || list.find((c) => c.id === homeId)?.name?.trim() || "";
|
||||
return name ? i18n.t("companySwitcher.returnTo", { name }) : i18n.t("companySwitcher.returnHome");
|
||||
});
|
||||
|
||||
async function selectCompany(companyId: string) {
|
||||
if (!companyId || companyId === activeCompanyId || switching) return;
|
||||
@@ -54,7 +66,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if list.length > 0}
|
||||
{#if list.length > 0 || canRevert}
|
||||
{#if canSwitch}
|
||||
<DropdownMenu bind:open={menuOpen} align="end">
|
||||
{#snippet trigger({ open, toggle })}
|
||||
@@ -69,11 +81,22 @@
|
||||
data-tour="company-switcher"
|
||||
title={label}
|
||||
>
|
||||
<Building2 class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{#if staffTenantActing || canRevert}
|
||||
<Building2 class="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
{:else}
|
||||
<Building2 class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
<span class="min-w-0 truncate">{switching ? i18n.t("switcher.switching") : label}</span>
|
||||
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
{/snippet}
|
||||
{#if canRevert}
|
||||
<DropdownMenuItem onclick={() => void selectCompany(homeId)}>
|
||||
<Undo2 class="h-3.5 w-3.5 text-primary" />
|
||||
<span class="truncate">{revertLabel}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{/if}
|
||||
<DropdownMenuLabel>{i18n.t("companySwitcher.companies")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{#each list as company (company.id)}
|
||||
@@ -93,7 +116,7 @@
|
||||
{:else}
|
||||
<div
|
||||
class="inline-flex h-8 max-w-[12rem] items-center gap-1.5 rounded-md border border-transparent px-2 text-xs font-medium text-muted-foreground sm:max-w-[18rem] sm:px-2.5"
|
||||
data-tour="company-switcher"
|
||||
data-testid="company-switcher"
|
||||
title={label}
|
||||
>
|
||||
<Building2 class="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} を再接続",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import "./layout.css";
|
||||
import favicon from "$lib/assets/favicon.svg";
|
||||
import Nav from "$lib/components/Nav.svelte";
|
||||
import CommandPalette from "$lib/components/CommandPalette.svelte";
|
||||
import AdminNav from "$lib/components/AdminNav.svelte";
|
||||
@@ -228,7 +227,8 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<link rel="icon" href="/descrybe_logo.png" type="image/png" sizes="any" />
|
||||
<link rel="shortcut icon" href="/descrybe_logo.png" />
|
||||
{#if !isMarketingPage}
|
||||
<title>Descrybe</title>
|
||||
{/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}
|
||||
</div>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -1 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Descrybe">
|
||||
<rect width="32" height="32" rx="8" fill="#634FFC"/>
|
||||
<path fill="#FFFFFF" d="M10 7.5h7.1c5.05 0 8.9 3.55 8.9 8.5s-3.85 8.5-8.9 8.5H10V7.5zm4.1 3.35v10.3h3c2.95 0 5.05-2.15 5.05-5.15S20.05 10.85 17.1 10.85h-3z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 317 B |
Reference in New Issue
Block a user