diff --git a/DECISIONS.md b/DECISIONS.md index d5d48f4d..4a06fb7e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1420,7 +1420,9 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-31] SKV broker hardening (two skeptic refutations on PR #1757, same classes as the bank fixes): the token route's code exchange now requires a verified connector state (signature, key, svc 'skv') plus an existing pending row before spending Arcim's client secret, and a concurrently consumed state withholds the tokens with 409 (SKV has no revoke endpoint; the pair expires unused); refresh requires the presented token's hash to match an ACTIVE ledger row under the presenting key (it was an open refresh oracle for any leaked token); the metering redaction gains a 10+-digit rule because personnummer/orgnr/redovisare12 in SKV data-proxy paths slipped the EB-tuned thresholds and rested in cleartext; authorize-url adopts the countHeldConnections reservation re-count; all SKV base URLs are https-only (loopback excepted). SELF-HOSTING.md's connector section collapsed from three contradictory copies (accreted across the stack merges) to one. [2026-08-31] lib/connect/instance/upstreams.ts reuses lib/entitlements/own-credentials.ts instead of duplicating the own-credentials checks: the seam was forward-ported into the entitlement partition during the stack's bottom-up merge (skeptic refutation on PR #1747), and two copies of "what counts as own credentials" would eventually disagree, splitting the gate from the routing. upstreams.ts re-exports the two functions for its instance-side callers. [2026-08-31] UpgradeNote is self-host-aware (operator-skeptic refutation on PR #1758): on a self-host every UpgradeNote surface is by definition a connector capability (local capabilities are always on), so the component centrally swaps the hosted subscription copy + /settings/billing link for the connector-key note, mirroring CAPABILITY_BLOCKED_MESSAGE_SELF_HOSTED_SV; the SKV connect button tooltip branches the same way. Fixed centrally rather than per panel so the EB/SKV/AGI/reports surfaces can never drift. SOVEREIGN.md updated to the merged reality (infra live, keys not sold, wiring pending). Standing rule reaffirmed: no connector key is issued before the instance-side wiring (PR6b) lands, or a paying customer sees granted capabilities with clients that still call the upstreams directly and fail on missing env credentials. +[2026-09-01] multi_user seat gate enforced app-side via a NEW gated RPC (resolve_active_company_gated) instead of editing resolve_active_company/current_active_company_id: the zero-arg RPC and the RLS twin also run on self-hosted DBs where the paywall must never bite, and the app picks the gated overload only when isMultiUserEnforced(). RLS convergence rides the existing used_fallback write-back. company_capability_config deliberately does not apply to multi_user (no expiry to hang the 20-day grace on). [2026-09-01] EU reverse-charge packs book directly on 4515/4535 instead of adding a 45xx D / 4598 K basbelopp pair (Anders' literal suggestion): same ruta 20/21 outcome, standard BAS practice for a template that owns the cost account anyway, and a 3-business-line pack would return null from convertLibraryToBookingTemplate and silently vanish from the transaction picker. The 4598 motkonto pattern remains the right tool only where the user's own cost account must be preserved (engine-generated bookings, supplier invoices). [2026-09-01] Floating supplier-invoice underlag gets a standing daily reanchor cron (/api/documents/reanchor/cron) instead of another one-off repair migration: prod case 2026-08-28 (kontantmetod payment verifikat, doc eligible on every static condition, inline anchor silently did nothing, no log line recorded why) is the second time a hand-written sweep (20260727180000, 20260824150000) was needed; the inline anchor is best-effort by design, so the retry belongs in infrastructure. anchorSupplierInvoiceDocument also stops claiming success on a zero-row guarded update and logs its silent bail branches. +[2026-09-01] multi_user skeptic fixes: Stripe cancel EXPIRES the multi_user stripe grant instead of deleting it (grace anchor; other grants still deleted per freeze-and-retain); app-side state checks go RPC-first via SECURITY DEFINER company_multi_user_state (capability_grants RLS hides team rows from non-team users, byrå clients would misread as frozen); byra-kind teams get a standing team-scoped multi_user grant via backfill + teams trigger (WL-10 assumption made real; partner billing is out-of-band); PGRST202 on resolution fails OPEN (pre-migration DB has no multi_user rows: gated fallback would freeze all non-owners); /api/v1 got the same dormancy gate as MCP. RLS-level enforcement and the mid-session API fallback write-back window stay v2 follow-ups (documented, same class as pre-existing stale-preference fallback). [2026-09-01] Declined CodeRabbit's UpgradeNote suggestion (PR #1758 follow-up) to append the self-host connector sentence to children instead of replacing them: every caller's children is hosted subscription copy ("... kräver ett abonnemang"), so appending would show subscription wording on a self-host, the exact thing the branch exists to avoid; the "CSV/SIE import stays free" text it cited is a code comment in BankSyncNowButton, not children. Replace-on-self-host stays; a dedicated selfHosted children prop can come when a caller actually needs per-panel reassurance there. [2026-09-01] getConnectorConfig() rebuilds baseUrl as origin + path (userinfo/query/fragment stripped, warn-logged without the raw value): /api/connector/status echoes baseUrl to the operator and the sync/proxy URLs get paths appended, so nothing secret-shaped pasted into GNUBOK_CONNECT_URL may survive; the stripped parts were never meaningful in a base URL. The status route is also Cache-Control: no-store (key prefix + wiring layout out of shared browser caches). diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 70603aa8..21bdf243 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -12,6 +12,10 @@ import { SettingsHotkey } from '@/components/settings/SettingsHotkey' import { SessionTimeoutController } from '@/components/auth/SessionTimeoutController' import { SandboxBanner } from '@/components/dashboard/SandboxBanner' import TrialExpiredDialog from '@/components/billing/TrialExpiredDialog' +import MultiUserGraceBanner from '@/components/billing/MultiUserGraceBanner' +import { resolveDormantCompanyIds } from '@/lib/company/active-company' +import { getMultiUserState } from '@/lib/entitlements/multi-user' +import { createServiceClient } from '@/lib/supabase/server' import { getExtensionNavItems } from '@/lib/extensions/sectors' import { CompanyProvider, type ByraTeamRef } from '@/contexts/CompanyContext' import { ReferenceDataSeed } from '@/components/providers/ReferenceDataSeed' @@ -408,6 +412,62 @@ export default async function DashboardLayout({ const isSandbox = settings?.is_sandbox === true + // Multi-user seat gate, switcher side: which of the user's OTHER companies + // are frozen for them (non-owner membership, multi_user lapsed past grace). + // Zero queries for the common owner-of-everything user; the grants read + // runs only when a non-owner membership exists. + const dormantCompanyIds = await resolveDormantCompanyIds( + supabase, + (allMemberships || []) + .filter((m) => m.companies) + .map((m) => ({ + company_id: m.company_id, + role: m.role as string, + companies: { + team_id: ((m.companies as { team_id?: string | null } | null)?.team_id ?? null), + }, + })), + ) + + // The entitlements-derived multiUser state is computed from the grant rows + // the CALLER can see, and RLS hides team-scoped grants from users outside + // the team (byrå clients): re-verify any non-entitled answer through the + // SECURITY DEFINER state RPC before acting on it. One extra round trip only + // in the rare non-entitled case. + const activeMultiUser = + entitlements.multiUser.state === 'entitled' + ? entitlements.multiUser + : await getMultiUserState(supabase, companyId) + + // Grace countdown banner data: only while the ACTIVE company is in its + // 20-day window AND actually has affected people (>= 1 non-owner member). + // Service client because other members' emails are not readable through + // the caller's RLS (same reason as GET /api/company/members). + let graceBanner: { graceEndsAt: string; affectedEmails: string[]; isAffectedUser: boolean } | null = + null + if (!isSandbox && activeMultiUser.state === 'grace' && activeMultiUser.graceEndsAt) { + const serviceClient = await createServiceClient() + const { data: memberRows } = await serviceClient + .from('company_members') + .select('user_id, role') + .eq('company_id', companyId) + const affected = (memberRows || []).filter((m) => m.role !== 'owner') + if (affected.length > 0) { + const { data: affectedProfiles } = await serviceClient + .from('profiles') + .select('id, email') + .in('id', affected.map((a) => a.user_id)) + const emailById = new Map((affectedProfiles || []).map((p) => [p.id, p.email as string | null])) + graceBanner = { + graceEndsAt: activeMultiUser.graceEndsAt, + affectedEmails: affected + .map((a) => emailById.get(a.user_id)) + .filter((e): e is string => !!e), + isAffectedUser: affected.some((a) => a.user_id === user.id), + } + } + } + // Client-driven UI preferences (sidebar collapse + fold state). Read here // so the shell renders at the right width on first paint; the nav toggles // flip the data attribute client-side and persist via /api/user/ui-state. @@ -458,6 +518,8 @@ export default async function DashboardLayout({ trialEndsAt: entitlements.trialEndsAt, entitlementState: entitlements.entitlementState, trialExpiredAt: entitlements.trialExpiredAt, + multiUser: activeMultiUser, + lockedCompanyIds: [...dormantCompanyIds], } // Signpost gate (WL-01): a company is opened ONLY on its home domain. When @@ -511,6 +573,14 @@ export default async function DashboardLayout({ Hoppa till innehåll {isSandbox && } + {graceBanner && ( + + )} ({ requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), })) +// Multi-user seat gate: mocked so the queued table mock's enqueue order stays +// untouched for the pre-existing tests; the gate's own behavior is covered in +// lib/entitlements/__tests__/multi-user.test.ts. +const getMultiUserStateMock = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/entitlements/multi-user', () => ({ + getMultiUserState: (...args: unknown[]) => getMultiUserStateMock(...args), +})) + vi.mock('@/lib/supabase/server', () => ({ createServiceClient: () => serviceSupabase, })) @@ -87,6 +95,7 @@ beforeEach(() => { error: null, }) requireWriteMock.mockResolvedValue({ ok: true }) + getMultiUserStateMock.mockResolvedValue({ state: 'entitled', graceEndsAt: null }) isConfiguredMock.mockReturnValue(true) sendEmailMock.mockResolvedValue({ success: true, messageId: 'msg-1' }) brandSenderMock.getSenderForCompany.mockResolvedValue({ @@ -133,6 +142,38 @@ describe('POST /api/company/members/invite', () => { expect(body.error).toBe('Behörighet saknas.') }) + it('blocks invites with 403 + upsell when multi_user is frozen', async () => { + getMultiUserStateMock.mockResolvedValue({ state: 'frozen', graceEndsAt: null }) + enqueue({ data: { role: 'owner' } }) // caller membership + + const { status, body } = await parseJsonResponse<{ + error: string + capability_blocked: boolean + capability: string + }>(await post({ email: 'x@y.se' })) + + expect(status).toBe(403) + expect(body.error).toBe('Bjud in fler personer med betald plan.') + expect(body.capability_blocked).toBe(true) + expect(body.capability).toBe('multi_user') + expect(sendEmailMock).not.toHaveBeenCalled() + }) + + it('still allows invites during the post-lapse grace window', async () => { + getMultiUserStateMock.mockResolvedValue({ + state: 'grace', + graceEndsAt: new Date(Date.now() + 5 * 86_400_000).toISOString(), + }) + enqueue({ data: { role: 'owner' } }) // caller membership + enqueue({ data: [] }) // existing members + enqueue({ data: null }) // existing invite + enqueue({ data: { name: 'Acme AB' } }) // company name + enqueue({ data: null }) // insert invitation + + const { status } = await parseJsonResponse(await post({ email: 'x@y.se' })) + expect(status).toBe(200) + }) + it('rejects an invalid email with 400', async () => { enqueue({ data: { role: 'owner' } }) const { status } = await parseJsonResponse(await post({ email: 'not-an-email' })) diff --git a/app/api/company/members/invite/route.ts b/app/api/company/members/invite/route.ts index f9a0dcf9..09a702e1 100644 --- a/app/api/company/members/invite/route.ts +++ b/app/api/company/members/invite/route.ts @@ -6,6 +6,8 @@ import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { generateInviteToken, getInviteExpiry } from '@/lib/auth/invite-tokens' import { getErrorMessage } from '@/lib/errors/get-error-message' +import { CAPABILITY } from '@/lib/entitlements/keys' +import { getMultiUserState } from '@/lib/entitlements/multi-user' import { getEmailService } from '@/lib/email/service' import { getSenderForCompany, getBaseUrlForBrand } from '@/lib/email/brand-sender' import { @@ -70,6 +72,24 @@ export const POST = withRouteContext( return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 }) } + // Multi-user seat gate: inviting more people requires the multi_user + // capability (paid plan or trial). A company in its post-lapse grace + // window may still invite (its people were promised 20 undisturbed + // days); only the frozen state blocks. Same envelope shape as + // capabilityBlockedResponse so the UI upsells consistently. + const multiUserAccess = await getMultiUserState(serviceClient, companyId) + if (multiUserAccess.state === 'frozen') { + return NextResponse.json( + { + error: 'Bjud in fler personer med betald plan.', + error_en: 'Invite more people with a paid plan.', + capability_blocked: true, + capability: CAPABILITY.multi_user, + }, + { status: 403 }, + ) + } + const validation = await validateBody(request, InviteSchema, { log, operation: 'company_members.invite', diff --git a/app/api/company/members/route.ts b/app/api/company/members/route.ts index 45a636cd..747fda3c 100644 --- a/app/api/company/members/route.ts +++ b/app/api/company/members/route.ts @@ -1,6 +1,7 @@ import { createServiceClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { withRouteContext } from '@/lib/api/with-route-context' +import { getMultiUserState } from '@/lib/entitlements/multi-user' /** * GET /api/company/members @@ -60,6 +61,13 @@ export const GET = withRouteContext('company_members.list', async (_request, ctx const currentMember = members?.find((m) => m.user_id === user.id) const canInvite = currentMember?.role === 'owner' || currentMember?.role === 'admin' + // Multi-user seat gate: when the company is frozen (no multi_user grant + // active or in grace), the invite POST answers 403, so the UI swaps the + // invite form for the paid-plan upsell instead of a dead form. + const multiUserAccess = canInvite + ? await getMultiUserState(serviceClient, companyId) + : null + return NextResponse.json({ data: { members: (members || []).map((m) => ({ @@ -73,6 +81,7 @@ export const GET = withRouteContext('company_members.list', async (_request, ctx })), invitations: invitations || [], canInvite, + inviteRequiresUpgrade: multiUserAccess?.state === 'frozen', }, }) }) diff --git a/app/api/notifications/multi-user-grace/cron/route.ts b/app/api/notifications/multi-user-grace/cron/route.ts new file mode 100644 index 00000000..af4f0a24 --- /dev/null +++ b/app/api/notifications/multi-user-grace/cron/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { withCronContext } from '@/lib/api/with-cron-context' +import { createServiceClient } from '@/lib/supabase/server' +import { runMultiUserGraceReminders } from '@/lib/notifications/multi-user-grace' + +ensureInitialized() + +/** + * GET /api/notifications/multi-user-grace/cron, daily 07:00 UTC. + * + * Mails company owners when the multi_user grace window opens (their extra + * members will pause in 20 days) and again on its last day. Windowed on the + * grant expiry timestamps so a once-daily run sends each mail exactly once; + * see lib/notifications/multi-user-grace.ts. + */ +export const GET = withCronContext('cron.multi_user_grace', async (_request, ctx) => { + const supabase = createServiceClient() + const summary = await runMultiUserGraceReminders(supabase, new Date()) + + ctx.log.info('multi-user grace reminder summary', { ...summary }) + + return NextResponse.json({ success: true, ...summary }) +}) + +export const POST = GET + +/** A grants scan plus a handful of emails. */ +export const maxDuration = 300 diff --git a/app/paused/page.tsx b/app/paused/page.tsx new file mode 100644 index 00000000..ac5c2277 --- /dev/null +++ b/app/paused/page.tsx @@ -0,0 +1,77 @@ +import { redirect } from 'next/navigation' +import { getTranslations } from 'next-intl/server' +import { Lock } from 'lucide-react' +import { createClient } from '@/lib/supabase/server' +import { getActiveCompanyId } from '@/lib/company/context' +import { PausedSignOutButton } from './sign-out-button' + +/** + * Multi-user seat gate: the landing for a user whose EVERY membership is + * frozen (non-owner in companies whose multi_user entitlement lapsed past + * its 20-day grace). Middleware routes here instead of onboarding, so a + * locked-out colleague is told what happened and who can fix it rather than + * being walked into creating a pointless company. + * + * Self-healing on purpose: the page re-runs the gated resolution first, so + * the moment an owner pays, a reload lands the user straight back in the + * company. Nothing here is stateful. + */ +export default async function PausedPage() { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) redirect('/login') + + // Anything accessible after all (an owner upgraded, or the user was made + // owner somewhere) leaves this page immediately. + const companyId = await getActiveCompanyId(supabase, user.id).catch(() => null) + if (companyId) redirect('/') + + const { data: memberships } = await supabase + .from('company_members') + .select('company_id, companies:company_id(name, archived_at)') + .eq('user_id', user.id) + type Row = { company_id: string; companies: { name: string; archived_at: string | null } | null } + const rows = ((memberships ?? []) as unknown as Row[]).filter( + (m) => m.companies && m.companies.archived_at === null, + ) + + // Current display names (company_settings.company_name; companies.name is + // frozen at creation). RLS scopes the read to the user's own companies. + const { data: settingsNames } = await supabase + .from('company_settings') + .select('company_id, company_name') + .in('company_id', rows.map((m) => m.company_id)) + const nameByCompany = new Map( + (settingsNames ?? []).map((s) => [s.company_id, s.company_name as string | null]), + ) + const companyNames = rows.map( + (m) => nameByCompany.get(m.company_id) || m.companies?.name || '', + ) + + // No memberships at all means this page is the wrong destination. + if (companyNames.length === 0) redirect('/') + + const t = await getTranslations('paused') + + return ( +
+
+
+
+

{t('title')}

+

+ {companyNames.length === 1 + ? t('body_single', { companyName: companyNames[0] }) + : t('body_multiple', { companyNames: companyNames.join(', ') })} +

+

{t('body_action')}

+
+ +
+
+
+ ) +} diff --git a/app/paused/sign-out-button.tsx b/app/paused/sign-out-button.tsx new file mode 100644 index 00000000..76f7c6af --- /dev/null +++ b/app/paused/sign-out-button.tsx @@ -0,0 +1,22 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { createClient } from '@/lib/supabase/client' +import { Button } from '@/components/ui/button' + +/** Sign-out affordance for the paused page: back to login as another user. */ +export function PausedSignOutButton({ label }: { label: string }) { + const router = useRouter() + + async function handleSignOut() { + const supabase = createClient() + await supabase.auth.signOut() + router.push('/login') + } + + return ( + + ) +} diff --git a/components/billing/MultiUserGraceBanner.tsx b/components/billing/MultiUserGraceBanner.tsx new file mode 100644 index 00000000..c4797bd0 --- /dev/null +++ b/components/billing/MultiUserGraceBanner.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { useTranslations } from 'next-intl' + +/** + * Multi-user grace countdown: shown to EVERYONE in a company whose + * multi_user entitlement has lapsed but is still inside its 20-day grace + * window, and only when the company actually has affected people (at least + * one non-owner member). The affected member reads their own version; the + * owner (and everyone else) reads which accounts pause and when. + * + * Server-gated: the dashboard layout renders this only in the grace state + * with a non-empty affected list, so the component itself only formats. + * Same chrome treatment as SandboxBanner: environment notice on secondary, + * never a warning fill (status colors are data, not chrome). + */ +export function MultiUserGraceBanner({ + graceEndsAt, + affectedEmails, + isAffectedUser, + companyName, +}: { + graceEndsAt: string + affectedEmails: string[] + /** True when the signed-in user is one of the accounts that will pause. */ + isAffectedUser: boolean + companyName: string +}) { + const t = useTranslations('multi_user') + + // Computed in an effect so server and client markup agree at hydration; + // an hourly tick keeps a long-lived tab honest (same pattern as + // SubscriptionTouchpoint). + const [daysLeft, setDaysLeft] = useState(null) + useEffect(() => { + const update = () => { + const msLeft = new Date(graceEndsAt).getTime() - Date.now() + setDaysLeft(Math.max(0, Math.ceil(msLeft / 86_400_000))) + } + update() + const id = setInterval(update, 3_600_000) + return () => clearInterval(id) + }, [graceEndsAt]) + + if (daysLeft === null) return null + + const message = isAffectedUser + ? t('banner_affected', { companyName, days: daysLeft }) + : t('banner_owner', { emails: affectedEmails.join(', '), days: daysLeft }) + + return ( +
+ {message} + + {t('banner_cta')} + +
+ ) +} + +export default MultiUserGraceBanner diff --git a/components/dashboard/CompanySwitcher.tsx b/components/dashboard/CompanySwitcher.tsx index cd0389c6..b032839e 100644 --- a/components/dashboard/CompanySwitcher.tsx +++ b/components/dashboard/CompanySwitcher.tsx @@ -8,10 +8,10 @@ import { cn } from '@/lib/utils' import { useCompany } from '@/contexts/CompanyContext' import { performCompanySwitch } from '@/lib/company/switch-client' import { useToast } from '@/components/ui/use-toast' -import { Check, ChevronsUpDown, Plus, Loader2 } from 'lucide-react' +import { Check, ChevronsUpDown, Plus, Loader2, Lock } from 'lucide-react' export default function CompanySwitcher() { - const { company, companies, isSandbox, foreignCompanies = [] } = useCompany() + const { company, companies, isSandbox, foreignCompanies = [], lockedCompanyIds = [] } = useCompany() const t = useTranslations('company_switcher') const { toast } = useToast() const [open, setOpen] = useState(false) @@ -87,7 +87,13 @@ export default function CompanySwitcher() { if (result?.error) { setIsPending(false) toast({ - title: t(result.error === 'not_member' ? 'error_no_access' : 'error_switch_failed'), + title: t( + result.error === 'not_member' + ? 'error_no_access' + : result.error === 'company_locked' + ? 'error_locked' + : 'error_switch_failed', + ), variant: 'destructive', }) } @@ -146,7 +152,23 @@ export default function CompanySwitcher() { )}
- {companies.map(({ company: c, role }) => ( + {companies.map(({ company: c, role }) => + lockedCompanyIds.includes(c.id) ? ( + // Multi-user seat gate: frozen for this user until the + // company pays. Shown, not hidden: the membership exists + // and the row explains why it cannot be entered. +
+ + {c.name} + {t('locked_note')} + +
+ ) : ( - ))} + ), + )}
)} diff --git a/components/settings/CompanyMembersSection.tsx b/components/settings/CompanyMembersSection.tsx index 3a0f915d..ce198126 100644 --- a/components/settings/CompanyMembersSection.tsx +++ b/components/settings/CompanyMembersSection.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useCallback } from 'react' +import Link from 'next/link' import { useLocale, useTranslations } from 'next-intl' import { AttnLine } from '@/components/ui/attn-line' import { Button } from '@/components/ui/button' @@ -81,6 +82,10 @@ export function CompanyMembersSection() { const [removingId, setRemovingId] = useState(null) const [revokingId, setRevokingId] = useState(null) const [canInvite, setCanInvite] = useState(false) + // Multi-user seat gate: true when inviting requires the paid plan + // (multi_user frozen). The form is swapped for the upsell line; the POST's + // own 403 stays the real enforcement. + const [inviteRequiresUpgrade, setInviteRequiresUpgrade] = useState(false) const [shareInvite, setShareInvite] = useState(null) const fetchMembers = useCallback(async () => { @@ -118,6 +123,7 @@ export function CompanyMembersSection() { setMembers(parsed.members) setInvitations(parsed.invitations) setCanInvite(parsed.canInvite) + setInviteRequiresUpgrade(parsed.inviteRequiresUpgrade) } catch { setMembers(null) setInvitations(null) @@ -374,8 +380,22 @@ export function CompanyMembersSection() { )} + {/* Multi-user seat gate: no invite form without the paid plan. One + muted sentence with the billing link, not a dead form. */} + {canInvite && inviteRequiresUpgrade && ( +

+ {t('members_invite_upgrade')}{' '} + + {t('members_invite_upgrade_cta')} + +

+ )} + {/* Inline invite: the list's own last row instead of a separate card. */} - {canInvite && ( + {canInvite && !inviteRequiresUpgrade && (