mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-19 03:16:40 +00:00
Merge branch 'dev' into feat/logo-path-in-enterprise
This commit is contained in:
@@ -18,6 +18,8 @@ import { build } from "@server/build";
|
||||
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;
|
||||
@@ -70,6 +72,7 @@ export default async function OrgLayout(props: {
|
||||
} catch (e) {}
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<ApplyInternalRedirect orgId={orgId} />
|
||||
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
|
||||
<OrgPolicyResult
|
||||
orgId={orgId}
|
||||
@@ -86,7 +89,7 @@ export default async function OrgLayout(props: {
|
||||
try {
|
||||
const getSubscription = cache(() =>
|
||||
internal.get<AxiosResponse<GetOrgSubscriptionResponse>>(
|
||||
`/org/${orgId}/billing/subscription`,
|
||||
`/org/${orgId}/billing/subscriptions`,
|
||||
cookie
|
||||
)
|
||||
);
|
||||
@@ -104,7 +107,9 @@ export default async function OrgLayout(props: {
|
||||
env={env.app.environment}
|
||||
sandbox_mode={env.app.sandbox_mode}
|
||||
>
|
||||
<ApplyInternalRedirect orgId={orgId} />
|
||||
{props.children}
|
||||
{build === "saas" && <SubscriptionViolation />}
|
||||
<SetLastOrgCookie orgId={orgId} />
|
||||
</SubscriptionStatusProvider>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
@@ -27,9 +28,11 @@ import {
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
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";
|
||||
@@ -49,8 +52,8 @@ export default function Page() {
|
||||
const [roleMappingMode, setRoleMappingMode] = useState<
|
||||
"role" | "expression"
|
||||
>("role");
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
const t = useTranslations();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const params = useParams();
|
||||
|
||||
@@ -361,6 +364,9 @@ export default function Page() {
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.autoProvisioning}
|
||||
/>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
@@ -806,7 +812,7 @@ export default function Page() {
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createLoading}
|
||||
disabled={createLoading || !isPaidUser(tierMatrix.orgOidc)}
|
||||
loading={createLoading}
|
||||
onClick={() => {
|
||||
// log any issues with the form
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { build } from "@server/build";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{}>;
|
||||
}
|
||||
|
||||
export default async function Layout(props: LayoutProps) {
|
||||
const env = pullEnv();
|
||||
|
||||
if (build !== "saas" && !env.flags.useOrgOnlyIdp) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return props.children;
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
|
||||
@@ -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,7 +188,7 @@ export default function CredentialsPage() {
|
||||
</Alert>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
{build !== "oss" && (
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -203,7 +196,9 @@ export default function CredentialsPage() {
|
||||
setShouldDisconnect(false);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
disabled={isSecurityFeatureDisabled()}
|
||||
disabled={
|
||||
!isPaidUser(tierMatrix.rotateCredentials)
|
||||
}
|
||||
>
|
||||
{t("regenerateCredentialsButton")}
|
||||
</Button>
|
||||
@@ -212,7 +207,9 @@ export default function CredentialsPage() {
|
||||
setShouldDisconnect(true);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
disabled={isSecurityFeatureDisabled()}
|
||||
disabled={
|
||||
!isPaidUser(tierMatrix.rotateCredentials)
|
||||
}
|
||||
>
|
||||
{t("remoteExitNodeRegenerateAndDisconnect")}
|
||||
</Button>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,15 +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;
|
||||
};
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const handleConfirmRegenerate = async () => {
|
||||
try {
|
||||
@@ -128,7 +119,9 @@ export default function CredentialsPage() {
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<PaidFeaturesAlert />
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.rotateCredentials}
|
||||
/>
|
||||
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
@@ -181,7 +174,7 @@ export default function CredentialsPage() {
|
||||
</Alert>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
{build !== "oss" && (
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -189,7 +182,9 @@ export default function CredentialsPage() {
|
||||
setShouldDisconnect(false);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
disabled={isSecurityFeatureDisabled()}
|
||||
disabled={
|
||||
!isPaidUser(tierMatrix.rotateCredentials)
|
||||
}
|
||||
>
|
||||
{t("regenerateCredentialsButton")}
|
||||
</Button>
|
||||
@@ -198,7 +193,9 @@ export default function CredentialsPage() {
|
||||
setShouldDisconnect(true);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
disabled={isSecurityFeatureDisabled()}
|
||||
disabled={
|
||||
!isPaidUser(tierMatrix.rotateCredentials)
|
||||
}
|
||||
>
|
||||
{t("clientRegenerateAndDisconnect")}
|
||||
</Button>
|
||||
|
||||
@@ -28,10 +28,19 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect, useTransition } from "react";
|
||||
import { Check, Ban, Shield, ShieldOff, Clock, CheckCircle2, XCircle } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
Ban,
|
||||
Shield,
|
||||
ShieldOff,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
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 "-";
|
||||
@@ -111,13 +120,13 @@ function getPlatformFieldConfig(
|
||||
osVersion: { show: true, labelKey: "iosVersion" },
|
||||
kernelVersion: { show: false, labelKey: "kernelVersion" },
|
||||
arch: { show: true, labelKey: "architecture" },
|
||||
deviceModel: { show: true, labelKey: "deviceModel" },
|
||||
deviceModel: { show: true, labelKey: "deviceModel" }
|
||||
},
|
||||
android: {
|
||||
osVersion: { show: true, labelKey: "androidVersion" },
|
||||
kernelVersion: { show: true, labelKey: "kernelVersion" },
|
||||
arch: { show: true, labelKey: "architecture" },
|
||||
deviceModel: { show: true, labelKey: "deviceModel" },
|
||||
deviceModel: { show: true, labelKey: "deviceModel" }
|
||||
},
|
||||
unknown: {
|
||||
osVersion: { show: true, labelKey: "osVersion" },
|
||||
@@ -133,7 +142,6 @@ function getPlatformFieldConfig(
|
||||
return configs[normalizedPlatform] || configs.unknown;
|
||||
}
|
||||
|
||||
|
||||
export default function GeneralPage() {
|
||||
const { client, updateClient } = useClientContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
@@ -145,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 ? (
|
||||
@@ -423,7 +433,8 @@ export default function GeneralPage() {
|
||||
{t(
|
||||
fieldConfig
|
||||
.osVersion
|
||||
?.labelKey || "osVersion"
|
||||
?.labelKey ||
|
||||
"osVersion"
|
||||
)}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
@@ -559,8 +570,7 @@ export default function GeneralPage() {
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* Device Security Section */}
|
||||
{build !== "oss" && (
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
@@ -572,20 +582,27 @@ export default function GeneralPage() {
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
{client.posture && Object.keys(client.posture).length > 0 ? (
|
||||
<PaidFeaturesAlert tiers={tierMatrix.devicePosture} />
|
||||
|
||||
{client.posture &&
|
||||
Object.keys(client.posture).length > 0 ? (
|
||||
<>
|
||||
{!isPaidUser && <PaidFeaturesAlert />}
|
||||
<InfoSections cols={3}>
|
||||
{client.posture.biometricsEnabled !== null &&
|
||||
client.posture.biometricsEnabled !== undefined && (
|
||||
{client.posture.biometricsEnabled !==
|
||||
null &&
|
||||
client.posture.biometricsEnabled !==
|
||||
undefined && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("biometricsEnabled")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{isPaidUser
|
||||
{isPaidUser(
|
||||
tierMatrix.devicePosture
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture.biometricsEnabled
|
||||
client.posture
|
||||
.biometricsEnabled
|
||||
)
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
@@ -593,15 +610,19 @@ export default function GeneralPage() {
|
||||
)}
|
||||
|
||||
{client.posture.diskEncrypted !== null &&
|
||||
client.posture.diskEncrypted !== undefined && (
|
||||
client.posture.diskEncrypted !==
|
||||
undefined && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("diskEncrypted")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{isPaidUser
|
||||
{isPaidUser(
|
||||
tierMatrix.devicePosture
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture.diskEncrypted
|
||||
client.posture
|
||||
.diskEncrypted
|
||||
)
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
@@ -609,31 +630,40 @@ export default function GeneralPage() {
|
||||
)}
|
||||
|
||||
{client.posture.firewallEnabled !== null &&
|
||||
client.posture.firewallEnabled !== undefined && (
|
||||
client.posture.firewallEnabled !==
|
||||
undefined && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("firewallEnabled")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{isPaidUser
|
||||
{isPaidUser(
|
||||
tierMatrix.devicePosture
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture.firewallEnabled
|
||||
client.posture
|
||||
.firewallEnabled
|
||||
)
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
|
||||
{client.posture.autoUpdatesEnabled !== null &&
|
||||
client.posture.autoUpdatesEnabled !== undefined && (
|
||||
{client.posture.autoUpdatesEnabled !==
|
||||
null &&
|
||||
client.posture.autoUpdatesEnabled !==
|
||||
undefined && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("autoUpdatesEnabled")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{isPaidUser
|
||||
{isPaidUser(
|
||||
tierMatrix.devicePosture
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture.autoUpdatesEnabled
|
||||
client.posture
|
||||
.autoUpdatesEnabled
|
||||
)
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
@@ -641,29 +671,40 @@ export default function GeneralPage() {
|
||||
)}
|
||||
|
||||
{client.posture.tpmAvailable !== null &&
|
||||
client.posture.tpmAvailable !== undefined && (
|
||||
client.posture.tpmAvailable !==
|
||||
undefined && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("tpmAvailable")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{isPaidUser
|
||||
{isPaidUser(
|
||||
tierMatrix.devicePosture
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture.tpmAvailable
|
||||
client.posture
|
||||
.tpmAvailable
|
||||
)
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
|
||||
{client.posture.windowsAntivirusEnabled !== null &&
|
||||
client.posture.windowsAntivirusEnabled !== undefined && (
|
||||
{client.posture.windowsAntivirusEnabled !==
|
||||
null &&
|
||||
client.posture
|
||||
.windowsAntivirusEnabled !==
|
||||
undefined && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("windowsAntivirusEnabled")}
|
||||
{t(
|
||||
"windowsAntivirusEnabled"
|
||||
)}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{isPaidUser
|
||||
{isPaidUser(
|
||||
tierMatrix.devicePosture
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture
|
||||
.windowsAntivirusEnabled
|
||||
@@ -674,30 +715,40 @@ export default function GeneralPage() {
|
||||
)}
|
||||
|
||||
{client.posture.macosSipEnabled !== null &&
|
||||
client.posture.macosSipEnabled !== undefined && (
|
||||
client.posture.macosSipEnabled !==
|
||||
undefined && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("macosSipEnabled")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{isPaidUser
|
||||
{isPaidUser(
|
||||
tierMatrix.devicePosture
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture.macosSipEnabled
|
||||
client.posture
|
||||
.macosSipEnabled
|
||||
)
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
|
||||
{client.posture.macosGatekeeperEnabled !== null &&
|
||||
client.posture.macosGatekeeperEnabled !==
|
||||
{client.posture.macosGatekeeperEnabled !==
|
||||
null &&
|
||||
client.posture
|
||||
.macosGatekeeperEnabled !==
|
||||
undefined && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("macosGatekeeperEnabled")}
|
||||
{t(
|
||||
"macosGatekeeperEnabled"
|
||||
)}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{isPaidUser
|
||||
{isPaidUser(
|
||||
tierMatrix.devicePosture
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture
|
||||
.macosGatekeeperEnabled
|
||||
@@ -707,15 +758,21 @@ export default function GeneralPage() {
|
||||
</InfoSection>
|
||||
)}
|
||||
|
||||
{client.posture.macosFirewallStealthMode !== null &&
|
||||
client.posture.macosFirewallStealthMode !==
|
||||
{client.posture.macosFirewallStealthMode !==
|
||||
null &&
|
||||
client.posture
|
||||
.macosFirewallStealthMode !==
|
||||
undefined && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("macosFirewallStealthMode")}
|
||||
{t(
|
||||
"macosFirewallStealthMode"
|
||||
)}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{isPaidUser
|
||||
{isPaidUser(
|
||||
tierMatrix.devicePosture
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture
|
||||
.macosFirewallStealthMode
|
||||
@@ -725,7 +782,8 @@ export default function GeneralPage() {
|
||||
</InfoSection>
|
||||
)}
|
||||
|
||||
{client.posture.linuxAppArmorEnabled !== null &&
|
||||
{client.posture.linuxAppArmorEnabled !==
|
||||
null &&
|
||||
client.posture.linuxAppArmorEnabled !==
|
||||
undefined && (
|
||||
<InfoSection>
|
||||
@@ -733,7 +791,9 @@ export default function GeneralPage() {
|
||||
{t("linuxAppArmorEnabled")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{isPaidUser
|
||||
{isPaidUser(
|
||||
tierMatrix.devicePosture
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture
|
||||
.linuxAppArmorEnabled
|
||||
@@ -743,7 +803,8 @@ export default function GeneralPage() {
|
||||
</InfoSection>
|
||||
)}
|
||||
|
||||
{client.posture.linuxSELinuxEnabled !== null &&
|
||||
{client.posture.linuxSELinuxEnabled !==
|
||||
null &&
|
||||
client.posture.linuxSELinuxEnabled !==
|
||||
undefined && (
|
||||
<InfoSection>
|
||||
@@ -751,7 +812,9 @@ export default function GeneralPage() {
|
||||
{t("linuxSELinuxEnabled")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{isPaidUser
|
||||
{isPaidUser(
|
||||
tierMatrix.devicePosture
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture
|
||||
.linuxSELinuxEnabled
|
||||
|
||||
@@ -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";
|
||||
@@ -20,11 +20,6 @@ export interface AuthPageProps {
|
||||
export default async function AuthPage(props: AuthPageProps) {
|
||||
const orgId = (await props.params).orgId;
|
||||
|
||||
// custom auth branding is only available in enterprise and saas
|
||||
if (build === "oss") {
|
||||
redirect(`/${orgId}/settings/general/`);
|
||||
}
|
||||
|
||||
let subscriptionStatus: GetOrgTierResponse | null = null;
|
||||
try {
|
||||
const subRes = await getCachedSubscription(orgId);
|
||||
|
||||
@@ -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(`/`);
|
||||
@@ -55,14 +57,17 @@ export default async function GeneralSettingsPage({
|
||||
{
|
||||
title: t("security"),
|
||||
href: `/{orgId}/settings/general/security`
|
||||
}
|
||||
},
|
||||
// PaidFeaturesAlert
|
||||
...(!env.flags.disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
title: t("authPage"),
|
||||
href: `/{orgId}/settings/general/auth-page`
|
||||
}
|
||||
]
|
||||
: [])
|
||||
];
|
||||
if (build !== "oss") {
|
||||
navItems.push({
|
||||
title: t("authPage"),
|
||||
href: `/{orgId}/settings/general/auth-page`
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -3,12 +3,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useActionState,
|
||||
type ComponentRef
|
||||
} from "react";
|
||||
import { useState, useRef, useActionState, type ComponentRef } from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -48,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 = [
|
||||
@@ -107,10 +103,13 @@ type SectionFormProps = {
|
||||
|
||||
export default function SecurityPage() {
|
||||
const { org } = useOrgContext();
|
||||
const { env } = useEnvContext();
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<LogRetentionSectionForm org={org.org} />
|
||||
{build !== "oss" && <SecuritySettingsSectionForm org={org.org} />}
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<SecuritySettingsSectionForm org={org.org} />
|
||||
)}
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -137,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();
|
||||
@@ -218,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) => (
|
||||
@@ -243,15 +265,19 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
)}
|
||||
/>
|
||||
|
||||
{build !== "oss" && (
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<>
|
||||
<PaidFeaturesAlert />
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.accessLogs}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsLogRetentionDaysAccess"
|
||||
render={({ field }) => {
|
||||
const isDisabled = !isPaidUser;
|
||||
const isDisabled = !isPaidUser(
|
||||
tierMatrix.accessLogs
|
||||
);
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
@@ -289,7 +315,40 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOG_RETENTION_OPTIONS.map(
|
||||
{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
|
||||
) => (
|
||||
@@ -317,7 +376,9 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
control={form.control}
|
||||
name="settingsLogRetentionDaysAction"
|
||||
render={({ field }) => {
|
||||
const isDisabled = !isPaidUser;
|
||||
const isDisabled = !isPaidUser(
|
||||
tierMatrix.actionLogs
|
||||
);
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
@@ -355,7 +416,40 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOG_RETENTION_OPTIONS.map(
|
||||
{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
|
||||
) => (
|
||||
@@ -522,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">
|
||||
@@ -574,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">
|
||||
@@ -654,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">
|
||||
@@ -740,7 +843,12 @@ function SecuritySettingsSectionForm({ org }: SectionFormProps) {
|
||||
type="submit"
|
||||
form="security-settings-section-form"
|
||||
loading={loadingSave}
|
||||
disabled={loadingSave}
|
||||
disabled={
|
||||
loadingSave ||
|
||||
!isPaidUser(tierMatrix.twoFactorEnforcement) ||
|
||||
!isPaidUser(tierMatrix.sessionDurationPolicies) ||
|
||||
!isPaidUser(tierMatrix.passwordExpirationPolicies)
|
||||
}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
|
||||
@@ -13,13 +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();
|
||||
@@ -27,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);
|
||||
@@ -207,10 +207,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.accessLogs) || build === "oss") {
|
||||
console.log(
|
||||
"Access denied: subscription inactive or license locked"
|
||||
);
|
||||
@@ -611,21 +608,7 @@ export default function GeneralPage() {
|
||||
description={t("accessLogsDescription")}
|
||||
/>
|
||||
|
||||
{build == "saas" && !subscription?.subscribed ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("subscriptionRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{build == "enterprise" && !isUnlocked() ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("licenseRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<PaidFeaturesAlert tiers={tierMatrix.accessLogs} />
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
@@ -635,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,
|
||||
@@ -654,10 +640,7 @@ export default function GeneralPage() {
|
||||
// Row expansion props
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
disabled={
|
||||
(build == "saas" && !subscription?.subscribed) ||
|
||||
(build == "enterprise" && !isUnlocked())
|
||||
}
|
||||
disabled={!isPaidUser(tierMatrix.accessLogs) || build === "oss"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
import { ColumnFilter } from "@app/components/ColumnFilter";
|
||||
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";
|
||||
@@ -25,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);
|
||||
@@ -92,6 +92,9 @@ export default function GeneralPage() {
|
||||
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
if (build === "oss") {
|
||||
return;
|
||||
}
|
||||
const defaultRange = getDefaultDateRange();
|
||||
queryDateTime(
|
||||
defaultRange.startDate,
|
||||
@@ -191,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"
|
||||
);
|
||||
@@ -461,21 +461,7 @@ export default function GeneralPage() {
|
||||
description={t("actionLogsDescription")}
|
||||
/>
|
||||
|
||||
{build == "saas" && !subscription?.subscribed ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("subscriptionRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{build == "enterprise" && !isUnlocked() ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("licenseRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<PaidFeaturesAlert tiers={tierMatrix.actionLogs} />
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
@@ -486,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={{
|
||||
@@ -506,10 +495,7 @@ export default function GeneralPage() {
|
||||
// Row expansion props
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
disabled={
|
||||
(build == "saas" && !subscription?.subscribed) ||
|
||||
(build == "enterprise" && !isUnlocked())
|
||||
}
|
||||
disabled={!isPaidUser(tierMatrix.actionLogs) || build === "oss"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const router = useRouter();
|
||||
@@ -110,6 +111,9 @@ export default function GeneralPage() {
|
||||
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
if (build === "oss") {
|
||||
return;
|
||||
}
|
||||
const defaultRange = getDefaultDateRange();
|
||||
queryDateTime(
|
||||
defaultRange.startDate,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
@@ -63,6 +60,8 @@ import {
|
||||
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;
|
||||
@@ -76,8 +75,7 @@ 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({
|
||||
maintenanceModeEnabled: z.boolean().optional(),
|
||||
@@ -157,13 +155,6 @@ function MaintenanceSectionForm({
|
||||
}
|
||||
}
|
||||
|
||||
const isSecurityFeatureDisabled = () => {
|
||||
const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked();
|
||||
const isSaasNotSubscribed =
|
||||
build === "saas" && !subscription?.isSubscribed();
|
||||
return isEnterpriseNotLicensed || isSaasNotSubscribed;
|
||||
};
|
||||
|
||||
if (!resource.http) {
|
||||
return null;
|
||||
}
|
||||
@@ -187,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>
|
||||
@@ -259,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">
|
||||
@@ -332,7 +330,11 @@ function MaintenanceSectionForm({
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={isSecurityFeatureDisabled()}
|
||||
disabled={
|
||||
!isPaidUser(
|
||||
tierMatrix.maintencePage
|
||||
)
|
||||
}
|
||||
placeholder="We'll be back soon!"
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -358,7 +360,11 @@ function MaintenanceSectionForm({
|
||||
<Textarea
|
||||
{...field}
|
||||
rows={4}
|
||||
disabled={isSecurityFeatureDisabled()}
|
||||
disabled={
|
||||
!isPaidUser(
|
||||
tierMatrix.maintencePage
|
||||
)
|
||||
}
|
||||
placeholder={t(
|
||||
"maintenancePageMessagePlaceholder"
|
||||
)}
|
||||
@@ -387,7 +393,11 @@ function MaintenanceSectionForm({
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={isSecurityFeatureDisabled()}
|
||||
disabled={
|
||||
!isPaidUser(
|
||||
tierMatrix.maintencePage
|
||||
)
|
||||
}
|
||||
placeholder={t(
|
||||
"maintenanceTime"
|
||||
)}
|
||||
@@ -413,7 +423,10 @@ function MaintenanceSectionForm({
|
||||
<Button
|
||||
type="submit"
|
||||
loading={maintenanceSaveLoading}
|
||||
disabled={maintenanceSaveLoading}
|
||||
disabled={
|
||||
maintenanceSaveLoading ||
|
||||
!isPaidUser(tierMatrix.maintencePage)
|
||||
}
|
||||
form="maintenance-settings-form"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
@@ -739,7 +752,7 @@ export default function GeneralForm() {
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
{build !== "oss" && (
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<MaintenanceSectionForm
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
|
||||
@@ -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,15 +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;
|
||||
};
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
// Fetch site defaults for wireguard sites to show in obfuscated config
|
||||
useEffect(() => {
|
||||
@@ -205,7 +196,9 @@ export default function CredentialsPage() {
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<PaidFeaturesAlert />
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.rotateCredentials}
|
||||
/>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<InfoSections cols={3}>
|
||||
@@ -269,7 +262,7 @@ export default function CredentialsPage() {
|
||||
</Alert>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
{build !== "oss" && (
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -277,7 +270,11 @@ export default function CredentialsPage() {
|
||||
setShouldDisconnect(false);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
disabled={isSecurityFeatureDisabled()}
|
||||
disabled={
|
||||
!isPaidUser(
|
||||
tierMatrix.rotateCredentials
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("regenerateCredentialsButton")}
|
||||
</Button>
|
||||
@@ -286,7 +283,11 @@ export default function CredentialsPage() {
|
||||
setShouldDisconnect(true);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
disabled={isSecurityFeatureDisabled()}
|
||||
disabled={
|
||||
!isPaidUser(
|
||||
tierMatrix.rotateCredentials
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("siteRegenerateAndDisconnect")}
|
||||
</Button>
|
||||
@@ -313,7 +314,9 @@ export default function CredentialsPage() {
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<PaidFeaturesAlert />
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.rotateCredentials}
|
||||
/>
|
||||
|
||||
<SettingsSectionBody>
|
||||
{!loadingDefaults && (
|
||||
@@ -383,11 +386,15 @@ export default function CredentialsPage() {
|
||||
</>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
{build === "enterprise" && (
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
onClick={() => setModalOpen(true)}
|
||||
disabled={isSecurityFeatureDisabled()}
|
||||
disabled={
|
||||
!isPaidUser(
|
||||
tierMatrix.rotateCredentials
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("siteRegenerateAndDisconnect")}
|
||||
</Button>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -7,22 +7,35 @@ import { cache } from "react";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Props = {
|
||||
searchParams: Promise<{ code?: string }>;
|
||||
searchParams: Promise<{ code?: string; user?: string }>;
|
||||
};
|
||||
|
||||
function deviceRedirectSearchParams(params: {
|
||||
code?: string;
|
||||
user?: string;
|
||||
}): string {
|
||||
const search = new URLSearchParams();
|
||||
if (params.code) search.set("code", params.code);
|
||||
if (params.user) search.set("user", params.user);
|
||||
const q = search.toString();
|
||||
return q ? `?${q}` : "";
|
||||
}
|
||||
|
||||
export default async function DeviceLoginPage({ searchParams }: Props) {
|
||||
const user = await verifySession({ forceLogin: true });
|
||||
|
||||
const params = await searchParams;
|
||||
const code = params.code || "";
|
||||
const defaultUser = params.user;
|
||||
|
||||
if (!user) {
|
||||
const redirectDestination = code
|
||||
? `/auth/login/device?code=${encodeURIComponent(code)}`
|
||||
: "/auth/login/device";
|
||||
redirect(
|
||||
`/auth/login?forceLogin=true&redirect=${encodeURIComponent(redirectDestination)}`
|
||||
);
|
||||
const redirectDestination = `/auth/login/device${deviceRedirectSearchParams({ code, user: params.user })}`;
|
||||
const loginUrl = new URL("/auth/login", "http://x");
|
||||
loginUrl.searchParams.set("forceLogin", "true");
|
||||
loginUrl.searchParams.set("redirect", redirectDestination);
|
||||
if (defaultUser) loginUrl.searchParams.set("user", defaultUser);
|
||||
console.log("loginUrl", loginUrl.pathname + loginUrl.search);
|
||||
redirect(loginUrl.pathname + loginUrl.search);
|
||||
}
|
||||
|
||||
const userName = user
|
||||
@@ -37,6 +50,7 @@ export default async function DeviceLoginPage({ searchParams }: Props) {
|
||||
userEmail={user?.email || ""}
|
||||
userName={userName}
|
||||
initialCode={code}
|
||||
userQueryParam={defaultUser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,14 +72,17 @@ export default async function Page(props: {
|
||||
searchParams.redirect = redirectUrl;
|
||||
}
|
||||
|
||||
const defaultUser = searchParams.user as string | undefined;
|
||||
|
||||
// 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")
|
||||
@@ -151,6 +154,7 @@ export default async function Page(props: {
|
||||
<SmartLoginForm
|
||||
redirect={redirectUrl}
|
||||
forceLogin={forceLogin}
|
||||
defaultUser={defaultUser}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -162,9 +166,11 @@ 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -184,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")}
|
||||
|
||||
@@ -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}` : ""}`);
|
||||
}
|
||||
|
||||
@@ -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("/");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -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";
|
||||
@@ -23,6 +23,7 @@ import Script from "next/script";
|
||||
import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
|
||||
import { TailwindIndicator } from "@app/components/TailwindIndicator";
|
||||
import { ViewportHeightFix } from "@app/components/ViewportHeightFix";
|
||||
import StoreInternalRedirect from "@app/components/StoreInternalRedirect";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
|
||||
@@ -79,6 +80,7 @@ export default async function RootLayout({
|
||||
return (
|
||||
<html suppressHydrationWarning lang={locale}>
|
||||
<body className={`${font.className} h-screen-safe overflow-hidden`}>
|
||||
<StoreInternalRedirect />
|
||||
<TopLoader />
|
||||
{build === "saas" && (
|
||||
<Script
|
||||
|
||||
@@ -121,7 +121,11 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
|
||||
href: "/{orgId}/settings/access/roles",
|
||||
icon: <Users className="size-4 flex-none" />
|
||||
},
|
||||
...(build === "saas" || env?.flags.useOrgOnlyIdp
|
||||
// PaidFeaturesAlert
|
||||
...((build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
|
||||
build === "saas" ||
|
||||
env?.app.identityProviderMode === "org" ||
|
||||
(env?.app.identityProviderMode === undefined && build !== "oss")
|
||||
? [
|
||||
{
|
||||
title: "sidebarIdentityProviders",
|
||||
@@ -130,7 +134,7 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(build !== "oss"
|
||||
...(!env?.flags.disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
title: "sidebarApprovals",
|
||||
@@ -155,7 +159,7 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
|
||||
href: "/{orgId}/settings/logs/request",
|
||||
icon: <SquareMousePointer className="size-4 flex-none" />
|
||||
},
|
||||
...(build != "oss"
|
||||
...(!env?.flags.disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
title: "sidebarLogsAccess",
|
||||
@@ -248,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",
|
||||
|
||||
@@ -10,6 +10,7 @@ import OrganizationLanding from "@app/components/OrganizationLanding";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { Layout } from "@app/components/Layout";
|
||||
import RedirectToOrg from "@app/components/RedirectToOrg";
|
||||
import { InitialSetupCompleteResponse } from "@server/routers/auth";
|
||||
import { cookies } from "next/headers";
|
||||
import { build } from "@server/build";
|
||||
@@ -80,15 +81,16 @@ export default async function Page(props: {
|
||||
const lastOrgCookie = allCookies.get("pangolin-last-org")?.value;
|
||||
|
||||
const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie);
|
||||
let targetOrgId: string | null = null;
|
||||
if (lastOrgExists && lastOrgCookie) {
|
||||
redirect(`/${lastOrgCookie}`);
|
||||
targetOrgId = lastOrgCookie;
|
||||
} else {
|
||||
let ownedOrg = orgs.find((org) => org.isOwner);
|
||||
if (!ownedOrg) {
|
||||
ownedOrg = orgs[0];
|
||||
}
|
||||
if (ownedOrg) {
|
||||
redirect(`/${ownedOrg.orgId}`);
|
||||
targetOrgId = ownedOrg.orgId;
|
||||
} else {
|
||||
if (!env.flags.disableUserCreateOrg || user.serverAdmin) {
|
||||
redirect("/setup");
|
||||
@@ -96,6 +98,10 @@ export default async function Page(props: {
|
||||
}
|
||||
}
|
||||
|
||||
if (targetOrgId) {
|
||||
return <RedirectToOrg targetOrgId={targetOrgId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<Layout orgs={orgs} navItems={[]}>
|
||||
|
||||
24
src/components/ApplyInternalRedirect.tsx
Normal file
24
src/components/ApplyInternalRedirect.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { consumeInternalRedirectPath } from "@app/lib/internalRedirect";
|
||||
|
||||
type ApplyInternalRedirectProps = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export default function ApplyInternalRedirect({
|
||||
orgId
|
||||
}: ApplyInternalRedirectProps) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const path = consumeInternalRedirectPath();
|
||||
if (path) {
|
||||
router.replace(`/${orgId}${path}`);
|
||||
}
|
||||
}, [orgId, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
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;
|
||||
@@ -50,9 +52,12 @@ export function ApprovalFeed({
|
||||
Object.fromEntries(searchParams.entries())
|
||||
);
|
||||
|
||||
const { data, isFetching, refetch } = useQuery(
|
||||
approvalQueries.listApprovals(orgId, filters)
|
||||
);
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const { data, isFetching, refetch } = useQuery({
|
||||
...approvalQueries.listApprovals(orgId, filters),
|
||||
enabled: isPaidUser(tierMatrix.deviceApprovals)
|
||||
});
|
||||
|
||||
const approvals = data?.approvals ?? [];
|
||||
|
||||
@@ -209,19 +214,19 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
|
||||
|
||||
{approval.type === "user_device" && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{approval.deviceName ? (
|
||||
<>
|
||||
{t("requestingNewDeviceApproval")}:{" "}
|
||||
{approval.niceId ? (
|
||||
<Link
|
||||
href={`/${orgId}/settings/clients/user/${approval.niceId}/general`}
|
||||
className="text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
{approval.deviceName}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{approval.deviceName}</span>
|
||||
)}
|
||||
{approval.deviceName ? (
|
||||
<>
|
||||
{t("requestingNewDeviceApproval")}:{" "}
|
||||
{approval.niceId ? (
|
||||
<Link
|
||||
href={`/${orgId}/settings/clients/user/${approval.niceId}/general`}
|
||||
className="text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
{approval.deviceName}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{approval.deviceName}</span>
|
||||
)}
|
||||
{approval.fingerprint && (
|
||||
<InfoPopup>
|
||||
<div className="space-y-1 text-sm">
|
||||
@@ -229,7 +234,10 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
|
||||
{t("deviceInformation")}
|
||||
</div>
|
||||
<div className="text-muted-foreground whitespace-pre-line">
|
||||
{formatFingerprintInfo(approval.fingerprint, t)}
|
||||
{formatFingerprintInfo(
|
||||
approval.fingerprint,
|
||||
t
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</InfoPopup>
|
||||
|
||||
@@ -35,6 +35,8 @@ import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
export type AuthPageCustomizationProps = {
|
||||
orgId: string;
|
||||
@@ -162,14 +164,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(
|
||||
@@ -200,8 +202,6 @@ export default function AuthPageBrandingForm({
|
||||
}
|
||||
|
||||
async function deleteBranding() {
|
||||
if (!isPaidUser) return;
|
||||
|
||||
try {
|
||||
const updateRes = await api.delete(
|
||||
`/org/${orgId}/login-page-branding`
|
||||
@@ -244,7 +244,9 @@ export default function AuthPageBrandingForm({
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<PaidFeaturesAlert />
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.loginPageBranding}
|
||||
/>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -357,7 +359,7 @@ export default function AuthPageBrandingForm({
|
||||
</div>
|
||||
|
||||
{build === "saas" ||
|
||||
env.env.flags.useOrgOnlyIdp ? (
|
||||
env.env.app.identityProviderMode === "org" ? (
|
||||
<>
|
||||
<div className="mt-3 mb-6">
|
||||
<SettingsSectionTitle>
|
||||
@@ -472,7 +474,7 @@ export default function AuthPageBrandingForm({
|
||||
disabled={
|
||||
isUpdatingBranding ||
|
||||
isDeletingBranding ||
|
||||
!isPaidUser
|
||||
!isPaidUser(tierMatrix.loginPageBranding)
|
||||
}
|
||||
className="gap-1"
|
||||
>
|
||||
@@ -487,7 +489,7 @@ export default function AuthPageBrandingForm({
|
||||
disabled={
|
||||
isUpdatingBranding ||
|
||||
isDeletingBranding ||
|
||||
!isPaidUser
|
||||
!isPaidUser(tierMatrix.loginPageBranding)
|
||||
}
|
||||
>
|
||||
{t("saveAuthPageBranding")}
|
||||
|
||||
@@ -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>
|
||||
@@ -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")}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ClientInfoCardProps = {};
|
||||
@@ -16,6 +17,12 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||
const { client, updateClient } = useClientContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const userDisplayName = getUserDisplayName({
|
||||
email: client.userEmail,
|
||||
name: client.userName,
|
||||
username: client.userUsername
|
||||
});
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
@@ -25,8 +32,12 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||
<InfoSectionContent>{client.name}</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||
<InfoSectionContent>{client.niceId}</InfoSectionContent>
|
||||
<InfoSectionTitle>
|
||||
{userDisplayName ? t("user") : t("identifier")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{userDisplayName || client.niceId}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||
|
||||
@@ -303,7 +303,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>({
|
||||
|
||||
@@ -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
|
||||
@@ -160,9 +162,12 @@ export default function CreateRoleForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{build !== "oss" && (
|
||||
<div>
|
||||
<PaidFeaturesAlert />
|
||||
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.deviceApprovals}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -173,7 +178,9 @@ export default function CreateRoleForm({
|
||||
<CheckboxWithLabel
|
||||
{...field}
|
||||
disabled={
|
||||
!isPaidUser
|
||||
!isPaidUser(
|
||||
tierMatrix.deviceApprovals
|
||||
)
|
||||
}
|
||||
value="on"
|
||||
checked={form.watch(
|
||||
@@ -208,7 +215,7 @@ export default function CreateRoleForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -29,6 +29,7 @@ type DashboardLoginFormProps = {
|
||||
searchParams?: {
|
||||
[key: string]: string | string[] | undefined;
|
||||
};
|
||||
defaultUser?: string;
|
||||
};
|
||||
|
||||
export default function DashboardLoginForm({
|
||||
@@ -36,7 +37,8 @@ export default function DashboardLoginForm({
|
||||
idps,
|
||||
forceLogin,
|
||||
showOrgLogin,
|
||||
searchParams
|
||||
searchParams,
|
||||
defaultUser
|
||||
}: DashboardLoginFormProps) {
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
@@ -75,6 +77,7 @@ export default function DashboardLoginForm({
|
||||
redirect={redirect}
|
||||
idps={idps}
|
||||
forceLogin={forceLogin}
|
||||
defaultEmail={defaultUser}
|
||||
onLogin={(redirectUrl) => {
|
||||
if (redirectUrl) {
|
||||
const safe = cleanRedirect(redirectUrl);
|
||||
|
||||
@@ -55,12 +55,14 @@ type DeviceLoginFormProps = {
|
||||
userEmail: string;
|
||||
userName?: string;
|
||||
initialCode?: string;
|
||||
userQueryParam?: string;
|
||||
};
|
||||
|
||||
export default function DeviceLoginForm({
|
||||
userEmail,
|
||||
userName,
|
||||
initialCode = ""
|
||||
initialCode = "",
|
||||
userQueryParam
|
||||
}: DeviceLoginFormProps) {
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
@@ -219,9 +221,12 @@ export default function DeviceLoginForm({
|
||||
const currentSearch =
|
||||
typeof window !== "undefined" ? window.location.search : "";
|
||||
const redirectTarget = `/auth/login/device${currentSearch || ""}`;
|
||||
router.push(
|
||||
`/auth/login?forceLogin=true&redirect=${encodeURIComponent(redirectTarget)}`
|
||||
);
|
||||
const loginUrl = new URL("/auth/login", "http://x");
|
||||
loginUrl.searchParams.set("forceLogin", "true");
|
||||
loginUrl.searchParams.set("redirect", redirectTarget);
|
||||
if (userQueryParam)
|
||||
loginUrl.searchParams.set("user", userQueryParam);
|
||||
router.push(loginUrl.pathname + loginUrl.search);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,7 +397,7 @@ export default function EditInternalResourceDialog({
|
||||
);
|
||||
|
||||
const availableSites = sites.filter(
|
||||
(site) => site.type === "newt" && site.subnet
|
||||
(site) => site.type === "newt"
|
||||
);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
|
||||
@@ -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,9 +170,12 @@ export default function EditRoleForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{build !== "oss" && (
|
||||
<div>
|
||||
<PaidFeaturesAlert />
|
||||
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.deviceApprovals}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -181,7 +186,9 @@ export default function EditRoleForm({
|
||||
<CheckboxWithLabel
|
||||
{...field}
|
||||
disabled={
|
||||
!isPaidUser
|
||||
!isPaidUser(
|
||||
tierMatrix.deviceApprovals
|
||||
)
|
||||
}
|
||||
value="on"
|
||||
checked={form.watch(
|
||||
@@ -216,7 +223,7 @@ export default function EditRoleForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -250,20 +250,41 @@ export default function GenerateLicenseKeyForm({
|
||||
const submitLicenseRequest = async (payload: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.put<
|
||||
AxiosResponse<GenerateNewLicenseResponse>
|
||||
>(`/org/${orgId}/license`, payload);
|
||||
// Check if this is a business/enterprise license request
|
||||
if (payload.useCaseType === "business") {
|
||||
const response = await api.put<
|
||||
AxiosResponse<string>
|
||||
>(`/org/${orgId}/license/enterprise`, { ...payload, tier: "big_license" } );
|
||||
|
||||
if (response.data.data?.licenseKey?.licenseKey) {
|
||||
setGeneratedKey(response.data.data.licenseKey.licenseKey);
|
||||
onGenerated?.();
|
||||
toast({
|
||||
title: t("generateLicenseKeyForm.toasts.success.title"),
|
||||
description: t(
|
||||
"generateLicenseKeyForm.toasts.success.description"
|
||||
),
|
||||
variant: "default"
|
||||
});
|
||||
console.log("Checkout session response:", response.data);
|
||||
const checkoutUrl = response.data.data;
|
||||
if (checkoutUrl) {
|
||||
window.location.href = checkoutUrl;
|
||||
} else {
|
||||
toast({
|
||||
title: "Failed to get checkout URL",
|
||||
description: "Please try again later",
|
||||
variant: "destructive"
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
// Personal license flow
|
||||
const response = await api.put<
|
||||
AxiosResponse<GenerateNewLicenseResponse>
|
||||
>(`/org/${orgId}/license`, payload);
|
||||
|
||||
if (response.data.data?.licenseKey?.licenseKey) {
|
||||
setGeneratedKey(response.data.data.licenseKey.licenseKey);
|
||||
onGenerated?.();
|
||||
toast({
|
||||
title: t("generateLicenseKeyForm.toasts.success.title"),
|
||||
description: t(
|
||||
"generateLicenseKeyForm.toasts.success.description"
|
||||
),
|
||||
variant: "default"
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -1066,16 +1087,16 @@ export default function GenerateLicenseKeyForm({
|
||||
)}
|
||||
|
||||
{!generatedKey && useCaseType === "business" && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="generate-license-business-form"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
{t(
|
||||
"generateLicenseKeyForm.buttons.generateLicenseKey"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="generate-license-business-form"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
{t(
|
||||
"generateLicenseKeyForm.buttons.generateLicenseKey"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
||||
@@ -10,12 +10,12 @@ import { Badge } from "./ui/badge";
|
||||
import moment from "moment";
|
||||
import { DataTable } from "./ui/data-table";
|
||||
import { GeneratedLicenseKey } from "@server/routers/generatedLicense/types";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import GenerateLicenseKeyForm from "./GenerateLicenseKeyForm";
|
||||
import NewPricingLicenseForm from "./NewPricingLicenseForm";
|
||||
|
||||
type GnerateLicenseKeysTableProps = {
|
||||
licenseKeys: GeneratedLicenseKey[];
|
||||
@@ -29,12 +29,15 @@ function obfuscateLicenseKey(key: string): string {
|
||||
return `${firstPart}••••••••••••••••••••${lastPart}`;
|
||||
}
|
||||
|
||||
const GENERATE_QUERY = "generate";
|
||||
|
||||
export default function GenerateLicenseKeysTable({
|
||||
licenseKeys,
|
||||
orgId
|
||||
}: GnerateLicenseKeysTableProps) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
@@ -42,6 +45,19 @@ export default function GenerateLicenseKeysTable({
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showGenerateForm, setShowGenerateForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get(GENERATE_QUERY) !== null) {
|
||||
setShowGenerateForm(true);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete(GENERATE_QUERY);
|
||||
const qs = next.toString();
|
||||
const url = qs
|
||||
? `${window.location.pathname}?${qs}`
|
||||
: window.location.pathname;
|
||||
window.history.replaceState(null, "", url);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleLicenseGenerated = () => {
|
||||
// Refresh the data after license is generated
|
||||
refreshData();
|
||||
@@ -158,6 +174,48 @@ export default function GenerateLicenseKeysTable({
|
||||
: t("licenseTierPersonal");
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "users",
|
||||
friendlyName: t("users"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("users")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const users = row.original.users;
|
||||
return users === -1 ? "∞" : users;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "sites",
|
||||
friendlyName: t("sites"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("sites")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const sites = row.original.sites;
|
||||
return sites === -1 ? "∞" : sites;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "terminateAt",
|
||||
friendlyName: t("licenseTableValidUntil"),
|
||||
@@ -198,7 +256,7 @@ export default function GenerateLicenseKeysTable({
|
||||
}}
|
||||
/>
|
||||
|
||||
<GenerateLicenseKeyForm
|
||||
<NewPricingLicenseForm
|
||||
open={showGenerateForm}
|
||||
setOpen={setShowGenerateForm}
|
||||
orgId={orgId}
|
||||
|
||||
65
src/components/IdpGlobalModeBanner.tsx
Normal file
65
src/components/IdpGlobalModeBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -54,6 +54,7 @@ type LoginFormProps = {
|
||||
idps?: LoginFormIDP[];
|
||||
orgId?: string;
|
||||
forceLogin?: boolean;
|
||||
defaultEmail?: string;
|
||||
};
|
||||
|
||||
export default function LoginForm({
|
||||
@@ -61,7 +62,8 @@ export default function LoginForm({
|
||||
onLogin,
|
||||
idps,
|
||||
orgId,
|
||||
forceLogin
|
||||
forceLogin,
|
||||
defaultEmail
|
||||
}: LoginFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -116,7 +118,7 @@ export default function LoginForm({
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
email: defaultEmail ?? "",
|
||||
password: ""
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
913
src/components/NewPricingLicenseForm.tsx
Normal file
913
src/components/NewPricingLicenseForm.tsx
Normal file
@@ -0,0 +1,913 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useState } from "react";
|
||||
import { useForm, type Resolver } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React from "react";
|
||||
import { StrategySelect, StrategyOption } from "./StrategySelect";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
|
||||
const TIER_TO_LICENSE_ID = {
|
||||
starter: "small_license",
|
||||
scale: "big_license"
|
||||
} as const;
|
||||
|
||||
type FormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
orgId: string;
|
||||
onGenerated?: () => void;
|
||||
};
|
||||
|
||||
export default function NewPricingLicenseForm({
|
||||
open,
|
||||
setOpen,
|
||||
orgId,
|
||||
onGenerated
|
||||
}: FormProps) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const { user } = useUserContext();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [generatedKey, setGeneratedKey] = useState<string | null>(null);
|
||||
const [personalUseOnly, setPersonalUseOnly] = useState(false);
|
||||
const [selectedTier, setSelectedTier] = useState<"starter" | "scale">(
|
||||
"starter"
|
||||
);
|
||||
|
||||
const personalFormSchema = z.object({
|
||||
email: z.email(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
primaryUse: z.string().min(1),
|
||||
country: z.string().min(1),
|
||||
phoneNumber: z.string().optional(),
|
||||
agreedToTerms: z.boolean().refine((val) => val === true),
|
||||
complianceConfirmed: z.boolean().refine((val) => val === true)
|
||||
});
|
||||
|
||||
const businessFormSchema = z.object({
|
||||
email: z.email(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
primaryUse: z.string().min(1),
|
||||
industry: z.string().min(1),
|
||||
companyName: z.string().min(1),
|
||||
companyWebsite: z.string().optional(),
|
||||
companyPhoneNumber: z.string().optional(),
|
||||
agreedToTerms: z.boolean().refine((val) => val === true),
|
||||
complianceConfirmed: z.boolean().refine((val) => val === true)
|
||||
});
|
||||
|
||||
type PersonalFormData = z.infer<typeof personalFormSchema>;
|
||||
type BusinessFormData = z.infer<typeof businessFormSchema>;
|
||||
|
||||
const personalForm = useForm<PersonalFormData>({
|
||||
resolver: zodResolver(personalFormSchema) as Resolver<PersonalFormData>,
|
||||
defaultValues: {
|
||||
email: user?.email || "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
primaryUse: "",
|
||||
country: "",
|
||||
phoneNumber: "",
|
||||
agreedToTerms: false,
|
||||
complianceConfirmed: false
|
||||
}
|
||||
});
|
||||
|
||||
const businessForm = useForm<BusinessFormData>({
|
||||
resolver: zodResolver(businessFormSchema) as Resolver<BusinessFormData>,
|
||||
defaultValues: {
|
||||
email: user?.email || "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
primaryUse: "",
|
||||
industry: "",
|
||||
companyName: "",
|
||||
companyWebsite: "",
|
||||
companyPhoneNumber: "",
|
||||
agreedToTerms: false,
|
||||
complianceConfirmed: false
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
resetForm();
|
||||
setGeneratedKey(null);
|
||||
setPersonalUseOnly(false);
|
||||
setSelectedTier("starter");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
function resetForm() {
|
||||
personalForm.reset({
|
||||
email: user?.email || "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
primaryUse: "",
|
||||
country: "",
|
||||
phoneNumber: "",
|
||||
agreedToTerms: false,
|
||||
complianceConfirmed: false
|
||||
});
|
||||
businessForm.reset({
|
||||
email: user?.email || "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
primaryUse: "",
|
||||
industry: "",
|
||||
companyName: "",
|
||||
companyWebsite: "",
|
||||
companyPhoneNumber: "",
|
||||
agreedToTerms: false,
|
||||
complianceConfirmed: false
|
||||
});
|
||||
}
|
||||
|
||||
const tierOptions: StrategyOption<"starter" | "scale">[] = [
|
||||
{
|
||||
id: "starter",
|
||||
title: t("newPricingLicenseForm.tiers.starter.title"),
|
||||
description: t("newPricingLicenseForm.tiers.starter.description")
|
||||
},
|
||||
{
|
||||
id: "scale",
|
||||
title: t("newPricingLicenseForm.tiers.scale.title"),
|
||||
description: t("newPricingLicenseForm.tiers.scale.description")
|
||||
}
|
||||
];
|
||||
|
||||
const submitLicenseRequest = async (
|
||||
payload: Record<string, unknown>
|
||||
): Promise<void> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Check if this is a business/enterprise license request
|
||||
if (!personalUseOnly) {
|
||||
const response = await api.put<AxiosResponse<string>>(
|
||||
`/org/${orgId}/license/enterprise`,
|
||||
{ ...payload, tier: TIER_TO_LICENSE_ID[selectedTier] }
|
||||
);
|
||||
|
||||
console.log("Checkout session response:", response.data);
|
||||
const checkoutUrl = response.data.data;
|
||||
if (checkoutUrl) {
|
||||
window.location.href = checkoutUrl;
|
||||
} else {
|
||||
toast({
|
||||
title: "Failed to get checkout URL",
|
||||
description: "Please try again later",
|
||||
variant: "destructive"
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
// Personal license flow
|
||||
const response = await api.put<
|
||||
AxiosResponse<GenerateNewLicenseResponse>
|
||||
>(`/org/${orgId}/license`, payload);
|
||||
|
||||
if (response.data.data?.licenseKey?.licenseKey) {
|
||||
setGeneratedKey(response.data.data.licenseKey.licenseKey);
|
||||
onGenerated?.();
|
||||
toast({
|
||||
title: t("generateLicenseKeyForm.toasts.success.title"),
|
||||
description: t(
|
||||
"generateLicenseKeyForm.toasts.success.description"
|
||||
),
|
||||
variant: "default"
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast({
|
||||
title: t("generateLicenseKeyForm.toasts.error.title"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("generateLicenseKeyForm.toasts.error.description")
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const onSubmitPersonal = async (values: PersonalFormData) => {
|
||||
await submitLicenseRequest({
|
||||
email: values.email,
|
||||
useCaseType: "personal",
|
||||
personal: {
|
||||
firstName: values.firstName,
|
||||
lastName: values.lastName,
|
||||
aboutYou: { primaryUse: values.primaryUse },
|
||||
personalInfo: {
|
||||
country: values.country,
|
||||
phoneNumber: values.phoneNumber || ""
|
||||
}
|
||||
},
|
||||
business: undefined,
|
||||
consent: {
|
||||
agreedToTerms: values.agreedToTerms,
|
||||
acknowledgedPrivacyPolicy: values.agreedToTerms,
|
||||
complianceConfirmed: values.complianceConfirmed
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmitBusiness = async (values: BusinessFormData) => {
|
||||
const payload = {
|
||||
email: values.email,
|
||||
useCaseType: "business",
|
||||
personal: undefined,
|
||||
business: {
|
||||
firstName: values.firstName,
|
||||
lastName: values.lastName,
|
||||
jobTitle: "N/A",
|
||||
aboutYou: {
|
||||
primaryUse: values.primaryUse,
|
||||
industry: values.industry,
|
||||
prospectiveUsers: 100,
|
||||
prospectiveSites: 100
|
||||
},
|
||||
companyInfo: {
|
||||
companyName: values.companyName,
|
||||
countryOfResidence: "N/A",
|
||||
stateProvinceRegion: "N/A",
|
||||
postalZipCode: "N/A",
|
||||
companyWebsite: values.companyWebsite || "",
|
||||
companyPhoneNumber: values.companyPhoneNumber || ""
|
||||
}
|
||||
},
|
||||
consent: {
|
||||
agreedToTerms: values.agreedToTerms,
|
||||
acknowledgedPrivacyPolicy: values.agreedToTerms,
|
||||
complianceConfirmed: values.complianceConfirmed
|
||||
}
|
||||
};
|
||||
|
||||
await submitLicenseRequest(payload);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
setGeneratedKey(null);
|
||||
resetForm();
|
||||
};
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={handleClose}>
|
||||
<CredenzaContent className="max-w-4xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("newPricingLicenseForm.title")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("newPricingLicenseForm.description")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-6">
|
||||
{generatedKey ? (
|
||||
<div className="space-y-4">
|
||||
<CopyTextBox
|
||||
text={generatedKey}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Tier selection - required when not personal use */}
|
||||
{!personalUseOnly && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t(
|
||||
"newPricingLicenseForm.chooseTier"
|
||||
)}
|
||||
</label>
|
||||
<StrategySelect
|
||||
options={tierOptions}
|
||||
defaultValue={selectedTier}
|
||||
onChange={(value) =>
|
||||
setSelectedTier(value)
|
||||
}
|
||||
cols={2}
|
||||
/>
|
||||
<a
|
||||
href="https://pangolin.net/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"newPricingLicenseForm.viewPricingLink"
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Personal use only checkbox at the bottom of options */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="personal-use-only"
|
||||
checked={personalUseOnly}
|
||||
onCheckedChange={(checked) => {
|
||||
setPersonalUseOnly(
|
||||
checked === true
|
||||
);
|
||||
if (checked) {
|
||||
businessForm.reset();
|
||||
} else {
|
||||
personalForm.reset();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="personal-use-only"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t(
|
||||
"newPricingLicenseForm.personalUseOnly"
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* License disclosure - only when personal use */}
|
||||
{personalUseOnly && (
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{t(
|
||||
"generateLicenseKeyForm.alerts.commercialUseDisclosure.title"
|
||||
)}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"generateLicenseKeyForm.alerts.commercialUseDisclosure.description"
|
||||
)
|
||||
.split(
|
||||
"Fossorial Commercial License Terms"
|
||||
)
|
||||
.map((part, index) => (
|
||||
<span key={index}>
|
||||
{part}
|
||||
{index === 0 && (
|
||||
<a
|
||||
href="https://pangolin.net/fcl.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Fossorial
|
||||
Commercial
|
||||
License Terms
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Personal form: only when personal use only is checked */}
|
||||
{personalUseOnly && (
|
||||
<Form {...personalForm}>
|
||||
<form
|
||||
onSubmit={personalForm.handleSubmit(
|
||||
onSubmitPersonal
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="new-pricing-license-personal-form"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={
|
||||
personalForm.control
|
||||
}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.firstName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={
|
||||
personalForm.control
|
||||
}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.lastName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={personalForm.control}
|
||||
name="primaryUse"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.primaryUseQuestion"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={
|
||||
personalForm.control
|
||||
}
|
||||
name="country"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.country"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={
|
||||
personalForm.control
|
||||
}
|
||||
name="phoneNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.phoneNumberOptional"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4">
|
||||
<FormField
|
||||
control={
|
||||
personalForm.control
|
||||
}
|
||||
name="agreedToTerms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 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={
|
||||
personalForm.control
|
||||
}
|
||||
name="complianceConfirmed"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
<div>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.complianceConfirmation"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/fcl.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
https://pangolin.net/fcl.html
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{/* Business form: when not personal use - enter business info then continue to checkout */}
|
||||
{!personalUseOnly && (
|
||||
<Form {...businessForm}>
|
||||
<form
|
||||
onSubmit={businessForm.handleSubmit(
|
||||
onSubmitBusiness
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="new-pricing-license-business-form"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={
|
||||
businessForm.control
|
||||
}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.firstName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={
|
||||
businessForm.control
|
||||
}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.lastName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={businessForm.control}
|
||||
name="primaryUse"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.primaryUseQuestion"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={businessForm.control}
|
||||
name="industry"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.industryQuestion"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={businessForm.control}
|
||||
name="companyName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.companyName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={
|
||||
businessForm.control
|
||||
}
|
||||
name="companyWebsite"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.companyWebsite"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={
|
||||
businessForm.control
|
||||
}
|
||||
name="companyPhoneNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.companyPhoneNumber"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4">
|
||||
<FormField
|
||||
control={
|
||||
businessForm.control
|
||||
}
|
||||
name="agreedToTerms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 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={
|
||||
businessForm.control
|
||||
}
|
||||
name="complianceConfirmed"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
<div>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.complianceConfirmation"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/fcl.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
https://pangolin.net/fcl.html
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">
|
||||
{t("generateLicenseKeyForm.buttons.close")}
|
||||
</Button>
|
||||
</CredenzaClose>
|
||||
|
||||
{!generatedKey && personalUseOnly && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="new-pricing-license-personal-form"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
{t(
|
||||
"generateLicenseKeyForm.buttons.generateLicenseKey"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!generatedKey && !personalUseOnly && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="new-pricing-license-business-form"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
{t(
|
||||
"newPricingLicenseForm.buttons.continueToCheckout"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
|
||||
@@ -1,28 +1,169 @@
|
||||
"use client";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { build } from "@server/build";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
|
||||
export function PaidFeaturesAlert() {
|
||||
import { Card, CardContent } from "@app/components/ui/card";
|
||||
import { build } from "@server/build";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
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-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";
|
||||
|
||||
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 ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("subscriptionRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{build === "saas" && !hasSaasSubscription(tiers) ? (
|
||||
<Card className={bannerClassName}>
|
||||
<CardContent className={bannerContentClassName}>
|
||||
<div className={bannerRowClassName}>
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
{build === "enterprise" && !hasEnterpriseLicense ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("licenseRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Card className={bannerClassName}>
|
||||
<CardContent className={bannerContentClassName}>
|
||||
<div className={bannerRowClassName}>
|
||||
<KeyRound className={bannerIconClassName} />
|
||||
<span>
|
||||
{t.rich("licenseRequiredToUse", {
|
||||
enterpriseLicenseLink: enterpriseDocsLinkRenderer,
|
||||
pangolinCloudLink: pangolinCloudLinkRenderer
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{build === "oss" && !hasEnterpriseLicense ? (
|
||||
<Card className={bannerClassName}>
|
||||
<CardContent className={bannerContentClassName}>
|
||||
<div className={bannerRowClassName}>
|
||||
<KeyRound className={bannerIconClassName} />
|
||||
<span>
|
||||
{t.rich("ossEnterpriseEditionRequired", {
|
||||
enterpriseEditionLink: enterpriseDocsLinkRenderer,
|
||||
pangolinCloudLink: pangolinCloudLinkRenderer
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
24
src/components/RedirectToOrg.tsx
Normal file
24
src/components/RedirectToOrg.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getInternalRedirectTarget } from "@app/lib/internalRedirect";
|
||||
|
||||
type RedirectToOrgProps = {
|
||||
targetOrgId: string;
|
||||
};
|
||||
|
||||
export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const target = getInternalRedirectTarget(targetOrgId);
|
||||
router.replace(target);
|
||||
} catch {
|
||||
router.replace(`/${targetOrgId}`);
|
||||
}
|
||||
}, [targetOrgId, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
@@ -42,6 +42,7 @@ const isValidEmail = (str: string): boolean => {
|
||||
type SmartLoginFormProps = {
|
||||
redirect?: string;
|
||||
forceLogin?: boolean;
|
||||
defaultUser?: string;
|
||||
};
|
||||
|
||||
type ViewState =
|
||||
@@ -59,7 +60,8 @@ type ViewState =
|
||||
|
||||
export default function SmartLoginForm({
|
||||
redirect,
|
||||
forceLogin
|
||||
forceLogin,
|
||||
defaultUser
|
||||
}: SmartLoginFormProps) {
|
||||
const router = useRouter();
|
||||
const { lookup, loading, error } = useUserLookup();
|
||||
@@ -72,10 +74,18 @@ export default function SmartLoginForm({
|
||||
const form = useForm<z.infer<typeof identifierSchema>>({
|
||||
resolver: zodResolver(identifierSchema),
|
||||
defaultValues: {
|
||||
identifier: ""
|
||||
identifier: defaultUser ?? ""
|
||||
}
|
||||
});
|
||||
|
||||
const hasAutoLookedUp = useRef(false);
|
||||
useEffect(() => {
|
||||
if (defaultUser?.trim() && !hasAutoLookedUp.current) {
|
||||
hasAutoLookedUp.current = true;
|
||||
void handleLookup({ identifier: defaultUser.trim() });
|
||||
}
|
||||
}, [defaultUser]);
|
||||
|
||||
const handleLookup = async (values: z.infer<typeof identifierSchema>) => {
|
||||
const identifier = values.identifier.trim();
|
||||
const isEmail = isValidEmail(identifier);
|
||||
|
||||
27
src/components/StoreInternalRedirect.tsx
Normal file
27
src/components/StoreInternalRedirect.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { INTERNAL_REDIRECT_KEY } from "@app/lib/internalRedirect";
|
||||
|
||||
const TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
export default function StoreInternalRedirect() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const value = params.get("internal_redirect");
|
||||
if (value != null && value !== "") {
|
||||
try {
|
||||
const payload = JSON.stringify({
|
||||
path: value,
|
||||
expiresAt: Date.now() + TTL_MS
|
||||
});
|
||||
window.localStorage.setItem(INTERNAL_REDIRECT_KEY, payload);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
50
src/components/SubscriptionViolation.tsx
Normal file
50
src/components/SubscriptionViolation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -226,6 +226,21 @@ export default function SupporterStatus({
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div className="my-4 p-4 border border-blue-500/50 bg-blue-500/10 rounded-lg">
|
||||
<p className="text-sm">
|
||||
<strong>Business & Enterprise Users:</strong> For larger organizations or teams requiring advanced features, consider our self-serve enterprise license and Enterprise Edition.{" "}
|
||||
<Link
|
||||
href="https://pangolin.net/pricing?hosting=self-host"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline inline-flex items-center gap-1"
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="py-6">
|
||||
<p className="mb-3 text-center">
|
||||
{t("supportKeyOptions")}
|
||||
|
||||
@@ -1,41 +1,13 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as NProgress from "nprogress";
|
||||
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
export function TopLoader() {
|
||||
return (
|
||||
<>
|
||||
<NextTopLoader showSpinner={false} color="var(--color-primary)" />
|
||||
<FinishingLoader />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FinishingLoader() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
React.useEffect(() => {
|
||||
NProgress.done();
|
||||
}, [pathname, router, searchParams]);
|
||||
React.useEffect(() => {
|
||||
const linkClickListener = (ev: MouseEvent) => {
|
||||
const element = ev.target as HTMLElement;
|
||||
const closestlink = element.closest("a");
|
||||
const isOpenToNewTabClick =
|
||||
ev.ctrlKey ||
|
||||
ev.shiftKey ||
|
||||
ev.metaKey || // apple
|
||||
(ev.button && ev.button == 1); // middle click, >IE9 + everyone else
|
||||
|
||||
if (closestlink && isOpenToNewTabClick) {
|
||||
NProgress.done();
|
||||
}
|
||||
};
|
||||
window.addEventListener("click", linkClickListener);
|
||||
return () => window.removeEventListener("click", linkClickListener);
|
||||
}, []);
|
||||
return null;
|
||||
return (
|
||||
<NextTopLoader
|
||||
color="var(--color-primary)"
|
||||
showSpinner={false}
|
||||
height={2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
const approvalsRes = await api.get<{
|
||||
data: { approvals: Array<{ approvalId: number; clientId: number }> };
|
||||
}>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`);
|
||||
|
||||
|
||||
const approval = approvalsRes.data.data.approvals[0];
|
||||
|
||||
if (!approval) {
|
||||
@@ -232,7 +232,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
const approvalsRes = await api.get<{
|
||||
data: { approvals: Array<{ approvalId: number; clientId: number }> };
|
||||
}>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`);
|
||||
|
||||
|
||||
const approval = approvalsRes.data.data.approvals[0];
|
||||
|
||||
if (!approval) {
|
||||
@@ -548,7 +548,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{clientRow.approvalState === "pending" && build !== "oss" && (
|
||||
{clientRow.approvalState === "pending" && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => approveDevice(clientRow)}
|
||||
@@ -652,17 +652,10 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
}
|
||||
];
|
||||
|
||||
if (build === "oss") {
|
||||
return allOptions.filter((option) => option.value !== "pending" && option.value !== "denied");
|
||||
}
|
||||
|
||||
return allOptions;
|
||||
}, [t]);
|
||||
|
||||
const statusFilterDefaultValues = useMemo(() => {
|
||||
if (build === "oss") {
|
||||
return ["active"];
|
||||
}
|
||||
return ["active", "pending"];
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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];
|
||||
@@ -91,7 +91,7 @@ export function NewtSiteInstallCommands({
|
||||
- NEWT_SECRET=${secret}${acceptClientsEnv}`
|
||||
],
|
||||
"Docker Run": [
|
||||
`docker run -dit fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
`docker run -dit --network host fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
]
|
||||
},
|
||||
kubernetes: {
|
||||
|
||||
@@ -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,32 +43,20 @@ export function OlmInstallCommands({
|
||||
All: [
|
||||
{
|
||||
title: t("install"),
|
||||
command: `curl -fsSL https://static.pangolin.net/get-olm.sh | bash`
|
||||
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | sudo bash`
|
||||
},
|
||||
{
|
||||
title: t("run"),
|
||||
command: `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
}
|
||||
]
|
||||
},
|
||||
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}`
|
||||
command: `sudo pangolin up --id ${id} --secret ${secret} --endpoint ${endpoint} --attach`
|
||||
}
|
||||
]
|
||||
},
|
||||
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}`
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
type CleanRedirectOptions = {
|
||||
fallback?: string;
|
||||
maxRedirectDepth?: number;
|
||||
/** When true, preserve all query params on the path (for internal redirects). Default false. */
|
||||
allowAllQueryParams?: boolean;
|
||||
};
|
||||
|
||||
const ALLOWED_QUERY_PARAMS = new Set([
|
||||
@@ -16,14 +18,18 @@ export function cleanRedirect(
|
||||
input: string,
|
||||
options: CleanRedirectOptions = {}
|
||||
): string {
|
||||
const { fallback = "/", maxRedirectDepth = 2 } = options;
|
||||
const {
|
||||
fallback = "/",
|
||||
maxRedirectDepth = 2,
|
||||
allowAllQueryParams = false
|
||||
} = options;
|
||||
|
||||
if (!input || typeof input !== "string") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return sanitizeUrl(input, fallback, maxRedirectDepth);
|
||||
return sanitizeUrl(input, fallback, maxRedirectDepth, allowAllQueryParams);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
@@ -32,7 +38,8 @@ export function cleanRedirect(
|
||||
function sanitizeUrl(
|
||||
input: string,
|
||||
fallback: string,
|
||||
remainingRedirectDepth: number
|
||||
remainingRedirectDepth: number,
|
||||
allowAllQueryParams: boolean = false
|
||||
): string {
|
||||
if (
|
||||
input.startsWith("javascript:") ||
|
||||
@@ -56,7 +63,7 @@ function sanitizeUrl(
|
||||
const cleanParams = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of url.searchParams.entries()) {
|
||||
if (!ALLOWED_QUERY_PARAMS.has(key)) {
|
||||
if (!allowAllQueryParams && !ALLOWED_QUERY_PARAMS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -68,7 +75,8 @@ function sanitizeUrl(
|
||||
const cleanedRedirect = sanitizeUrl(
|
||||
value,
|
||||
"",
|
||||
remainingRedirectDepth - 1
|
||||
remainingRedirectDepth - 1,
|
||||
allowAllQueryParams
|
||||
);
|
||||
|
||||
if (cleanedRedirect) {
|
||||
|
||||
51
src/lib/internalRedirect.ts
Normal file
51
src/lib/internalRedirect.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
|
||||
export const INTERNAL_REDIRECT_KEY = "internal_redirect";
|
||||
|
||||
/**
|
||||
* Consumes the internal_redirect value from localStorage if present and valid
|
||||
* (within TTL). Removes it from storage. Returns the path segment (with leading
|
||||
* slash) to append to an orgId, or null if none/expired/invalid.
|
||||
*/
|
||||
export function consumeInternalRedirectPath(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(INTERNAL_REDIRECT_KEY);
|
||||
if (raw == null || raw === "") return null;
|
||||
|
||||
window.localStorage.removeItem(INTERNAL_REDIRECT_KEY);
|
||||
|
||||
const { path: storedPath, expiresAt } = JSON.parse(raw) as {
|
||||
path?: string;
|
||||
expiresAt?: number;
|
||||
};
|
||||
if (
|
||||
typeof storedPath !== "string" ||
|
||||
storedPath === "" ||
|
||||
typeof expiresAt !== "number" ||
|
||||
Date.now() > expiresAt
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleaned = cleanRedirect(storedPath, {
|
||||
fallback: "",
|
||||
allowAllQueryParams: true
|
||||
});
|
||||
if (!cleaned) return null;
|
||||
|
||||
return cleaned.startsWith("/") ? cleaned : `/${cleaned}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full redirect target for an org: either `/${orgId}` or
|
||||
* `/${orgId}${path}` if a valid internal_redirect was stored. Consumes the
|
||||
* stored value.
|
||||
*/
|
||||
export function getInternalRedirectTarget(orgId: string): string {
|
||||
const path = consumeInternalRedirectPath();
|
||||
return path ? `/${orgId}${path}` : `/${orgId}`;
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,29 +2,6 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
// If build is OSS, block access to private routes
|
||||
if (build === "oss") {
|
||||
const pathname = request.nextUrl.pathname;
|
||||
|
||||
// Define private route patterns that should be blocked in OSS build
|
||||
const privateRoutes = [
|
||||
"/settings/billing",
|
||||
"/settings/remote-exit-nodes",
|
||||
"/settings/idp",
|
||||
"/auth/org"
|
||||
];
|
||||
|
||||
// Check if current path matches any private route pattern
|
||||
const isPrivateRoute = privateRoutes.some((route) =>
|
||||
pathname.includes(route)
|
||||
);
|
||||
|
||||
if (isPrivateRoute) {
|
||||
// Return 404 to make it seem like the route doesn't exist
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,50 +32,53 @@ export function SubscriptionStatusProvider({
|
||||
});
|
||||
};
|
||||
|
||||
const isActive = () => {
|
||||
if (subscriptionStatus?.subscription?.status === "active") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getTier = () => {
|
||||
const tierPriceSet = getTierPriceSet(env, sandbox_mode);
|
||||
|
||||
if (subscriptionStatus?.items && subscriptionStatus.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 = subscriptionStatus.items.find(
|
||||
(item) => item.priceId === priceId
|
||||
);
|
||||
if (matchingItem) {
|
||||
return tierId;
|
||||
const getTier = (): {
|
||||
tier: Tier | null;
|
||||
active: boolean;
|
||||
} => {
|
||||
if (subscriptionStatus?.subscriptions) {
|
||||
// Iterate through all subscriptions
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user