From a860c690ed4b90f400f86f31085a36c41e860fed Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:37:20 +0200 Subject: [PATCH] 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- .../auth/callback/__tests__/route.test.ts | 104 +++++++++--- app/(auth)/auth/callback/route.ts | 21 ++- app/(auth)/login/login-client.tsx | 10 +- .../clients/landing/__tests__/route.test.ts | 100 +++--------- app/api/clients/landing/route.ts | 55 +++---- lib/company/__tests__/landing-server.test.ts | 148 ++++++++++++++++++ lib/company/landing-server.ts | 59 +++++++ 7 files changed, 357 insertions(+), 140 deletions(-) create mode 100644 lib/company/__tests__/landing-server.test.ts create mode 100644 lib/company/landing-server.ts diff --git a/app/(auth)/auth/callback/__tests__/route.test.ts b/app/(auth)/auth/callback/__tests__/route.test.ts index 7e77654f..dcb46293 100644 --- a/app/(auth)/auth/callback/__tests__/route.test.ts +++ b/app/(auth)/auth/callback/__tests__/route.test.ts @@ -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> = { + 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> = { - 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() + }) +}) diff --git a/app/(auth)/auth/callback/route.ts b/app/(auth)/auth/callback/route.ts index 706995ce..60d07119 100644 --- a/app/(auth)/auth/callback/route.ts +++ b/app/(auth)/auth/callback/route.ts @@ -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 diff --git a/app/(auth)/login/login-client.tsx b/app/(auth)/login/login-client.tsx index b020df49..57b86013 100644 --- a/app/(auth)/login/login-client.tsx +++ b/app/(auth)/login/login-client.tsx @@ -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) diff --git a/app/api/clients/landing/__tests__/route.test.ts b/app/api/clients/landing/__tests__/route.test.ts index 3c973772..68144524 100644 --- a/app/api/clients/landing/__tests__/route.test.ts +++ b/app/api/clients/landing/__tests__/route.test.ts @@ -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') }) }) diff --git a/app/api/clients/landing/route.ts b/app/api/clients/landing/route.ts index db358a96..9e5cdf15 100644 --- a/app/api/clients/landing/route.ts +++ b/app/api/clients/landing/route.ts @@ -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 } }) -}) +} diff --git a/lib/company/__tests__/landing-server.test.ts b/lib/company/__tests__/landing-server.test.ts new file mode 100644 index 00000000..63d96904 --- /dev/null +++ b/lib/company/__tests__/landing-server.test.ts @@ -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']) + }) +}) diff --git a/lib/company/landing-server.ts b/lib/company/landing-server.ts new file mode 100644 index 00000000..fa606231 --- /dev/null +++ b/lib/company/landing-server.ts @@ -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(({ 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) })), + }) +}