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>
232 lines
8.5 KiB
TypeScript
232 lines
8.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
|
|
const { supabase: serviceSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
|
|
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createServiceClient: () => serviceSupabase,
|
|
}))
|
|
|
|
vi.mock('@/lib/auth/invite-tokens', () => ({
|
|
hashInviteToken: (t: string) => `hash-${t}`,
|
|
}))
|
|
|
|
import {
|
|
acceptPendingInviteByToken,
|
|
acceptPendingTeamInviteByToken,
|
|
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,
|
|
})
|
|
|
|
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()
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
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', () => {
|
|
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 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)
|
|
})
|
|
})
|