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,111 +189,118 @@ export default function ResourceAuthenticationPage() {
return ( return (
<> <>
<SettingsContainer> <SettingsContainer>
{build !== "oss" && ( {build !== "oss" &&
<SettingsSection> isPaidUser(tierMatrix[TierFeature.ResourcePolicies]) && (
<SettingsSectionHeader> <SettingsSection>
<SettingsSectionTitle> <SettingsSectionHeader>
{t("resourcePolicySelectTitle")} <SettingsSectionTitle>
</SettingsSectionTitle> {t("resourcePolicySelectTitle")}
<SettingsSectionDescription> </SettingsSectionTitle>
{t("resourcePolicySelectDescription")} <SettingsSectionDescription>
</SettingsSectionDescription> {t("resourcePolicySelectDescription")}
</SettingsSectionHeader> </SettingsSectionDescription>
<SettingsSectionBody> </SettingsSectionHeader>
<StrategySelect <SettingsSectionBody>
options={resourcePolicyTypes} <StrategySelect
value={selectedResourceType} options={resourcePolicyTypes}
onChange={(value) => { value={selectedResourceType}
form.setValue("type", value); onChange={(value) => {
}} form.setValue("type", value);
cols={2} }}
/> cols={2}
{selectedResourceType === "shared" && ( />
<Popover> {selectedResourceType === "shared" && (
<PopoverTrigger asChild> <Popover>
<Button <PopoverTrigger asChild>
variant="outline" <Button
role="combobox" variant="outline"
className={ role="combobox"
"w-full md:w-1/2 justify-between" className={
} "w-full md:w-1/2 justify-between"
>
<span className="truncate max-w-37.5">
{selectedPolicy
? selectedPolicy.name
: t("resourcePolicySelect")}
</span>
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-45">
<Command shouldFilter={false}>
<CommandInput
placeholder={t("siteSearch")}
value={
resourcePolicysearchQuery
} }
onValueChange={ >
setResourcePolicySearchQuery <span className="truncate max-w-37.5">
} {selectedPolicy
/> ? selectedPolicy.name
<CommandList> : t(
<CommandEmpty> "resourcePolicySelect"
{t( )}
"resourcePolicyNotFound" </span>
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-45">
<Command shouldFilter={false}>
<CommandInput
placeholder={t(
"siteSearch"
)} )}
</CommandEmpty> value={
<CommandGroup> resourcePolicysearchQuery
{policiesList.map( }
(policy) => ( onValueChange={
<CommandItem setResourcePolicySearchQuery
key={ }
policy.resourcePolicyId />
} <CommandList>
value={policy.resourcePolicyId.toString()} <CommandEmpty>
onSelect={() => {t(
setSelectedPolicy( "resourcePolicyNotFound"
{ )}
id: policy.resourcePolicyId, </CommandEmpty>
name: policy.name <CommandGroup>
} {policiesList.map(
) (policy) => (
} <CommandItem
> key={
<CheckIcon policy.resourcePolicyId
className={cn( }
"mr-2 h-4 w-4", value={policy.resourcePolicyId.toString()}
policy.resourcePolicyId === onSelect={() =>
selectedPolicy?.id setSelectedPolicy(
? "opacity-100" {
: "opacity-0" id: policy.resourcePolicyId,
)} name: policy.name
/> }
{policy.name} )
</CommandItem> }
) >
)} <CheckIcon
</CommandGroup> className={cn(
</CommandList> "mr-2 h-4 w-4",
</Command> policy.resourcePolicyId ===
</PopoverContent> selectedPolicy?.id
</Popover> ? "opacity-100"
)} : "opacity-0"
</SettingsSectionBody> )}
<SettingsSectionFooter className="justify-start"> />
<Button {
onClick={() => policy.name
startTransition( }
handleSaveResourcePolicyType </CommandItem>
) )
} )}
loading={isUpdatingResource} </CommandGroup>
> </CommandList>
{t("resourcePolicyTypeSave")} </Command>
</Button> </PopoverContent>
</SettingsSectionFooter> </Popover>
</SettingsSection> )}
)} </SettingsSectionBody>
<SettingsSectionFooter className="justify-start">
<Button
onClick={() =>
startTransition(
handleSaveResourcePolicyType
)
}
loading={isUpdatingResource}
>
{t("resourcePolicyTypeSave")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
)}
{selectedResourceType === "inline" ? ( {selectedResourceType === "inline" ? (
<ResourcePolicyProvider policy={policies.defaultPolicy}> <ResourcePolicyProvider policy={policies.defaultPolicy}>

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,71 +194,87 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
return <></>; return <></>;
} }
return ( const policyTiers = tierMatrix[TierFeature.ResourcePolicies];
<Form {...form}> const isDisabled = !isPaidUser(policyTiers);
<SettingsContainer>
{/* Name */}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourcePolicyName")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourcePolicyNameDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name")}</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"resourcePolicyNamePlaceholder"
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<CreatePolicyUsersRolesSectionForm return (
form={form} <>
allRoles={allRoles} <PaidFeaturesAlert tiers={policyTiers} />
allUsers={allUsers} <Form {...form}>
allIdps={allIdps} <div
/> className={
<CreatePolicyAuthMethodsSectionForm form={form} /> isDisabled
<CreatePolicyOtpEmailSectionForm ? "pointer-events-none opacity-50"
form={form} : undefined
emailEnabled={env.email.emailEnabled} }
/> >
<CreatePolicyRulesSectionForm <SettingsContainer>
form={form} {/* Name */}
isMaxmindAvailable={isMaxmindAvailable} <SettingsSection>
isMaxmindAsnAvailable={isMaxmindAsnAvailable} <SettingsSectionHeader>
/> <SettingsSectionTitle>
</SettingsContainer> {t("resourcePolicyName")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourcePolicyNameDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"resourcePolicyNamePlaceholder"
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<CreatePolicyUsersRolesSectionForm
form={form}
allRoles={allRoles}
allUsers={allUsers}
allIdps={allIdps}
/>
<CreatePolicyAuthMethodsSectionForm form={form} />
<CreatePolicyOtpEmailSectionForm
form={form}
emailEnabled={env.email.emailEnabled}
/>
<CreatePolicyRulesSectionForm
form={form}
isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindAsnAvailable}
/>
</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>
</>
); );
} }