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>
115 lines
3.7 KiB
TypeScript
115 lines
3.7 KiB
TypeScript
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
|
|
}
|
|
}
|