diff --git a/DECISIONS.md b/DECISIONS.md index 73f5e852..a92ce399 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -363,3 +363,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(auth)/auth/callback/route.ts b/app/(auth)/auth/callback/route.ts index 1808b409..d726e1ab 100644 --- a/app/(auth)/auth/callback/route.ts +++ b/app/(auth)/auth/callback/route.ts @@ -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 } diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 051b32c2..bee67af4 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -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) { diff --git a/app/(onboarding)/onboarding/page.tsx b/app/(onboarding)/onboarding/page.tsx index 13fa427e..02ca1a6d 100644 --- a/app/(onboarding)/onboarding/page.tsx +++ b/app/(onboarding)/onboarding/page.tsx @@ -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} /> ) } diff --git a/app/(onboarding)/select-company/page.tsx b/app/(onboarding)/select-company/page.tsx index 6a8e8ab2..fcdc2258 100644 --- a/app/(onboarding)/select-company/page.tsx +++ b/app/(onboarding)/select-company/page.tsx @@ -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} /> ) } diff --git a/components/onboarding/BankIdCompanyPicker.tsx b/components/onboarding/BankIdCompanyPicker.tsx index 8902a7d3..6f8d0022 100644 --- a/components/onboarding/BankIdCompanyPicker.tsx +++ b/components/onboarding/BankIdCompanyPicker.tsx @@ -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({

{t('subtitle')}

+ {hasPendingInvite && ( +
+

+ {t('pending_invite_note')} +

+
+ )} + {enrichmentStale && (
diff --git a/components/onboarding/journey/OnboardingJourney.tsx b/components/onboarding/journey/OnboardingJourney.tsx index 1a23c484..84305d19 100644 --- a/components/onboarding/journey/OnboardingJourney.tsx +++ b/components/onboarding/journey/OnboardingJourney.tsx @@ -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 ( ({ + 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 = {}) => ({ + 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) + }) +}) diff --git a/lib/company/pending-invites.ts b/lib/company/pending-invites.ts new file mode 100644 index 00000000..56d8ea18 --- /dev/null +++ b/lib/company/pending-invites.ts @@ -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 { + 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 { + 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 + } +} diff --git a/messages/en.json b/messages/en.json index 67038bc8..1f94a91a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 32d2fbaa..bd5b9249 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",