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>
This commit is contained in:
Mattsson
2026-08-27 13:37:20 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent b30c71086e
commit a860c690ed
7 changed files with 357 additions and 140 deletions
@@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
const resolveBrandByHostMock = vi.fn()
vi.mock('@/lib/branding/resolve', () => ({
resolveBrandByHost: (...args: unknown[]) => resolveBrandByHostMock(...args),
}))
const resolveBrandsForTeamsMock = vi.fn()
vi.mock('@/lib/branding/team-brands', () => ({
resolveBrandsForTeams: (...args: unknown[]) => resolveBrandsForTeamsMock(...args),
}))
import { resolveLandingDestination } from '../landing-server'
const { supabase, enqueue, reset, findCall } = createQueuedMockSupabase()
const client = supabase as unknown as SupabaseClient
const byraMembership = { team_id: 'team-1', role: 'owner', teams: { kind: 'byra' } }
function membership(role: string, teamId = 'team-1') {
return { team_id: teamId, role, teams: { kind: 'byra' } }
}
beforeEach(() => {
vi.clearAllMocks()
reset()
resolveBrandByHostMock.mockResolvedValue(null)
resolveBrandsForTeamsMock.mockResolvedValue(new Map())
})
describe('resolveLandingDestination', () => {
it('returns / for a user with no byrå membership, without resolving brands', async () => {
enqueue({ data: [] })
const dest = await resolveLandingDestination(client, 'user-1', 'app.accounted.se')
expect(dest).toBe('/')
expect(resolveBrandsForTeamsMock).not.toHaveBeenCalled()
})
it('returns /clients for byrå staff on their own brand host', async () => {
resolveBrandByHostMock.mockResolvedValue({ teamId: 'team-1' })
resolveBrandsForTeamsMock.mockResolvedValue(
new Map([['team-1', { domain: 'app.amnas.se', appName: 'Amnas' }]]),
)
enqueue({ data: [byraMembership] })
const dest = await resolveLandingDestination(client, 'user-1', 'app.amnas.se')
expect(dest).toBe('/clients')
expect(resolveBrandByHostMock).toHaveBeenCalledWith('app.amnas.se')
})
it('returns / for byrå staff on a foreign brand host', async () => {
resolveBrandByHostMock.mockResolvedValue({ teamId: 'team-other' })
resolveBrandsForTeamsMock.mockResolvedValue(
new Map([['team-1', { domain: 'app.amnas.se', appName: 'Amnas' }]]),
)
enqueue({ data: [byraMembership] })
const dest = await resolveLandingDestination(client, 'user-1', 'app.ziffr.se')
expect(dest).toBe('/')
})
it('returns /clients for a brandless byrå on the canonical host (WL-01)', async () => {
resolveBrandByHostMock.mockResolvedValue(null)
resolveBrandsForTeamsMock.mockResolvedValue(new Map())
enqueue({ data: [byraMembership] })
const dest = await resolveLandingDestination(client, 'user-1', 'app.accounted.se')
expect(dest).toBe('/clients')
})
it('returns / for a branded byrå landing on the canonical host', async () => {
resolveBrandByHostMock.mockResolvedValue(null)
resolveBrandsForTeamsMock.mockResolvedValue(
new Map([['team-1', { domain: 'app.amnas.se', appName: 'Amnas' }]]),
)
enqueue({ data: [byraMembership] })
const dest = await resolveLandingDestination(client, 'user-1', 'app.accounted.se')
expect(dest).toBe('/')
})
it('skips the host-brand lookup when host is empty', async () => {
enqueue({ data: [] })
const dest = await resolveLandingDestination(client, 'user-1', '')
expect(dest).toBe('/')
expect(resolveBrandByHostMock).not.toHaveBeenCalled()
})
it('degrades to / when the membership query errors', async () => {
enqueue({ data: null, error: { message: 'boom' } })
const dest = await resolveLandingDestination(client, 'user-1', 'app.amnas.se')
expect(dest).toBe('/')
})
// Role gate (2026-08-27): only owner/admin get the automatic cockpit
// landing. Ported from the pre-extraction route tests (PR #1970).
it('byrå owner on the canonical host lands in the cockpit', async () => {
enqueue({ data: [membership('owner')] })
const dest = await resolveLandingDestination(client, 'user-1', 'app.accounted.se')
expect(dest).toBe('/clients')
// The mock returns fixtures regardless of the select string, so pin the
// role column into the query: dropping it would send every owner to '/'
// while these tests stayed green.
expect(findCall('team_members', 'select')?.[0]).toContain('role')
})
it('byrå admin on the canonical host lands in the cockpit', async () => {
enqueue({ data: [membership('admin')] })
const dest = await resolveLandingDestination(client, 'user-1', 'app.accounted.se')
expect(dest).toBe('/clients')
})
it('plain byrå member lands on / like a regular user (role gate)', async () => {
enqueue({ data: [membership('member')] })
const dest = await resolveLandingDestination(client, 'user-1', 'app.accounted.se')
expect(dest).toBe('/')
// No qualifying teams: the brand lookup must not run.
expect(resolveBrandsForTeamsMock).not.toHaveBeenCalled()
})
it('mixed roles: an admin membership still wins the cockpit landing', async () => {
enqueue({ data: [membership('member', 'byra-1'), membership('admin', 'byra-2')] })
const dest = await resolveLandingDestination(client, 'user-1', 'app.accounted.se')
expect(dest).toBe('/clients')
// Only the qualifying team reaches the brand lookup.
expect(resolveBrandsForTeamsMock).toHaveBeenCalledWith(['byra-2'])
})
})
+59
View File
@@ -0,0 +1,59 @@
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) })),
})
}