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
@@ -25,8 +25,40 @@ vi.mock('@/lib/auth/invite-tokens', () => ({
|
||||
hashInviteToken: vi.fn(),
|
||||
}))
|
||||
|
||||
// Default '/' keeps every pre-WL-14 expectation intact: the helper resolving
|
||||
// '/' is byte-identical to the old hardcoded dashboard redirect.
|
||||
const resolveLandingDestinationMock = vi.fn()
|
||||
vi.mock('@/lib/company/landing-server', () => ({
|
||||
resolveLandingDestination: async (...args: unknown[]) =>
|
||||
(await resolveLandingDestinationMock(...args)) ?? '/',
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
// Shared by the MCP OAuth consent and WL-14 describe blocks: an SSR client
|
||||
// whose team_members lookup finds an existing membership.
|
||||
function clientWithTeamMembership() {
|
||||
const chain: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
select: vi.fn(() => chain),
|
||||
eq: vi.fn(() => chain),
|
||||
limit: vi.fn(() => chain),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: { team_id: 'team-1' }, error: null }),
|
||||
}
|
||||
return {
|
||||
auth: {
|
||||
verifyOtp,
|
||||
exchangeCodeForSession,
|
||||
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }),
|
||||
mfa: {
|
||||
getAuthenticatorAssuranceLevel: vi.fn().mockResolvedValue({ data: null }),
|
||||
listFactors: vi.fn().mockResolvedValue({ data: null }),
|
||||
},
|
||||
},
|
||||
from: vi.fn(() => chain),
|
||||
rpc: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('GET /auth/callback: recovery flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -160,28 +192,6 @@ describe('GET /auth/callback: resuming an MCP OAuth consent flow (issue #1814)',
|
||||
// for a fresh session; anything else still lands on the dashboard.
|
||||
const CONSENT = '/api/mcp-oauth/authorize?response_type=code&state=xyz'
|
||||
|
||||
function clientWithTeamMembership() {
|
||||
const chain: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
select: vi.fn(() => chain),
|
||||
eq: vi.fn(() => chain),
|
||||
limit: vi.fn(() => chain),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: { team_id: 'team-1' }, error: null }),
|
||||
}
|
||||
return {
|
||||
auth: {
|
||||
verifyOtp,
|
||||
exchangeCodeForSession,
|
||||
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }),
|
||||
mfa: {
|
||||
getAuthenticatorAssuranceLevel: vi.fn().mockResolvedValue({ data: null }),
|
||||
listFactors: vi.fn().mockResolvedValue({ data: null }),
|
||||
},
|
||||
},
|
||||
from: vi.fn(() => chain),
|
||||
rpc: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(createServerClient).mockImplementation(() => clientWithTeamMembership() as never)
|
||||
@@ -239,3 +249,53 @@ describe('GET /auth/callback: resuming an MCP OAuth consent flow (issue #1814)',
|
||||
expect(response.headers.get('location')).toBe('http://localhost:3000/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /auth/callback: WL-14 cockpit landing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(createServerClient).mockImplementation(() => clientWithTeamMembership() as never)
|
||||
})
|
||||
|
||||
it('lands byrå staff in the cockpit when the helper resolves /clients', async () => {
|
||||
verifyOtp.mockResolvedValue({ error: null })
|
||||
resolveLandingDestinationMock.mockResolvedValue('/clients')
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/auth/callback?token_hash=abc&type=magiclink',
|
||||
{ headers: { 'x-forwarded-host': 'app.amnas.se' } }
|
||||
)
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.headers.get('location')).toBe('http://localhost:3000/clients')
|
||||
expect(resolveLandingDestinationMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
'app.amnas.se'
|
||||
)
|
||||
})
|
||||
|
||||
it('degrades to the dashboard when the helper throws', async () => {
|
||||
verifyOtp.mockResolvedValue({ error: null })
|
||||
resolveLandingDestinationMock.mockRejectedValue(new Error('brands unavailable'))
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/auth/callback?token_hash=abc&type=magiclink'
|
||||
)
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.headers.get('location')).toBe('http://localhost:3000/')
|
||||
})
|
||||
|
||||
it('never consults the helper when next resumes the MCP OAuth consent flow', async () => {
|
||||
verifyOtp.mockResolvedValue({ error: null })
|
||||
const consent = '/api/mcp-oauth/authorize?response_type=code&state=xyz'
|
||||
|
||||
const request = new NextRequest(
|
||||
`http://localhost:3000/auth/callback?token_hash=abc&type=signup&next=${encodeURIComponent(consent)}`
|
||||
)
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.headers.get('location')).toBe(`http://localhost:3000${consent}`)
|
||||
expect(resolveLandingDestinationMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { hashInviteToken } from '@/lib/auth/invite-tokens'
|
||||
import { INVITE_COOKIE_NAME } from '@/lib/auth/consume-invite-cookie'
|
||||
import { safeReturnTo } from '@/lib/auth/safe-return-to'
|
||||
import { resolveLandingDestination } from '@/lib/company/landing-server'
|
||||
|
||||
/**
|
||||
* The one `next` destination this callback honours for a fresh session: the
|
||||
@@ -240,8 +241,24 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// Redirect to the dashboard (it handles zero-company and incomplete
|
||||
// states), unless the session was created to resume an MCP OAuth
|
||||
// consent flow: that page handles the zero-company state too.
|
||||
redirectPath = resumeOAuth ?? '/'
|
||||
// consent flow: that page handles the zero-company state too. With no
|
||||
// explicit destination, byrå staff on their byrå's home domain land in
|
||||
// the cockpit instead (WL-14): this callback is the OAuth/magic-link
|
||||
// twin of the login page's resolvePostLoginDestination call, covering
|
||||
// only AAL1 sessions (MFA-enrolled users exited to /mfa/verify above,
|
||||
// which applies the same rule). Any failure degrades to '/'.
|
||||
if (resumeOAuth) {
|
||||
redirectPath = resumeOAuth
|
||||
} else {
|
||||
try {
|
||||
const host =
|
||||
request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? ''
|
||||
redirectPath = await resolveLandingDestination(supabase, user.id, host)
|
||||
} catch (err) {
|
||||
console.error('[auth/callback] landing resolution failed:', err)
|
||||
redirectPath = '/'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create redirect and explicitly set auth cookies on the response
|
||||
|
||||
@@ -249,9 +249,13 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
|
||||
return
|
||||
}
|
||||
|
||||
// Always land on the picker after BankID login so the user sees
|
||||
// fresh CompanyRoles fetched during this session's enrichment.
|
||||
router.push('/select-company')
|
||||
// Byrå staff on their byrå's home domain land in the cockpit
|
||||
// (WL-14). Everyone else keeps the picker: landing on
|
||||
// /select-company after BankID is deliberate, so the user sees
|
||||
// fresh CompanyRoles fetched during this session's enrichment
|
||||
// (and any failure inside the helper degrades to it).
|
||||
const dest = await resolvePostLoginDestination()
|
||||
router.push(dest === '/clients' ? '/clients' : '/select-company')
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error('[login] BankID complete error', error)
|
||||
|
||||
@@ -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 } })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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) })),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user