From 0b0d7b6cf04975fd284a30b132b87a2900f7ae65 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Tue, 24 Mar 2026 21:41:38 +0100 Subject: [PATCH] Block app access until organizations add a paid plan --- .../__tests__/middlewares/middlewares.test.ts | 90 ++++++++++++++++++ apps/api/src/__tests__/routes/tablo.test.ts | 14 +++ apps/api/src/middlewares/middleware.ts | 46 ++++++++++ apps/api/src/routers/tablo.ts | 28 ++++-- apps/api/src/routers/tablo_data.ts | 33 +++++-- apps/main/src/components/TrialUpsellModal.tsx | 4 +- apps/main/src/components/UpgradePanel.tsx | 92 ++++++++++++------- .../main/src/contexts/UpgradeBlockContext.tsx | 24 ++++- apps/main/src/hooks/stripe.test.ts | 40 ++++++++ apps/main/src/hooks/stripe.ts | 46 ++++++++-- apps/main/src/hooks/tasks.ts | 11 +++ .../2026-03-24-no-plan-subscription-gate.md | 76 +++++++++++++++ 12 files changed, 437 insertions(+), 67 deletions(-) create mode 100644 apps/main/src/hooks/stripe.test.ts create mode 100644 docs/superpowers/plans/2026-03-24-no-plan-subscription-gate.md diff --git a/apps/api/src/__tests__/middlewares/middlewares.test.ts b/apps/api/src/__tests__/middlewares/middlewares.test.ts index 71072d6..c18bf7e 100644 --- a/apps/api/src/__tests__/middlewares/middlewares.test.ts +++ b/apps/api/src/__tests__/middlewares/middlewares.test.ts @@ -24,6 +24,42 @@ describe("Middleware Tests", () => { }), }); + const createBillingStateSupabaseMock = (input: { + ownerPlan?: string | null; + }) => { + const ownerPlan = input.ownerPlan ?? "none"; + + return { + from: vi.fn((table: string) => { + if (table === "profiles") { + return { + select: vi.fn((selectArg: string) => { + if (selectArg.includes("organization_id")) { + return { + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: { organization_id: 1 }, + error: null, + }), + }), + }; + } + + return { + eq: vi.fn().mockResolvedValue({ + data: [{ plan: ownerPlan }], + error: null, + }), + }; + }), + }; + } + + throw new Error(`Unexpected table ${table}`); + }), + }; + }; + describe("Supabase Middleware", () => { it("should inject supabase client into context", async () => { const app = new Hono(); @@ -337,6 +373,60 @@ describe("Middleware Tests", () => { }); }); + describe("Active Plan Access Middleware", () => { + it("should reject requests when the organization has no active plan", async () => { + const app = new Hono(); + app.use(async (c, next) => { + // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection + (c as any).set( + "supabase", + createBillingStateSupabaseMock({ + ownerPlan: "none", + }) as any + ); + // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection + (c as any).set("user", { id: "owner-user" } as any); + await next(); + }); + app.use(middlewareManager.activePlanAccess); + app.post("/test", (c) => c.json({ success: true })); + + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + const client = testClient(app) as any; + const res = await client.test.$post(); + const data = await res.json(); + + expect(res.status).toBe(402); + expect(data.error).toBe("An active subscription is required"); + }); + + it("should allow requests when the organization has an active plan", async () => { + const app = new Hono(); + app.use(async (c, next) => { + // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection + (c as any).set( + "supabase", + createBillingStateSupabaseMock({ + ownerPlan: "solo", + }) as any + ); + // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection + (c as any).set("user", { id: "owner-user" } as any); + await next(); + }); + app.use(middlewareManager.activePlanAccess); + app.post("/test", (c) => c.json({ success: true })); + + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + const client = testClient(app) as any; + const res = await client.test.$post(); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.success).toBe(true); + }); + }); + describe("StreamChat Middleware", () => { it("should inject StreamChat client into context", async () => { const app = new Hono(); diff --git a/apps/api/src/__tests__/routes/tablo.test.ts b/apps/api/src/__tests__/routes/tablo.test.ts index 917c531..f8c7c7b 100644 --- a/apps/api/src/__tests__/routes/tablo.test.ts +++ b/apps/api/src/__tests__/routes/tablo.test.ts @@ -223,6 +223,20 @@ describe("Tablo Endpoint", () => { expect(res.status).toBe(401); }); + it("should deny owner from creating a tablo when the organization has no active plan", async () => { + await supabaseAdmin.from("profiles").update({ plan: "none" }).eq("id", ownerUser.userId); + + const res = await createTabloRequest(ownerUser, client, { + name: "Blocked Tablo", + status: "todo", + color: "#FF0000", + }); + + expect(res.status).toBe(402); + const data = await res.json(); + expect(data.error).toBe("An active subscription is required"); + }); + it("should deny unauthenticated tablo creation", async () => { const res = await client.tablos.create.$post({ json: { diff --git a/apps/api/src/middlewares/middleware.ts b/apps/api/src/middlewares/middleware.ts index 7f9c80d..989e670 100644 --- a/apps/api/src/middlewares/middleware.ts +++ b/apps/api/src/middlewares/middleware.ts @@ -39,6 +39,10 @@ export type Middlewares = { Variables: { supabase: SupabaseClient; user: User }; Bindings: { user: User }; }>; + activePlanAccessMiddleware: MiddlewareHandler<{ + Variables: { supabase: SupabaseClient; user: User }; + Bindings: { user: User }; + }>; transporterMiddleware: MiddlewareHandler<{ Variables: { transporter: Transporter }; }>; @@ -188,6 +192,43 @@ export class MiddlewareManager { const regularUserCheckMiddleware = createProfileAccessMiddleware(false); const billingCheckoutAccessMiddleware = createProfileAccessMiddleware(true); + const activePlanAccessMiddleware = createMiddleware<{ + Variables: { supabase: SupabaseClient; user: User }; + Bindings: { user: User }; + }>(async (c, next) => { + const supabase = c.get("supabase"); + const user = c.get("user"); + + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("organization_id") + .eq("id", user.id) + .single(); + + if (profileError || !profile?.organization_id) { + return c.json({ error: "Failed to resolve your organization" }, 500); + } + + const { data: organizationProfiles, error: organizationProfilesError } = await supabase + .from("profiles") + .select("plan") + .eq("organization_id", profile.organization_id); + + if (organizationProfilesError || !organizationProfiles) { + return c.json({ error: "Failed to resolve organization plans" }, 500); + } + + const hasAnyPlan = organizationProfiles.some( + (organizationProfile) => + organizationProfile.plan && String(organizationProfile.plan).toLowerCase() !== "none" + ); + + if (!hasAnyPlan) { + return c.json({ error: "An active subscription is required" }, 402); + } + + await next(); + }); const transporterMiddleware = createMiddleware(async (c: Context, next: Next) => { const transporter = createTransporter(config); @@ -218,6 +259,7 @@ export class MiddlewareManager { r2Middleware, regularUserCheckMiddleware, billingCheckoutAccessMiddleware, + activePlanAccessMiddleware, transporterMiddleware, stripeSyncMiddleware, stripeMiddleware, @@ -256,6 +298,10 @@ export class MiddlewareManager { return this.middlewares.billingCheckoutAccessMiddleware; } + get activePlanAccess() { + return this.middlewares.activePlanAccessMiddleware; + } + get transporter() { return this.middlewares.transporterMiddleware; } diff --git a/apps/api/src/routers/tablo.ts b/apps/api/src/routers/tablo.ts index f466c85..43f6ace 100644 --- a/apps/api/src/routers/tablo.ts +++ b/apps/api/src/routers/tablo.ts @@ -96,10 +96,14 @@ const ensureTabloChannelMember = async ( }; const createTablo = (middlewareManager: ReturnType) => - factory.createHandlers(middlewareManager.regularUserCheck, verifyTabloLimitForUser, async (c) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const data = await c.req.json(); + factory.createHandlers( + middlewareManager.regularUserCheck, + middlewareManager.activePlanAccess, + verifyTabloLimitForUser, + async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const data = await c.req.json(); const typedPayload = data as PostTablo; @@ -161,8 +165,9 @@ const createTablo = (middlewareManager: ReturnType) => factory.createHandlers(middlewareManager.regularUserCheck, async (c) => { @@ -852,9 +857,14 @@ const generateWebcalUrl = (middlewareManager: ReturnType) => - factory.createHandlers(middlewareManager.regularUserCheck, verifyTabloLimitForUser, async (c) => { - return c.json({ canCreate: true }); - }); + factory.createHandlers( + middlewareManager.regularUserCheck, + middlewareManager.activePlanAccess, + verifyTabloLimitForUser, + async (c) => { + return c.json({ canCreate: true }); + } + ); export const getTabloRouter = (config: AppConfig) => { const tabloRouter = new Hono(); diff --git a/apps/api/src/routers/tablo_data.ts b/apps/api/src/routers/tablo_data.ts index 37c75b8..3bb7a32 100644 --- a/apps/api/src/routers/tablo_data.ts +++ b/apps/api/src/routers/tablo_data.ts @@ -145,7 +145,8 @@ const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => { } }); -const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => { +const postTabloFile = (middlewareManager: ReturnType) => + factory.createHandlers(checkTabloMember, async (c) => { const tabloId = c.req.param("tabloId"); const user = c.get("user"); // Get the file path - supports both wildcard (*) and named parameter (:fileName) @@ -187,7 +188,7 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => { console.error("Error uploading file:", error); return c.json({ error: "Failed to upload file" }, 500); } -}); + }); const deleteTabloFile = (middlewareManager: ReturnType) => factory.createHandlers(checkTabloMember, async (c) => { @@ -335,7 +336,10 @@ const getTabloFolders = factory.createHandlers(checkTabloMember, async (c) => { // POST /tablo-data/:tabloId/folders - Create a new folder (admin only) const createTabloFolder = (middlewareManager: ReturnType) => - factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { + factory.createHandlers( + middlewareManager.regularUserCheck, + checkTabloAdmin, + async (c) => { const tabloId = c.req.param("tabloId"); const s3_client = c.get("s3_client"); const user = c.get("user"); @@ -376,11 +380,15 @@ const createTabloFolder = (middlewareManager: ReturnType) => - factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { + factory.createHandlers( + middlewareManager.regularUserCheck, + checkTabloAdmin, + async (c) => { const tabloId = c.req.param("tabloId"); const folderId = c.req.param("folderId"); const s3_client = c.get("s3_client"); @@ -425,11 +433,15 @@ const updateTabloFolder = (middlewareManager: ReturnType) => - factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { + factory.createHandlers( + middlewareManager.regularUserCheck, + checkTabloAdmin, + async (c) => { const tabloId = c.req.param("tabloId"); const folderId = c.req.param("folderId"); const s3_client = c.get("s3_client"); @@ -457,7 +469,8 @@ const deleteTabloFolder = (middlewareManager: ReturnType { // File routes using wildcard to support nested paths (e.g., "folder-123/file.pdf") // These must be defined after the specific routes above tabloDataRouter.get("/:tabloId/file/:path{.+}", ...getTabloFile); - tabloDataRouter.post("/:tabloId/file/:path{.+}", ...postTabloFile); + tabloDataRouter.post("/:tabloId/file/:path{.+}", ...postTabloFile(middlewareManager)); tabloDataRouter.delete("/:tabloId/file/:path{.+}", ...deleteTabloFile(middlewareManager)); // Legacy routes for backward compatibility (single-level file names only) tabloDataRouter.get("/:tabloId/:fileName", ...getTabloFile); - tabloDataRouter.post("/:tabloId/:fileName", ...postTabloFile); + tabloDataRouter.post("/:tabloId/:fileName", ...postTabloFile(middlewareManager)); tabloDataRouter.delete("/:tabloId/:fileName", ...deleteTabloFile(middlewareManager)); return tabloDataRouter; diff --git a/apps/main/src/components/TrialUpsellModal.tsx b/apps/main/src/components/TrialUpsellModal.tsx index 032e0ef..ab30614 100644 --- a/apps/main/src/components/TrialUpsellModal.tsx +++ b/apps/main/src/components/TrialUpsellModal.tsx @@ -9,6 +9,7 @@ import { } from "@xtablo/ui/components/dialog"; import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react"; import { useEffect, useState } from "react"; +import { useMaybeUpgradeBlock } from "../contexts/UpgradeBlockContext"; import { useOrganization } from "../hooks/organization"; import { useCreateCheckoutSession, useTrialExpiration } from "../hooks/stripe"; import { useMaybeUser } from "../providers/UserStoreProvider"; @@ -44,6 +45,7 @@ export function TrialUpsellModal() { const { daysRemaining } = useTrialExpiration(); const { data: organizationData } = useOrganization(); const user = useMaybeUser(); + const upgradeBlock = useMaybeUpgradeBlock(); const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession(); const shouldShowModal = Boolean( @@ -78,7 +80,7 @@ export function TrialUpsellModal() { return () => clearInterval(interval); }, [shouldShowModal]); - if (!shouldShowModal || daysRemaining === null) { + if (!shouldShowModal || daysRemaining === null || upgradeBlock?.isBlocked) { return null; } diff --git a/apps/main/src/components/UpgradePanel.tsx b/apps/main/src/components/UpgradePanel.tsx index 3506f36..aa2cbc9 100644 --- a/apps/main/src/components/UpgradePanel.tsx +++ b/apps/main/src/components/UpgradePanel.tsx @@ -18,7 +18,7 @@ import { useCreateCheckoutSession } from "../hooks/stripe"; * Prevents access to the app until they upgrade to a paid plan */ export function UpgradePanel() { - const { isBlocked } = useUpgradeBlock(); + const { isBlocked, reason } = useUpgradeBlock(); const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession(); const { mutate: signOut } = useLogout(); const { data: organizationData } = useOrganization(); @@ -31,11 +31,42 @@ export function UpgradePanel() { const isBillingOwner = organizationData?.is_billing_owner ?? false; const requiredTeamQuantity = organizationData?.required_team_quantity ?? 1; const checkoutPlan = requiredPlan === "team" ? "team" : "solo"; - - const checkoutLabel = - checkoutPlan === "team" - ? `Passer au plan Teams (${requiredTeamQuantity} siège${requiredTeamQuantity > 1 ? "s" : ""})` - : "Passer au plan Solo"; + const title = + reason === "no_plan" + ? "Choisissez un abonnement pour continuer" + : "Votre période d'essai est terminée"; + const description = + reason === "no_plan" + ? "Aucune formule active n'est associée à votre organisation. Sélectionnez Solo, Teams ou Founder pour continuer." + : `Pour continuer à utiliser XTablo, activez votre abonnement ${requiredPlan === "team" ? "Teams" : "Solo"}.`; + const featureTitle = + reason === "no_plan" + ? "Choisissez la formule adaptée à votre organisation :" + : requiredPlan === "team" + ? "Ce que vous obtenez avec Teams :" + : "Ce que vous obtenez avec Solo :"; + const features = + reason === "no_plan" + ? [ + "Solo : 1 utilisateur et 10 tablos actifs", + `Teams : ${requiredTeamQuantity} siège${requiredTeamQuantity > 1 ? "s" : ""} ou plus selon votre équipe`, + "Founder : fonctionnalités illimitées", + ] + : requiredPlan === "team" + ? [ + "Utilisateurs multiples (facturation par siège)", + "Tablos illimités", + "Chat en temps réel", + "Stockage de fichiers", + "Support prioritaire", + ] + : [ + "1 utilisateur", + "10 tablos actifs", + "Chat en temps réel", + "Stockage de fichiers", + "Support prioritaire", + ]; return (
@@ -45,36 +76,14 @@ export function UpgradePanel() {
- Votre période d'essai est terminée - - Pour continuer à utiliser XTablo, activez votre abonnement{" "} - {requiredPlan === "team" ? "Teams" : "Solo"}. - + {title} + {description}
- - {requiredPlan === "team" - ? "Ce que vous obtenez avec Teams :" - : "Ce que vous obtenez avec Solo :"} - + {featureTitle}
    - {(requiredPlan === "team" - ? [ - "Utilisateurs multiples (facturation par siège)", - "Tablos illimités", - "Chat en temps réel", - "Stockage de fichiers", - "Support prioritaire", - ] - : [ - "1 utilisateur", - "10 tablos actifs", - "Chat en temps réel", - "Stockage de fichiers", - "Support prioritaire", - ] - ).map((feature) => ( + {features.map((feature) => (
  • {feature} @@ -87,7 +96,7 @@ export function UpgradePanel() { + +