Files
accounted/lib/company/landing-server.ts
T
MattssonandClaude Fable 5 a860c690ed feat(white-label): WL-14 cockpit landing for BankID and OAuth/magic-link logins (#1972)
* feat(white-label): WL-14 cockpit landing for BankID and OAuth/magic-link logins

Byra staff logging in via BankID or the Google/magic-link callback on
their brand domain landed on /select-company resp. / instead of the
cockpit, because those two paths bypassed the WL-14 landing rule.

- Extract the rule into resolveLandingDestination
  (lib/company/landing-server.ts) so server code can call it without an
  HTTP round-trip; /api/clients/landing becomes a thin wrapper.
- Auth callback: with no explicit destination, AAL1 sessions resolve the
  landing from the request host, degrading to / on any failure
  (MFA-enrolled users already get the rule via /mfa/verify).
- BankID login: byra staff on their brand host get /clients; everyone
  else keeps the deliberate /select-company picker byte-identically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): address PR 1972 review findings

- /api/clients/landing: requireAuth() directly instead of
  withRouteContext, which 4xxed byra staff without a company of their
  own (COMPANY_CONTEXT_MISSING) and silently sent the cockpit's primary
  persona to /select-company. MFA enforcement unchanged.
- landing-server: log the byra membership query error before degrading
  to '/' so a persistent failure is distinguishable from no membership.
- Deduplicate the clientWithTeamMembership test mock to file scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): paginate the byra membership query

fetchAllRows per repo convention: PostgREST silently caps unpaginated
selects at 1000 rows, which could hide a qualifying owner/admin
membership. Errors still degrade to '/' with a log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 13:37:20 +02:00

60 lines
2.2 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { resolveBrandByHost } from '@/lib/branding/resolve'
import { resolveBrandsForTeams } from '@/lib/branding/team-brands'
import { isCockpitLandingRole, resolveLandingPath } from '@/lib/company/home-domain'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
interface ByraMembershipRow {
team_id: string
role: string
}
/**
* Post-login landing decision (WL-14), callable server-side without an HTTP
* round-trip: byrå owners/admins land in the cockpit ('/clients') when `host`
* is their byrå's home domain: the byrå's brand domain, or the canonical
* domain for a byrå without white label (WL-01). Plain byrå members and
* everyone else get '/' so their flow stays byte-identical (role gate
* 2026-08-27, see isCockpitLandingRole).
*
* `supabase` must be authenticated as `userId` (RLS scopes the membership
* query). Callers that redirect on the result should degrade to '/' on any
* thrown error: the rule is a convenience, never a gate.
*/
export async function resolveLandingDestination(
supabase: SupabaseClient,
userId: string,
host: string,
): Promise<'/clients' | '/'> {
const hostBrand = host ? await resolveBrandByHost(host) : null
let memberships: ByraMembershipRow[]
try {
memberships = await fetchAllRows<ByraMembershipRow>(({ from, to }) =>
supabase
.from('team_members')
.select('team_id, role, teams:team_id!inner(kind)')
.eq('user_id', userId)
.eq('teams.kind', 'byra')
.order('team_id', { ascending: true })
.range(from, to),
)
} catch (err) {
// Degrading to '/' is safe but must not be silent: a persistent query
// failure would otherwise look identical to "no byrå membership".
console.error('[landing-server] byra membership query failed:', err)
return '/'
}
const byraTeamIds = memberships
.filter((m) => isCockpitLandingRole(m.role as string))
.map((m) => m.team_id as string)
if (byraTeamIds.length === 0) return '/'
const brandByTeam = await resolveBrandsForTeams(byraTeamIds)
return resolveLandingPath({
hostBrandTeamId: hostBrand?.teamId ?? null,
byraTeams: byraTeamIds.map((id) => ({ teamId: id, hasBrand: brandByTeam.has(id) })),
})
}