From 99c94d467a906a194165042a7a1464c3eedc0310 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:44:23 +0200 Subject: [PATCH] fix(white-label): back-to-clients link points at the byra cockpit's home domain (#1973) * fix(white-label): back-to-clients link points at the byra cockpit's home domain A byra member working a company homed on another host (e.g. a pre-byra company on canonical) got a relative /clients on the wrong host instead of their white-label cockpit. resolveCockpitHref mirrors WL-14's home rule: relative when the current host is the cockpit's home (brand domain, or canonical for a brandless byra), else an absolute URL there. Cross- host links render a plain with a 'Hanteras via' hint; the hop lands on the brand host's login (per-host sessions, WL-01) and WL-14 then lands on /clients. Co-Authored-By: Claude Fable 5 * fix(white-label): show the cross-host cockpit hint as visible text CodeRabbit: title-only hints never surface on touch devices, so the expanded-sidebar and mobile external back-links now render the 'Hanteras via {domain}' line as small muted text under the label (same pattern as the switcher's foreign entries). Also corrects the comments claiming the no-company branch never renders the back-link: it can, on its cockpit/settings surfaces, where the relative fallback matches pre-change behavior. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- app/(dashboard)/layout.tsx | 33 +++++- components/dashboard/DashboardNav.tsx | 116 ++++++++++++++++++---- contexts/CompanyContext.tsx | 9 ++ lib/company/__tests__/home-domain.test.ts | 75 ++++++++++++++ lib/company/home-domain.ts | 32 ++++++ 5 files changed, 240 insertions(+), 25 deletions(-) diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 65aeddf3..70562ebe 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -23,6 +23,7 @@ import { resolveBrandsForTeams } from '@/lib/branding/team-brands' import { partitionCompaniesByHomeDomain, isCompanyHomedOnHost, + resolveCockpitHref, } from '@/lib/company/home-domain' import HomeDomainSignpost from '@/components/dashboard/HomeDomainSignpost' import type { AccountingFramework, EntityType, CompanyRole, Team } from '@/types' @@ -258,9 +259,15 @@ export default async function DashboardLayout({ .order('ledger_account', { ascending: true }), hostHeader ? resolveBrandByHost(hostHeader) : Promise.resolve(null), resolveBrandsForTeams( - (allMemberships || []).map( - (m) => (m.companies as { team_id?: string | null } | null)?.team_id ?? null, - ), + // The byrå team's own id rides along so its brand resolves even when + // none of the user's companies belong to it (pre-byrå companies have + // team_id null; a fresh byrå may have zero clients). Batched and + // cached, so this is free on the common path. + (allMemberships || []) + .map( + (m) => (m.companies as { team_id?: string | null } | null)?.team_id ?? null, + ) + .concat(byraTeam ? [byraTeam.id] : []), ), ]) const hasWebshop = navFlags.hasWebshop @@ -279,6 +286,22 @@ export default async function DashboardLayout({ (allSettingsNames || []).map((s) => [s.company_id, s.company_name as string | null]), ) + // Where "Tillbaka till klienter" points: the byrå cockpit's home domain + // (WL-01), relative when this host already is that home. The no-company + // branch above renders before brands resolve, so only these two branches + // carry the href; DashboardNav falls back to '/clients'. + const byraTeamWithHref: ByraTeamRef | null = byraTeam + ? { + ...byraTeam, + cockpitHref: resolveCockpitHref({ + byraTeamId: byraTeam.id, + brandByTeam, + hostBrandTeamId: hostBrand?.teamId ?? null, + canonicalDomain, + }), + } + : null + if (!companyRow || !memberRow) { // Stale cookie pointing to a deleted/inaccessible company. // Render the empty-state dashboard so user can switch or create a company. @@ -294,7 +317,7 @@ export default async function DashboardLayout({ }), isTeamMember, team, - byraTeam, + byraTeam: byraTeamWithHref, foreignCompanies: [], isSandbox: false, capabilities: [], @@ -403,7 +426,7 @@ export default async function DashboardLayout({ companies: homePartition.visible, isTeamMember, team, - byraTeam, + byraTeam: byraTeamWithHref, foreignCompanies, isSandbox, capabilities: entitlements.capabilities, diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index bfe72539..f1b465a3 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -340,6 +340,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa const { identity: agentIdentity } = useAgentSheet() const tNav = useTranslations('nav') const tCommon = useTranslations('common') + const tSwitcher = useTranslations('company_switcher') const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) const [isClosing, setIsClosing] = useState(false) const closeTimerRef = useRef | null>(null) @@ -374,11 +375,27 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa } const cockpitMode = !!byraTeam && (onSettings ? lastNonSettingsCockpit || ctxByra : onCockpitPath) + // Where "Tillbaka till klienter" points (home-domain rule, WL-01): absolute + // URL when the byrå cockpit is homed on another host (resolveCockpitHref in + // the layout), relative '/clients' otherwise. The layout's pre-brand + // no-company branch leaves cockpitHref unset; there the relative fallback + // reproduces the pre-cockpitHref link exactly on the cockpit/settings + // surfaces that branch can render. Cross-host hops render a plain + // (no client-router prefetch across origins) and carry a "Hanteras via" + // hint, since the other host will ask for a login (per-host sessions). + const cockpitHref = byraTeam?.cockpitHref ?? '/clients' + const cockpitExternal = cockpitHref.startsWith('https://') + const cockpitHint = cockpitExternal + ? tSwitcher('managed_via', { domain: new URL(cockpitHref).hostname }) + : null // Byrå-scope surfaces, not company surfaces: they must stay reachable even // when the active company is unresolved. const ALWAYS_ENABLED = new Set(['/settings', '/clients', '/byra', '/byra/automations', '/byra/kpi']) const isItemEnabled = (href: string) => { - const base = href.split('?')[0] + // The back-to-clients link may be absolute (cross-host cockpit); judge + // it by its path so it stays as reachable as the relative form. + const path = href.startsWith('https://') ? new URL(href).pathname : href + const base = path.split('?')[0] return hasCompany || ALWAYS_ENABLED.has(base) || base.startsWith('/settings') } type ExpandableGroup = Exclude @@ -726,12 +743,22 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa // Collapsed 64px rail: icon-only rows, native title tooltips, count // bubbles pinned to the icon corner. const renderRailItem = ( - item: { href: string; labelKey: NavLabelKey; icon: typeof LayoutDashboard; comingSoon?: boolean }, + item: { + href: string + labelKey: NavLabelKey + icon: typeof LayoutDashboard + comingSoon?: boolean + /** Cross-origin href: render a plain , no client-router prefetch. */ + external?: boolean + /** Extra tooltip context (e.g. "Hanteras via {domain}"). */ + hint?: string | null + }, ) => { const active = isActive(item.href) const enabled = isItemEnabled(item.href) && !item.comingSoon const badge = badgeFor(item.href) const label = tNav(item.labelKey) + const title = item.hint ? `${label} · ${item.hint}` : label const inner = ( {renderNavIcon( @@ -751,12 +778,19 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa ? cn('transition-colors duration-150', active ? 'bg-secondary' : 'hover:bg-secondary/60') : 'opacity-40 cursor-not-allowed', ) + if (enabled && item.external) { + return ( + + {inner} + + ) + } return enabled ? ( - + {inner} ) : ( -
+
{inner}
) @@ -823,7 +857,13 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa {/* Rail counterpart of the back-to-clients link. */} {byraTeam && !cockpitMode && - renderRailItem({ href: '/clients', labelKey: 'back_to_clients', icon: ArrowLeft })} + renderRailItem({ + href: cockpitHref, + labelKey: 'back_to_clients', + icon: ArrowLeft, + external: cockpitExternal, + hint: cockpitHint, + })} {railItems.map((item) => renderRailItem(item))} {visibleExtensionNavItems.map((item) => { const Icon = resolveIcon(item.icon) @@ -867,13 +907,32 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa cockpit, above everything, separated by a hairline. */} {byraTeam && !cockpitMode && (
- - - {tNav('back_to_clients')} - + {cockpitExternal ? ( + + + + {tNav('back_to_clients')} + {/* Visible cross-host hint: title alone never surfaces + on touch, and the hop lands on the other host's + login (per-host sessions), so say where it goes. */} + + {cockpitHint} + + + + ) : ( + + + {tNav('back_to_clients')} + + )}
)} @@ -1099,14 +1158,31 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa {/* Byrå members inside a company: route back to the cockpit. */} {byraTeam && !cockpitMode && (
- - - {tNav('back_to_clients')} - + {cockpitExternal ? ( + + + + {tNav('back_to_clients')} + {/* Visible cross-host hint: touch has no title tooltip. */} + + {cockpitHint} + + + + ) : ( + + + {tNav('back_to_clients')} + + )}
)} diff --git a/contexts/CompanyContext.tsx b/contexts/CompanyContext.tsx index d4590f22..bd1bde95 100644 --- a/contexts/CompanyContext.tsx +++ b/contexts/CompanyContext.tsx @@ -10,6 +10,15 @@ export interface ByraTeamRef { id: string name: string role: 'owner' | 'admin' | 'member' + /** + * Where "Tillbaka till klienter" points: '/clients' when the current host + * is the byrå cockpit's home domain, else an absolute URL to that home + * (resolveCockpitHref). Optional: the no-company layout branch renders + * before brands resolve and leaves it unset; consumers fall back to + * '/clients', which on that branch's cockpit/settings surfaces matches the + * pre-cockpitHref behavior exactly. + */ + cockpitHref?: string } /** diff --git a/lib/company/__tests__/home-domain.test.ts b/lib/company/__tests__/home-domain.test.ts index e2574137..ffe5ee0f 100644 --- a/lib/company/__tests__/home-domain.test.ts +++ b/lib/company/__tests__/home-domain.test.ts @@ -4,6 +4,7 @@ import { isCompanyHomedOnHost, isCockpitLandingRole, resolveLandingPath, + resolveCockpitHref, type TeamBrandRef, } from '../home-domain' @@ -200,3 +201,77 @@ describe('resolveLandingPath', () => { expect(resolveLandingPath({ hostBrandTeamId: 'byra-1', byraTeams: [] })).toBe('/') }) }) + +describe('resolveCockpitHref', () => { + const brandByTeam = new Map([['byra-1', siffraBrand]]) + const canonicalDomain = CANONICAL.canonicalDomain + + it('brandless byrå on the canonical host stays relative', () => { + expect( + resolveCockpitHref({ + byraTeamId: 'byra-1', + brandByTeam: new Map(), + hostBrandTeamId: null, + canonicalDomain, + }), + ).toBe('/clients') + }) + + it('branded byrå on its own brand host stays relative', () => { + expect( + resolveCockpitHref({ + byraTeamId: 'byra-1', + brandByTeam, + hostBrandTeamId: 'byra-1', + canonicalDomain, + }), + ).toBe('/clients') + }) + + it('branded byrå on the canonical host points at the brand domain (problem 4)', () => { + expect( + resolveCockpitHref({ + byraTeamId: 'byra-1', + brandByTeam, + hostBrandTeamId: null, + canonicalDomain, + }), + ).toBe('https://app.siffra.se/clients') + }) + + it("branded byrå on someone ELSE's brand host points at its own brand domain", () => { + expect( + resolveCockpitHref({ + byraTeamId: 'byra-1', + brandByTeam, + hostBrandTeamId: 'byra-2', + canonicalDomain, + }), + ).toBe('https://app.siffra.se/clients') + }) + + it('brandless byrå on a foreign brand host points home to canonical (WL-14 symmetry)', () => { + expect( + resolveCockpitHref({ + byraTeamId: 'byra-1', + brandByTeam: new Map(), + hostBrandTeamId: 'byra-2', + canonicalDomain, + }), + ).toBe('https://app.gnubok.se/clients') + }) + + it('byrå with zero client companies still resolves via its own brand entry', () => { + // The layout adds the byrå team id to the resolveBrandsForTeams id list + // precisely so this map entry exists even when no membership company + // belongs to the byrå (pre-byrå companies have team_id null). + expect( + resolveCockpitHref({ + byraTeamId: 'byra-1', + brandByTeam: new Map([['byra-1', siffraBrand]]), + hostBrandTeamId: null, + canonicalDomain, + }), + ).toBe('https://app.siffra.se/clients') + }) +}) diff --git a/lib/company/home-domain.ts b/lib/company/home-domain.ts index 5c691ab5..d0ddddb3 100644 --- a/lib/company/home-domain.ts +++ b/lib/company/home-domain.ts @@ -129,3 +129,35 @@ export function resolveLandingPath(opts: { // Canonical host: only a brandless byrå homes its cockpit here. return opts.byraTeams.some((t) => !t.hasBrand) ? '/clients' : '/' } + +/** + * Href for the pinned "Tillbaka till klienter" link: the byrå cockpit lives + * on the byrå's home domain (its brand domain when the team has a brand, else + * the canonical domain, same rule as WL-14's resolveLandingPath). Relative + * '/clients' when the current host IS that home; an absolute URL otherwise, + * so a byrå member working a company homed on a different host (e.g. a + * pre-byrå company on canonical) is sent to their white-label cockpit. + * + * Navigation rule only, like everything in this file: the hop lands on the + * brand host's login when there is no session there (per-host sessions, + * WL-01), and WL-14 then lands the user on /clients. + */ +export function resolveCockpitHref(opts: { + /** teams.id of the user's byrå team. */ + byraTeamId: string + brandByTeam: Map + /** teams.id of the brand serving the current host, null on canonical. */ + hostBrandTeamId: string | null + canonicalDomain: string +}): string { + const brand = opts.brandByTeam.get(opts.byraTeamId) + if (brand) { + return opts.hostBrandTeamId === opts.byraTeamId + ? '/clients' + : `https://${brand.domain}/clients` + } + // Brandless byrå: the cockpit is homed on the canonical domain. + return opts.hostBrandTeamId === null + ? '/clients' + : `https://${opts.canonicalDomain}/clients` +}