Files
accounted/app/api/team/accept/route.ts
T
Mattsson 52e99295de 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>
2026-08-27 23:08:04 +02:00

233 lines
7.8 KiB
TypeScript

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
team_id: string
email: string
role: string
status: string
expires_at: string
teams: { name: string; kind: string } | null
}
/**
* GET /api/team/accept?token=xxx
* Validates an invite token and returns invite info (for the invite page).
* Handles both company invitations and byrå-team invitations (WL-08 invite
* unfreeze). Team invitations resolve only for teams with kind='byra':
* personal teams are uninvitable, so a token pointing at one is invalid.
* No auth required: this is a public endpoint.
*/
export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get('token')
if (!token) {
return NextResponse.json({ error: 'Token saknas.' }, { status: 400 })
}
const tokenHash = hashInviteToken(token)
const serviceClient = createServiceClient()
const { data: companyInvite } = await serviceClient
.from('company_invitations')
.select('id, email, status, expires_at, company_id, companies:company_id(name)')
.eq('token_hash', tokenHash)
.single()
if (companyInvite) {
if (companyInvite.status !== 'pending') {
return NextResponse.json({ error: 'Inbjudan har redan använts.' }, { status: 410 })
}
const expired = new Date(companyInvite.expires_at) < new Date()
const { data: alreadyHasAccount } = await serviceClient.rpc('check_email_exists', {
email_to_check: companyInvite.email,
})
return NextResponse.json({
data: {
type: 'company',
companyName: (companyInvite.companies as unknown as { name: string })?.name || 'Företag',
email: companyInvite.email,
expired,
alreadyHasAccount,
},
})
}
// 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: invitations exist for byrå teams only. A personal-team token
// (or a team whose kind was reverted after issue) is indistinguishable from
// an invalid token on purpose.
if (!teamInvite || teamInvite.teams?.kind !== 'byra') {
return NextResponse.json({ error: 'Inbjudan hittades inte eller är ogiltig.' }, { status: 404 })
}
if (teamInvite.status !== 'pending') {
return NextResponse.json({ error: 'Inbjudan har redan använts.' }, { status: 410 })
}
const expired = new Date(teamInvite.expires_at) < new Date()
const { data: alreadyHasAccount } = await serviceClient.rpc('check_email_exists', {
email_to_check: teamInvite.email,
})
const teamName = teamInvite.teams?.name || 'Team'
return NextResponse.json({
data: {
type: 'team',
// companyName doubles as "what you are joining" for the invite page,
// which renders it for every invite type: kept for compatibility.
companyName: teamName,
teamName,
email: teamInvite.email,
expired,
alreadyHasAccount,
},
})
}
/**
* POST /api/team/accept
* Accepts a company or byrå-team invite after the user has signed up.
*
* Team acceptance inserts a team_members row; the DB sync trigger
* (sync_team_member_to_companies) then grants membership in every company
* attached to the team, so no company_members writes happen here.
*/
export async function POST(request: NextRequest) {
const { user, error } = await requireAuth()
if (error) return error
const body = await request.json()
const token = body.token as string
if (!token) {
return NextResponse.json({ error: 'Token saknas.' }, { status: 400 })
}
const tokenHash = hashInviteToken(token)
const serviceClient = createServiceClient()
const { data: companyInvite, error: companyLookupError } = await serviceClient
.from('company_invitations')
.select('id, company_id, email, role, status, expires_at')
.eq('token_hash', tokenHash)
.single()
if (companyLookupError && companyLookupError.code !== 'PGRST116') {
console.error('[team/accept] company lookup error:', companyLookupError.message)
}
if (companyInvite) {
return acceptCompanyInvite(serviceClient, user, companyInvite)
}
// 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 })
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 })
}
}
/** The pre-existing company-invite acceptance flow, unchanged. */
async function acceptCompanyInvite(
serviceClient: ReturnType<typeof createServiceClient>,
user: { id: string; email?: string | null },
companyInvite: {
id: string
company_id: string
email: string
role: string
status: string
expires_at: string
},
) {
if (companyInvite.status !== 'pending') {
return NextResponse.json({ error: 'Inbjudan är ogiltig.' }, { status: 400 })
}
if (new Date(companyInvite.expires_at) < new Date()) {
await serviceClient
.from('company_invitations')
.update({ status: 'expired' })
.eq('id', companyInvite.id)
return NextResponse.json({ error: 'Inbjudan har gått ut.' }, { status: 410 })
}
if (user.email?.toLowerCase() !== companyInvite.email.toLowerCase()) {
return NextResponse.json({ error: 'E-postadressen matchar inte inbjudan.' }, { status: 403 })
}
// Add user to company
const { error: memberError } = await serviceClient
.from('company_members')
.insert({
company_id: companyInvite.company_id,
user_id: user.id,
role: companyInvite.role,
source: 'direct',
})
if (memberError) {
if (memberError.code === '23505') {
return NextResponse.json({ error: 'Du är redan medlem.' }, { status: 409 })
}
return NextResponse.json({ error: 'Kunde inte lägga till medlem.' }, { status: 500 })
}
// Set active company. Non-fatal on failure: the membership insert already
// succeeded and middleware falls back to it, but log so silent
// persistence failures (#701) are observable.
const { error: prefError } = await serviceClient
.from('user_preferences')
.upsert({
user_id: user.id,
active_company_id: companyInvite.company_id,
}, { onConflict: 'user_id' })
if (prefError) {
console.error('[team/accept] failed to set active company', prefError)
}
// Mark invite as accepted
await serviceClient
.from('company_invitations')
.update({ status: 'accepted' })
.eq('id', companyInvite.id)
return NextResponse.json({
data: { type: 'company', companyId: companyInvite.company_id },
})
}