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:
co-authored by
Claude Fable 5
parent
89d0e1b994
commit
52e99295de
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user