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:
co-authored by
Claude Fable 5
parent
a860c690ed
commit
99c94d467a
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user