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:
co-authored by
Claude Fable 5
parent
b30c71086e
commit
a860c690ed
@@ -6,47 +6,29 @@ import {
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset, findCall } = createQueuedMockSupabase()
|
||||
const { supabase, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
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),
|
||||
const resolveLandingDestinationMock = vi.fn()
|
||||
vi.mock('@/lib/company/landing-server', () => ({
|
||||
resolveLandingDestination: (...args: unknown[]) => resolveLandingDestinationMock(...args),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
const noParams = { params: Promise.resolve({}) }
|
||||
|
||||
function authed() {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
resolveBrandByHostMock.mockResolvedValue(null)
|
||||
resolveBrandsForTeamsMock.mockResolvedValue(new Map())
|
||||
})
|
||||
|
||||
function membership(role: string) {
|
||||
return { team_id: 'byra-1', role, teams: { kind: 'byra' } }
|
||||
}
|
||||
|
||||
// The landing rule itself (role gate, brand/host matching, WL-01 canonical
|
||||
// fallback, error degradation) is covered where it lives:
|
||||
// lib/company/__tests__/landing-server.test.ts. This suite covers only the
|
||||
// HTTP wrapper contract.
|
||||
describe('GET /api/clients/landing', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
@@ -54,66 +36,26 @@ describe('GET /api/clients/landing', () => {
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const res = await GET(createMockRequest('/api/clients/landing'), noParams)
|
||||
|
||||
const res = await GET(createMockRequest('/api/clients/landing'))
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('non-byrå user lands on /', async () => {
|
||||
authed()
|
||||
enqueue({ data: [] })
|
||||
it('returns the helper destination, passing the forwarded host and user', async () => {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
resolveLandingDestinationMock.mockResolvedValue('/clients')
|
||||
|
||||
const res = await GET(createMockRequest('/api/clients/landing'), noParams)
|
||||
const res = await GET(
|
||||
createMockRequest('/api/clients/landing', {
|
||||
headers: { 'x-forwarded-host': 'app.amnas.se' },
|
||||
})
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: { destination: string } }>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.destination).toBe('/')
|
||||
})
|
||||
|
||||
it('byrå owner on the canonical host lands in the cockpit', async () => {
|
||||
authed()
|
||||
enqueue({ data: [membership('owner')] })
|
||||
|
||||
const res = await GET(createMockRequest('/api/clients/landing'), noParams)
|
||||
const { body } = await parseJsonResponse<{ data: { destination: string } }>(res)
|
||||
|
||||
expect(body.data.destination).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 () => {
|
||||
authed()
|
||||
enqueue({ data: [membership('admin')] })
|
||||
|
||||
const res = await GET(createMockRequest('/api/clients/landing'), noParams)
|
||||
const { body } = await parseJsonResponse<{ data: { destination: string } }>(res)
|
||||
|
||||
expect(body.data.destination).toBe('/clients')
|
||||
})
|
||||
|
||||
it('plain byrå member lands on / like a regular user (role gate)', async () => {
|
||||
authed()
|
||||
enqueue({ data: [membership('member')] })
|
||||
|
||||
const res = await GET(createMockRequest('/api/clients/landing'), noParams)
|
||||
const { body } = await parseJsonResponse<{ data: { destination: string } }>(res)
|
||||
|
||||
expect(body.data.destination).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 () => {
|
||||
authed()
|
||||
enqueue({ data: [membership('member'), { team_id: 'byra-2', role: 'admin', teams: { kind: 'byra' } }] })
|
||||
|
||||
const res = await GET(createMockRequest('/api/clients/landing'), noParams)
|
||||
const { body } = await parseJsonResponse<{ data: { destination: string } }>(res)
|
||||
|
||||
expect(body.data.destination).toBe('/clients')
|
||||
// Only the qualifying team reaches the brand lookup.
|
||||
expect(resolveBrandsForTeamsMock).toHaveBeenCalledWith(['byra-2'])
|
||||
// No active-company requirement: byrå staff without a company of their
|
||||
// own (the cockpit's primary persona) must still get a destination.
|
||||
expect(resolveLandingDestinationMock).toHaveBeenCalledWith(supabase, 'user-1', 'app.amnas.se')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,43 +1,30 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { resolveBrandByHost } from '@/lib/branding/resolve'
|
||||
import { resolveBrandsForTeams } from '@/lib/branding/team-brands'
|
||||
import { isCockpitLandingRole, resolveLandingPath } from '@/lib/company/home-domain'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { resolveLandingDestination } from '@/lib/company/landing-server'
|
||||
|
||||
/**
|
||||
* GET /api/clients/landing
|
||||
*
|
||||
* Post-login landing decision (WL-14): byrå owners/admins land in the cockpit
|
||||
* ('/clients') when the current 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). Called
|
||||
* by the login and MFA-verify pages when no explicit destination was
|
||||
* requested; any failure degrades to '/' at the caller.
|
||||
* Post-login landing decision (WL-14): thin HTTP wrapper around
|
||||
* resolveLandingDestination (lib/company/landing-server.ts), for the client
|
||||
* auth surfaces (login and MFA-verify pages) that cannot call it in-process.
|
||||
* The helper carries the whole rule, including the owner/admin role gate
|
||||
* (2026-08-27). Called when no explicit destination was requested; any
|
||||
* failure degrades to '/' at the caller.
|
||||
*
|
||||
* Uses requireAuth() directly (the sanctioned withRouteContext opt-out, MFA
|
||||
* still enforced) because the decision needs no active company. Byrå staff
|
||||
* without a company of their own are the cockpit's primary persona and must
|
||||
* still land on /clients; withRouteContext would 4xx them with
|
||||
* COMPANY_CONTEXT_MISSING.
|
||||
*/
|
||||
export const GET = withRouteContext('clients.landing', async (request, ctx) => {
|
||||
export async function GET(request: Request) {
|
||||
const auth = await requireAuth()
|
||||
if (auth.error) return auth.error
|
||||
const { user, supabase } = auth
|
||||
|
||||
const host =
|
||||
request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? ''
|
||||
const hostBrand = host ? await resolveBrandByHost(host) : null
|
||||
|
||||
const { data: memberships } = await ctx.supabase
|
||||
.from('team_members')
|
||||
.select('team_id, role, teams:team_id!inner(kind)')
|
||||
.eq('user_id', ctx.user.id)
|
||||
.eq('teams.kind', 'byra')
|
||||
|
||||
const byraTeamIds = (memberships ?? [])
|
||||
.filter((m) => isCockpitLandingRole(m.role as string))
|
||||
.map((m) => m.team_id as string)
|
||||
if (byraTeamIds.length === 0) {
|
||||
return NextResponse.json({ data: { destination: '/' } })
|
||||
}
|
||||
|
||||
const brandByTeam = await resolveBrandsForTeams(byraTeamIds)
|
||||
const destination = resolveLandingPath({
|
||||
hostBrandTeamId: hostBrand?.teamId ?? null,
|
||||
byraTeams: byraTeamIds.map((id) => ({ teamId: id, hasBrand: brandByTeam.has(id) })),
|
||||
})
|
||||
|
||||
const destination = await resolveLandingDestination(supabase, user.id, host)
|
||||
return NextResponse.json({ data: { destination } })
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user