* 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>
44 lines
1.8 KiB
TypeScript
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 } })
|
|
})
|