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:
co-authored by
Claude Fable 5
parent
23776337c8
commit
b30c71086e
@@ -1288,3 +1288,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-26] OAuth consent pre-checks ALL scopes (one-click, list collapsed in details): founder call after the read-only default dead-ended agent flows; defensible because every write is staged for approval, rows stay untickable, grant revocable.
|
||||
[2026-08-26] accounting_method optional with form default (AB=accrual, EF=cash) in CompanySetupSchema/planCompanySetup: founder call to cut agent onboarding input to orgnr + moms period; the default is flagged (accounting_method_defaulted) and must be read back in the preview, never silent.
|
||||
[2026-08-26] Removed the 1580 entry from ACCOUNT_DESCRIPTIONS and flipped AccountNumber name precedence to DB-name-first: the entry falsely labeled 1580 'Fordran for skatt' (tax receivables are 1640/1650; 1580 was traditionally card/coupon receivables, moved by BAS to 1686), and the hardcoded name silently overrode users' own kontoplan names. No replacement entry: 1580 is deliberately off-catalog, so companies with a legacy 1580 now see their own account name.
|
||||
[2026-08-27] Cockpit auto-landing gated to byrå owner/admin (isCockpitLandingRole; landing route + '/' bounce), superseding the 2026-08-05 all-members widening: plain members land like regular users and open the cockpit from the nav; the middleware zero-company steer stays ungated because a member with no client companies has nowhere else to land. Allowlist over role!=='member' so future roles default to the regular landing.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cookies } from 'next/headers'
|
||||
import DashboardContent from '@/components/dashboard/DashboardContent'
|
||||
import { ChecklistSkeleton, PanesSkeleton } from '@/components/dashboard/HemSkeletons'
|
||||
import { COMPANY_PICKED_COOKIE } from '@/lib/company/context'
|
||||
import { isCockpitLandingRole } from '@/lib/company/home-domain'
|
||||
import {
|
||||
getDashboardAuthContext,
|
||||
getDashboardCompanyId,
|
||||
@@ -43,10 +44,12 @@ export default async function DashboardPage() {
|
||||
redirect('/onboarding')
|
||||
}
|
||||
|
||||
// Byrå landing: every byrå team member (owner, admin AND member: widened
|
||||
// from owner/admin on the founder's call 2026-08-05, so invited consultants
|
||||
// land right too) homes to the cockpit, not to an auto-resolved client
|
||||
// company. companyId above can be the middleware's first-membership
|
||||
// Byrå landing: byrå owners/admins home to the cockpit, not to an
|
||||
// auto-resolved client company. Role-gated 2026-08-27 (superseding the
|
||||
// 2026-08-05 all-members widening): plain members land like regular users
|
||||
// and open the cockpit from the nav when they want it; the middleware's
|
||||
// zero-company steer stays ungated since a member with no client companies
|
||||
// has nowhere else to land. companyId above can be the middleware's
|
||||
// fallback (which it also writes back to user_preferences, so the DB can't
|
||||
// tell picked from auto-picked); the session cookie stamped by
|
||||
// setActiveCompany is the explicit-choice signal. Once they enter a client
|
||||
@@ -57,7 +60,11 @@ export default async function DashboardPage() {
|
||||
getDashboardTeamMemberships(),
|
||||
])
|
||||
if (!cookieStore.has(COMPANY_PICKED_COOKIE)) {
|
||||
if (teamMemberships.some((m) => m.teams?.kind === 'byra')) {
|
||||
if (
|
||||
teamMemberships.some(
|
||||
(m) => m.teams?.kind === 'byra' && isCockpitLandingRole(m.role),
|
||||
)
|
||||
) {
|
||||
redirect('/byra')
|
||||
}
|
||||
}
|
||||
@@ -84,8 +91,21 @@ export default async function DashboardPage() {
|
||||
throw new Error(`company_settings fetch failed: ${settingsError.message}`)
|
||||
}
|
||||
|
||||
// If onboarding is not complete, redirect to onboarding
|
||||
// If onboarding is not complete, redirect to onboarding. Exception: a byrå
|
||||
// member who did NOT explicitly pick this company this session goes to the
|
||||
// cockpit instead. The auto-resolved company can be onboarding-incomplete
|
||||
// through no action of theirs (a client mid migration-reset repoints every
|
||||
// member's active_company_id), and the first-run wizard is a dead end for
|
||||
// role 'member': WL-15 refuses client creation and the shell has no nav.
|
||||
// Before the owner/admin landing gate the /byra bounce above shielded every
|
||||
// byrå member from this path; this keeps that shield without the gate.
|
||||
if (!settings?.onboarding_complete) {
|
||||
if (
|
||||
!cookieStore.has(COMPANY_PICKED_COOKIE) &&
|
||||
teamMemberships.some((m) => m.teams?.kind === 'byra')
|
||||
) {
|
||||
redirect('/byra')
|
||||
}
|
||||
redirect('/onboarding')
|
||||
}
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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: '/' } })
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
partitionCompaniesByHomeDomain,
|
||||
isCompanyHomedOnHost,
|
||||
isCockpitLandingRole,
|
||||
resolveLandingPath,
|
||||
type TeamBrandRef,
|
||||
} from '../home-domain'
|
||||
@@ -138,6 +139,25 @@ describe('isCompanyHomedOnHost', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCockpitLandingRole', () => {
|
||||
it('owner gets the automatic cockpit landing', () => {
|
||||
expect(isCockpitLandingRole('owner')).toBe(true)
|
||||
})
|
||||
|
||||
it('admin gets the automatic cockpit landing', () => {
|
||||
expect(isCockpitLandingRole('admin')).toBe(true)
|
||||
})
|
||||
|
||||
it('plain member lands like a regular user', () => {
|
||||
expect(isCockpitLandingRole('member')).toBe(false)
|
||||
})
|
||||
|
||||
it('unknown roles default to the regular landing (allowlist)', () => {
|
||||
expect(isCockpitLandingRole('viewer')).toBe(false)
|
||||
expect(isCockpitLandingRole('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveLandingPath', () => {
|
||||
it("byrå staff on their brand host land in the cockpit", () => {
|
||||
expect(
|
||||
|
||||
@@ -95,11 +95,25 @@ export function isCompanyHomedOnHost(opts: {
|
||||
return opts.companyTeamId === opts.hostBrandTeamId
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a byrå team role gets the AUTOMATIC cockpit landing (2026-08-27:
|
||||
* owner/admin only, superseding the 2026-08-05 all-members widening). Plain
|
||||
* members land like regular users; they can still open the cockpit manually
|
||||
* (nav visibility and access are membership-based, unchanged). Callers drop
|
||||
* non-qualifying memberships BEFORE resolveLandingPath, which stays pure.
|
||||
* Allowlist, not `role !== 'member'`, so any future role defaults to the
|
||||
* regular landing.
|
||||
*/
|
||||
export function isCockpitLandingRole(role: string): boolean {
|
||||
return role === 'owner' || role === 'admin'
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-login landing (WL-14): byrå staff land in the cockpit on the byrå's
|
||||
* home domain: the brand domain when the team has a brand, else the canonical
|
||||
* domain (a byrå team without white label is a valid state, WL-01). Everyone
|
||||
* else keeps today's '/' byte-identically.
|
||||
* else keeps today's '/' byte-identically. Callers pass only memberships that
|
||||
* pass isCockpitLandingRole.
|
||||
*/
|
||||
export function resolveLandingPath(opts: {
|
||||
/** teams.id of the brand serving the current host, null on canonical. */
|
||||
|
||||
@@ -499,8 +499,10 @@ async function updateSessionInner(
|
||||
return supabaseResponse
|
||||
}
|
||||
|
||||
// Byrå team members (any role: widened from owner/admin, founder call
|
||||
// 2026-08-05) with zero client companies (a fresh byrå) home to the
|
||||
// Byrå team members (any role: deliberately NOT gated by
|
||||
// isCockpitLandingRole even after the 2026-08-27 owner/admin landing
|
||||
// gate, because a plain member with zero companies has nowhere else to
|
||||
// land) with zero client companies (a fresh byrå) home to the
|
||||
// EMPTY cockpit, never to the company onboarding wizard: clients are
|
||||
// created from the cockpit, and forcing the wizard here would make a
|
||||
// byrå user create a personal company just to get in. Cockpit-shaped
|
||||
|
||||
Reference in New Issue
Block a user