mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Gate usage limit banners behind HEXCLAVE_DISABLE_PLAN_LIMITS (#1728)
## Summary Threads `arePlanLimitsEnforced()` from the backend through the plan-usage API response so the dashboard can hide all usage limit banners when `HEXCLAVE_DISABLE_PLAN_LIMITS=true`. Backend adds `are_plan_limits_enforced` to `planUsageResponseSchema` (`.optional().default(true)` for backward compat with older backends) → SDK surfaces it as `PlanUsage.arePlanLimitsEnforced` with `?? true` runtime fallback (since `getPlanUsage()` returns raw JSON without yup validation) → banner components early-return `null` when `!arePlanLimitsEnforced`. For the projects page (outside admin-app context, no project selected), a server action reads the env var directly via `getEnvVariable(\"STACK_DISABLE_PLAN_LIMITS\", \"false\")`. Link to Devin session: https://app.devin.ai/sessions/09ca53f13c294c8e98c7a7227a52217d Requested by: @Developing-Gamer <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for a “plan limits enforced” flag to control messaging across usage and billing-related screens. * Projects and team invitation capacity checks now respect the same enforcement toggle (including admin-seat invitation blocking). * **Bug Fixes** * Limit banners and “plan limit exceeded” alerts now only render when enforcement is enabled (and overage/threshold conditions are met). * Updated usage payload handling to default to enforcement enabled when the flag is missing; added/updated tests for both enabled and disabled scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: armaan <armaan@stack-auth.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
56dd0f357b
commit
d78405d7e7
@ -3,6 +3,7 @@ import { getClickhouseAdminClientForMetrics } from "@/lib/clickhouse";
|
||||
import { getSubscriptionMapForCustomer } from "@/lib/payments/customer-data";
|
||||
import { isActiveSubscription } from "@/lib/payments";
|
||||
import {
|
||||
arePlanLimitsEnforced,
|
||||
getBillingTeamId,
|
||||
getNonAnonymousUserCountForTenancies,
|
||||
getOwnedProjectAndTenancyIdsForBillingTeam,
|
||||
@ -423,6 +424,7 @@ export async function getPlanUsageForProject(project: UsageSourceProject, now: D
|
||||
period_start_millis: period.start.getTime(),
|
||||
period_end_millis: period.end.getTime(),
|
||||
next_plan_id: getNextPlanId(planId),
|
||||
are_plan_limits_enforced: arePlanLimitsEnforced(),
|
||||
rows: buildRows({
|
||||
planId,
|
||||
dashboardAdmins,
|
||||
|
||||
@ -16,5 +16,6 @@ NEXT_PUBLIC_HEXCLAVE_HEAD_TAGS=# a JSON array of head tags to inject, e.g. '[{ "
|
||||
HEXCLAVE_DEVELOPMENT_TRANSLATION_LOCALE=# enter the locale to use for the translation provider here, for example: de-DE. Only works during development, not in production. Optional, by default don't translate
|
||||
NEXT_PUBLIC_HEXCLAVE_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS=# JSON array of project IDs that get development features (set to '["internal"]' in .env.development). Leave empty here so a platform-set legacy NEXT_PUBLIC_STACK_* value isn't treated as a conflict in prod builds.
|
||||
NEXT_PUBLIC_HEXCLAVE_DEBUGGER_ON_ASSERTION_ERROR=# set to true to open the debugger on assertion errors (set to true in .env.development)
|
||||
HEXCLAVE_DISABLE_PLAN_LIMITS=# set to "true" to bypass enforcement of plan limits in the dashboard (seat-capacity gate on the projects page). Default unset/false preserves enforcement.
|
||||
HEXCLAVE_FEATUREBASE_JWT_SECRET=# used for Featurebase SSO, you probably won't have to set this
|
||||
HEXCLAVE_CHANGELOG_URL=# Used for raw github link to root changelog.md file.
|
||||
|
||||
@ -13,4 +13,5 @@ HEXCLAVE_ARTIFICIAL_DEVELOPMENT_DELAY_MS=50
|
||||
NEXT_PUBLIC_HEXCLAVE_DEBUGGER_ON_ASSERTION_ERROR=false
|
||||
NEXT_PUBLIC_HEXCLAVE_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS='["internal"]'
|
||||
|
||||
HEXCLAVE_DISABLE_PLAN_LIMITS=false
|
||||
HEXCLAVE_FEATUREBASE_JWT_SECRET=secret-value
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use server";
|
||||
import { isRemoteDevelopmentEnvironmentEnabled } from "@/lib/remote-development-environment/env";
|
||||
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
|
||||
import { hexclaveAppInternalsSymbol } from "@hexclave/next";
|
||||
|
||||
async function getServerApp() {
|
||||
@ -49,3 +50,7 @@ export async function inviteUser(teamId: string, email: string, origin: string)
|
||||
}
|
||||
await team.inviteUser({ email, callbackUrl });
|
||||
}
|
||||
|
||||
export async function getArePlanLimitsEnforced(): Promise<boolean> {
|
||||
return getEnvVariable("STACK_DISABLE_PLAN_LIMITS", "false") !== "true";
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@ import { stringCompare } from "@hexclave/shared/dist/utils/strings";
|
||||
import { urlString } from "@hexclave/shared/dist/utils/urls";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import * as yup from "yup";
|
||||
import { inviteUser, listInvitations, revokeInvitation } from "./actions";
|
||||
import { getArePlanLimitsEnforced, inviteUser, listInvitations, revokeInvitation } from "./actions";
|
||||
import Footer from "./footer";
|
||||
import PreviewProjectRedirect from "./preview-project-redirect";
|
||||
|
||||
@ -513,14 +513,16 @@ type TeamAddUserDialogData = {
|
||||
userCount: number,
|
||||
seatLimit: number,
|
||||
hasPaidPlan: boolean,
|
||||
arePlanLimitsEnforced: boolean,
|
||||
};
|
||||
|
||||
async function loadTeamAddUserDialogData(team: Team): Promise<TeamAddUserDialogData> {
|
||||
const [invitations, users, admins, products] = await Promise.all([
|
||||
const [invitations, users, admins, products, arePlanLimitsEnforced] = await Promise.all([
|
||||
listInvitations(team.id),
|
||||
team.listUsers(),
|
||||
team.getItem("dashboard_admins"),
|
||||
team.listProducts(),
|
||||
getArePlanLimitsEnforced(),
|
||||
]);
|
||||
|
||||
return {
|
||||
@ -528,6 +530,7 @@ async function loadTeamAddUserDialogData(team: Team): Promise<TeamAddUserDialogD
|
||||
userCount: users.length,
|
||||
seatLimit: admins.quantity,
|
||||
hasPaidPlan: isPaidPlan(products),
|
||||
arePlanLimitsEnforced,
|
||||
};
|
||||
}
|
||||
|
||||
@ -597,7 +600,7 @@ function TeamAddUserDialog(props: { team: Team }) {
|
||||
}, [props.team]);
|
||||
|
||||
const activeSeats = dialogData == null ? null : dialogData.userCount + dialogData.invitations.length;
|
||||
const atCapacity = dialogData != null && activeSeats != null && activeSeats >= dialogData.seatLimit;
|
||||
const atCapacity = dialogData != null && dialogData.arePlanLimitsEnforced && activeSeats != null && activeSeats >= dialogData.seatLimit;
|
||||
|
||||
const handleInvite = async () => {
|
||||
if (dialogData == null || atCapacity) {
|
||||
|
||||
@ -329,6 +329,7 @@ export function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () =
|
||||
export function AnalyticsEventLimitBanner() {
|
||||
const adminApp = useAdminApp();
|
||||
const project = adminApp.useProject();
|
||||
const planUsage = adminApp.usePlanUsage();
|
||||
const user = useDashboardInternalUser();
|
||||
const teams = user.useTeams();
|
||||
|
||||
@ -337,7 +338,7 @@ export function AnalyticsEventLimitBanner() {
|
||||
[teams, project.ownerTeamId],
|
||||
);
|
||||
|
||||
if (ownerTeam == null) {
|
||||
if (!planUsage.arePlanLimitsEnforced || ownerTeam == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -351,6 +352,7 @@ export function AnalyticsEventLimitBanner() {
|
||||
export function SessionReplayLimitBanner() {
|
||||
const adminApp = useAdminApp();
|
||||
const project = adminApp.useProject();
|
||||
const planUsage = adminApp.usePlanUsage();
|
||||
const user = useDashboardInternalUser();
|
||||
const teams = user.useTeams();
|
||||
|
||||
@ -359,7 +361,7 @@ export function SessionReplayLimitBanner() {
|
||||
[teams, project.ownerTeamId],
|
||||
);
|
||||
|
||||
if (ownerTeam == null) {
|
||||
if (!planUsage.arePlanLimitsEnforced || ownerTeam == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ function createPlanUsageState() {
|
||||
periodStart: new Date(Date.UTC(2026, 5, 1)),
|
||||
periodEnd: new Date(Date.UTC(2026, 6, 1)),
|
||||
nextPlanId: "team",
|
||||
arePlanLimitsEnforced: true,
|
||||
rows: [
|
||||
{
|
||||
itemId: "dashboard_admins",
|
||||
@ -148,6 +149,17 @@ describe("Usage settings page", () => {
|
||||
expect(authUsageFill.style.width).toBe("0%");
|
||||
});
|
||||
|
||||
it("hides overage banner when plan limits are not enforced", () => {
|
||||
planUsageState = {
|
||||
...createPlanUsageState(),
|
||||
arePlanLimitsEnforced: false,
|
||||
};
|
||||
|
||||
render(<PageClient />);
|
||||
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("starts checkout for the next plan from the upgrade CTA", async () => {
|
||||
render(<PageClient />);
|
||||
|
||||
|
||||
@ -288,16 +288,18 @@ function UsageBody({
|
||||
planUsage,
|
||||
analyticsTimeoutRow,
|
||||
onUpgrade,
|
||||
arePlanLimitsEnforced,
|
||||
}: {
|
||||
planUsage: PlanUsageData,
|
||||
analyticsTimeoutRow: UsageRow | undefined,
|
||||
onUpgrade: (() => void) | undefined,
|
||||
arePlanLimitsEnforced: boolean,
|
||||
}) {
|
||||
const overageRows = getOverageRows(planUsage.rows);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{overageRows.length > 0 && (
|
||||
{arePlanLimitsEnforced && overageRows.length > 0 && (
|
||||
<div
|
||||
role="alert"
|
||||
className="relative grid w-full gap-4 rounded-2xl border border-amber-500/40 bg-amber-500/[0.08] p-4 text-sm sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
|
||||
@ -370,6 +372,7 @@ export default function PageClient() {
|
||||
planUsage={planUsageForDisplay}
|
||||
analyticsTimeoutRow={analyticsTimeoutRow}
|
||||
onUpgrade={handleUpgrade}
|
||||
arePlanLimitsEnforced={planUsage.arePlanLimitsEnforced}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@ -26,6 +26,7 @@ export const planUsageResponseSchema = yupObject({
|
||||
period_start_millis: yupNumber().integer().defined(),
|
||||
period_end_millis: yupNumber().integer().defined(),
|
||||
next_plan_id: yupString().oneOf(UPGRADE_PLAN_IDS).nullable().defined(),
|
||||
are_plan_limits_enforced: yupBoolean().optional().default(true),
|
||||
rows: yupArray(planUsageRowSchema).defined(),
|
||||
}).defined();
|
||||
|
||||
|
||||
@ -351,6 +351,8 @@ export class _HexclaveAdminAppImplIncomplete<HasTokenStore extends boolean, Proj
|
||||
periodStart: new Date(data.period_start_millis),
|
||||
periodEnd: new Date(data.period_end_millis),
|
||||
nextPlanId: data.next_plan_id,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- getPlanUsage() returns raw JSON without yup validation, so this field can be undefined at runtime if the backend hasn't deployed the field yet
|
||||
arePlanLimitsEnforced: data.are_plan_limits_enforced ?? true,
|
||||
rows: data.rows.map((row) => ({
|
||||
itemId: row.item_id,
|
||||
displayName: row.display_name,
|
||||
|
||||
@ -21,5 +21,6 @@ export type PlanUsage = {
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
nextPlanId: PlanUsageNextPlanId | null,
|
||||
arePlanLimitsEnforced: boolean,
|
||||
rows: PlanUsageRow[],
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user