* 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>
164 lines
6.2 KiB
TypeScript
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`
|
|
}
|