Paywall resource policies

This commit is contained in:
Owen
2026-05-04 15:30:49 -07:00
parent 20ebdc6289
commit 43f2e32231
10 changed files with 243 additions and 186 deletions

View File

@@ -24,7 +24,8 @@ export enum TierFeature {
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
StandaloneHealthChecks = "standaloneHealthChecks", StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules", AlertingRules = "alertingRules",
WildcardSubdomain = "wildcardSubdomain" WildcardSubdomain = "wildcardSubdomain",
ResourcePolicies = "resourcePolicies"
} }
export const tierMatrix: Record<TierFeature, Tier[]> = { export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -66,5 +67,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"], [TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
[TierFeature.AlertingRules]: ["tier3", "enterprise"], [TierFeature.AlertingRules]: ["tier3", "enterprise"],
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"] [TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"]
}; };

View File

@@ -389,7 +389,7 @@ authenticated.delete(
"/resource-policy/:resourcePolicyId", "/resource-policy/:resourcePolicyId",
verifyResourcePolicyAccess, verifyResourcePolicyAccess,
verifyValidLicense, verifyValidLicense,
// verifyValidSubscription(tierMatrix.loginPageDomain), // todo: use the correct subscription ? verifyValidSubscription(tierMatrix.resourcePolicies),
verifyLimits, verifyLimits,
verifyUserHasAction(ActionsEnum.deleteResourcePolicy), verifyUserHasAction(ActionsEnum.deleteResourcePolicy),
logActionAudit(ActionsEnum.deleteResourcePolicy), logActionAudit(ActionsEnum.deleteResourcePolicy),
@@ -399,7 +399,7 @@ authenticated.delete(
authenticated.get( authenticated.get(
"/org/:orgId/resource-policies", "/org/:orgId/resource-policies",
verifyValidLicense, verifyValidLicense,
// verifyValidSubscription(tierMatrix.loginPageDomain), // todo: use the correct subscription ? verifyValidSubscription(tierMatrix.resourcePolicies),
verifyOrgAccess, verifyOrgAccess,
verifyLimits, verifyLimits,
verifyUserHasAction(ActionsEnum.listResourcePolicies), verifyUserHasAction(ActionsEnum.listResourcePolicies),
@@ -410,7 +410,7 @@ authenticated.get(
authenticated.post( authenticated.post(
"/org/:orgId/resource-policy", "/org/:orgId/resource-policy",
verifyValidLicense, verifyValidLicense,
// verifyValidSubscription(tierMatrix.loginPageDomain), // todo: use the correct subscription ? verifyValidSubscription(tierMatrix.resourcePolicies),
verifyOrgAccess, verifyOrgAccess,
verifyLimits, verifyLimits,
verifyUserHasAction(ActionsEnum.createResourcePolicy), verifyUserHasAction(ActionsEnum.createResourcePolicy),

View File

@@ -1,3 +1,16 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { hashPassword } from "@server/auth/password"; import { hashPassword } from "@server/auth/password";
import { import {
db, db,

View File

@@ -1,7 +1,7 @@
/* /*
* This file is part of a proprietary work. * This file is part of a proprietary work.
* *
* Copyright (c) 2025 Fossorial, Inc. * Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved. * All rights reserved.
* *
* This file is licensed under the Fossorial Commercial License. * This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/* /*
* This file is part of a proprietary work. * This file is part of a proprietary work.
* *
* Copyright (c) 2025 Fossorial, Inc. * Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved. * All rights reserved.
* *
* This file is licensed under the Fossorial Commercial License. * This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/* /*
* This file is part of a proprietary work. * This file is part of a proprietary work.
* *
* Copyright (c) 2025 Fossorial, Inc. * Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved. * All rights reserved.
* *
* This file is licensed under the Fossorial Commercial License. * This file is licensed under the Fossorial Commercial License.

View File

@@ -25,7 +25,10 @@ import {
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi"; import { OpenAPITags } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils"; import {
validateAndConstructDomain,
checkWildcardDomainConflict
} from "@server/lib/domainUtils";
import { build } from "@server/build"; import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -304,11 +307,30 @@ async function updateHttpResource(
const updateData = parsedBody.data; const updateData = parsedBody.data;
const isLicensed = await isLicensedOrSubscribed(
resource.orgId,
tierMatrix.wildcardSubdomain
);
if (updateData.resourcePolicyId != null) { if (updateData.resourcePolicyId != null) {
if (!isLicensed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Resource policies are not supported on your current plan. Please upgrade to access this feature."
)
);
}
const [existingPolicy] = await db const [existingPolicy] = await db
.select() .select()
.from(resourcePolicies) .from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, updateData.resourcePolicyId)) .where(
eq(
resourcePolicies.resourcePolicyId,
updateData.resourcePolicyId
)
)
.limit(1); .limit(1);
if (!existingPolicy) { if (!existingPolicy) {
@@ -346,10 +368,6 @@ async function updateHttpResource(
// Wildcard subdomains are a paid feature // Wildcard subdomains are a paid feature
if (updateData.subdomain && updateData.subdomain.includes("*")) { if (updateData.subdomain && updateData.subdomain.includes("*")) {
const isLicensed = await isLicensedOrSubscribed(
resource.orgId,
tierMatrix.wildcardSubdomain
);
if (!isLicensed) { if (!isLicensed) {
return next( return next(
createHttpError( createHttpError(
@@ -494,10 +512,6 @@ async function updateHttpResource(
headers = null; headers = null;
} }
const isLicensed = await isLicensedOrSubscribed(
resource.orgId,
tierMatrix.maintencePage
);
if (!isLicensed) { if (!isLicensed) {
updateData.maintenanceModeEnabled = undefined; updateData.maintenanceModeEnabled = undefined;
updateData.maintenanceModeType = undefined; updateData.maintenanceModeType = undefined;
@@ -560,7 +574,12 @@ async function updateRawResource(
const [existingPolicy] = await db const [existingPolicy] = await db
.select() .select()
.from(resourcePolicies) .from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, updateData.resourcePolicyId)) .where(
eq(
resourcePolicies.resourcePolicyId,
updateData.resourcePolicyId
)
)
.limit(1); .limit(1);
if (!existingPolicy) { if (!existingPolicy) {

View File

@@ -41,7 +41,7 @@ import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CaretSortIcon } from "@radix-ui/react-icons"; import { CaretSortIcon } from "@radix-ui/react-icons";
import { build } from "@server/build"; import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowRightIcon, CheckIcon, ShieldAlertIcon } from "lucide-react"; import { ArrowRightIcon, CheckIcon, ShieldAlertIcon } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -70,6 +70,7 @@ export default function ResourceAuthenticationPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { env } = useEnvContext(); const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus();
const api = createApiClient({ env }); const api = createApiClient({ env });
const router = useRouter(); const router = useRouter();
@@ -188,7 +189,8 @@ export default function ResourceAuthenticationPage() {
return ( return (
<> <>
<SettingsContainer> <SettingsContainer>
{build !== "oss" && ( {build !== "oss" &&
isPaidUser(tierMatrix[TierFeature.ResourcePolicies]) && (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -220,7 +222,9 @@ export default function ResourceAuthenticationPage() {
<span className="truncate max-w-37.5"> <span className="truncate max-w-37.5">
{selectedPolicy {selectedPolicy
? selectedPolicy.name ? selectedPolicy.name
: t("resourcePolicySelect")} : t(
"resourcePolicySelect"
)}
</span> </span>
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" /> <CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
@@ -228,7 +232,9 @@ export default function ResourceAuthenticationPage() {
<PopoverContent className="p-0 w-45"> <PopoverContent className="p-0 w-45">
<Command shouldFilter={false}> <Command shouldFilter={false}>
<CommandInput <CommandInput
placeholder={t("siteSearch")} placeholder={t(
"siteSearch"
)}
value={ value={
resourcePolicysearchQuery resourcePolicysearchQuery
} }
@@ -268,7 +274,9 @@ export default function ResourceAuthenticationPage() {
: "opacity-0" : "opacity-0"
)} )}
/> />
{policy.name} {
policy.name
}
</CommandItem> </CommandItem>
) )
)} )}

View File

@@ -29,6 +29,8 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from "./ui/dropdown-menu"; } from "./ui/dropdown-menu";
import ConfirmDeleteDialog from "./ConfirmDeleteDialog"; import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
type ResourcePolicyRow = ListResourcePoliciesResponse["policies"][number]; type ResourcePolicyRow = ListResourcePoliciesResponse["policies"][number];
@@ -253,6 +255,9 @@ export function ResourcePoliciesTable({
return ( return (
<> <>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.ResourcePolicies]}
/>
{selectedResourcePolicy && ( {selectedResourcePolicy && (
<ConfirmDeleteDialog <ConfirmDeleteDialog
open={isDeleteModalOpen} open={isDeleteModalOpen}

View File

@@ -9,29 +9,22 @@ import {
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle SettingsSectionTitle
} from "@app/components/Settings"; } from "@app/components/Settings";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { orgQueries } from "@app/lib/queries"; import { orgQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build"; import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import z from "zod";
import { type PolicyFormValues, createPolicySchema } from "."; import { type PolicyFormValues, createPolicySchema } from ".";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { orgs, type ResourcePolicy } from "@server/db"; import { orgs, type ResourcePolicy } from "@server/db";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { import {
Form, Form,
@@ -42,13 +35,14 @@ import {
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { useMemo, useTransition } from "react"; import { useMemo, useTransition } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { CreatePolicyUsersRolesSectionForm } from "./CreatePolicyUserRolesSectionForm"; import { CreatePolicyUsersRolesSectionForm } from "./CreatePolicyUserRolesSectionForm";
import { CreatePolicyAuthMethodsSectionForm } from "./CreatePolicyAuthMethodsSectionForm"; import { CreatePolicyAuthMethodsSectionForm } from "./CreatePolicyAuthMethodsSectionForm";
import { CreatePolicyOtpEmailSectionForm } from "./CreatePolicyOtpEmailSectionForm"; import { CreatePolicyOtpEmailSectionForm } from "./CreatePolicyOtpEmailSectionForm";
import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm"; import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
// ─── CreatePolicyForm ───────────────────────────────────────────────────────── // ─── CreatePolicyForm ─────────────────────────────────────────────────────────
@@ -200,8 +194,20 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
return <></>; return <></>;
} }
const policyTiers = tierMatrix[TierFeature.ResourcePolicies];
const isDisabled = !isPaidUser(policyTiers);
return ( return (
<>
<PaidFeaturesAlert tiers={policyTiers} />
<Form {...form}> <Form {...form}>
<div
className={
isDisabled
? "pointer-events-none opacity-50"
: undefined
}
>
<SettingsContainer> <SettingsContainer>
{/* Name */} {/* Name */}
<SettingsSection> <SettingsSection>
@@ -220,7 +226,9 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t("name")}</FormLabel> <FormLabel>
{t("name")}
</FormLabel>
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
@@ -254,17 +262,19 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
isMaxmindAsnAvailable={isMaxmindAsnAvailable} isMaxmindAsnAvailable={isMaxmindAsnAvailable}
/> />
</SettingsContainer> </SettingsContainer>
</div>
<div className="flex py-6 justify-end"> <div className="flex py-6 justify-end">
<Button <Button
type="button" type="button"
onClick={() => startTransition(onSubmit)} onClick={() => startTransition(onSubmit)}
loading={isSubmitting} loading={isSubmitting}
disabled={isSubmitting} disabled={isSubmitting || isDisabled}
> >
{t("resourcePoliciesCreate")} {t("resourcePoliciesCreate")}
</Button> </Button>
</div> </div>
</Form> </Form>
</>
); );
} }