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
+126 -2
View File
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase()
const { supabase: serviceSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => serviceSupabase,
@@ -11,7 +11,11 @@ vi.mock('@/lib/auth/invite-tokens', () => ({
hashInviteToken: (t: string) => `hash-${t}`,
}))
import { acceptPendingInviteByToken, hasPendingInviteForEmail } from '../pending-invites'
import {
acceptPendingInviteByToken,
acceptPendingTeamInviteByToken,
hasPendingInviteForEmail,
} from '../pending-invites'
const user = { id: 'user-1', email: 'invitee@test.se' }
@@ -28,6 +32,17 @@ const pendingInvite = (overrides: Record<string, unknown> = {}) => ({
...overrides,
})
const teamInvite = (overrides: Record<string, unknown> = {}) => ({
id: 'ti-1',
team_id: 'team-1',
email: 'invitee@test.se',
role: 'admin',
status: 'pending',
expires_at: futureIso(),
teams: { name: 'Byrån', kind: 'byra' },
...overrides,
})
beforeEach(() => {
vi.clearAllMocks()
reset()
@@ -87,6 +102,102 @@ describe('acceptPendingInviteByToken', () => {
enqueue({ error: { code: '42501', message: 'denied' } })
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(false)
})
it('falls back to the team invite when the token is not a company invite', async () => {
enqueue({ data: null, error: { message: 'not found' } }) // company lookup miss
enqueue({ data: teamInvite() }) // team invitation lookup
enqueue({}) // team_members insert
enqueue({ data: { active_company_id: 'existing-co' } }) // prefs already set: no company lookup
enqueue({}) // team_invitations status update
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(true)
})
})
describe('acceptPendingTeamInviteByToken', () => {
it('accepts a valid byrå-team invite and points a company-less user at the first company', async () => {
enqueue({ data: teamInvite() }) // team invitation lookup
enqueue({}) // team_members insert
enqueue({ data: { active_company_id: null } }) // prefs: none set
enqueue({ data: { id: 'co-1' } }) // first company lookup
enqueue({}) // prefs upsert
enqueue({}) // team_invitations status update
await expect(acceptPendingTeamInviteByToken(user, 'tok')).resolves.toEqual({
status: 'accepted',
teamId: 'team-1',
teamName: 'Byrån',
})
})
it('accepts a byrå with no companies without setting an active company', async () => {
enqueue({ data: teamInvite() })
enqueue({}) // team_members insert
enqueue({ data: { active_company_id: null } }) // prefs: none set
enqueue({ data: null }) // no company for the team
enqueue({}) // team_invitations status update
await expect(acceptPendingTeamInviteByToken(user, 'tok')).resolves.toEqual({
status: 'accepted',
teamId: 'team-1',
teamName: 'Byrån',
})
})
it('caps the role: a member invite never becomes owner', async () => {
enqueue({ data: teamInvite({ role: 'owner' }) })
enqueue({}) // insert
enqueue({ data: { active_company_id: 'existing' } })
enqueue({}) // status update
await acceptPendingTeamInviteByToken(user, 'tok')
// The insert wrote 'member' (owner is never granted via invite).
expect(findCalls('team_members', 'insert')[0]?.[0]).toMatchObject({ role: 'member' })
})
it('reports already_member on a duplicate membership (23505)', async () => {
enqueue({ data: teamInvite() })
enqueue({ error: { code: '23505', message: 'duplicate' } })
await expect(acceptPendingTeamInviteByToken(user, 'tok')).resolves.toEqual({
status: 'already_member',
})
})
it('reports expired and marks the invite expired', async () => {
enqueue({ data: teamInvite({ expires_at: pastIso() }) })
enqueue({}) // status = expired update
await expect(acceptPendingTeamInviteByToken(user, 'tok')).resolves.toEqual({
status: 'expired',
})
})
it('reports wrong_email when the account is not the invited one', async () => {
enqueue({ data: teamInvite({ email: 'other@test.se' }) })
await expect(acceptPendingTeamInviteByToken(user, 'tok')).resolves.toEqual({
status: 'wrong_email',
})
})
it('reports invalid for a non-byrå team, a missing row, or a non-pending invite', async () => {
enqueue({ data: teamInvite({ teams: { name: 'X', kind: 'personal' } }) })
await expect(acceptPendingTeamInviteByToken(user, 'tok')).resolves.toEqual({ status: 'invalid' })
enqueue({ data: null })
await expect(acceptPendingTeamInviteByToken(user, 'tok')).resolves.toEqual({ status: 'invalid' })
enqueue({ data: teamInvite({ status: 'accepted' }) })
await expect(acceptPendingTeamInviteByToken(user, 'tok')).resolves.toEqual({ status: 'invalid' })
})
it('reports error on a non-duplicate membership insert failure', async () => {
enqueue({ data: teamInvite() })
enqueue({ error: { code: '42501', message: 'denied' } })
await expect(acceptPendingTeamInviteByToken(user, 'tok')).resolves.toEqual({ status: 'error' })
})
it('reports invalid when the user has no email', async () => {
await expect(acceptPendingTeamInviteByToken({ id: 'u' }, 'tok')).resolves.toEqual({
status: 'invalid',
})
})
})
describe('hasPendingInviteForEmail', () => {
@@ -100,7 +211,20 @@ describe('hasPendingInviteForEmail', () => {
await expect(hasPendingInviteForEmail('invitee@test.se')).resolves.toBe(false)
})
it('returns true when only a byrå-team invite exists (company table empty)', async () => {
enqueue({ data: [] }) // company invites: none
enqueue({ data: [{ id: 'ti-1' }] }) // team invites: one pending
await expect(hasPendingInviteForEmail('invitee@test.se')).resolves.toBe(true)
})
it('returns false when neither table has a pending invite', async () => {
enqueue({ data: [] }) // company
enqueue({ data: [] }) // team
await expect(hasPendingInviteForEmail('invitee@test.se')).resolves.toBe(false)
})
it('returns false when the query errors', async () => {
enqueue({ data: null, error: { message: 'boom' } })
enqueue({ data: null, error: { message: 'boom' } })
await expect(hasPendingInviteForEmail('invitee@test.se')).resolves.toBe(false)
})
+176 -11
View File
@@ -49,7 +49,12 @@ export async function acceptPendingInviteByToken(
new Date(invite.expires_at) < new Date() ||
user.email.toLowerCase() !== invite.email.toLowerCase()
) {
return false
// Not a (valid) company invite for this token: it may be a byrå-team
// invite. Trying the team path here is what lets /onboarding and
// /select-company heal a byrå invitee who reached them without
// membership, instead of funneling them into creating a company.
const teamOutcome = await acceptPendingTeamInviteByToken(user, token)
return teamOutcome.status === 'accepted' || teamOutcome.status === 'already_member'
}
const { error: memberError } = await serviceClient.from('company_members').insert({
@@ -89,24 +94,184 @@ export async function acceptPendingInviteByToken(
}
/**
* True when a pending, unexpired invitation exists for this email.
* Invitation emails are lowercased at creation (invite route Zod schema),
* so the lowercase equality match is exact. Used to tell an invitee who
* arrived without the invite token ("go open the link in the email")
* apart from a genuine first-time user. Never throws.
* Outcome of a byrå-team invite acceptance attempt. The route maps each to an
* HTTP status; the callback and the onboarding recovery treat `accepted` and
* `already_member` as success (the user is in the team either way).
*/
export async function hasPendingInviteForEmail(email: string): Promise<boolean> {
export type TeamInviteAcceptOutcome =
| { status: 'accepted'; teamId: string; teamName: string | null }
| { status: 'already_member' }
| { status: 'invalid' }
| { status: 'expired' }
| { status: 'wrong_email' }
| { status: 'error' }
/**
* Accept a pending byrå-team invitation from a raw `gnubok-invite-token`.
*
* The single server-side implementation of team-invite acceptance, shared by
* POST /api/team/accept, the auth callback, and the onboarding/select-company
* recovery nets. Before this existed, only the route understood
* `team_invitations`, so a byrå staffer who signed up with email+password
* (hosted requires email confirmation, so the register page never gets a
* session to run its client-side accept) had their invite accepted by no
* server path before the dashboard, and landed on /onboarding as an apparent
* first-timer.
*
* Mirrors the company-invite acceptance above: requires a pending, unexpired
* invitation on a team whose `kind='byra'`, whose email matches the
* authenticated user. Inserts `team_members` with the capped role (invites
* never mint owners), points a company-less user at the team's first
* non-archived company, and marks the invite accepted. Never throws.
*/
export async function acceptPendingTeamInviteByToken(
user: AuthUserLike,
token: string,
): Promise<TeamInviteAcceptOutcome> {
if (!user.email) return { status: 'invalid' }
try {
const serviceClient = createServiceClient()
const { data } = await serviceClient
const tokenHash = hashInviteToken(token)
const { data: inviteRaw } = 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 invite = inviteRaw as unknown as {
id: string
team_id: string
email: string
role: string
status: string
expires_at: string
teams: { name: string; kind: string } | null
} | null
// Kind gate mirrors the route: invitations exist for byrå teams only. A
// personal-team token (or a team reverted after issue) is indistinguishable
// from an invalid token on purpose.
if (!invite || invite.teams?.kind !== 'byra' || invite.status !== 'pending') {
return { status: 'invalid' }
}
if (new Date(invite.expires_at) < new Date()) {
await serviceClient
.from('team_invitations')
.update({ status: 'expired' })
.eq('id', invite.id)
return { status: 'expired' }
}
if (user.email.toLowerCase() !== invite.email.toLowerCase()) {
return { status: 'wrong_email' }
}
// Invitations never mint team owners (the invite route's schema forbids it;
// re-checked here against hand-edited rows).
const memberRole = invite.role === 'admin' ? 'admin' : 'member'
const { error: memberError } = await serviceClient.from('team_members').insert({
team_id: invite.team_id,
user_id: user.id,
role: memberRole,
})
if (memberError) {
// 23505 = already a member. Left unsettled to preserve the route's
// long-standing 409 contract; the caller still treats it as success
// because the membership exists.
if (memberError.code === '23505') return { status: 'already_member' }
console.error('[pending-invites] team membership insert failed', memberError)
return { status: 'error' }
}
// 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', invite.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('[pending-invites] failed to set active company', prefError)
}
}
}
await serviceClient
.from('team_invitations')
.update({ status: 'accepted' })
.eq('id', invite.id)
return {
status: 'accepted',
teamId: invite.team_id,
teamName: invite.teams?.name ?? null,
}
} catch (err) {
console.error('[pending-invites] team acceptance retry failed', err)
return { status: 'error' }
}
}
/**
* True when a pending, unexpired invitation exists for this email, in EITHER
* the company or the byrå-team invite table. Invitation emails are lowercased
* at creation (invite route Zod schema), so the lowercase equality match is
* exact. Used to tell an invitee who arrived without the invite token ("go
* open the link in the email") apart from a genuine first-time user. Never
* throws.
*/
export async function hasPendingInviteForEmail(email: string): Promise<boolean> {
const normalized = email.trim().toLowerCase()
const nowIso = new Date().toISOString()
try {
const serviceClient = createServiceClient()
const { data: companyInvites } = await serviceClient
.from('company_invitations')
.select('id')
.eq('email', email.trim().toLowerCase())
.eq('email', normalized)
.eq('status', 'pending')
.gt('expires_at', new Date().toISOString())
.gt('expires_at', nowIso)
.limit(1)
return (data ?? []).length > 0
if ((companyInvites ?? []).length > 0) return true
// Byrå-team invites are the other kind an invitee can arrive on: without
// this a tokenless byrå invitee is misread as a first-timer and funneled
// into creating a company.
const { data: teamInvites } = await serviceClient
.from('team_invitations')
.select('id')
.eq('email', normalized)
.eq('status', 'pending')
.gt('expires_at', nowIso)
.limit(1)
return (teamInvites ?? []).length > 0
} catch (err) {
console.error('[pending-invites] pending lookup failed', err)
return false