fix(white-label): accept byrå-team invites before landing, so admins reach /clients (#2002)

A newly-invited byrå admin/member who signed up with email+password landed
on /onboarding instead of the cockpit. Root cause: team-invite acceptance
lived only in POST /api/team/accept, which the email-confirmation signup flow
never reaches before the dashboard (no session for the register page's
client-side accept), while the auth callback and the onboarding/select-company
recovery only understood company_invitations. So the invitee's byrå membership
did not exist when landing resolved, and they were funneled into creating a
company.

- New shared helper acceptPendingTeamInviteByToken (lib/company/pending-invites)
  is the single server-side implementation of team-invite acceptance.
- POST /api/team/accept delegates to it; HTTP contract unchanged.
- /auth/callback accepts a team invite BEFORE the silent-team check and before
  resolveLandingDestination runs, so an owner/admin resolves to /clients; the
  invite cookie is cleared on success, kept otherwise for the retry.
- acceptPendingInviteByToken (onboarding/select-company recovery) tries the
  company path, then falls back to the team helper.
- hasPendingInviteForEmail checks both invite tables, so a tokenless byrå
  invitee is not misread as a first-timer.

No migration (team invite tables already exist). Company-invite and
non-invite flows are untouched.


Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-27 23:08:04 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 89d0e1b994
commit 52e99295de
6 changed files with 413 additions and 103 deletions
+21 -88
View File
@@ -2,6 +2,7 @@ import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse, type NextRequest } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { hashInviteToken } from '@/lib/auth/invite-tokens'
import { acceptPendingTeamInviteByToken } from '@/lib/company/pending-invites'
interface TeamInviteRow {
id: string
@@ -135,96 +136,28 @@ export async function POST(request: NextRequest) {
return acceptCompanyInvite(serviceClient, user, companyInvite)
}
// No company invitation for this token: try byrå-team invitations.
const { data: teamInviteRaw } = await serviceClient
.from('team_invitations')
.select('id, team_id, email, role, status, expires_at, teams:team_id(name, kind)')
.eq('token_hash', tokenHash)
.single()
const teamInvite = teamInviteRaw as unknown as TeamInviteRow | null
// Kind gate mirrors GET: byrå teams only; anything else is an invalid token.
if (!teamInvite || teamInvite.teams?.kind !== 'byra' || teamInvite.status !== 'pending') {
return NextResponse.json({ error: 'Inbjudan är ogiltig.' }, { status: 400 })
}
if (new Date(teamInvite.expires_at) < new Date()) {
await serviceClient
.from('team_invitations')
.update({ status: 'expired' })
.eq('id', teamInvite.id)
return NextResponse.json({ error: 'Inbjudan har gått ut.' }, { status: 410 })
}
if (user.email?.toLowerCase() !== teamInvite.email.toLowerCase()) {
return NextResponse.json({ error: 'E-postadressen matchar inte inbjudan.' }, { status: 403 })
}
// Invitations never mint team owners (the invite route's schema already
// forbids it; re-checked here against hand-edited rows).
const memberRole = teamInvite.role === 'admin' ? 'admin' : 'member'
const { error: memberError } = await serviceClient
.from('team_members')
.insert({
team_id: teamInvite.team_id,
user_id: user.id,
role: memberRole,
})
if (memberError) {
if (memberError.code === '23505') {
// No company invitation for this token: try byrå-team invitations. The
// acceptance itself lives in the shared helper (lib/company/pending-invites)
// so the callback and onboarding recovery accept team invites the same way;
// this route only maps the outcome onto its long-standing HTTP contract.
const outcome = await acceptPendingTeamInviteByToken(user, token)
switch (outcome.status) {
case 'accepted':
return NextResponse.json({
data: { type: 'team', teamId: outcome.teamId, teamName: outcome.teamName },
})
case 'already_member':
return NextResponse.json({ error: 'Du är redan medlem.' }, { status: 409 })
}
return NextResponse.json({ error: 'Kunde inte lägga till medlem.' }, { status: 500 })
case 'expired':
return NextResponse.json({ error: 'Inbjudan har gått ut.' }, { status: 410 })
case 'wrong_email':
return NextResponse.json({ error: 'E-postadressen matchar inte inbjudan.' }, { status: 403 })
case 'error':
return NextResponse.json({ error: 'Kunde inte lägga till medlem.' }, { status: 500 })
case 'invalid':
default:
return NextResponse.json({ error: 'Inbjudan är ogiltig.' }, { status: 400 })
}
// Point a company-less user at one of the team's companies so their first
// dashboard load resolves. A consultant with their own firma keeps their
// active company untouched: joining a byrå must never hijack the context.
const { data: prefs } = await serviceClient
.from('user_preferences')
.select('active_company_id')
.eq('user_id', user.id)
.maybeSingle()
if (!(prefs as { active_company_id: string | null } | null)?.active_company_id) {
const { data: firstCompany } = await serviceClient
.from('companies')
.select('id')
.eq('team_id', teamInvite.team_id)
.is('archived_at', null)
.order('created_at', { ascending: true })
.limit(1)
.maybeSingle()
if (firstCompany) {
const { error: prefError } = await serviceClient
.from('user_preferences')
.upsert({
user_id: user.id,
active_company_id: (firstCompany as { id: string }).id,
}, { onConflict: 'user_id' })
if (prefError) {
console.error('[team/accept] failed to set active company', prefError)
}
}
}
// Mark invite as accepted
await serviceClient
.from('team_invitations')
.update({ status: 'accepted' })
.eq('id', teamInvite.id)
return NextResponse.json({
data: {
type: 'team',
teamId: teamInvite.team_id,
teamName: teamInvite.teams?.name ?? null,
},
})
}
/** The pre-existing company-invite acceptance flow, unchanged. */