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 <a> 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-27 13:44:23 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent a860c690ed
commit 99c94d467a
5 changed files with 240 additions and 25 deletions
+28 -5
View File
@@ -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,
+96 -20
View File
@@ -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<ReturnType<typeof setTimeout> | 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 <a>
// (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<GroupKey, 'top'>
@@ -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 <a>, 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 = (
<span className="relative">
{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 (
<a key={item.href} href={item.href} className={baseClass} title={title} aria-label={title}>
{inner}
</a>
)
}
return enabled ? (
<NavLink key={item.href} href={item.href} className={baseClass} title={label} aria-label={label}>
<NavLink key={item.href} href={item.href} className={baseClass} title={title} aria-label={title}>
{inner}
</NavLink>
) : (
<div key={item.href} className={baseClass} title={label} aria-disabled="true">
<div key={item.href} className={baseClass} title={title} aria-disabled="true">
{inner}
</div>
)
@@ -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 && (
<div className="mb-2">
<NavLink
href="/clients"
className="group flex items-center px-3 py-[7px] text-[13px] rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors duration-150"
>
<ArrowLeft className="mr-2.5 h-[15px] w-[15px] flex-shrink-0 text-muted-foreground group-hover:text-foreground" />
<span className="flex-1">{tNav('back_to_clients')}</span>
</NavLink>
{cockpitExternal ? (
<a
href={cockpitHref}
title={cockpitHint ?? undefined}
className="group flex items-center px-3 py-[7px] text-[13px] rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors duration-150"
>
<ArrowLeft className="mr-2.5 h-[15px] w-[15px] flex-shrink-0 text-muted-foreground group-hover:text-foreground" />
<span className="flex-1 min-w-0">
<span className="block">{tNav('back_to_clients')}</span>
{/* 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. */}
<span className="block truncate text-[11px] text-muted-foreground">
{cockpitHint}
</span>
</span>
</a>
) : (
<NavLink
href={cockpitHref}
className="group flex items-center px-3 py-[7px] text-[13px] rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors duration-150"
>
<ArrowLeft className="mr-2.5 h-[15px] w-[15px] flex-shrink-0 text-muted-foreground group-hover:text-foreground" />
<span className="flex-1">{tNav('back_to_clients')}</span>
</NavLink>
)}
<div className="mx-3 mt-2 border-t border-border/60" />
</div>
)}
@@ -1099,14 +1158,31 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
{/* Byrå members inside a company: route back to the cockpit. */}
{byraTeam && !cockpitMode && (
<div className="mb-1.5">
<NavLink
href="/clients"
onClick={closeMobileMenu}
className="flex items-center gap-3 px-3 min-h-[44px] rounded-lg text-foreground active:bg-muted/60 transition-colors"
>
<ArrowLeft className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
<span className="text-sm flex-1">{tNav('back_to_clients')}</span>
</NavLink>
{cockpitExternal ? (
<a
href={cockpitHref}
onClick={closeMobileMenu}
className="flex items-center gap-3 px-3 min-h-[44px] rounded-lg text-foreground active:bg-muted/60 transition-colors"
>
<ArrowLeft className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0">
<span className="block text-sm">{tNav('back_to_clients')}</span>
{/* Visible cross-host hint: touch has no title tooltip. */}
<span className="block truncate text-[11px] text-muted-foreground">
{cockpitHint}
</span>
</span>
</a>
) : (
<NavLink
href={cockpitHref}
onClick={closeMobileMenu}
className="flex items-center gap-3 px-3 min-h-[44px] rounded-lg text-foreground active:bg-muted/60 transition-colors"
>
<ArrowLeft className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
<span className="text-sm flex-1">{tNav('back_to_clients')}</span>
</NavLink>
)}
<div className="mx-3 mt-1.5 h-px bg-border/30" />
</div>
)}
+9
View File
@@ -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
}
/**
+75
View File
@@ -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')
})
})
+32
View File
@@ -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<string, TeamBrandRef>
/** 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`
}