fix(invites): accept invite on BankID signup, recover missed invites on onboarding surfaces (#1157)

An invited user who registered via BankID was funneled into creating a
company instead of joining the one they were invited to: the register
page's BankID path never processed the gnubok-invite-token cookie
(unlike the login, MFA-verify, and auth-callback paths). Observed in
production 2026-07-24.

- register: BankID signup now accepts the pending invite before routing
  to /select-company, mirroring the login page's BankID path.
- lib/company/pending-invites: acceptPendingInviteByToken retries a
  missed acceptance from the cookie (pending + unexpired + email match,
  same rules as POST /api/team/accept); hasPendingInviteForEmail detects
  a stranded invitee whose cookie is gone.
- /onboarding and /select-company retry acceptance from the cookie and
  redirect to the dashboard on success, making the auth callback's
  long-promised fallback real; with no cookie but a pending invitation,
  both surfaces show a 'join via the link in the invitation email' hint
  instead of silently asking the invitee to create a company.
- No new accept path without the token: the hint deliberately points
  back to the mailed link, so mailbox possession stays required and no
  company name is leaked to unverified emails.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-24 19:36:09 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent d3c37dac08
commit 15300aa8e2
11 changed files with 317 additions and 4 deletions
+1
View File
@@ -363,3 +363,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-24] BankID signup email guard = createUser email_exists error mapping, not a profiles.email pre-check: profiles is not a reliable mirror of auth.users (anonymize tombstones scrub it), and the error-based check has no TOCTOU window. Deleted-account emails stay blocked for re-signup by design (tombstone keeps auth.users.email).
[2026-07-24] Journey onboarding stack MERGED to main (#1141, #1145 ex-#1142, #1143) after founder preview click-through + "safe to merge". Production flag NEXT_PUBLIC_ONBOARDING_JOURNEY deliberately NOT set at merge time: BankID roles-prefill path is reducer-tested but not yet live-verified, so the flip is an explicit founder step, followed by one BankID smoke and then PR D (wizard deletion, /companies/new mode='add', picker restyle).
[2026-07-24] Onboarding journey migration COMPLETE with PR #1150: wizard deleted, /companies/new on journey mode='add', BankID picker = searchable list, flag conditional removed (env var cleaned from Vercel post-merge). Bot-review triage: compliance findings on getUser()/redirect()/ensure_user_team skipped as App Router misreadings or pre-existing patterns; fixed the real ones (stale select_company keys, unused hasExistingCompanies plumbing).
[2026-07-24] Invite recovery on onboarding surfaces = cookie retry + hint, NOT accept-by-email: a BankID signup's email is confirmed via a client-delivered magiclink (no mailbox proof), so auto-joining on email match would let anyone who registers the invitee's address claim the membership. The cookie/token path keeps mailbox possession required; cookie-less invitees get pointed back to the mailed link, with no company name leaked.
+3 -2
View File
@@ -200,8 +200,9 @@ export async function GET(request: NextRequest) {
for (const { name, value, options } of pendingCookies) {
response.cookies.set({ name, value, ...options })
}
// Keep the invite cookie alive so the onboarding page fallback can
// retry acceptance (only clear it when successfully processed above).
// Keep the invite cookie alive so the /onboarding and /select-company
// pages can retry acceptance via acceptPendingInviteByToken (only clear
// it when successfully processed above).
return response
}
+28
View File
@@ -151,6 +151,34 @@ function RegisterPageContent() {
return
}
// Invited signup: accept the pending invite before routing to the
// picker, same as the login page's BankID path. Without this, an
// invitee who registers with BankID lands on /select-company with no
// membership and gets funneled into creating a company instead of
// joining the one they were invited to.
const inviteCookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/)
const inviteToken = inviteCookieMatch?.[1]
if (inviteToken) {
try {
const res = await fetch('/api/team/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: inviteToken }),
})
if (res.ok) {
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
window.location.href = '/'
return
}
} catch (err) {
console.error('[register] invite acceptance failed:', err instanceof Error ? err.message : String(err))
}
// Clear cookie even on failure to avoid retrying stale tokens
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
}
router.push('/select-company')
router.refresh()
} catch (error) {
+22
View File
@@ -1,6 +1,11 @@
import { createClient } from '@/lib/supabase/server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import OnboardingJourney from '@/components/onboarding/journey/OnboardingJourney'
import {
acceptPendingInviteByToken,
hasPendingInviteForEmail,
} from '@/lib/company/pending-invites'
import type { EntityType } from '@/types'
import type { EnrichmentCompanyRole } from '@/lib/company-lookup/types'
import { mapEntityType as mapTicEntityType } from '@/lib/company-lookup/entity-type-map'
@@ -53,6 +58,20 @@ export default async function OnboardingPage({
redirect('/login')
}
// Invite recovery: an invitee normally never reaches this page (the client
// flows or the auth callback attach them to the company first), so landing
// here with a live invite cookie means acceptance was missed. Retry it and
// skip onboarding entirely on success; if only a pending invitation exists
// (cookie lost, e.g. confirmation opened on another device), surface a hint
// instead of silently asking the invitee to create a company.
const inviteToken = (await cookies()).get('gnubok-invite-token')?.value
if (inviteToken && (await acceptPendingInviteByToken(user, inviteToken))) {
redirect('/')
}
const hasPendingInvite = user.email
? await hasPendingInviteForEmail(user.email)
: false
const { data: teamMembership } = await supabase
.from('team_members')
.select('team_id')
@@ -101,6 +120,9 @@ export default async function OnboardingPage({
initialOrgNumber={initialOrgNumber}
initialEntityType={initialEntityType}
initialLegalName={initialLegalName}
// A deep-linked orgnr is a deliberate create-this-company pick from the
// BankID list: don't distract that flow with the invite hint.
hasPendingInvite={hasPendingInvite && !initialOrgNumber}
/>
)
}
+20 -2
View File
@@ -1,5 +1,10 @@
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { createClient, createServiceClient } from '@/lib/supabase/server'
import {
acceptPendingInviteByToken,
hasPendingInviteForEmail,
} from '@/lib/company/pending-invites'
import type { EnrichmentCompanyRole } from '@/lib/company-lookup/types'
import BankIdCompanyPicker, {
type MemberCompany,
@@ -18,8 +23,16 @@ export default async function SelectCompanyPage() {
redirect('/login')
}
// All four lookups key only on user.id, one parallel batch instead of
// four serial round-trips on the post-BankID-login landing page.
// Invite recovery, same as /onboarding: BankID users land here, so a missed
// invite acceptance (e.g. a register flow that dropped the cookie handling)
// gets retried before the picker funnels the invitee into creating a company.
const inviteToken = (await cookies()).get('gnubok-invite-token')?.value
if (inviteToken && (await acceptPendingInviteByToken(user, inviteToken))) {
redirect('/')
}
// All lookups key only on user.id/email, one parallel batch instead of
// serial round-trips on the post-BankID-login landing page.
const [
// Existing Accounted memberships.
{ data: memberships },
@@ -30,6 +43,9 @@ export default async function SelectCompanyPage() {
// user-keyed in `bankid_enrichment` because it lands before company
// selection, see fetchAndStoreEnrichment in the tic extension.
{ data: enrichmentRow },
// Pending invitation for this email with no cookie to accept it from:
// rendered as a "check your invite email" hint in the picker.
hasPendingInvite,
] = await Promise.all([
supabase
.from('company_members')
@@ -57,6 +73,7 @@ export default async function SelectCompanyPage() {
.select('company_roles, created_at, updated_at')
.eq('user_id', user.id)
.maybeSingle(),
user.email ? hasPendingInviteForEmail(user.email) : Promise.resolve(false),
])
type CompanyRow = {
@@ -172,6 +189,7 @@ export default async function SelectCompanyPage() {
memberCompanies={memberCompanies}
ticCompanies={ticCompanies}
enrichmentStale={enrichmentStale}
hasPendingInvite={hasPendingInvite}
/>
)
}
@@ -43,6 +43,9 @@ interface BankIdCompanyPickerProps {
memberCompanies: MemberCompany[]
ticCompanies: TicPickerCompany[]
enrichmentStale: boolean
/** A pending invitation exists for this email but no invite token is at
* hand: point the user back to the link in the invitation email. */
hasPendingInvite?: boolean
}
type SetupState = { kind: 'idle' } | { kind: 'opening'; companyId: string }
@@ -78,6 +81,7 @@ export default function BankIdCompanyPicker({
memberCompanies,
ticCompanies,
enrichmentStale,
hasPendingInvite = false,
}: BankIdCompanyPickerProps) {
const router = useRouter()
const { toast } = useToast()
@@ -149,6 +153,14 @@ export default function BankIdCompanyPicker({
<p className="text-muted-foreground text-sm mt-1.5">{t('subtitle')}</p>
</header>
{hasPendingInvite && (
<div className="mb-6 rounded-lg border bg-muted/30 p-3">
<p className="text-sm text-muted-foreground text-center">
{t('pending_invite_note')}
</p>
</div>
)}
{enrichmentStale && (
<div className="mb-6 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/60 dark:bg-amber-950/30">
<div className="flex items-start gap-2.5">
@@ -61,6 +61,10 @@ interface OnboardingJourneyProps {
initialOrgNumber?: string
initialEntityType?: EntityType
initialLegalName?: string
/** A pending invitation exists for this user's email: an invitee has
* likely landed here by mistake (lost invite cookie), so the first
* question carries a "join via the link in the email" hint. */
hasPendingInvite?: boolean
}
export default function OnboardingJourney({
@@ -69,6 +73,7 @@ export default function OnboardingJourney({
initialOrgNumber,
initialEntityType,
initialLegalName,
hasPendingInvite = false,
}: OnboardingJourneyProps) {
const router = useRouter()
const t = useTranslations('onboarding')
@@ -298,6 +303,7 @@ export default function OnboardingJourney({
return (
<Question
title={t('journey_orgnr_title')}
sub={hasPendingInvite ? t('journey_pending_invite_note') : undefined}
attn={
state.serverError === 'org_number_invalid'
? t('journey_err_org_invalid')
@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => serviceSupabase,
}))
vi.mock('@/lib/auth/invite-tokens', () => ({
hashInviteToken: (t: string) => `hash-${t}`,
}))
import { acceptPendingInviteByToken, hasPendingInviteForEmail } from '../pending-invites'
const user = { id: 'user-1', email: 'invitee@test.se' }
const futureIso = () => new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
const pastIso = () => new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
const pendingInvite = (overrides: Record<string, unknown> = {}) => ({
id: 'inv-1',
company_id: 'co-1',
email: 'invitee@test.se',
role: 'admin',
status: 'pending',
expires_at: futureIso(),
...overrides,
})
beforeEach(() => {
vi.clearAllMocks()
reset()
})
describe('acceptPendingInviteByToken', () => {
it('accepts a valid pending invite', async () => {
enqueue({ data: pendingInvite() }) // invitation lookup
enqueue({}) // company_members insert
enqueue({}) // user_preferences upsert
enqueue({}) // invitation status update
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(true)
})
it('treats an existing membership (23505) as fulfilled', async () => {
enqueue({ data: pendingInvite() })
enqueue({ error: { code: '23505', message: 'duplicate' } })
enqueue({})
enqueue({})
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(true)
})
it('rejects when the invitation is not found', async () => {
enqueue({ data: null, error: { message: 'not found' } })
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(false)
})
it('rejects a non-pending invitation', async () => {
enqueue({ data: pendingInvite({ status: 'accepted' }) })
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(false)
})
it('rejects an expired invitation', async () => {
enqueue({ data: pendingInvite({ expires_at: pastIso() }) })
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(false)
})
it('rejects when the email does not match', async () => {
enqueue({ data: pendingInvite({ email: 'other@test.se' }) })
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(false)
})
it('matches emails case-insensitively', async () => {
enqueue({ data: pendingInvite({ email: 'Invitee@Test.se' }) })
enqueue({})
enqueue({})
enqueue({})
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(true)
})
it('rejects when the user has no email', async () => {
await expect(acceptPendingInviteByToken({ id: 'user-1' }, 'tok')).resolves.toBe(false)
})
it('returns false on a non-duplicate membership insert error', async () => {
enqueue({ data: pendingInvite() })
enqueue({ error: { code: '42501', message: 'denied' } })
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(false)
})
})
describe('hasPendingInviteForEmail', () => {
it('returns true when a pending invite row exists', async () => {
enqueue({ data: [{ id: 'inv-1' }] })
await expect(hasPendingInviteForEmail('Invitee@Test.se')).resolves.toBe(true)
})
it('returns false when there are no rows', async () => {
enqueue({ data: [] })
await expect(hasPendingInviteForEmail('invitee@test.se')).resolves.toBe(false)
})
it('returns false when the query errors', async () => {
enqueue({ data: null, error: { message: 'boom' } })
await expect(hasPendingInviteForEmail('invitee@test.se')).resolves.toBe(false)
})
})
+114
View File
@@ -0,0 +1,114 @@
import { createServiceClient } from '@/lib/supabase/server'
import { hashInviteToken } from '@/lib/auth/invite-tokens'
/**
* Invite-recovery helpers for the onboarding surfaces.
*
* An invited user is supposed to be attached to the company by one of the
* client flows (invite page one-click join, login/register/mfa cookie
* handling) or by the auth callback. When all of those miss (confirmation
* link opened on another device, cookie expired, transient failure), the
* user lands on /onboarding or /select-company as an apparent first-timer.
* These helpers let those pages heal the miss instead of funneling a
* confused invitee into creating a company.
*/
interface AuthUserLike {
id: string
email?: string | null
}
/**
* Retry invite acceptance from a raw `gnubok-invite-token` cookie value.
* Mirrors the acceptance in POST /api/team/accept: requires a pending,
* unexpired invitation whose email matches the authenticated user.
* Returns true when the user is now a member of the invited company
* (including the already-a-member case, which it settles by marking the
* invitation accepted). Never throws; failures return false and leave the
* normal onboarding flow to render.
*/
export async function acceptPendingInviteByToken(
user: AuthUserLike,
token: string,
): Promise<boolean> {
if (!user.email) return false
try {
const serviceClient = createServiceClient()
const tokenHash = hashInviteToken(token)
const { data: invite } = await serviceClient
.from('company_invitations')
.select('id, company_id, email, role, status, expires_at')
.eq('token_hash', tokenHash)
.single()
if (
!invite ||
invite.status !== 'pending' ||
new Date(invite.expires_at) < new Date() ||
user.email.toLowerCase() !== invite.email.toLowerCase()
) {
return false
}
const { error: memberError } = await serviceClient.from('company_members').insert({
company_id: invite.company_id,
user_id: user.id,
role: invite.role,
source: 'direct',
})
// 23505 = already a member: the invitation is fulfilled, settle it below.
if (memberError && memberError.code !== '23505') {
console.error('[pending-invites] membership insert failed', memberError)
return false
}
// Non-fatal on failure: middleware falls back to the membership.
const { error: prefError } = await serviceClient
.from('user_preferences')
.upsert(
{ user_id: user.id, active_company_id: invite.company_id },
{ onConflict: 'user_id' },
)
if (prefError) {
console.error('[pending-invites] failed to set active company', prefError)
}
await serviceClient
.from('company_invitations')
.update({ status: 'accepted' })
.eq('id', invite.id)
return true
} catch (err) {
console.error('[pending-invites] acceptance retry failed', err)
return false
}
}
/**
* 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.
*/
export async function hasPendingInviteForEmail(email: string): Promise<boolean> {
try {
const serviceClient = createServiceClient()
const { data } = await serviceClient
.from('company_invitations')
.select('id')
.eq('email', email.trim().toLowerCase())
.eq('status', 'pending')
.gt('expires_at', new Date().toISOString())
.limit(1)
return (data ?? []).length > 0
} catch (err) {
console.error('[pending-invites] pending lookup failed', err)
return false
}
}
+2
View File
@@ -1313,6 +1313,7 @@
"journey_ans_monthly": "Monthly",
"journey_ans_yearly": "Yearly",
"journey_dup_note": "You already have {name} in Accounted.",
"journey_pending_invite_note": "You have a pending invitation to a company. Open the link in the invitation email to join instead.",
"journey_fact_vat": "VAT registered",
"journey_fact_ceased": "Deregistered",
"journey_cancel_add": "Back to Accounted"
@@ -1325,6 +1326,7 @@
"greeting_evening": "Good evening",
"subtitle": "Choose a company to open or add a new one.",
"enrichment_stale": "The information from BankID is more than a week old. Sign in with BankID again to refresh the list.",
"pending_invite_note": "You have a pending invitation to a company. Open the link in the invitation email to join.",
"section_your_companies": "Your companies in {appName}",
"already_in_app": "Already in {appName}",
"setup_manually": "Set up manually",
+2
View File
@@ -1313,6 +1313,7 @@
"journey_ans_monthly": "Månadsvis",
"journey_ans_yearly": "Årsvis",
"journey_dup_note": "Du har redan {name} i Accounted.",
"journey_pending_invite_note": "Du har en väntande inbjudan till ett företag. Öppna länken i inbjudningsmejlet för att gå med istället.",
"journey_fact_vat": "Momsregistrerat",
"journey_fact_ceased": "Avregistrerat",
"journey_cancel_add": "Tillbaka till Accounted"
@@ -1325,6 +1326,7 @@
"greeting_evening": "God kväll",
"subtitle": "Välj ett företag att öppna eller lägg till ett nytt.",
"enrichment_stale": "Uppgifterna från BankID är äldre än en vecka. Logga in med BankID igen för att uppdatera listan.",
"pending_invite_note": "Du har en väntande inbjudan till ett företag. Öppna länken i inbjudningsmejlet för att gå med.",
"section_your_companies": "Dina företag i {appName}",
"already_in_app": "Finns redan i {appName}",
"setup_manually": "Sätts upp manuellt",