From b30c71086e833a87b9557f17177bfd5419864e7d Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:49:00 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + app/(dashboard)/page.tsx | 32 ++++- .../clients/landing/__tests__/route.test.ts | 119 ++++++++++++++++++ app/api/clients/landing/route.ts | 13 +- lib/company/__tests__/home-domain.test.ts | 20 +++ lib/company/home-domain.ts | 16 ++- lib/supabase/middleware.ts | 6 +- 7 files changed, 193 insertions(+), 14 deletions(-) create mode 100644 app/api/clients/landing/__tests__/route.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index f746f4c6..e750b812 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1288,3 +1288,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index 0d074d4a..e286d688 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -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') } diff --git a/app/api/clients/landing/__tests__/route.test.ts b/app/api/clients/landing/__tests__/route.test.ts new file mode 100644 index 00000000..3c973772 --- /dev/null +++ b/app/api/clients/landing/__tests__/route.test.ts @@ -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']) + }) +}) diff --git a/app/api/clients/landing/route.ts b/app/api/clients/landing/route.ts index cfbc42c5..db358a96 100644 --- a/app/api/clients/landing/route.ts +++ b/app/api/clients/landing/route.ts @@ -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: '/' } }) } diff --git a/lib/company/__tests__/home-domain.test.ts b/lib/company/__tests__/home-domain.test.ts index 0f043a06..e2574137 100644 --- a/lib/company/__tests__/home-domain.test.ts +++ b/lib/company/__tests__/home-domain.test.ts @@ -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( diff --git a/lib/company/home-domain.ts b/lib/company/home-domain.ts index 4ba89fab..5c691ab5 100644 --- a/lib/company/home-domain.ts +++ b/lib/company/home-domain.ts @@ -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. */ diff --git a/lib/supabase/middleware.ts b/lib/supabase/middleware.ts index c4ce5442..776662bf 100644 --- a/lib/supabase/middleware.ts +++ b/lib/supabase/middleware.ts @@ -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