feat(byra): gate the automatic cockpit landing to owner/admin (#1970)

* feat(byra): gate the automatic cockpit landing to owner/admin

Plain byra members now land like regular users; owner/admin keep the
cockpit landing at both decision sites (post-login /api/clients/landing
and the '/' bounce). The middleware zero-company steer stays ungated:
a member with zero companies has nowhere else to land. Cockpit access
itself is unchanged (nav + /clients remain membership-based).

Supersedes the 2026-08-05 all-members widening (DECISIONS.md).

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

* fix(byra): keep byra members out of the first-run wizard on auto-landing

Skeptic finding: a member whose auto-resolved active company is
onboarding-incomplete (e.g. mid migration-reset, which repoints
active_company_id itself) fell through the new role gate into
/onboarding, a dead end for role member (WL-15 refuses client
creation). Byra members without a picked-company cookie now go to
/byra at the onboarding check, restoring the pre-gate shield.

Also pins the role column into the landing route's select assertion
so dropping it can't pass the mocked tests silently.

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 11:49:00 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 23776337c8
commit b30c71086e
7 changed files with 193 additions and 14 deletions
@@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
parseJsonResponse,
} from '@/tests/helpers'
const { supabase, enqueue, reset, findCall } = 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),
}))
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' } }
}
describe('GET /api/clients/landing', () => {
it('returns 401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(createMockRequest('/api/clients/landing'), noParams)
expect(res.status).toBe(401)
})
it('non-byrå user lands on /', async () => {
authed()
enqueue({ data: [] })
const res = await GET(createMockRequest('/api/clients/landing'), noParams)
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'])
})
})
+8 -5
View File
@@ -2,15 +2,16 @@ 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 { resolveLandingPath } from '@/lib/company/home-domain'
import { isCockpitLandingRole, resolveLandingPath } from '@/lib/company/home-domain'
/**
* GET /api/clients/landing
*
* Post-login landing decision (WL-14): byrå staff land in the cockpit
* 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). Everyone else gets '/' so their flow stays byte-identical. Called
* (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.
*/
@@ -21,11 +22,13 @@ export const GET = withRouteContext('clients.landing', async (request, ctx) => {
const { data: memberships } = await ctx.supabase
.from('team_members')
.select('team_id, teams:team_id!inner(kind)')
.select('team_id, role, teams:team_id!inner(kind)')
.eq('user_id', ctx.user.id)
.eq('teams.kind', 'byra')
const byraTeamIds = (memberships ?? []).map((m) => m.team_id as string)
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: '/' } })
}