Files
accounted/lib/company/home-domain.ts
T
MattssonandClaude Fable 5 99c94d467a 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>
2026-08-27 13:44:23 +02:00

164 lines
6.2 KiB
TypeScript

/**
* The home-domain rule (WL-01, locked): every company has exactly one home
* domain: its byrå team's brand domain when the team has a brand, else the
* canonical domain. A company is opened and worked in ONLY on its home
* domain; everywhere else the UI shows a signpost, never the company.
*
* Explicitly a NAVIGATION rule, not a security boundary: access is governed
* solely by company membership and RLS, unchanged. These helpers are pure so
* the rule is unit-testable; the dashboard layout feeds them the host brand
* (resolveBrandByHost) and the per-team brand map (resolveBrandsForTeams).
*/
export interface TeamBrandRef {
domain: string
appName: string
}
export interface ForeignCompanyRef {
id: string
name: string
/** The host where this company is actually worked in. */
domain: string
/** Brand app name for the signpost copy; canonical app name when brandless. */
appName: string
}
export interface HomeDomainPartition<T> {
/** Companies homed on the current host: the only ones the switcher offers. */
visible: T[]
/** Companies homed elsewhere: shown as non-clickable "Hanteras via" entries. */
foreign: Array<{ item: T; domain: string; appName: string }>
}
/**
* Partition a user's companies by home domain for the current host.
*
* - On a brand host (hostBrandTeamId set): visible = companies owned by that
* brand's team; everything else is foreign (its own brand domain, or the
* canonical domain for brandless companies).
* - On the canonical host (hostBrandTeamId null): visible = companies WITHOUT
* a brand; branded companies are foreign under their brand domain.
*
* A company whose team has no entry in brandByTeam is brandless and homed on
* the canonical domain, so the canonical hot path (no brands anywhere) yields
* visible = everything and foreign = [] : byte-identical behavior.
*/
export function partitionCompaniesByHomeDomain<T>(opts: {
companies: T[]
getTeamId: (company: T) => string | null
brandByTeam: Map<string, TeamBrandRef>
hostBrandTeamId: string | null
canonicalDomain: string
canonicalAppName: string
}): HomeDomainPartition<T> {
const { companies, getTeamId, brandByTeam, hostBrandTeamId } = opts
const visible: T[] = []
const foreign: HomeDomainPartition<T>['foreign'] = []
for (const company of companies) {
const teamId = getTeamId(company)
const brand = teamId ? brandByTeam.get(teamId) : undefined
if (hostBrandTeamId !== null) {
if (teamId === hostBrandTeamId) {
visible.push(company)
} else {
foreign.push({
item: company,
domain: brand?.domain ?? opts.canonicalDomain,
appName: brand?.appName ?? opts.canonicalAppName,
})
}
} else if (!brand) {
visible.push(company)
} else {
foreign.push({ item: company, domain: brand.domain, appName: brand.appName })
}
}
return { visible, foreign }
}
/**
* Whether the active company is homed on the current host (its brand domain
* equals the host's brand domain; null equals null on canonical). When false
* the layout renders the signpost instead of the company's dashboard.
*/
export function isCompanyHomedOnHost(opts: {
companyTeamId: string | null
brandByTeam: Map<string, TeamBrandRef>
hostBrandTeamId: string | null
}): boolean {
const brand = opts.companyTeamId ? opts.brandByTeam.get(opts.companyTeamId) : undefined
if (opts.hostBrandTeamId === null) return !brand
return opts.companyTeamId === opts.hostBrandTeamId
}
/**
* Whether a byrå team role gets the AUTOMATIC cockpit landing (2026-08-27:
* owner/admin only, superseding the 2026-08-05 all-members widening). Plain
* members land like regular users; they can still open the cockpit manually
* (nav visibility and access are membership-based, unchanged). Callers drop
* non-qualifying memberships BEFORE resolveLandingPath, which stays pure.
* Allowlist, not `role !== 'member'`, so any future role defaults to the
* regular landing.
*/
export function isCockpitLandingRole(role: string): boolean {
return role === 'owner' || role === 'admin'
}
/**
* Post-login landing (WL-14): byrå staff land in the cockpit on the byrå's
* home domain: the brand domain when the team has a brand, else the canonical
* domain (a byrå team without white label is a valid state, WL-01). Everyone
* else keeps today's '/' byte-identically. Callers pass only memberships that
* pass isCockpitLandingRole.
*/
export function resolveLandingPath(opts: {
/** teams.id of the brand serving the current host, null on canonical. */
hostBrandTeamId: string | null
/** The user's byrå team memberships with brand presence. */
byraTeams: Array<{ teamId: string; hasBrand: boolean }>
}): '/clients' | '/' {
if (opts.hostBrandTeamId !== null) {
return opts.byraTeams.some((t) => t.teamId === opts.hostBrandTeamId)
? '/clients'
: '/'
}
// 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`
}