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
@@ -1318,3 +1318,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-27] Added `npm run check:types`, a typecheck ratchet (scripts/checks/no-new-type-errors.mjs + typecheck-baseline.json), wired into the core-build `checks` job next to check:lint. Reason: `npm test` does NOT typecheck. Vitest transpiles and discards types, so a type error passes all 18 000 tests and only surfaces in `npm run build` minutes later; that happened TWICE on 2026-08-27 (a widened errorKind union in the MCP server that lib/events/types.ts still contradicted, and an `interface` that would not assign into `Record<string, unknown>[]` because interfaces have no implicit index signature). It is not merely a faster copy of the build job: `tsc --noEmit` also covers `__tests__` files, which the Next.js build never compiles, and that is where all 539 baseline errors live. Baseline is keyed per FILE, deliberately unlike the per-RULE lint ratchet: the legacy errors are concentrated in a handful of old test files and TS2322 is common enough that a code-keyed budget would silently absorb a real regression somewhere else, whereas per-file trips the moment a previously-clean file gains an error. Verified the gate actually fires by introducing a deliberate `const x: number = 'str'` and watching it fail with the exact location, then restoring. Cost measured: 36 s cold (what CI pays, since tsconfig.tsbuildinfo is gitignored) and 4.4 s warm locally via the existing `incremental: true`. The script sets NODE_OPTIONS=--max-old-space-size=8192 because a bare tsc dies with "Ineffective mark-compacts near heap limit" on this graph after about two minutes, which reads like a hang rather than a misconfiguration; it also detects that OOM string and exits 2 with a "raise HEAP_MB" message rather than silently reporting zero errors. NOT changed: Definition of Done item 1 still says only lint + test. CI enforcement is the stronger mechanism and does not need the policy edit; adding it to DoD is a founder call.
|
||||
[2026-08-27] Dropped VAT cadence localStorage persistence from PR #1998 (kept the settings-row gate): skeptic pass refuted it twice (SSR hydration mismatch from render-phase localStorage read; persisting a cadence that deviates from moms_period keeps the filing pipeline open on the wrong period type with no downstream period-type validation). The mount-time re-seed from moms_period is the self-healing control; FyPicker already persists the rakenskapsar pick.
|
||||
[2026-08-27] Invite-only brand signup ships accepting a low-severity allowlist enumeration residual: POST /api/auth/signup returns 403 for a non-allowlisted email vs 200/400 for an allowlisted one, and the 403 short-circuits before GoTrue, so it is captcha-free and unthrottled: someone with candidate emails can test which are on a brand's allowlist. Not closed because (a) the app deliberately never holds the Turnstile secret (it lives in Supabase/GoTrue; a repo test forbids TURNSTILE_SECRET_KEY in app env), and (b) the clear "you're not invited, go to Accounted" redirect UX inherently reveals the verdict. It leaks membership of guessed emails, not the list, and no ledger/credential data. Follow-up option if it matters later: add signup-endpoint rate limiting. The related fail-OPEN (a brands-table error was read as unbranded, opening invite-only signup during a DB blip) WAS fixed: the gate now returns lookupFailed and both signup routes answer 503.
|
||||
[2026-08-27] Byrå-team invite acceptance was implemented only in POST /api/team/accept, which the email+password signup flow never reaches before the dashboard (hosted requires email confirmation, so the register page gets no session to run its client-side accept, and the auth callback + onboarding recovery only knew company_invitations). A new byrå admin therefore landed on /onboarding instead of /clients. Fix: one shared server helper acceptPendingTeamInviteByToken (lib/company/pending-invites.ts), called by the route (unchanged HTTP contract), the auth callback (accepts BEFORE landing resolves, so resolveLandingDestination sees the membership and sends admins to /clients; cookie cleared on success), and acceptPendingInviteByToken (onboarding/select-company recovery, tries company then team). hasPendingInviteForEmail now checks both invite tables. No migration.
|
||||
|
||||
@@ -25,6 +25,12 @@ vi.mock('@/lib/auth/invite-tokens', () => ({
|
||||
hashInviteToken: vi.fn(),
|
||||
}))
|
||||
|
||||
const acceptPendingTeamInviteByTokenMock = vi.fn()
|
||||
vi.mock('@/lib/company/pending-invites', () => ({
|
||||
acceptPendingTeamInviteByToken: (...args: unknown[]) =>
|
||||
acceptPendingTeamInviteByTokenMock(...args),
|
||||
}))
|
||||
|
||||
// Default '/' keeps every pre-WL-14 expectation intact: the helper resolving
|
||||
// '/' is byte-identical to the old hardcoded dashboard redirect.
|
||||
const resolveLandingDestinationMock = vi.fn()
|
||||
@@ -299,3 +305,52 @@ describe('GET /auth/callback: WL-14 cockpit landing', () => {
|
||||
expect(resolveLandingDestinationMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /auth/callback: byrå-team invite acceptance', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(createServerClient).mockImplementation(() => clientWithTeamMembership() as never)
|
||||
acceptPendingTeamInviteByTokenMock.mockResolvedValue({ status: 'invalid' })
|
||||
})
|
||||
|
||||
it('accepts a byrå-team invite from the cookie, lands the admin in /clients, and clears the cookie', async () => {
|
||||
verifyOtp.mockResolvedValue({ error: null })
|
||||
acceptPendingTeamInviteByTokenMock.mockResolvedValue({
|
||||
status: 'accepted',
|
||||
teamId: 'team-1',
|
||||
teamName: 'Byrån',
|
||||
})
|
||||
// Membership now exists, so the landing helper resolves the cockpit.
|
||||
resolveLandingDestinationMock.mockResolvedValue('/clients')
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/auth/callback?token_hash=abc&type=signup',
|
||||
{ headers: { cookie: 'gnubok-invite-token=gnubok_inv_team', 'x-forwarded-host': 'app.amnas.se' } }
|
||||
)
|
||||
const response = await GET(request)
|
||||
|
||||
expect(acceptPendingTeamInviteByTokenMock).toHaveBeenCalledWith(
|
||||
{ id: 'user-1', email: undefined },
|
||||
'gnubok_inv_team'
|
||||
)
|
||||
expect(response.headers.get('location')).toBe('http://localhost:3000/clients')
|
||||
// Membership exists now, so the cookie is cleared instead of left for a
|
||||
// retry that would only 409.
|
||||
expect(response.headers.get('set-cookie') ?? '').toContain('gnubok-invite-token=;')
|
||||
})
|
||||
|
||||
it('keeps the cookie for the onboarding retry when the team invite is not (yet) accepted', async () => {
|
||||
verifyOtp.mockResolvedValue({ error: null })
|
||||
acceptPendingTeamInviteByTokenMock.mockResolvedValue({ status: 'invalid' })
|
||||
resolveLandingDestinationMock.mockResolvedValue('/')
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/auth/callback?token_hash=abc&type=signup',
|
||||
{ headers: { cookie: 'gnubok-invite-token=gnubok_inv_team' } }
|
||||
)
|
||||
const response = await GET(request)
|
||||
|
||||
// Not consumed: the cookie is not actively deleted here (no max-age=0).
|
||||
expect(response.headers.get('set-cookie') ?? '').not.toContain('gnubok-invite-token=;')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { hashInviteToken } from '@/lib/auth/invite-tokens'
|
||||
import { INVITE_COOKIE_NAME } from '@/lib/auth/consume-invite-cookie'
|
||||
import { safeReturnTo } from '@/lib/auth/safe-return-to'
|
||||
import { resolveLandingDestination } from '@/lib/company/landing-server'
|
||||
import { acceptPendingTeamInviteByToken } from '@/lib/company/pending-invites'
|
||||
|
||||
/**
|
||||
* The one `next` destination this callback honours for a fresh session: the
|
||||
@@ -71,6 +72,9 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (authenticated) {
|
||||
let redirectPath = next
|
||||
// Set once a byrå-team invite is accepted below, so the final response
|
||||
// clears the invite cookie instead of leaving it for a redundant retry.
|
||||
let inviteConsumed = false
|
||||
|
||||
// Password recovery flow: the user just exchanged a recovery token, so they
|
||||
// have a fresh session whose only purpose is to call updateUser({ password })
|
||||
@@ -203,6 +207,29 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Byrå-TEAM invite: the company-invite block above only knows
|
||||
// company_invitations, so a byrå staffer's invite was accepted by no
|
||||
// server path before landing resolved, and they were funneled to
|
||||
// /onboarding as a first-timer. Accept it here, BEFORE the silent-team
|
||||
// check (so no stray "Personal" team is minted) and BEFORE landing
|
||||
// resolves, so resolveLandingDestination sees the byrå membership and
|
||||
// sends an owner/admin to /clients. Company-invite and non-invite flows
|
||||
// are untouched. On success the cookie is cleared on the final response;
|
||||
// otherwise it survives for the /onboarding + /select-company retry.
|
||||
if (inviteToken && !inviteConsumed) {
|
||||
try {
|
||||
const outcome = await acceptPendingTeamInviteByToken(
|
||||
{ id: user.id, email: user.email },
|
||||
inviteToken,
|
||||
)
|
||||
if (outcome.status === 'accepted' || outcome.status === 'already_member') {
|
||||
inviteConsumed = true
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[auth/callback] team invite acceptance failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure user has a silent team (for new signups and existing users without one)
|
||||
const { data: teamMembership } = await supabase
|
||||
.from('team_members')
|
||||
@@ -267,8 +294,13 @@ export async function GET(request: NextRequest) {
|
||||
response.cookies.set({ name, value, ...options })
|
||||
}
|
||||
// Keep the invite cookie alive so the /onboarding and /select-company
|
||||
// pages can retry acceptance via acceptPendingInviteByToken (only clear
|
||||
// it when successfully processed above).
|
||||
// pages can retry acceptance via acceptPendingInviteByToken, UNLESS a team
|
||||
// invite was just accepted above (then the membership exists and a retry
|
||||
// would only 409). The company-invite success path returns earlier and
|
||||
// clears the cookie itself.
|
||||
if (inviteConsumed) {
|
||||
response.cookies.delete('gnubok-invite-token')
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { hashInviteToken } from '@/lib/auth/invite-tokens'
|
||||
import { acceptPendingTeamInviteByToken } from '@/lib/company/pending-invites'
|
||||
|
||||
interface TeamInviteRow {
|
||||
id: string
|
||||
@@ -135,96 +136,28 @@ export async function POST(request: NextRequest) {
|
||||
return acceptCompanyInvite(serviceClient, user, companyInvite)
|
||||
}
|
||||
|
||||
// No company invitation for this token: try byrå-team invitations.
|
||||
const { data: teamInviteRaw } = 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 teamInvite = teamInviteRaw as unknown as TeamInviteRow | null
|
||||
|
||||
// Kind gate mirrors GET: byrå teams only; anything else is an invalid token.
|
||||
if (!teamInvite || teamInvite.teams?.kind !== 'byra' || teamInvite.status !== 'pending') {
|
||||
return NextResponse.json({ error: 'Inbjudan är ogiltig.' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (new Date(teamInvite.expires_at) < new Date()) {
|
||||
await serviceClient
|
||||
.from('team_invitations')
|
||||
.update({ status: 'expired' })
|
||||
.eq('id', teamInvite.id)
|
||||
return NextResponse.json({ error: 'Inbjudan har gått ut.' }, { status: 410 })
|
||||
}
|
||||
|
||||
if (user.email?.toLowerCase() !== teamInvite.email.toLowerCase()) {
|
||||
return NextResponse.json({ error: 'E-postadressen matchar inte inbjudan.' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Invitations never mint team owners (the invite route's schema already
|
||||
// forbids it; re-checked here against hand-edited rows).
|
||||
const memberRole = teamInvite.role === 'admin' ? 'admin' : 'member'
|
||||
|
||||
const { error: memberError } = await serviceClient
|
||||
.from('team_members')
|
||||
.insert({
|
||||
team_id: teamInvite.team_id,
|
||||
user_id: user.id,
|
||||
role: memberRole,
|
||||
})
|
||||
|
||||
if (memberError) {
|
||||
if (memberError.code === '23505') {
|
||||
// No company invitation for this token: try byrå-team invitations. The
|
||||
// acceptance itself lives in the shared helper (lib/company/pending-invites)
|
||||
// so the callback and onboarding recovery accept team invites the same way;
|
||||
// this route only maps the outcome onto its long-standing HTTP contract.
|
||||
const outcome = await acceptPendingTeamInviteByToken(user, token)
|
||||
switch (outcome.status) {
|
||||
case 'accepted':
|
||||
return NextResponse.json({
|
||||
data: { type: 'team', teamId: outcome.teamId, teamName: outcome.teamName },
|
||||
})
|
||||
case 'already_member':
|
||||
return NextResponse.json({ error: 'Du är redan medlem.' }, { status: 409 })
|
||||
}
|
||||
return NextResponse.json({ error: 'Kunde inte lägga till medlem.' }, { status: 500 })
|
||||
case 'expired':
|
||||
return NextResponse.json({ error: 'Inbjudan har gått ut.' }, { status: 410 })
|
||||
case 'wrong_email':
|
||||
return NextResponse.json({ error: 'E-postadressen matchar inte inbjudan.' }, { status: 403 })
|
||||
case 'error':
|
||||
return NextResponse.json({ error: 'Kunde inte lägga till medlem.' }, { status: 500 })
|
||||
case 'invalid':
|
||||
default:
|
||||
return NextResponse.json({ error: 'Inbjudan är ogiltig.' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 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', teamInvite.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('[team/accept] failed to set active company', prefError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark invite as accepted
|
||||
await serviceClient
|
||||
.from('team_invitations')
|
||||
.update({ status: 'accepted' })
|
||||
.eq('id', teamInvite.id)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
type: 'team',
|
||||
teamId: teamInvite.team_id,
|
||||
teamName: teamInvite.teams?.name ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** The pre-existing company-invite acceptance flow, unchanged. */
|
||||
|
||||
@@ -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