Merge branch 'dev' into refactor/paginated-tables

This commit is contained in:
Fred KISSIE
2026-02-13 06:03:09 +01:00
184 changed files with 6127 additions and 4176 deletions

View File

@@ -19,6 +19,7 @@ import OrgPolicyResult from "@app/components/OrgPolicyResult";
import UserProvider from "@app/providers/UserProvider";
import { Layout } from "@app/components/Layout";
import ApplyInternalRedirect from "@app/components/ApplyInternalRedirect";
import SubscriptionViolation from "@app/components/SubscriptionViolation";
export default async function OrgLayout(props: {
children: React.ReactNode;
@@ -108,6 +109,7 @@ export default async function OrgLayout(props: {
>
<ApplyInternalRedirect orgId={orgId} />
{props.children}
{build === "saas" && <SubscriptionViolation />}
<SetLastOrgCookie orgId={orgId} />
</SubscriptionStatusProvider>
);

View File

@@ -11,6 +11,7 @@ import type { GetOrgResponse } from "@server/routers/org";
import type { ListRolesResponse } from "@server/routers/role";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export interface ApprovalFeedPageProps {
params: Promise<{ orgId: string }>;
@@ -29,10 +30,9 @@ export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) {
// Fetch roles to check if approvals are enabled
let hasApprovalsEnabled = false;
const rolesRes = await internal
.get<AxiosResponse<ListRolesResponse>>(
`/org/${params.orgId}/roles`,
await authCookieHeader()
)
.get<
AxiosResponse<ListRolesResponse>
>(`/org/${params.orgId}/roles`, await authCookieHeader())
.catch((e) => {});
if (rolesRes && rolesRes.status === 200) {
@@ -52,7 +52,7 @@ export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) {
<ApprovalsBanner />
<PaidFeaturesAlert />
<PaidFeaturesAlert tiers={tierMatrix.deviceApprovals} />
<OrgProvider org={org}>
<div className="container mx-auto max-w-12xl">

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,6 @@ import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useState, useEffect } from "react";
import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink } from "lucide-react";
import {
@@ -41,12 +40,13 @@ import {
InfoSectionTitle
} from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { useTranslations } from "next-intl";
import { AxiosResponse } from "axios";
import { ListRolesResponse } from "@server/routers/role";
import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget";
import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export default function GeneralPage() {
const { env } = useEnvContext();
@@ -60,7 +60,6 @@ export default function GeneralPage() {
"role" | "expression"
>("role");
const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc");
const { isUnlocked } = useLicenseStatusContext();
const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
const [redirectUrl, setRedirectUrl] = useState(
@@ -499,6 +498,10 @@ export default function GeneralPage() {
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<PaidFeaturesAlert
tiers={tierMatrix.autoProvisioning}
/>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}

View File

@@ -1,6 +1,7 @@
"use client";
import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget";
import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import {
SettingsContainer,
SettingsSection,
@@ -31,6 +32,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { ListRolesResponse } from "@server/routers/role";
import { AxiosResponse } from "axios";
import { InfoIcon } from "lucide-react";
@@ -50,7 +52,6 @@ export default function Page() {
const [roleMappingMode, setRoleMappingMode] = useState<
"role" | "expression"
>("role");
const { isUnlocked } = useLicenseStatusContext();
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
@@ -363,6 +364,9 @@ export default function Page() {
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<PaidFeaturesAlert
tiers={tierMatrix.autoProvisioning}
/>
<Form {...form}>
<form
className="space-y-4"
@@ -808,7 +812,7 @@ export default function Page() {
</Button>
<Button
type="submit"
disabled={createLoading || !isPaidUser}
disabled={createLoading || !isPaidUser(tierMatrix.orgOidc)}
loading={createLoading}
onClick={() => {
// log any issues with the form

View File

@@ -2,9 +2,11 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import IdpTable, { IdpRow } from "@app/components/private/OrgIdpTable";
import IdpTable, { IdpRow } from "@app/components/OrgIdpTable";
import { getTranslations } from "next-intl/server";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { IdpGlobalModeBanner } from "@app/components/IdpGlobalModeBanner";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type OrgIdpPageProps = {
params: Promise<{ orgId: string }>;
@@ -35,7 +37,9 @@ export default async function OrgIdpPage(props: OrgIdpPageProps) {
description={t("idpManageDescription")}
/>
<PaidFeaturesAlert />
<IdpGlobalModeBanner />
<PaidFeaturesAlert tiers={tierMatrix.orgOidc} />
<IdpTable idps={idps} orgId={params.orgId} />
</>

View File

@@ -23,9 +23,6 @@ import {
} from "@server/routers/remoteExitNode/types";
import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { build } from "@server/build";
import {
InfoSection,
InfoSectionContent,
@@ -36,6 +33,8 @@ import CopyToClipboard from "@app/components/CopyToClipboard";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export default function CredentialsPage() {
const { env } = useEnvContext();
@@ -45,6 +44,8 @@ export default function CredentialsPage() {
const t = useTranslations();
const { remoteExitNode } = useRemoteExitNodeContext();
const { isPaidUser } = usePaidStatus();
const [modalOpen, setModalOpen] = useState(false);
const [credentials, setCredentials] =
useState<PickRemoteExitNodeDefaultsResponse | null>(null);
@@ -57,16 +58,6 @@ export default function CredentialsPage() {
const [showCredentialsAlert, setShowCredentialsAlert] = useState(false);
const [shouldDisconnect, setShouldDisconnect] = useState(true);
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext();
const isSecurityFeatureDisabled = () => {
const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked();
const isSaasNotSubscribed =
build === "saas" && !subscription?.isSubscribed();
return isEnterpriseNotLicensed || isSaasNotSubscribed;
};
const handleConfirmRegenerate = async () => {
try {
const response = await api.get<
@@ -138,7 +129,9 @@ export default function CredentialsPage() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<PaidFeaturesAlert />
<PaidFeaturesAlert
tiers={tierMatrix.rotateCredentials}
/>
<InfoSections cols={3}>
<InfoSection>
@@ -195,27 +188,33 @@ export default function CredentialsPage() {
</Alert>
)}
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
variant="outline"
onClick={() => {
setShouldDisconnect(false);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
>
{t("regenerateCredentialsButton")}
</Button>
<Button
onClick={() => {
setShouldDisconnect(true);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
>
{t("remoteExitNodeRegenerateAndDisconnect")}
</Button>
</SettingsSectionFooter>
{!env.flags.disableEnterpriseFeatures && (
<SettingsSectionFooter>
<Button
variant="outline"
onClick={() => {
setShouldDisconnect(false);
setModalOpen(true);
}}
disabled={
!isPaidUser(tierMatrix.rotateCredentials)
}
>
{t("regenerateCredentialsButton")}
</Button>
<Button
onClick={() => {
setShouldDisconnect(true);
setModalOpen(true);
}}
disabled={
!isPaidUser(tierMatrix.rotateCredentials)
}
>
{t("remoteExitNodeRegenerateAndDisconnect")}
</Button>
</SettingsSectionFooter>
)}
</SettingsSection>
</SettingsContainer>

View File

@@ -47,8 +47,8 @@ import { ListIdpsResponse } from "@server/routers/idp";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import Image from "next/image";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { TierId } from "@server/lib/billing/tiers";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type UserType = "internal" | "oidc";
@@ -76,7 +76,7 @@ export default function Page() {
const api = createApiClient({ env });
const t = useTranslations();
const subscription = useSubscriptionStatusContext();
const { hasSaasSubscription } = usePaidStatus();
const [selectedOption, setSelectedOption] = useState<string | null>(
"internal"
@@ -238,7 +238,7 @@ export default function Page() {
}
async function fetchIdps() {
if (build === "saas" && !subscription?.subscribed) {
if (build === "saas" && !hasSaasSubscription(tierMatrix.orgOidc)) {
return;
}

View File

@@ -19,9 +19,6 @@ import { useTranslations } from "next-intl";
import { PickClientDefaultsResponse } from "@server/routers/client";
import { useClientContext } from "@app/hooks/useClientContext";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { build } from "@server/build";
import {
InfoSection,
InfoSectionContent,
@@ -33,6 +30,8 @@ import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { OlmInstallCommands } from "@app/components/olm-install-commands";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export default function CredentialsPage() {
const { env } = useEnvContext();
@@ -54,17 +53,7 @@ export default function CredentialsPage() {
const [showCredentialsAlert, setShowCredentialsAlert] = useState(false);
const [shouldDisconnect, setShouldDisconnect] = useState(true);
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext();
const isSecurityFeatureDisabled = () => {
const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked();
const isSaasNotSubscribed =
build === "saas" && !subscription?.isSubscribed();
return (
isEnterpriseNotLicensed || isSaasNotSubscribed || build === "oss"
);
};
const { isPaidUser } = usePaidStatus();
const handleConfirmRegenerate = async () => {
try {
@@ -130,7 +119,9 @@ export default function CredentialsPage() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<PaidFeaturesAlert />
<PaidFeaturesAlert
tiers={tierMatrix.rotateCredentials}
/>
<InfoSections cols={3}>
<InfoSection>
@@ -183,27 +174,33 @@ export default function CredentialsPage() {
</Alert>
)}
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
variant="outline"
onClick={() => {
setShouldDisconnect(false);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
>
{t("regenerateCredentialsButton")}
</Button>
<Button
onClick={() => {
setShouldDisconnect(true);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
>
{t("clientRegenerateAndDisconnect")}
</Button>
</SettingsSectionFooter>
{!env.flags.disableEnterpriseFeatures && (
<SettingsSectionFooter>
<Button
variant="outline"
onClick={() => {
setShouldDisconnect(false);
setModalOpen(true);
}}
disabled={
!isPaidUser(tierMatrix.rotateCredentials)
}
>
{t("regenerateCredentialsButton")}
</Button>
<Button
onClick={() => {
setShouldDisconnect(true);
setModalOpen(true);
}}
disabled={
!isPaidUser(tierMatrix.rotateCredentials)
}
>
{t("clientRegenerateAndDisconnect")}
</Button>
</SettingsSectionFooter>
)}
</SettingsSection>
<OlmInstallCommands

View File

@@ -40,6 +40,7 @@ import {
import { useParams } from "next/navigation";
import { FaApple, FaWindows, FaLinux } from "react-icons/fa";
import { SiAndroid } from "react-icons/si";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
function formatTimestamp(timestamp: number | null | undefined): string {
if (!timestamp) return "-";
@@ -152,11 +153,13 @@ export default function GeneralPage() {
const [approvalId, setApprovalId] = useState<number | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [, startTransition] = useTransition();
const { env } = useEnvContext();
const showApprovalFeatures = build !== "oss" && isPaidUser;
const showApprovalFeatures =
build !== "oss" && isPaidUser(tierMatrix.deviceApprovals);
const formatPostureValue = (value: boolean | null | undefined) => {
if (value === null || value === undefined) return "-";
const formatPostureValue = (value: boolean | null | undefined | "-") => {
if (value === null || value === undefined || value === "-") return "-";
return (
<div className="flex items-center gap-2">
{value ? (
@@ -567,242 +570,280 @@ export default function GeneralPage() {
</SettingsSection>
)}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("deviceSecurity")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("deviceSecurityDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
{!env.flags.disableEnterpriseFeatures && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("deviceSecurity")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("deviceSecurityDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<PaidFeaturesAlert />
{client.posture &&
Object.keys(client.posture).length > 0 ? (
<>
<InfoSections cols={3}>
{client.posture.biometricsEnabled !== null &&
client.posture.biometricsEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("biometricsEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.biometricsEnabled ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
<SettingsSectionBody>
<PaidFeaturesAlert tiers={tierMatrix.devicePosture} />
{client.posture.diskEncrypted !== null &&
client.posture.diskEncrypted !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("diskEncrypted")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.diskEncrypted ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture &&
Object.keys(client.posture).length > 0 ? (
<>
<InfoSections cols={3}>
{client.posture.biometricsEnabled !==
null &&
client.posture.biometricsEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("biometricsEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.biometricsEnabled ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.firewallEnabled !== null &&
client.posture.firewallEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("firewallEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.firewallEnabled ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.diskEncrypted !== null &&
client.posture.diskEncrypted !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("diskEncrypted")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.diskEncrypted ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.autoUpdatesEnabled !== null &&
client.posture.autoUpdatesEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("autoUpdatesEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.autoUpdatesEnabled ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.firewallEnabled !== null &&
client.posture.firewallEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("firewallEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.firewallEnabled ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.tpmAvailable !== null &&
client.posture.tpmAvailable !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("tpmAvailable")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.tpmAvailable ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.autoUpdatesEnabled !==
null &&
client.posture.autoUpdatesEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("autoUpdatesEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.autoUpdatesEnabled ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.windowsAntivirusEnabled !==
null &&
client.posture.windowsAntivirusEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("windowsAntivirusEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.windowsAntivirusEnabled ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.tpmAvailable !== null &&
client.posture.tpmAvailable !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("tpmAvailable")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.tpmAvailable ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.macosSipEnabled !== null &&
client.posture.macosSipEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("macosSipEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.macosSipEnabled ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.windowsAntivirusEnabled !==
null &&
client.posture
.windowsAntivirusEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t(
"windowsAntivirusEnabled"
)}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.windowsAntivirusEnabled ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.macosGatekeeperEnabled !==
null &&
client.posture.macosGatekeeperEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("macosGatekeeperEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.macosGatekeeperEnabled ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.macosSipEnabled !== null &&
client.posture.macosSipEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("macosSipEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.macosSipEnabled ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.macosFirewallStealthMode !==
null &&
client.posture.macosFirewallStealthMode !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("macosFirewallStealthMode")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.macosFirewallStealthMode ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.macosGatekeeperEnabled !==
null &&
client.posture
.macosGatekeeperEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t(
"macosGatekeeperEnabled"
)}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.macosGatekeeperEnabled ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.linuxAppArmorEnabled !== null &&
client.posture.linuxAppArmorEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("linuxAppArmorEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.linuxAppArmorEnabled ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.macosFirewallStealthMode !==
null &&
client.posture
.macosFirewallStealthMode !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t(
"macosFirewallStealthMode"
)}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.macosFirewallStealthMode ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.linuxSELinuxEnabled !== null &&
client.posture.linuxSELinuxEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("linuxSELinuxEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.linuxSELinuxEnabled ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
</InfoSections>
</>
) : (
<div className="text-muted-foreground">
{t("noData")}
</div>
)}
</SettingsSectionBody>
</SettingsSection>
{client.posture.linuxAppArmorEnabled !==
null &&
client.posture.linuxAppArmorEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("linuxAppArmorEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.linuxAppArmorEnabled ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
{client.posture.linuxSELinuxEnabled !==
null &&
client.posture.linuxSELinuxEnabled !==
undefined && (
<InfoSection>
<InfoSectionTitle>
{t("linuxSELinuxEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser(
tierMatrix.devicePosture
)
? formatPostureValue(
client.posture
.linuxSELinuxEnabled ===
true
)
: "-"}
</InfoSectionContent>
</InfoSection>
)}
</InfoSections>
</>
) : (
<div className="text-muted-foreground">
{t("noData")}
</div>
)}
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
);
}

View File

@@ -1,5 +1,5 @@
import AuthPageBrandingForm from "@app/components/AuthPageBrandingForm";
import AuthPageSettings from "@app/components/private/AuthPageSettings";
import AuthPageSettings from "@app/components/AuthPageSettings";
import { SettingsContainer } from "@app/components/Settings";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";

View File

@@ -10,6 +10,7 @@ import { getTranslations } from "next-intl/server";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
import { build } from "@server/build";
import { pullEnv } from "@app/lib/pullEnv";
type GeneralSettingsProps = {
children: React.ReactNode;
@@ -23,6 +24,7 @@ export default async function GeneralSettingsPage({
const { orgId } = await params;
const user = await verifySession();
const env = pullEnv();
if (!user) {
redirect(`/`);
@@ -56,10 +58,15 @@ export default async function GeneralSettingsPage({
title: t("security"),
href: `/{orgId}/settings/general/security`
},
{
title: t("authPage"),
href: `/{orgId}/settings/general/auth-page`
}
// PaidFeaturesAlert
...(!env.flags.disableEnterpriseFeatures
? [
{
title: t("authPage"),
href: `/{orgId}/settings/general/auth-page`
}
]
: [])
];
return (

View File

@@ -43,6 +43,7 @@ import { SwitchInput } from "@app/components/SwitchInput";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import type { OrgContextType } from "@app/contexts/orgContext";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
// Session length options in hours
const SESSION_LENGTH_OPTIONS = [
@@ -102,10 +103,13 @@ type SectionFormProps = {
export default function SecurityPage() {
const { org } = useOrgContext();
const { env } = useEnvContext();
return (
<SettingsContainer>
<LogRetentionSectionForm org={org.org} />
<SecuritySettingsSectionForm org={org.org} />
{!env.flags.disableEnterpriseFeatures && (
<SecuritySettingsSectionForm org={org.org} />
)}
</SettingsContainer>
);
}
@@ -132,10 +136,11 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
const router = useRouter();
const t = useTranslations();
const { isPaidUser, hasSaasSubscription } = usePaidStatus();
const { isPaidUser, subscriptionTier } = usePaidStatus();
const [, formAction, loadingSave] = useActionState(performSave, null);
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const api = createApiClient({ env });
async function performSave() {
const isValid = await form.trigger();
@@ -213,13 +218,35 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
<SelectContent>
{LOG_RETENTION_OPTIONS.filter(
(option) => {
if (
hasSaasSubscription &&
option.value >
30
) {
if (build != "saas") {
return true;
}
let maxDays: number;
if (!subscriptionTier) {
// No tier
maxDays = 3;
} else if (subscriptionTier == "enterprise") {
// Enterprise - no limit
return true;
} else if (subscriptionTier == "tier3") {
maxDays = 90;
} else if (subscriptionTier == "tier2") {
maxDays = 30;
} else if (subscriptionTier == "tier1") {
maxDays = 7;
} else {
// Default to most restrictive
maxDays = 3;
}
// Filter out options that exceed the max
// Special values: -1 (forever) and 9001 (end of year) should be filtered
if (option.value < 0 || option.value > maxDays) {
return false;
}
return true;
}
).map((option) => (
@@ -238,120 +265,216 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
)}
/>
<PaidFeaturesAlert />
{!env.flags.disableEnterpriseFeatures && (
<>
<PaidFeaturesAlert
tiers={tierMatrix.accessLogs}
/>
<FormField
control={form.control}
name="settingsLogRetentionDaysAccess"
render={({ field }) => {
const isDisabled = !isPaidUser;
<FormField
control={form.control}
name="settingsLogRetentionDaysAccess"
render={({ field }) => {
const isDisabled = !isPaidUser(
tierMatrix.accessLogs
);
return (
<FormItem>
<FormLabel>
{t("logRetentionAccessLabel")}
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(value) => {
if (!isDisabled) {
field.onChange(
parseInt(
value,
10
)
);
}
}}
disabled={isDisabled}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.map(
(option) => (
<SelectItem
key={
option.value
}
value={option.value.toString()}
>
{t(
option.label
)}
</SelectItem>
)
return (
<FormItem>
<FormLabel>
{t(
"logRetentionAccessLabel"
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="settingsLogRetentionDaysAction"
render={({ field }) => {
const isDisabled = !isPaidUser;
return (
<FormItem>
<FormLabel>
{t("logRetentionActionLabel")}
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(value) => {
if (!isDisabled) {
field.onChange(
parseInt(
value,
10
)
);
}
}}
disabled={isDisabled}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.map(
(option) => (
<SelectItem
key={
option.value
}
value={option.value.toString()}
>
{t(
option.label
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(
value
) => {
if (
!isDisabled
) {
field.onChange(
parseInt(
value,
10
)
);
}
}}
disabled={
isDisabled
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
</SelectItem>
)
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.filter(
(option) => {
if (build != "saas") {
return true;
}
let maxDays: number;
if (!subscriptionTier) {
// No tier
maxDays = 3;
} else if (subscriptionTier == "enterprise") {
// Enterprise - no limit
return true;
} else if (subscriptionTier == "tier3") {
maxDays = 90;
} else if (subscriptionTier == "tier2") {
maxDays = 30;
} else if (subscriptionTier == "tier1") {
maxDays = 7;
} else {
// Default to most restrictive
maxDays = 3;
}
// Filter out options that exceed the max
// Special values: -1 (forever) and 9001 (end of year) should be filtered
if (option.value < 0 || option.value > maxDays) {
return false;
}
return true;
}
).map(
(
option
) => (
<SelectItem
key={
option.value
}
value={option.value.toString()}
>
{t(
option.label
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="settingsLogRetentionDaysAction"
render={({ field }) => {
const isDisabled = !isPaidUser(
tierMatrix.actionLogs
);
return (
<FormItem>
<FormLabel>
{t(
"logRetentionActionLabel"
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(
value
) => {
if (
!isDisabled
) {
field.onChange(
parseInt(
value,
10
)
);
}
}}
disabled={
isDisabled
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.filter(
(option) => {
if (build != "saas") {
return true;
}
let maxDays: number;
if (!subscriptionTier) {
// No tier
maxDays = 3;
} else if (subscriptionTier == "enterprise") {
// Enterprise - no limit
return true;
} else if (subscriptionTier == "tier3") {
maxDays = 90;
} else if (subscriptionTier == "tier2") {
maxDays = 30;
} else if (subscriptionTier == "tier1") {
maxDays = 7;
} else {
// Default to most restrictive
maxDays = 3;
}
// Filter out options that exceed the max
// Special values: -1 (forever) and 9001 (end of year) should be filtered
if (option.value < 0 || option.value > maxDays) {
return false;
}
return true;
}
).map(
(
option
) => (
<SelectItem
key={
option.value
}
value={option.value.toString()}
>
{t(
option.label
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</>
)}
</form>
</Form>
</SettingsSectionForm>
@@ -493,12 +616,17 @@ function SecuritySettingsSectionForm({ org }: SectionFormProps) {
id="security-settings-section-form"
className="space-y-4"
>
<PaidFeaturesAlert />
<PaidFeaturesAlert
tiers={tierMatrix.twoFactorEnforcement}
/>
<FormField
control={form.control}
name="requireTwoFactor"
render={({ field }) => {
const isDisabled = !isPaidUser;
const isDisabled = !isPaidUser(
tierMatrix.twoFactorEnforcement
);
return (
<FormItem className="col-span-2">
@@ -545,7 +673,9 @@ function SecuritySettingsSectionForm({ org }: SectionFormProps) {
control={form.control}
name="maxSessionLengthHours"
render={({ field }) => {
const isDisabled = !isPaidUser;
const isDisabled = !isPaidUser(
tierMatrix.sessionDurationPolicies
);
return (
<FormItem className="col-span-2">
@@ -625,7 +755,9 @@ function SecuritySettingsSectionForm({ org }: SectionFormProps) {
control={form.control}
name="passwordExpiryDays"
render={({ field }) => {
const isDisabled = !isPaidUser;
const isDisabled = !isPaidUser(
tierMatrix.passwordExpirationPolicies
);
return (
<FormItem className="col-span-2">
@@ -711,7 +843,12 @@ function SecuritySettingsSectionForm({ org }: SectionFormProps) {
type="submit"
form="security-settings-section-form"
loading={loadingSave}
disabled={loadingSave || !isPaidUser}
disabled={
loadingSave ||
!isPaidUser(tierMatrix.twoFactorEnforcement) ||
!isPaidUser(tierMatrix.sessionDurationPolicies) ||
!isPaidUser(tierMatrix.passwordExpirationPolicies)
}
>
{t("saveSettings")}
</Button>

View File

@@ -13,14 +13,13 @@ import { ArrowUpRight, Key, User } from "lucide-react";
import Link from "next/link";
import { ColumnFilter } from "@app/components/ColumnFilter";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { build } from "@server/build";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import axios from "axios";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export default function GeneralPage() {
const router = useRouter();
@@ -28,8 +27,8 @@ export default function GeneralPage() {
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { orgId } = useParams();
const subscription = useSubscriptionStatusContext();
const { isUnlocked } = useLicenseStatusContext();
const { isPaidUser } = usePaidStatus();
const [rows, setRows] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
@@ -208,11 +207,7 @@ export default function GeneralPage() {
}
) => {
console.log("Date range changed:", { startDate, endDate, page, size });
if (
(build == "saas" && !subscription?.subscribed) ||
(build == "enterprise" && !isUnlocked()) ||
build === "oss"
) {
if (!isPaidUser(tierMatrix.accessLogs) || build === "oss") {
console.log(
"Access denied: subscription inactive or license locked"
);
@@ -613,7 +608,7 @@ export default function GeneralPage() {
description={t("accessLogsDescription")}
/>
<PaidFeaturesAlert />
<PaidFeaturesAlert tiers={tierMatrix.accessLogs} />
<LogDataTable
columns={columns}
@@ -623,6 +618,9 @@ export default function GeneralPage() {
isRefreshing={isRefreshing}
onExport={() => startTransition(exportData)}
isExporting={isExporting}
// isExportDisabled={ // not disabling this because the user should be able to click the button and get the feedback about needing to upgrade the plan
// !isPaidUser(tierMatrix.accessLogs) || build === "oss"
// }
onDateRangeChange={handleDateRangeChange}
dateRange={{
start: dateRange.startDate,
@@ -642,11 +640,7 @@ export default function GeneralPage() {
// Row expansion props
expandable={true}
renderExpandedRow={renderExpandedRow}
disabled={
(build == "saas" && !subscription?.subscribed) ||
(build == "enterprise" && !isUnlocked()) ||
build === "oss"
}
disabled={!isPaidUser(tierMatrix.accessLogs) || build === "oss"}
/>
</>
);

View File

@@ -4,15 +4,14 @@ import { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { ColumnDef } from "@tanstack/react-table";
import axios from "axios";
import { Key, User } from "lucide-react";
@@ -26,8 +25,8 @@ export default function GeneralPage() {
const t = useTranslations();
const { orgId } = useParams();
const searchParams = useSearchParams();
const subscription = useSubscriptionStatusContext();
const { isUnlocked } = useLicenseStatusContext();
const { isPaidUser } = usePaidStatus();
const [rows, setRows] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
@@ -195,10 +194,7 @@ export default function GeneralPage() {
}
) => {
console.log("Date range changed:", { startDate, endDate, page, size });
if (
(build == "saas" && !subscription?.subscribed) ||
(build == "enterprise" && !isUnlocked())
) {
if (!isPaidUser(tierMatrix.actionLogs)) {
console.log(
"Access denied: subscription inactive or license locked"
);
@@ -465,7 +461,7 @@ export default function GeneralPage() {
description={t("actionLogsDescription")}
/>
<PaidFeaturesAlert />
<PaidFeaturesAlert tiers={tierMatrix.actionLogs} />
<LogDataTable
columns={columns}
@@ -476,6 +472,9 @@ export default function GeneralPage() {
onRefresh={refreshData}
isRefreshing={isRefreshing}
onExport={() => startTransition(exportData)}
// isExportDisabled={ // not disabling this because the user should be able to click the button and get the feedback about needing to upgrade the plan
// !isPaidUser(tierMatrix.logExport) || build === "oss"
// }
isExporting={isExporting}
onDateRangeChange={handleDateRangeChange}
dateRange={{
@@ -496,11 +495,7 @@ export default function GeneralPage() {
// Row expansion props
expandable={true}
renderExpandedRow={renderExpandedRow}
disabled={
(build == "saas" && !subscription?.subscribed) ||
(build == "enterprise" && !isUnlocked()) ||
build === "oss"
}
disabled={!isPaidUser(tierMatrix.actionLogs) || build === "oss"}
/>
</>
);

View File

@@ -1,54 +1,3 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import AuthPageSettings, {
AuthPageSettingsRef
} from "@app/components/private/AuthPageSettings";
import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { toast } from "@app/hooks/useToast";
import { useState, useRef } from "react";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
import { useRouter } from "next/navigation";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
export default function GeneralPage() {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const router = useRouter();
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { env } = useEnvContext();
return <p>dfas</p>;
return null;
}

View File

@@ -44,6 +44,7 @@ import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { orgQueries, resourceQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UserType } from "@server/types/UserTypes";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import SetResourcePasswordForm from "components/SetResourcePasswordForm";
@@ -131,7 +132,7 @@ export default function ResourceAuthenticationPage() {
const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery(
orgQueries.identityProviders({
orgId: org.org.orgId,
useOrgOnlyIdp: env.flags.useOrgOnlyIdp
useOrgOnlyIdp: env.app.identityProviderMode === "org"
})
);
@@ -164,7 +165,7 @@ export default function ResourceAuthenticationPage() {
const allIdps = useMemo(() => {
if (build === "saas") {
if (isPaidUser) {
if (isPaidUser(tierMatrix.orgOidc)) {
return orgIdps.map((idp) => ({
id: idp.idpId,
text: idp.name

View File

@@ -50,9 +50,6 @@ import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import { build } from "@server/build";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import {
@@ -64,6 +61,7 @@ import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { GetResourceResponse } from "@server/routers/resource/getResource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type MaintenanceSectionFormProps = {
resource: GetResourceResponse;
@@ -77,8 +75,6 @@ function MaintenanceSectionForm({
const { env } = useEnvContext();
const t = useTranslations();
const api = createApiClient({ env });
const { isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext();
const { isPaidUser } = usePaidStatus();
const MaintenanceFormSchema = z.object({
@@ -159,13 +155,6 @@ function MaintenanceSectionForm({
}
}
const isSecurityFeatureDisabled = () => {
const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked();
const isSaasNotSubscribed =
build === "saas" && !subscription?.isSubscribed();
return isEnterpriseNotLicensed || isSaasNotSubscribed || build === "oss";
};
if (!resource.http) {
return null;
}
@@ -189,13 +178,16 @@ function MaintenanceSectionForm({
className="space-y-4"
id="maintenance-settings-form"
>
<PaidFeaturesAlert></PaidFeaturesAlert>
<PaidFeaturesAlert
tiers={tierMatrix.maintencePage}
/>
<FormField
control={maintenanceForm.control}
name="maintenanceModeEnabled"
render={({ field }) => {
const isDisabled =
isSecurityFeatureDisabled() || resource.http === false;
!isPaidUser(tierMatrix.maintencePage) ||
resource.http === false;
return (
<FormItem>
@@ -261,7 +253,11 @@ function MaintenanceSectionForm({
defaultValue={
field.value
}
disabled={isSecurityFeatureDisabled()}
disabled={
!isPaidUser(
tierMatrix.maintencePage
)
}
className="flex flex-col space-y-1"
>
<FormItem className="flex items-start space-x-3 space-y-0">
@@ -334,7 +330,11 @@ function MaintenanceSectionForm({
<FormControl>
<Input
{...field}
disabled={isSecurityFeatureDisabled()}
disabled={
!isPaidUser(
tierMatrix.maintencePage
)
}
placeholder="We'll be back soon!"
/>
</FormControl>
@@ -360,7 +360,11 @@ function MaintenanceSectionForm({
<Textarea
{...field}
rows={4}
disabled={isSecurityFeatureDisabled()}
disabled={
!isPaidUser(
tierMatrix.maintencePage
)
}
placeholder={t(
"maintenancePageMessagePlaceholder"
)}
@@ -389,7 +393,11 @@ function MaintenanceSectionForm({
<FormControl>
<Input
{...field}
disabled={isSecurityFeatureDisabled()}
disabled={
!isPaidUser(
tierMatrix.maintencePage
)
}
placeholder={t(
"maintenanceTime"
)}
@@ -415,7 +423,10 @@ function MaintenanceSectionForm({
<Button
type="submit"
loading={maintenanceSaveLoading}
disabled={maintenanceSaveLoading || !isPaidUser }
disabled={
maintenanceSaveLoading ||
!isPaidUser(tierMatrix.maintencePage)
}
form="maintenance-settings-form"
>
{t("saveSettings")}
@@ -741,10 +752,12 @@ export default function GeneralForm() {
</SettingsSectionFooter>
</SettingsSection>
<MaintenanceSectionForm
resource={resource}
updateResource={updateResource}
/>
{!env.flags.disableEnterpriseFeatures && (
<MaintenanceSectionForm
resource={resource}
updateResource={updateResource}
/>
)}
</SettingsContainer>
<Credenza

View File

@@ -20,9 +20,6 @@ import { PickSiteDefaultsResponse } from "@server/routers/site";
import { useSiteContext } from "@app/hooks/useSiteContext";
import { generateKeypair } from "../wireguardConfig";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { build } from "@server/build";
import {
InfoSection,
InfoSectionContent,
@@ -40,6 +37,8 @@ import {
import { QRCodeCanvas } from "qrcode.react";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { NewtSiteInstallCommands } from "@app/components/newt-install-commands";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export default function CredentialsPage() {
const { env } = useEnvContext();
@@ -65,17 +64,7 @@ export default function CredentialsPage() {
const [loadingDefaults, setLoadingDefaults] = useState(false);
const [shouldDisconnect, setShouldDisconnect] = useState(true);
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext();
const isSecurityFeatureDisabled = () => {
const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked();
const isSaasNotSubscribed =
build === "saas" && !subscription?.isSubscribed();
return (
isEnterpriseNotLicensed || isSaasNotSubscribed || build === "oss"
);
};
const { isPaidUser } = usePaidStatus();
// Fetch site defaults for wireguard sites to show in obfuscated config
useEffect(() => {
@@ -207,7 +196,9 @@ export default function CredentialsPage() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<PaidFeaturesAlert />
<PaidFeaturesAlert
tiers={tierMatrix.rotateCredentials}
/>
<SettingsSectionBody>
<InfoSections cols={3}>
@@ -271,27 +262,37 @@ export default function CredentialsPage() {
</Alert>
)}
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
variant="outline"
onClick={() => {
setShouldDisconnect(false);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
>
{t("regenerateCredentialsButton")}
</Button>
<Button
onClick={() => {
setShouldDisconnect(true);
setModalOpen(true);
}}
disabled={isSecurityFeatureDisabled()}
>
{t("siteRegenerateAndDisconnect")}
</Button>
</SettingsSectionFooter>
{!env.flags.disableEnterpriseFeatures && (
<SettingsSectionFooter>
<Button
variant="outline"
onClick={() => {
setShouldDisconnect(false);
setModalOpen(true);
}}
disabled={
!isPaidUser(
tierMatrix.rotateCredentials
)
}
>
{t("regenerateCredentialsButton")}
</Button>
<Button
onClick={() => {
setShouldDisconnect(true);
setModalOpen(true);
}}
disabled={
!isPaidUser(
tierMatrix.rotateCredentials
)
}
>
{t("siteRegenerateAndDisconnect")}
</Button>
</SettingsSectionFooter>
)}
</SettingsSection>
<NewtSiteInstallCommands
@@ -313,7 +314,9 @@ export default function CredentialsPage() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<PaidFeaturesAlert />
<PaidFeaturesAlert
tiers={tierMatrix.rotateCredentials}
/>
<SettingsSectionBody>
{!loadingDefaults && (
@@ -383,14 +386,20 @@ export default function CredentialsPage() {
</>
)}
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={() => setModalOpen(true)}
disabled={isSecurityFeatureDisabled()}
>
{t("siteRegenerateAndDisconnect")}
</Button>
</SettingsSectionFooter>
{!env.flags.disableEnterpriseFeatures && (
<SettingsSectionFooter>
<Button
onClick={() => setModalOpen(true)}
disabled={
!isPaidUser(
tierMatrix.rotateCredentials
)
}
>
{t("siteRegenerateAndDisconnect")}
</Button>
</SettingsSectionFooter>
)}
</SettingsSection>
)}
</SettingsContainer>

View File

@@ -50,7 +50,7 @@ export default async function UsersPage(props: PageProps) {
title={t("userTitle")}
description={t("userDescription")}
/>
<Alert variant="neutral" className="mb-6">
<Alert className="mb-6">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("userAbount")}

View File

@@ -76,12 +76,13 @@ export default async function Page(props: {
// Only use SmartLoginForm if NOT (OSS build OR org-only IdP enabled)
const useSmartLogin =
build === "saas" || (build === "enterprise" && env.flags.useOrgOnlyIdp);
build === "saas" ||
(build === "enterprise" && env.app.identityProviderMode === "org");
let loginIdps: LoginFormIDP[] = [];
if (!useSmartLogin) {
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
if (build === "oss" || !env.flags.useOrgOnlyIdp) {
if (build === "oss" || env.app.identityProviderMode !== "org") {
const idpsRes = await cache(
async () =>
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
@@ -165,7 +166,8 @@ export default async function Page(props: {
forceLogin={forceLogin}
showOrgLogin={
!isInvite &&
(build === "saas" || env.flags.useOrgOnlyIdp)
(build === "saas" ||
env.app.identityProviderMode === "org")
}
searchParams={searchParams}
defaultUser={defaultUser}
@@ -188,7 +190,8 @@ export default async function Page(props: {
</p>
)}
{!isInvite && (build === "saas" || env.flags.useOrgOnlyIdp) ? (
{!isInvite &&
(build === "saas" || env.app.identityProviderMode === "org") ? (
<OrgSignInLink
href={`/auth/org${buildQueryString(searchParams)}`}
linkText={t("orgAuthSignInToOrg")}

View File

@@ -24,7 +24,7 @@ export default async function OrgAuthPage(props: {
const env = pullEnv();
if (build !== "saas" && !env.flags.useOrgOnlyIdp) {
if (build !== "saas" && env.app.identityProviderMode !== "org") {
const queryString = new URLSearchParams(searchParams as any).toString();
redirect(`/auth/login${queryString ? `?${queryString}` : ""}`);
}

View File

@@ -14,7 +14,7 @@ import {
LoadLoginPageResponse
} from "@server/routers/loginPage/types";
import { GetSessionTransferTokenRenponse } from "@server/routers/auth/types";
import ValidateSessionTransferToken from "@app/components/private/ValidateSessionTransferToken";
import ValidateSessionTransferToken from "@app/components/ValidateSessionTransferToken";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { OrgSelectionForm } from "@app/components/OrgSelectionForm";
import OrgLoginPage from "@app/components/OrgLoginPage";
@@ -35,7 +35,7 @@ export default async function OrgAuthPage(props: {
const env = pullEnv();
if (build !== "saas" && !env.flags.useOrgOnlyIdp) {
if (build !== "saas" && env.app.identityProviderMode !== "org") {
redirect("/");
}

View File

@@ -23,11 +23,10 @@ import type {
LoadLoginPageBrandingResponse,
LoadLoginPageResponse
} from "@server/routers/loginPage/types";
import { GetOrgTierResponse } from "@server/routers/billing/types";
import { TierId } from "@server/lib/billing/tiers";
import { CheckOrgUserAccessResponse } from "@server/routers/org";
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
export const dynamic = "force-dynamic";
@@ -110,6 +109,11 @@ export default async function ResourceAuthPage(props: {
} catch (e) {}
}
const normalizedPostAuthPath = normalizePostAuthPath(authInfo.postAuthPath);
if (normalizedPostAuthPath) {
redirectUrl = new URL(authInfo.url).origin + normalizedPostAuthPath;
}
const hasAuth =
authInfo.password ||
authInfo.pincode ||
@@ -204,7 +208,7 @@ export default async function ResourceAuthPage(props: {
}
let loginIdps: LoginFormIDP[] = [];
if (build === "saas" || env.flags.useOrgOnlyIdp) {
if (build === "saas" || env.app.identityProviderMode === "org") {
if (subscribed) {
const idpsRes = await cache(
async () =>

View File

@@ -5,7 +5,7 @@ import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider";
import { pullEnv } from "@app/lib/pullEnv";
import ThemeDataProvider from "@app/providers/ThemeDataProvider";
import SplashImage from "@app/components/private/SplashImage";
import SplashImage from "@app/components/SplashImage";
import SupportStatusProvider from "@app/providers/SupporterStatusProvider";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";

View File

@@ -121,16 +121,28 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
href: "/{orgId}/settings/access/roles",
icon: <Users className="size-4 flex-none" />
},
{
title: "sidebarIdentityProviders",
href: "/{orgId}/settings/idp",
icon: <Fingerprint className="size-4 flex-none" />
},
{
title: "sidebarApprovals",
href: "/{orgId}/settings/access/approvals",
icon: <UserCog className="size-4 flex-none" />
},
// PaidFeaturesAlert
...((build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
build === "saas" ||
env?.app.identityProviderMode === "org" ||
(env?.app.identityProviderMode === undefined && build !== "oss")
? [
{
title: "sidebarIdentityProviders",
href: "/{orgId}/settings/idp",
icon: <Fingerprint className="size-4 flex-none" />
}
]
: []),
...(!env?.flags.disableEnterpriseFeatures
? [
{
title: "sidebarApprovals",
href: "/{orgId}/settings/access/approvals",
icon: <UserCog className="size-4 flex-none" />
}
]
: []),
{
title: "sidebarShareableLinks",
href: "/{orgId}/settings/share-links",
@@ -147,16 +159,20 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
href: "/{orgId}/settings/logs/request",
icon: <SquareMousePointer className="size-4 flex-none" />
},
{
title: "sidebarLogsAccess",
href: "/{orgId}/settings/logs/access",
icon: <ScanEye className="size-4 flex-none" />
},
{
title: "sidebarLogsAction",
href: "/{orgId}/settings/logs/action",
icon: <Logs className="size-4 flex-none" />
}
...(!env?.flags.disableEnterpriseFeatures
? [
{
title: "sidebarLogsAccess",
href: "/{orgId}/settings/logs/access",
icon: <ScanEye className="size-4 flex-none" />
},
{
title: "sidebarLogsAction",
href: "/{orgId}/settings/logs/action",
icon: <Logs className="size-4 flex-none" />
}
]
: [])
];
const analytics = {
@@ -236,7 +252,9 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
href: "/admin/api-keys",
icon: <KeyRound className="size-4 flex-none" />
},
...(build === "oss" || !env?.flags.useOrgOnlyIdp
...(build === "oss" ||
env?.app.identityProviderMode === "global" ||
env?.app.identityProviderMode === undefined
? [
{
title: "sidebarIdentityProviders",

View File

@@ -31,6 +31,7 @@ import { Separator } from "./ui/separator";
import { InfoPopup } from "./ui/info-popup";
import { ApprovalsEmptyState } from "./ApprovalsEmptyState";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export type ApprovalFeedProps = {
orgId: string;
@@ -63,7 +64,7 @@ export function ApprovalFeed({
isFetchingNextPage
} = useInfiniteQuery({
...approvalQueries.listApprovals(orgId, filters),
enabled: isPaidUser
enabled: isPaidUser(tierMatrix.deviceApprovals)
});
const approvals = data?.pages.flatMap((data) => data.approvals) ?? [];

View File

@@ -35,6 +35,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { build } from "@server/build";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export type AuthPageCustomizationProps = {
orgId: string;
@@ -139,14 +140,14 @@ export default function AuthPageBrandingForm({
`Choose your preferred authentication method for {{resourceName}}`,
primaryColor: branding?.primaryColor ?? `#f36117` // default pangolin primary color
},
disabled: !isPaidUser
disabled: !isPaidUser(tierMatrix.loginPageBranding)
});
async function updateBranding() {
const isValid = await form.trigger();
const brandingData = form.getValues();
if (!isValid || !isPaidUser) return;
if (!isValid || !isPaidUser(tierMatrix.loginPageBranding)) return;
try {
const updateRes = await api.put(
@@ -177,8 +178,6 @@ export default function AuthPageBrandingForm({
}
async function deleteBranding() {
if (!isPaidUser) return;
try {
const updateRes = await api.delete(
`/org/${orgId}/login-page-branding`
@@ -221,7 +220,9 @@ export default function AuthPageBrandingForm({
<SettingsSectionBody>
<SettingsSectionForm>
<PaidFeaturesAlert />
<PaidFeaturesAlert
tiers={tierMatrix.loginPageBranding}
/>
<Form {...form}>
<form
@@ -321,7 +322,7 @@ export default function AuthPageBrandingForm({
</div>
{build === "saas" ||
env.env.flags.useOrgOnlyIdp ? (
env.env.app.identityProviderMode === "org" ? (
<>
<div className="mt-3 mb-6">
<SettingsSectionTitle>
@@ -436,7 +437,7 @@ export default function AuthPageBrandingForm({
disabled={
isUpdatingBranding ||
isDeletingBranding ||
!isPaidUser
!isPaidUser(tierMatrix.loginPageBranding)
}
className="gap-1"
>
@@ -451,7 +452,7 @@ export default function AuthPageBrandingForm({
disabled={
isUpdatingBranding ||
isDeletingBranding ||
!isPaidUser
!isPaidUser(tierMatrix.loginPageBranding)
}
>
{t("saveAuthPageBranding")}

View File

@@ -28,7 +28,7 @@ import { ListDomainsResponse } from "@server/routers/domain";
import { DomainRow } from "@app/components/DomainsTable";
import { toUnicode } from "punycode";
import { Globe, Trash2 } from "lucide-react";
import CertificateStatus from "@app/components/private/CertificateStatus";
import CertificateStatus from "@app/components/CertificateStatus";
import {
Credenza,
CredenzaBody,
@@ -42,10 +42,10 @@ import {
import DomainPicker from "@app/components/DomainPicker";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { InfoPopup } from "@app/components/ui/info-popup";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { build } from "@server/build";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { PaidFeaturesAlert } from "../PaidFeaturesAlert";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
// Auth page form schema
const AuthPageFormSchema = z.object({
@@ -75,7 +75,7 @@ function AuthPageSettings({
const t = useTranslations();
const { env } = useEnvContext();
const { hasSaasSubscription } = usePaidStatus();
const { isPaidUser } = usePaidStatus();
// Auth page domain state
const [loginPage, setLoginPage] = useState(defaultLoginPage);
@@ -177,7 +177,7 @@ function AuthPageSettings({
try {
// Handle auth page domain
if (data.authPageDomainId) {
if (build === "enterprise" || hasSaasSubscription) {
if (isPaidUser(tierMatrix.loginPageDomain)) {
const sanitizedSubdomain = data.authPageSubdomain
? finalizeSubdomainSanitize(data.authPageSubdomain)
: "";
@@ -285,7 +285,7 @@ function AuthPageSettings({
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<PaidFeaturesAlert />
<PaidFeaturesAlert tiers={tierMatrix.loginPageDomain} />
<Form {...form}>
<form
@@ -361,7 +361,11 @@ function AuthPageSettings({
onClick={() =>
setEditDomainOpen(true)
}
disabled={!hasSaasSubscription}
disabled={
!isPaidUser(
tierMatrix.loginPageDomain
)
}
>
{form.watch("authPageDomainId")
? t("changeDomain")
@@ -376,7 +380,9 @@ function AuthPageSettings({
clearAuthPageDomain
}
disabled={
!hasSaasSubscription
!isPaidUser(
tierMatrix.loginPageDomain
)
}
>
<Trash2 size="14" />
@@ -395,7 +401,9 @@ function AuthPageSettings({
{env.flags.usePangolinDns &&
(build === "enterprise" ||
!hasSaasSubscription) &&
!isPaidUser(
tierMatrix.loginPageDomain
)) &&
loginPage?.domainId &&
loginPage?.fullDomain &&
!hasUnsavedChanges && (
@@ -424,7 +432,7 @@ function AuthPageSettings({
disabled={
isSubmitting ||
!hasUnsavedChanges ||
!hasSaasSubscription
!isPaidUser(tierMatrix.loginPageDomain)
}
>
{t("saveAuthPageDomain")}
@@ -477,7 +485,10 @@ function AuthPageSettings({
handleDomainSelection(selectedDomain);
}
}}
disabled={!selectedDomain || !hasSaasSubscription}
disabled={
!selectedDomain ||
!isPaidUser(tierMatrix.loginPageDomain)
}
>
{t("selectDomain")}
</Button>

View File

@@ -20,6 +20,8 @@ import {
import { Input } from "@app/components/ui/input";
import { useTranslations } from "next-intl";
import { Control, FieldValues, Path } from "react-hook-form";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type Role = {
roleId: number;
@@ -49,6 +51,8 @@ export default function AutoProvisionConfigWidget<T extends FieldValues>({
}: AutoProvisionConfigWidgetProps<T>) {
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
return (
<div className="space-y-4">
<div className="mb-4">
@@ -57,6 +61,7 @@ export default function AutoProvisionConfigWidget<T extends FieldValues>({
label={t("idpAutoProvisionUsers")}
defaultChecked={autoProvision}
onCheckedChange={onAutoProvisionChange}
disabled={!isPaidUser(tierMatrix.autoProvisioning)}
/>
<span className="text-sm text-muted-foreground">
{t("idpAutoProvisionUsersDescription")}

View File

@@ -300,7 +300,7 @@ export default function CreateInternalResourceDialog({
const [udpCustomPorts, setUdpCustomPorts] = useState<string>("");
const availableSites = sites.filter(
(site) => site.type === "newt" && site.subnet
(site) => site.type === "newt"
);
const form = useForm<FormData>({

View File

@@ -36,6 +36,7 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { CheckboxWithLabel } from "./ui/checkbox";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type CreateRoleFormProps = {
open: boolean;
@@ -51,6 +52,7 @@ export default function CreateRoleForm({
const { org } = useOrgContext();
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
const { env } = useEnvContext();
const formSchema = z.object({
name: z
@@ -161,50 +163,60 @@ export default function CreateRoleForm({
)}
/>
<PaidFeaturesAlert />
{!env.flags.disableEnterpriseFeatures && (
<>
<PaidFeaturesAlert
tiers={tierMatrix.deviceApprovals}
/>
<FormField
control={form.control}
name="requireDeviceApproval"
render={({ field }) => (
<FormItem className="my-2">
<FormControl>
<CheckboxWithLabel
{...field}
disabled={!isPaidUser}
value="on"
checked={form.watch(
"requireDeviceApproval"
)}
onCheckedChange={(
checked
) => {
if (
checked !==
"indeterminate"
) {
form.setValue(
"requireDeviceApproval",
<FormField
control={form.control}
name="requireDeviceApproval"
render={({ field }) => (
<FormItem className="my-2">
<FormControl>
<CheckboxWithLabel
{...field}
disabled={
!isPaidUser(
tierMatrix.deviceApprovals
)
}
value="on"
checked={form.watch(
"requireDeviceApproval"
)}
onCheckedChange={(
checked
);
}
}}
label={t(
"requireDeviceApproval"
)}
/>
</FormControl>
) => {
if (
checked !==
"indeterminate"
) {
form.setValue(
"requireDeviceApproval",
checked
);
}
}}
label={t(
"requireDeviceApproval"
)}
/>
</FormControl>
<FormDescription>
{t(
"requireDeviceApprovalDescription"
)}
</FormDescription>
<FormDescription>
{t(
"requireDeviceApprovalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</form>
</Form>
</CredenzaBody>

View File

@@ -394,7 +394,7 @@ export default function EditInternalResourceDialog({
);
const availableSites = sites.filter(
(site) => site.type === "newt" && site.subnet
(site) => site.type === "newt"
);
const form = useForm<FormData>({

View File

@@ -42,6 +42,7 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { CheckboxWithLabel } from "./ui/checkbox";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type CreateRoleFormProps = {
role: Role;
@@ -59,6 +60,7 @@ export default function EditRoleForm({
const { org } = useOrgContext();
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
const { env } = useEnvContext();
const formSchema = z.object({
name: z
@@ -168,50 +170,61 @@ export default function EditRoleForm({
</FormItem>
)}
/>
<PaidFeaturesAlert />
<FormField
control={form.control}
name="requireDeviceApproval"
render={({ field }) => (
<FormItem className="my-2">
<FormControl>
<CheckboxWithLabel
{...field}
disabled={!isPaidUser}
value="on"
checked={form.watch(
"requireDeviceApproval"
)}
onCheckedChange={(
checked
) => {
if (
checked !==
"indeterminate"
) {
form.setValue(
"requireDeviceApproval",
{!env.flags.disableEnterpriseFeatures && (
<>
<PaidFeaturesAlert
tiers={tierMatrix.deviceApprovals}
/>
<FormField
control={form.control}
name="requireDeviceApproval"
render={({ field }) => (
<FormItem className="my-2">
<FormControl>
<CheckboxWithLabel
{...field}
disabled={
!isPaidUser(
tierMatrix.deviceApprovals
)
}
value="on"
checked={form.watch(
"requireDeviceApproval"
)}
onCheckedChange={(
checked
);
}
}}
label={t(
"requireDeviceApproval"
)}
/>
</FormControl>
) => {
if (
checked !==
"indeterminate"
) {
form.setValue(
"requireDeviceApproval",
checked
);
}
}}
label={t(
"requireDeviceApproval"
)}
/>
</FormControl>
<FormDescription>
{t(
"requireDeviceApprovalDescription"
)}
</FormDescription>
<FormDescription>
{t(
"requireDeviceApprovalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</form>
</Form>
</CredenzaBody>

View File

@@ -0,0 +1,65 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { Info } from "lucide-react";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build";
export function IdpGlobalModeBanner() {
const t = useTranslations();
const { env } = useEnvContext();
const { isPaidUser, hasEnterpriseLicense } = usePaidStatus();
const identityProviderModeUndefined =
env.app.identityProviderMode === undefined;
const paidUserForOrgOidc = isPaidUser(tierMatrix.orgOidc);
const enterpriseUnlicensed =
build === "enterprise" && !hasEnterpriseLicense;
if (build === "saas") {
return null;
}
if (!identityProviderModeUndefined) {
return null;
}
const adminPanelLinkRenderer = (chunks: React.ReactNode) => (
<Link href="/admin/idp" className="font-medium underline">
{chunks}
</Link>
);
return (
<Alert className="mb-6">
<Info className="h-4 w-4" />
<AlertDescription>
{paidUserForOrgOidc
? t.rich("idpGlobalModeBanner", {
adminPanelLink: adminPanelLinkRenderer,
configDocsLink: (chunks) => (
<Link
href="https://docs.pangolin.net/manage/identity-providers/add-an-idp#organization-identity-providers"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline"
>
{chunks}
</Link>
)
})
: enterpriseUnlicensed
? t.rich("idpGlobalModeBannerLicenseRequired", {
adminPanelLink: adminPanelLinkRenderer
})
: t.rich("idpGlobalModeBannerUpgradeRequired", {
adminPanelLink: adminPanelLinkRenderer
})}
</AlertDescription>
</Alert>
);
}

View File

@@ -39,7 +39,7 @@ export default function InviteStatusCard({
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [type, setType] = useState<
"rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in"
"rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in" | "user_limit_exceeded"
>("rejected");
useEffect(() => {
@@ -75,6 +75,11 @@ export default function InviteStatusCard({
error.includes("You must be logged in to accept an invite")
) {
return "not_logged_in";
} else if (
error.includes("user limit is exceeded") ||
error.includes("Can not accept")
) {
return "user_limit_exceeded";
} else {
return "rejected";
}
@@ -145,6 +150,17 @@ export default function InviteStatusCard({
<p className="text-center">{t("inviteCreateUser")}</p>
</div>
);
} else if (type === "user_limit_exceeded") {
return (
<div>
<p className="text-center mb-4 font-semibold">
Cannot Accept Invite
</p>
<p className="text-center text-sm">
This organization has reached its user limit. Please contact the organization administrator to upgrade their plan before accepting this invite.
</p>
</div>
);
}
}
@@ -165,6 +181,16 @@ export default function InviteStatusCard({
);
} else if (type === "user_does_not_exist") {
return <Button onClick={goToSignup}>{t("createAnAccount")}</Button>;
} else if (type === "user_limit_exceeded") {
return (
<Button
onClick={() => {
router.push("/");
}}
>
{t("goHome")}
</Button>
);
}
}

View File

@@ -120,6 +120,7 @@ type DataTableProps<TData, TValue> = {
// Row expansion props
expandable?: boolean;
renderExpandedRow?: (row: TData) => React.ReactNode;
isExportDisabled?: boolean;
};
export function LogDataTable<TData, TValue>({
@@ -145,7 +146,8 @@ export function LogDataTable<TData, TValue>({
isLoading = false,
expandable = false,
disabled = false,
renderExpandedRow
renderExpandedRow,
isExportDisabled
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
@@ -403,7 +405,7 @@ export function LogDataTable<TData, TValue>({
onClick={() =>
!disabled && onExport()
}
disabled={isExporting || disabled}
disabled={isExporting || disabled || isExportDisabled}
>
{isExporting ? (
<Loader className="mr-2 size-4 animate-spin" />

View File

@@ -2,11 +2,9 @@
import { useState } from "react";
import { Button } from "@app/components/ui/button";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useTranslations } from "next-intl";
import { Separator } from "./ui/separator";
import LoginPasswordForm from "./LoginPasswordForm";
import IdpLoginButtons from "./private/IdpLoginButtons";
import IdpLoginButtons from "./IdpLoginButtons";
import { LookupUserResponse } from "@server/routers/auth/lookupUser";
import UserProfileCard from "./UserProfileCard";

View File

@@ -37,7 +37,7 @@ export const MachineClientsBanner = ({ orgId }: MachineClientsBannerProps) => {
</Button>
</Link>
<Link
href="https://docs.pangolin.net/manage/clients/install-client#docker"
href="https://docs.pangolin.net/manage/clients/install-client#docker-pangolin-cli"
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -2,7 +2,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { IdpDataTable } from "@app/components/private/OrgIdpDataTable";
import { IdpDataTable } from "@app/components/OrgIdpDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useState } from "react";

View File

@@ -3,7 +3,7 @@ import {
LoadLoginPageBrandingResponse,
LoadLoginPageResponse
} from "@server/routers/loginPage/types";
import IdpLoginButtons from "@app/components/private/IdpLoginButtons";
import IdpLoginButtons from "@app/components/IdpLoginButtons";
import {
Card,
CardContent,

View File

@@ -1,28 +1,134 @@
"use client";
import { Card, CardContent } from "@app/components/ui/card";
import { build } from "@server/build";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { ExternalLink, KeyRound, Sparkles } from "lucide-react";
import { ExternalLink, KeyRound } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Tier } from "@server/types/Tiers";
import { useParams } from "next/navigation";
const TIER_ORDER: Tier[] = ["tier1", "tier2", "tier3", "enterprise"];
const TIER_TRANSLATION_KEYS: Record<Tier, "subscriptionTierTier1" | "subscriptionTierTier2" | "subscriptionTierTier3" | "subscriptionTierEnterprise"> = {
tier1: "subscriptionTierTier1",
tier2: "subscriptionTierTier2",
tier3: "subscriptionTierTier3",
enterprise: "subscriptionTierEnterprise"
};
function getRequiredTier(tiers: Tier[]): Tier | null {
if (tiers.length === 0) return null;
let min: Tier | null = null;
for (const tier of tiers) {
const idx = TIER_ORDER.indexOf(tier);
if (idx === -1) continue;
if (min === null || TIER_ORDER.indexOf(min) > idx) {
min = tier;
}
}
return min;
}
const bannerClassName =
"mb-6 border-primary/30 bg-linear-to-br from-primary/10 via-background to-background overflow-hidden";
"mb-6 border-purple-500/30 bg-linear-to-br from-purple-500/10 via-background to-background overflow-hidden";
const bannerContentClassName = "py-3 px-4";
const bannerRowClassName =
"flex items-center gap-2.5 text-sm text-muted-foreground";
const bannerIconClassName = "size-4 shrink-0 text-purple-500";
const docsLinkClassName =
"inline-flex items-center gap-1 font-medium text-purple-600 underline";
const PANGOLIN_CLOUD_SIGNUP_URL = "https://app.pangolin.net/auth/signup/";
const ENTERPRISE_DOCS_URL =
"https://docs.pangolin.net/self-host/enterprise-edition";
export function PaidFeaturesAlert() {
function getTierLinkRenderer(billingHref: string) {
return function tierLinkRenderer(chunks: React.ReactNode) {
return (
<Link href={billingHref} className={docsLinkClassName}>
{chunks}
</Link>
);
};
}
function getPangolinCloudLinkRenderer() {
return function pangolinCloudLinkRenderer(chunks: React.ReactNode) {
return (
<Link
href={PANGOLIN_CLOUD_SIGNUP_URL}
target="_blank"
rel="noopener noreferrer"
className={docsLinkClassName}
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</Link>
);
};
}
function getDocsLinkRenderer(href: string) {
return function docsLinkRenderer(chunks: React.ReactNode) {
return (
<Link
href={href}
target="_blank"
rel="noopener noreferrer"
className={docsLinkClassName}
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</Link>
);
};
}
type Props = {
tiers: Tier[];
};
export function PaidFeaturesAlert({ tiers }: Props) {
const t = useTranslations();
const { hasSaasSubscription, hasEnterpriseLicense } = usePaidStatus();
const params = useParams();
const orgId = params?.orgId as string | undefined;
const { hasSaasSubscription, hasEnterpriseLicense, isActive, subscriptionTier } = usePaidStatus();
const { env } = useEnvContext();
const requiredTier = getRequiredTier(tiers);
const requiredTierName = requiredTier ? t(TIER_TRANSLATION_KEYS[requiredTier]) : null;
const billingHref = orgId ? `/${orgId}/settings/billing` : "https://pangolin.net/pricing";
const tierLinkRenderer = getTierLinkRenderer(billingHref);
const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer();
const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL);
if (env.flags.disableEnterpriseFeatures) {
return null;
}
return (
<>
{build === "saas" && !hasSaasSubscription ? (
{build === "saas" && !hasSaasSubscription(tiers) ? (
<Card className={bannerClassName}>
<CardContent className={bannerContentClassName}>
<div className={bannerRowClassName}>
<KeyRound className="size-4 shrink-0 text-primary" />
<span>{t("subscriptionRequiredToUse")}</span>
<KeyRound className={bannerIconClassName} />
<span>
{requiredTierName
? isActive
? t.rich("upgradeToTierToUse", {
tier: requiredTierName,
tierLink: tierLinkRenderer
})
: t.rich("subscriptionRequiredTierToUse", {
tier: requiredTierName,
tierLink: tierLinkRenderer
})
: isActive
? t("mustUpgradeToUse")
: t("subscriptionRequiredToUse")}
</span>
</div>
</CardContent>
</Card>
@@ -32,8 +138,13 @@ export function PaidFeaturesAlert() {
<Card className={bannerClassName}>
<CardContent className={bannerContentClassName}>
<div className={bannerRowClassName}>
<KeyRound className="size-4 shrink-0 text-primary" />
<span>{t("licenseRequiredToUse")}</span>
<KeyRound className={bannerIconClassName} />
<span>
{t.rich("licenseRequiredToUse", {
enterpriseLicenseLink: enterpriseDocsLinkRenderer,
pangolinCloudLink: pangolinCloudLinkRenderer
})}
</span>
</div>
</CardContent>
</Card>
@@ -43,20 +154,11 @@ export function PaidFeaturesAlert() {
<Card className={bannerClassName}>
<CardContent className={bannerContentClassName}>
<div className={bannerRowClassName}>
<KeyRound className="size-4 shrink-0 text-primary" />
<KeyRound className={bannerIconClassName} />
<span>
{t.rich("ossEnterpriseEditionRequired", {
enterpriseEditionLink: (chunks) => (
<Link
href="https://docs.pangolin.net/self-host/enterprise-edition"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium text-foreground underline"
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</Link>
)
enterpriseEditionLink: enterpriseDocsLinkRenderer,
pangolinCloudLink: pangolinCloudLinkRenderer
})}
</span>
</div>

View File

@@ -118,7 +118,7 @@ function getActionsCategories(root: boolean) {
}
};
if (root || build === "saas" || env.flags.useOrgOnlyIdp) {
if (root || build === "saas" || env.app.identityProviderMode === "org") {
actionsByCategory["Identity Provider (IDP)"] = {
[t("actionCreateIdp")]: "createIdp",
[t("actionUpdateIdp")]: "updateIdp",

View File

@@ -11,7 +11,7 @@ import {
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import CertificateStatus from "@app/components/private/CertificateStatus";
import CertificateStatus from "@app/components/CertificateStatus";
import { toUnicode } from "punycode";
import { useEnvContext } from "@app/hooks/useEnvContext";

View File

@@ -5,16 +5,12 @@ import DeleteRoleForm from "@app/components/DeleteRoleForm";
import { RolesDataTable } from "@app/components/RolesDataTable";
import { Button } from "@app/components/ui/button";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { Role } from "@server/db";
import { ArrowRight, ArrowUpDown, Link, MoreHorizontal } from "lucide-react";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -38,11 +34,6 @@ export default function UsersTable({ roles }: RolesTableProps) {
const [roleToRemove, setRoleToRemove] = useState<RoleRow | null>(null);
const api = createApiClient(useEnvContext());
const { org } = useOrgContext();
const { isPaidUser } = usePaidStatus();
const t = useTranslations();
const [isRefreshing, startTransition] = useTransition();

View File

@@ -204,7 +204,9 @@ export default function SignupForm({
? env.branding.logo?.authPage?.height || 44
: 44;
const showOrgBanner = fromSmartLogin && (build === "saas" || env.flags.useOrgOnlyIdp);
const showOrgBanner =
fromSmartLogin &&
(build === "saas" || env.app.identityProviderMode === "org");
const orgBannerHref = redirect
? `/auth/org?redirect=${encodeURIComponent(redirect)}`
: "/auth/org";
@@ -226,388 +228,398 @@ export default function SignupForm({
</Alert>
)}
<Card className="w-full max-w-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={logoHeight} width={logoWidth} />
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p>
</div>
</CardHeader>
<CardContent className="pt-6">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("email")}</FormLabel>
<FormControl>
<Input
{...field}
disabled={!!emailParam}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>{t("password")}</FormLabel>
{passwordStrength.strength ===
"strong" && (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
<FormControl>
<div className="relative">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={logoHeight} width={logoWidth} />
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p>
</div>
</CardHeader>
<CardContent className="pt-6">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("email")}</FormLabel>
<FormControl>
<Input
type="password"
{...field}
onChange={(e) => {
field.onChange(e);
setPasswordValue(
e.target.value
);
}}
className={cn(
passwordStrength.strength ===
"strong" &&
"border-green-500 focus-visible:ring-green-500",
passwordStrength.strength ===
"medium" &&
"border-yellow-500 focus-visible:ring-yellow-500",
passwordStrength.strength ===
"weak" &&
passwordValue.length >
0 &&
"border-red-500 focus-visible:ring-red-500"
)}
autoComplete="new-password"
disabled={!!emailParam}
/>
</div>
</FormControl>
{passwordValue.length > 0 && (
<div className="space-y-3 mt-2">
{/* Password Strength Meter */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground">
{t("passwordStrength")}
</span>
<span
className={cn(
"text-sm font-semibold",
passwordStrength.strength ===
"strong" &&
"text-green-600 dark:text-green-400",
passwordStrength.strength ===
"medium" &&
"text-yellow-600 dark:text-yellow-400",
passwordStrength.strength ===
"weak" &&
"text-red-600 dark:text-red-400"
)}
>
{t(
`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`
)}
</span>
</div>
<Progress
value={
passwordStrength.percentage
}
className="h-2"
/>
</div>
{/* Requirements Checklist */}
<div className="bg-muted rounded-lg p-3 space-y-2">
<div className="text-sm font-medium text-foreground mb-2">
{t("passwordRequirements")}
</div>
<div className="grid grid-cols-1 gap-1.5">
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.length ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.length
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementLengthText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.uppercase ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.uppercase
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementUppercaseText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.lowercase ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.lowercase
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementLowercaseText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.number ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.number
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementNumberText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.special ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.special
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementSpecialText"
)}
</span>
</div>
</div>
</div>
</div>
)}
{/* Only show FormMessage when not showing our custom requirements */}
{passwordValue.length === 0 && (
</FormControl>
<FormMessage />
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>
{t("confirmPassword")}
</FormLabel>
{doPasswordsMatch && (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
<FormControl>
<div className="relative">
<Input
type="password"
{...field}
onChange={(e) => {
field.onChange(e);
setConfirmPasswordValue(
e.target.value
);
}}
className={cn(
doPasswordsMatch &&
"border-green-500 focus-visible:ring-green-500",
confirmPasswordValue.length >
0 &&
!doPasswordsMatch &&
"border-red-500 focus-visible:ring-red-500"
)}
autoComplete="new-password"
/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>
{t("password")}
</FormLabel>
{passwordStrength.strength ===
"strong" && (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
</FormControl>
{confirmPasswordValue.length > 0 &&
!doPasswordsMatch && (
<p className="text-sm text-red-600 mt-1">
{t("passwordsDoNotMatch")}
</p>
)}
{/* Only show FormMessage when field is empty */}
{confirmPasswordValue.length === 0 && (
<FormMessage />
)}
</FormItem>
)}
/>
{build === "saas" && (
<>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="flex flex-row items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(
checked
) => {
field.onChange(checked);
handleTermsChange(
checked as boolean
<FormControl>
<div className="relative">
<Input
type="password"
{...field}
onChange={(e) => {
field.onChange(e);
setPasswordValue(
e.target.value
);
}}
/>
</FormControl>
<div className="leading-none">
<FormLabel className="text-sm font-normal">
<div>
{t(
"signUpTerms.IAgreeToThe"
)}{" "}
<a
href="https://pangolin.net/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.termsOfService"
)}{" "}
</a>
{t("signUpTerms.and")}{" "}
<a
href="https://pangolin.net/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.privacyPolicy"
)}
</a>
</div>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="marketingEmailConsent"
render={({ field }) => (
<FormItem className="flex flex-row items-start">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<div className="leading-none">
<FormLabel className="text-sm font-normal">
{t(
"signUpMarketing.keepMeInTheLoop"
className={cn(
passwordStrength.strength ===
"strong" &&
"border-green-500 focus-visible:ring-green-500",
passwordStrength.strength ===
"medium" &&
"border-yellow-500 focus-visible:ring-yellow-500",
passwordStrength.strength ===
"weak" &&
passwordValue.length >
0 &&
"border-red-500 focus-visible:ring-red-500"
)}
</FormLabel>
<FormMessage />
autoComplete="new-password"
/>
</div>
</FormItem>
)}
/>
</>
)}
</FormControl>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{passwordValue.length > 0 && (
<div className="space-y-3 mt-2">
{/* Password Strength Meter */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground">
{t(
"passwordStrength"
)}
</span>
<span
className={cn(
"text-sm font-semibold",
passwordStrength.strength ===
"strong" &&
"text-green-600 dark:text-green-400",
passwordStrength.strength ===
"medium" &&
"text-yellow-600 dark:text-yellow-400",
passwordStrength.strength ===
"weak" &&
"text-red-600 dark:text-red-400"
)}
>
{t(
`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`
)}
</span>
</div>
<Progress
value={
passwordStrength.percentage
}
className="h-2"
/>
</div>
<Button type="submit" className="w-full">
{t("createAccount")}
</Button>
</form>
</Form>
</CardContent>
</Card>
{/* Requirements Checklist */}
<div className="bg-muted rounded-lg p-3 space-y-2">
<div className="text-sm font-medium text-foreground mb-2">
{t(
"passwordRequirements"
)}
</div>
<div className="grid grid-cols-1 gap-1.5">
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.length ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.length
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementLengthText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.uppercase ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.uppercase
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementUppercaseText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.lowercase ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.lowercase
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementLowercaseText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.number ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.number
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementNumberText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.special ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.special
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementSpecialText"
)}
</span>
</div>
</div>
</div>
</div>
)}
{/* Only show FormMessage when not showing our custom requirements */}
{passwordValue.length === 0 && (
<FormMessage />
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>
{t("confirmPassword")}
</FormLabel>
{doPasswordsMatch && (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
<FormControl>
<div className="relative">
<Input
type="password"
{...field}
onChange={(e) => {
field.onChange(e);
setConfirmPasswordValue(
e.target.value
);
}}
className={cn(
doPasswordsMatch &&
"border-green-500 focus-visible:ring-green-500",
confirmPasswordValue.length >
0 &&
!doPasswordsMatch &&
"border-red-500 focus-visible:ring-red-500"
)}
autoComplete="new-password"
/>
</div>
</FormControl>
{confirmPasswordValue.length > 0 &&
!doPasswordsMatch && (
<p className="text-sm text-red-600 mt-1">
{t("passwordsDoNotMatch")}
</p>
)}
{/* Only show FormMessage when field is empty */}
{confirmPasswordValue.length === 0 && (
<FormMessage />
)}
</FormItem>
)}
/>
{build === "saas" && (
<>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="flex flex-row items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(
checked
) => {
field.onChange(
checked
);
handleTermsChange(
checked as boolean
);
}}
/>
</FormControl>
<div className="leading-none">
<FormLabel className="text-sm font-normal">
<div>
{t(
"signUpTerms.IAgreeToThe"
)}{" "}
<a
href="https://pangolin.net/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.termsOfService"
)}{" "}
</a>
{t(
"signUpTerms.and"
)}{" "}
<a
href="https://pangolin.net/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.privacyPolicy"
)}
</a>
</div>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="marketingEmailConsent"
render={({ field }) => (
<FormItem className="flex flex-row items-start">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<div className="leading-none">
<FormLabel className="text-sm font-normal">
{t(
"signUpMarketing.keepMeInTheLoop"
)}
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
</>
)}
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button type="submit" className="w-full">
{t("createAccount")}
</Button>
</form>
</Form>
</CardContent>
</Card>
</>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { Button } from "@app/components/ui/button";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
export default function SubscriptionViolation() {
const context = useSubscriptionStatusContext();
const [isDismissed, setIsDismissed] = useState(false);
const params = useParams();
const orgId = params?.orgId as string | undefined;
const t = useTranslations();
if (!context?.limitsExceeded || isDismissed) return null;
const billingHref = orgId ? `/${orgId}/settings/billing` : "/";
return (
<div className="fixed bottom-0 left-0 right-0 w-full bg-amber-600 text-white p-4 text-center z-50">
<div className="flex flex-wrap justify-center items-center gap-2 sm:gap-4">
<p className="text-sm sm:text-base">
{t("subscriptionViolationMessage")}
</p>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
className="bg-white/20 hover:bg-white/30 text-white border-0"
asChild
>
<Link href={billingHref}>
{t("subscriptionViolationViewBilling")}
</Link>
</Button>
<Button
variant="ghost"
size="sm"
className="hover:bg-white/20 text-white"
onClick={() => setIsDismissed(true)}
>
{t("dismiss")}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -18,11 +18,11 @@ export type CommandItem = string | { title: string; command: string };
const PLATFORMS = [
"unix",
"windows",
"docker",
"kubernetes",
"podman",
"nixos"
"nixos",
"windows"
] as const;
type Platform = (typeof PLATFORMS)[number];

View File

@@ -14,7 +14,7 @@ import { Button } from "./ui/button";
export type CommandItem = string | { title: string; command: string };
const PLATFORMS = ["unix", "windows", "docker"] as const;
const PLATFORMS = ["unix", "docker", "windows"] as const;
type Platform = (typeof PLATFORMS)[number];
@@ -43,7 +43,7 @@ export function OlmInstallCommands({
All: [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | sudo bash`
},
{
title: t("run"),
@@ -51,24 +51,12 @@ export function OlmInstallCommands({
}
]
},
windows: {
x64: [
{
title: t("install"),
command: `curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`
},
{
title: t("run"),
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
}
]
},
docker: {
"Docker Compose": [
`services:
olm:
image: fosrl/olm
container_name: olm
pangolin-cli:
image: fosrl/pangolin-cli
container_name: pangolin-cli
restart: unless-stopped
network_mode: host
cap_add:
@@ -77,11 +65,24 @@ export function OlmInstallCommands({
- /dev/net/tun:/dev/net/tun
environment:
- PANGOLIN_ENDPOINT=${endpoint}
- OLM_ID=${id}
- OLM_SECRET=${secret}`
- CLIENT_ID=${id}
- CLIENT_SECRET=${secret}`
],
"Docker Run": [
`docker run -dit --network host --cap-add NET_ADMIN --device /dev/net/tun:/dev/net/tun fosrl/olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
`docker run -dit --network host --cap-add NET_ADMIN --device /dev/net/tun:/dev/net/tun fosrl/pangolin-cli up client --id ${id} --secret ${secret} --endpoint ${endpoint} --attach`
]
},
windows: {
x64: [
{
title: t("install"),
command: `# Download and run the installer to install Olm first\n
curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`
},
{
title: t("run"),
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
}
]
}
};

View File

@@ -1,13 +1,15 @@
import { GetOrgSubscriptionResponse } from "@server/routers/billing/types";
import { Tier } from "@server/types/Tiers";
import { createContext } from "react";
type SubscriptionStatusContextType = {
subscriptionStatus: GetOrgSubscriptionResponse | null;
updateSubscriptionStatus: (updatedSite: GetOrgSubscriptionResponse) => void;
isActive: () => boolean;
getTier: () => string | null;
getTier: () => { tier: Tier | null; active: boolean };
isSubscribed: () => boolean;
subscribed: boolean;
/** True when org has exceeded plan limits (sites, users, etc.). Only set when build === saas. */
limitsExceeded: boolean;
};
const SubscriptionStatusContext = createContext<

View File

@@ -1,6 +1,7 @@
import { build } from "@server/build";
import { useLicenseStatusContext } from "./useLicenseStatusContext";
import { useSubscriptionStatusContext } from "./useSubscriptionStatusContext";
import { Tier } from "@server/types/Tiers";
export function usePaidStatus() {
const { isUnlocked } = useLicenseStatusContext();
@@ -8,14 +9,31 @@ export function usePaidStatus() {
// Check if features are disabled due to licensing/subscription
const hasEnterpriseLicense = build === "enterprise" && isUnlocked();
const hasSaasSubscription =
build === "saas" &&
subscription?.isSubscribed() &&
subscription.isActive();
const tierData = subscription?.getTier();
function hasSaasSubscription(tiers: Tier[]): boolean {
return (
(build === "saas" &&
tierData?.active &&
tierData?.tier &&
tiers.includes(tierData.tier)) ||
false
);
}
function isPaidUser(tiers: Tier[]): boolean {
if (hasEnterpriseLicense) {
return true;
}
return hasSaasSubscription(tiers);
}
return {
hasEnterpriseLicense,
hasSaasSubscription,
isPaidUser: hasEnterpriseLicense || hasSaasSubscription
isPaidUser,
isActive: tierData?.active,
subscriptionTier: tierData?.tier
};
}

View File

@@ -1,5 +1,4 @@
import { build } from "@server/build";
import { TierId } from "@server/lib/billing/tiers";
import { cache } from "react";
import { getCachedSubscription } from "./getCachedSubscription";
import { priv } from ".";
@@ -21,7 +20,7 @@ export const isOrgSubscribed = cache(async (orgId: string) => {
try {
const subRes = await getCachedSubscription(orgId);
subscribed =
subRes.data.data.tier === TierId.STANDARD &&
(subRes.data.data.tier == "tier1" || subRes.data.data.tier == "tier2" || subRes.data.data.tier == "tier3") &&
subRes.data.data.active;
} catch {}
}

View File

@@ -32,7 +32,11 @@ export function pullEnv(): Env {
process.env.NEW_RELEASES_NOTIFICATION_ENABLED === "true"
? true
: false
}
},
identityProviderMode: process.env.IDENTITY_PROVIDER_MODE as
| "org"
| "global"
| undefined
},
email: {
emailEnabled: process.env.EMAIL_ENABLED === "true" ? true : false
@@ -64,8 +68,10 @@ export function pullEnv(): Env {
process.env.FLAGS_DISABLE_PRODUCT_HELP_BANNERS === "true"
? true
: false,
useOrgOnlyIdp:
process.env.USE_ORG_ONLY_IDP === "true" ? true : false
disableEnterpriseFeatures:
process.env.DISABLE_ENTERPRISE_FEATURES === "true"
? true
: false
},
branding: {

View File

@@ -8,6 +8,7 @@ export type Env = {
product_updates: boolean;
new_releases: boolean;
};
identityProviderMode?: "global" | "org";
};
server: {
externalPort: string;
@@ -34,7 +35,7 @@ export type Env = {
hideSupporterKey: boolean;
usePangolinDns: boolean;
disableProductHelpBanners: boolean;
useOrgOnlyIdp: boolean;
disableEnterpriseFeatures: boolean;
};
branding: {
appName?: string;

View File

@@ -1,10 +1,10 @@
"use client";
import SubscriptionStatusContext from "@app/contexts/subscriptionStatusContext";
import { getTierPriceSet, TierId } from "@server/lib/billing/tiers";
import { GetOrgSubscriptionResponse } from "@server/routers/billing/types";
import { useState } from "react";
import { build } from "@server/build";
import { Tier } from "@server/types/Tiers";
interface ProviderProps {
children: React.ReactNode;
@@ -32,58 +32,53 @@ export function SubscriptionStatusProvider({
});
};
const isActive = () => {
if (subscriptionStatus?.subscriptions) {
// Check if any subscription is active
return subscriptionStatus.subscriptions.some(
(sub) => sub.subscription?.status === "active"
);
}
return false;
};
const getTier = () => {
const tierPriceSet = getTierPriceSet(env, sandbox_mode);
const getTier = (): {
tier: Tier | null;
active: boolean;
} => {
if (subscriptionStatus?.subscriptions) {
// Iterate through all subscriptions
for (const { subscription, items } of subscriptionStatus.subscriptions) {
if (items && items.length > 0) {
// Iterate through tiers in order (earlier keys are higher tiers)
for (const [tierId, priceId] of Object.entries(tierPriceSet)) {
// Check if any subscription item matches this tier's price ID
const matchingItem = items.find(
(item) => item.priceId === priceId
);
if (matchingItem) {
return tierId;
}
}
for (const { subscription } of subscriptionStatus.subscriptions) {
if (
subscription.type == "tier1" ||
subscription.type == "tier2" ||
subscription.type == "tier3"
) {
return {
tier: subscription.type,
active: subscription.status === "active"
};
}
}
}
return null;
return {
tier: null,
active: false
};
};
const isSubscribed = () => {
if (build === "enterprise") {
return true;
}
return getTier() === TierId.STANDARD;
const { tier, active } = getTier();
return (
(tier == "tier1" || tier == "tier2" || tier == "tier3") &&
active
);
};
const [subscribed, setSubscribed] = useState<boolean>(isSubscribed());
const limitsExceeded = subscriptionStatusState?.limitsExceeded ?? false;
return (
<SubscriptionStatusContext.Provider
value={{
subscriptionStatus: subscriptionStatusState,
updateSubscriptionStatus,
isActive,
getTier,
isSubscribed,
subscribed
subscribed,
limitsExceeded
}}
>
{children}
@@ -91,4 +86,4 @@ export function SubscriptionStatusProvider({
);
}
export default SubscriptionStatusProvider;
export default SubscriptionStatusProvider;