Files
accounted/app/api/clients/landing/route.ts
T
MattssonandClaude Fable 5 b30c71086e 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>
2026-08-27 11:49:00 +02:00

44 lines
1.8 KiB
TypeScript

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'
/**
* 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.
*/
export const GET = withRouteContext('clients.landing', async (request, ctx) => {
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) })),
})
return NextResponse.json({ data: { destination } })
})