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:
co-authored by
Claude Fable 5
parent
89d0e1b994
commit
52e99295de
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user