From aabddb592f50b546a6bb2491d80e16545026edef Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:29:12 +0200 Subject: [PATCH] feat(billing): multi-user paywall: multi_user capability, 20-day grace, owner-only dormancy (#2099) * feat(billing): multi-user seat gate: multi_user capability, 20-day grace, owner-only dormancy Multiple people in one company becomes a paid capability (multi_user, the eighth PAID key). Derived at access time from capability_grants, no status column, no enforcement cron: - entitled: active grant (trial/stripe/team/manual/comp), everyone works - grace: newest grant expired < 20 days ago; countdown banner for everyone in companies with > 1 user; invites still allowed - frozen: only role=owner resolves; other memberships go dormant (rows untouched, paying reactivates instantly); invites 403 with paid-plan upsell Enforcement: new resolve_active_company_gated RPC (zero-arg RPC and RLS twin untouched: they also run on self-hosts, where the gate never bites), gated query fallback for service-role/API-key paths, setActiveCompany guard, MCP company-access check, invite route. Middleware routes all-frozen users to a new /paused page; the switcher greys locked companies. Migration 20260901081417 (applied to staging): trial trigger seeds multi_user, backfills for mid-trial companies, active Stripe subs, team agreements, and a grandfather grant (expires now, i.e. grace = deploy + 20 days) for existing unpaid multi-member companies. Daily cron mails owners at grace start and last day. Strings in sv+en; pg-real + unit tests included. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP * fix(billing): multi-user seat gate hardening from skeptic review - Stripe cancel now EXPIRES the multi_user stripe grant instead of deleting it: the 20-day grace window hangs on an expired row, so a deleted one froze churned payers' staff instantly with no banner and no mail. Other stripe grants keep the freeze-and-retain delete. - New SECURITY DEFINER company_multi_user_state() RPC (migration 20260901083726, applied to staging) and RPC-first getMultiUserState: capability_grants RLS hides team-scoped rows from non-team users, so user-client reads misread byra-covered companies as frozen (switch refusal, wrong switcher locks). - Byra-kind teams get a standing team-scoped multi_user grant (backfill + teams trigger): byra client companies have no company-scoped trial by design, so a grantless byra team would freeze every consultant and client user. - Comped/manual companies with active PAID-key grants extend to multi_user (a comped company must not read as paying while locking out user two). - /api/v1 gets the same dormancy gate as MCP (frozen non-owner -> 403). - PGRST202 on resolution fails OPEN (pre-migration DB has zero multi_user rows; the gated fallback would have frozen every non-owner mid-deploy). - Grace cron: covers team-scoped lapses (byra agreement ending) and skips the start mail for the hand-mailed grandfather cohort. - Tests updated/added across all touched surfaces; pg tests for the new RPC and byra trigger; trial-suppression pg test extended to 8 keys. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP * fix(billing): decouple seat-gate env check and fail open on gate read throws CI round 1 on #2099: - isMultiUserEnforced no longer imports has-capability: several route test suites partially mock that module and the vitest mock guard threw from inside the v1 seat gate, turning expected 4xx responses into 500s. multi_user is never a connector capability, so the bypass reduces to the same env reads, now inlined. - getMultiUserState wraps its resolution in a fail-open try/catch: a client without .rpc or a thrown network error must never lock users out. - no-phantom-columns ceiling 391 -> 393 with reasons: the seat gate's .or() scope filter (server-resolved UUIDs) and the Stripe cancel expiry update's timestamp .or(); all columns in both strings are literals. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP * fix(billing): membership-guard the multi-user entitlement RPCs (Superagent P3) company_multi_user_ok and company_multi_user_state are SECURITY DEFINER and were granted to authenticated with a caller-supplied company UUID: any logged-in user could probe an arbitrary company's billing state and grace deadline across tenants. Migration 20260901091752 (applied to staging) requires an auth.uid() membership in the target company when a JWT is present, keeps service-role/definer contexts unrestricted, and clamps the grace window to [0, 20] days. pg tests: stranger gets false/NULL, member reads normally, oversized p_grace_days cannot widen the probe. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 2 + app/(dashboard)/layout.tsx | 70 ++++ .../members/invite/__tests__/route.test.ts | 41 +++ app/api/company/members/invite/route.ts | 20 ++ app/api/company/members/route.ts | 9 + .../multi-user-grace/cron/route.ts | 29 ++ app/paused/page.tsx | 77 +++++ app/paused/sign-out-button.tsx | 22 ++ components/billing/MultiUserGraceBanner.tsx | 66 ++++ components/dashboard/CompanySwitcher.tsx | 33 +- components/settings/CompanyMembersSection.tsx | 22 +- .../__tests__/members-payload.test.ts | 20 +- components/settings/members-payload.ts | 12 +- .../sections/BillingSettingsContent.tsx | 2 +- contexts/CompanyContext.tsx | 14 + docker/crontab.hosted | 1 + docker/crontab.self-hosted | 1 + .../__tests__/company-routing.test.ts | 75 ++++- .../__tests__/multi-company-dispatch.test.ts | 12 + .../general/mcp-server/company-routing.ts | 15 + lib/api/v1/__tests__/with-api-v1.test.ts | 80 +++++ lib/api/v1/with-api-v1.ts | 23 ++ lib/company/__tests__/context.test.ts | 96 +++++- lib/company/actions.ts | 5 + lib/company/active-company.ts | 135 +++++++- lib/company/context.ts | 15 +- lib/entitlements/__tests__/multi-user.test.ts | 248 ++++++++++++++ lib/entitlements/has-capability.ts | 43 ++- lib/entitlements/keys.ts | 13 + lib/entitlements/multi-user-state.ts | 75 +++++ lib/entitlements/multi-user.ts | 140 ++++++++ lib/notifications/multi-user-grace.ts | 258 +++++++++++++++ .../__tests__/subscription-sync.test.ts | 39 ++- lib/stripe/subscription-sync.ts | 19 +- lib/supabase/middleware.ts | 62 +++- messages/en.json | 20 +- messages/sv.json | 20 +- .../20260901081417_multi_user_paywall.sql | 246 ++++++++++++++ ...901083726_multi_user_paywall_hardening.sql | 119 +++++++ ...1752_multi_user_state_membership_guard.sql | 94 ++++++ tests/pg/multi-user-paywall.pg.test.ts | 312 ++++++++++++++++++ tests/pg/trial-suppression-byra.pg.test.ts | 5 +- tests/schema/no-phantom-columns.test.ts | 8 +- vercel.json | 4 + 44 files changed, 2573 insertions(+), 49 deletions(-) create mode 100644 app/api/notifications/multi-user-grace/cron/route.ts create mode 100644 app/paused/page.tsx create mode 100644 app/paused/sign-out-button.tsx create mode 100644 components/billing/MultiUserGraceBanner.tsx create mode 100644 lib/entitlements/__tests__/multi-user.test.ts create mode 100644 lib/entitlements/multi-user-state.ts create mode 100644 lib/entitlements/multi-user.ts create mode 100644 lib/notifications/multi-user-grace.ts create mode 100644 supabase/migrations/20260901081417_multi_user_paywall.sql create mode 100644 supabase/migrations/20260901083726_multi_user_paywall_hardening.sql create mode 100644 supabase/migrations/20260901091752_multi_user_state_membership_guard.sql create mode 100644 tests/pg/multi-user-paywall.pg.test.ts 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 && (