add enterprise license system

This commit is contained in:
miloschwartz
2025-10-13 10:41:10 -07:00
parent 6b125bba7c
commit 37ceabdf5d
76 changed files with 3886 additions and 1931 deletions

View File

@@ -9,7 +9,7 @@ import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react";
import SetLastOrgCookie from "@app/components/SetLastOrgCookie";
import PrivateSubscriptionStatusProvider from "@app/providers/SubscriptionStatusProvider";
import SubscriptionStatusProvider from "@app/providers/SubscriptionStatusProvider";
import { GetOrgSubscriptionResponse } from "#private/routers/billing/getOrgSubscription";
import { pullEnv } from "@app/lib/pullEnv";
import { build } from "@server/build";
@@ -56,7 +56,7 @@ export default async function OrgLayout(props: {
}
let subscriptionStatus = null;
if (build != "oss") {
if (build === "saas") {
try {
const getSubscription = cache(() =>
internal.get<AxiosResponse<GetOrgSubscriptionResponse>>(
@@ -73,13 +73,13 @@ export default async function OrgLayout(props: {
}
return (
<PrivateSubscriptionStatusProvider
<SubscriptionStatusProvider
subscriptionStatus={subscriptionStatus}
env={env.app.environment}
sandbox_mode={env.app.sandbox_mode}
>
{props.children}
<SetLastOrgCookie orgId={orgId} />
</PrivateSubscriptionStatusProvider>
</SubscriptionStatusProvider>
);
}

View File

@@ -60,13 +60,6 @@ export default async function BillingSettingsPage({
const t = await getTranslations();
const navItems = [
{
title: t('billing'),
href: `/{orgId}/settings/billing`,
},
];
return (
<>
<OrgProvider org={org}>

View File

@@ -45,7 +45,10 @@ export default async function OrgIdpPage(props: OrgIdpPageProps) {
const subRes = await getSubscription();
subscriptionStatus = subRes.data.data;
} catch {}
const subscribed = subscriptionStatus?.tier === TierId.STANDARD;
const subscribed =
build === "enterprise"
? true
: subscriptionStatus?.tier === TierId.STANDARD;
return (
<>

View File

@@ -0,0 +1,42 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { cache } from "react";
import { getTranslations } from "next-intl/server";
import { build } from "@server/build";
type LicensesSettingsProps = {
children: React.ReactNode;
params: Promise<{ orgId: string }>;
};
export default async function LicensesSetingsLayoutProps({
children,
params
}: LicensesSettingsProps) {
const { orgId } = await params;
if (build !== "saas") {
redirect(`/${orgId}/settings`);
}
const getUser = cache(verifySession);
const user = await getUser();
if (!user) {
redirect(`/`);
}
const t = await getTranslations();
return (
<>
<SettingsSectionTitle
title={t("saasLicenseKeysSettingsTitle")}
description={t("saasLicenseKeysSettingsDescription")}
/>
{children}
</>
);
}

View File

@@ -0,0 +1,25 @@
import GenerateLicenseKeysTable from "@app/components/GenerateLicenseKeysTable";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListGeneratedLicenseKeysResponse } from "@server/private/routers/generatedLicense";
import { AxiosResponse } from "axios";
type Props = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function Page({ params }: Props) {
const { orgId } = await params;
let licenseKeys: ListGeneratedLicenseKeysResponse = [];
try {
const data = await internal.get<
AxiosResponse<ListGeneratedLicenseKeysResponse>
>(`/org/${orgId}/license`, await authCookieHeader());
licenseKeys = data.data.data;
} catch {}
return <GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />;
}

View File

@@ -77,9 +77,10 @@ export default function Page() {
const t = useTranslations();
const subscription = useSubscriptionStatusContext();
const subscribed = subscription?.getTier() === TierId.STANDARD;
const [selectedOption, setSelectedOption] = useState<string | null>("internal");
const [selectedOption, setSelectedOption] = useState<string | null>(
"internal"
);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(1);
@@ -204,7 +205,13 @@ export default function Page() {
googleAzureForm.reset();
genericOidcForm.reset();
}
}, [selectedOption, env.email.emailEnabled, internalForm, googleAzureForm, genericOidcForm]);
}, [
selectedOption,
env.email.emailEnabled,
internalForm,
googleAzureForm,
genericOidcForm
]);
useEffect(() => {
if (!selectedOption) {
@@ -232,7 +239,7 @@ export default function Page() {
}
async function fetchIdps() {
if (build === "saas" && !subscribed) {
if (build === "saas" && !subscription?.subscribed) {
return;
}
@@ -345,7 +352,9 @@ export default function Page() {
async function onSubmitGoogleAzure(
values: z.infer<typeof googleAzureFormSchema>
) {
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
const selectedUserOption = userOptions.find(
(opt) => opt.id === selectedOption
);
if (!selectedUserOption?.idpId) return;
setLoading(true);
@@ -385,7 +394,9 @@ export default function Page() {
async function onSubmitGenericOidc(
values: z.infer<typeof genericOidcFormSchema>
) {
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
const selectedUserOption = userOptions.find(
(opt) => opt.id === selectedOption
);
if (!selectedUserOption?.idpId) return;
setLoading(true);
@@ -675,214 +686,284 @@ export default function Page() {
</>
)}
{selectedOption && selectedOption !== "internal" && dataLoaded && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("userSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("userSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
{/* Google/Azure Form */}
{(() => {
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
return selectedUserOption?.variant === "google" || selectedUserOption?.variant === "azure";
})() && (
<Form {...googleAzureForm}>
<form
onSubmit={googleAzureForm.handleSubmit(
onSubmitGoogleAzure
{selectedOption &&
selectedOption !== "internal" &&
dataLoaded && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("userSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("userSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
{/* Google/Azure Form */}
{(() => {
const selectedUserOption =
userOptions.find(
(opt) =>
opt.id ===
selectedOption
);
return (
selectedUserOption?.variant ===
"google" ||
selectedUserOption?.variant ===
"azure"
);
})() && (
<Form {...googleAzureForm}>
<form
onSubmit={googleAzureForm.handleSubmit(
onSubmitGoogleAzure
)}
className="space-y-4"
id="create-user-form"
>
<FormField
control={
googleAzureForm.control
}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
className="space-y-4"
id="create-user-form"
>
<FormField
control={googleAzureForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
/>
<FormField
control={googleAzureForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("nameOptional")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
googleAzureForm.control
}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"nameOptional"
)}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={googleAzureForm.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("role")}
</FormLabel>
<Select
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("accessRoleSelect")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<FormField
control={
googleAzureForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("role")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"accessRoleSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={role.roleId}
key={
role.roleId
}
value={role.roleId.toString()}
>
{role.name}
{
role.name
}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
{/* Generic OIDC Form */}
{(() => {
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
return selectedUserOption?.variant !== "google" && selectedUserOption?.variant !== "azure";
})() && (
<Form {...genericOidcForm}>
<form
onSubmit={genericOidcForm.handleSubmit(
onSubmitGenericOidc
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
className="space-y-4"
id="create-user-form"
>
<FormField
control={genericOidcForm.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("username")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<p className="text-sm text-muted-foreground mt-1">
{t("usernameUniq")}
</p>
<FormMessage />
</FormItem>
)}
/>
/>
</form>
</Form>
)}
<FormField
control={genericOidcForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("emailOptional")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Generic OIDC Form */}
{(() => {
const selectedUserOption =
userOptions.find(
(opt) =>
opt.id ===
selectedOption
);
return (
selectedUserOption?.variant !==
"google" &&
selectedUserOption?.variant !==
"azure"
);
})() && (
<Form {...genericOidcForm}>
<form
onSubmit={genericOidcForm.handleSubmit(
onSubmitGenericOidc
)}
className="space-y-4"
id="create-user-form"
>
<FormField
control={
genericOidcForm.control
}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"username"
)}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<p className="text-sm text-muted-foreground mt-1">
{t(
"usernameUniq"
)}
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={genericOidcForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("nameOptional")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
genericOidcForm.control
}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"emailOptional"
)}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={genericOidcForm.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("role")}
</FormLabel>
<Select
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("accessRoleSelect")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<FormField
control={
genericOidcForm.control
}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"nameOptional"
)}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
genericOidcForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("role")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"accessRoleSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={role.roleId}
key={
role.roleId
}
value={role.roleId.toString()}
>
{role.name}
{
role.name
}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">

View File

@@ -5,6 +5,7 @@ import { ClientRow } from "../../../../components/ClientsTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListClientsResponse } from "@server/routers/client";
import ClientsTable from "../../../../components/ClientsTable";
import { getTranslations } from "next-intl/server";
type ClientsPageProps = {
params: Promise<{ orgId: string }>;
@@ -13,6 +14,8 @@ type ClientsPageProps = {
export const dynamic = "force-dynamic";
export default async function ClientsPage(props: ClientsPageProps) {
const t = await getTranslations();
const params = await props.params;
let clients: ListClientsResponse["clients"] = [];
try {
@@ -48,8 +51,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
return (
<>
<SettingsSectionTitle
title="Manage Clients (beta)"
description="Clients are devices that can connect to your sites"
title={t("manageClients")}
description={t("manageClientsDescription")}
/>
<ClientsTable clients={clientRows} orgId={params.orgId} />

View File

@@ -1,6 +1,8 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import AuthPageSettings, { AuthPageSettingsRef } from "@app/components/private/AuthPageSettings";
import AuthPageSettings, {
AuthPageSettingsRef
} from "@app/components/private/AuthPageSettings";
import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext";
@@ -134,7 +136,10 @@ export default function GeneralPage() {
});
// Also save auth page settings if they have unsaved changes
if (build === "saas" && authPageSettingsRef.current?.hasUnsavedChanges()) {
if (
build === "saas" &&
authPageSettingsRef.current?.hasUnsavedChanges()
) {
await authPageSettingsRef.current.saveAuthSettings();
}
@@ -239,7 +244,9 @@ export default function GeneralPage() {
</SettingsSectionBody>
</SettingsSection>
{build === "saas" && <AuthPageSettings ref={authPageSettingsRef} />}
{(build === "saas") && (
<AuthPageSettings ref={authPageSettingsRef} />
)}
{/* Save Button */}
<div className="flex justify-end">
@@ -276,7 +283,6 @@ export default function GeneralPage() {
</SettingsSectionFooter>
</SettingsSection>
)}
</SettingsContainer>
);
}

View File

@@ -99,7 +99,6 @@ export default function ResourceAuthenticationPage() {
const t = useTranslations();
const subscription = useSubscriptionStatusContext();
const subscribed = subscription?.getTier() === TierId.STANDARD;
const [pageLoading, setPageLoading] = useState(true);
@@ -141,8 +140,10 @@ export default function ResourceAuthenticationPage() {
useState(false);
const [loadingRemoveResourcePincode, setLoadingRemoveResourcePincode] =
useState(false);
const [loadingRemoveResourceHeaderAuth, setLoadingRemoveResourceHeaderAuth] =
useState(false);
const [
loadingRemoveResourceHeaderAuth,
setLoadingRemoveResourceHeaderAuth
] = useState(false);
const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false);
const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false);
@@ -234,7 +235,7 @@ export default function ResourceAuthenticationPage() {
);
if (build === "saas") {
if (subscribed) {
if (subscription?.subscribed) {
setAllIdps(
idpsResponse.data.data.idps.map((idp) => ({
id: idp.idpId,

View File

@@ -0,0 +1,17 @@
import { build } from "@server/build";
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
interface LayoutProps {
children: React.ReactNode;
}
export default async function AdminLicenseLayout(props: LayoutProps) {
if (build !== "enterprise") {
redirect(`/admin`);
}
return props.children;
}

View File

@@ -31,7 +31,6 @@ import {
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useRouter } from "next/navigation";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import {
SettingsContainer,
@@ -43,14 +42,10 @@ import {
SettingsSectionFooter
} from "@app/components/Settings";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Badge } from "@app/components/ui/badge";
import { Check, Heart, InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
import { Check, Heart, InfoIcon } from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox";
import { Progress } from "@app/components/ui/progress";
import { MinusCircle, PlusCircle } from "lucide-react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { SitePriceCalculator } from "../../../components/SitePriceCalculator";
import Link from "next/link";
import { Checkbox } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
@@ -70,13 +65,11 @@ export default function LicensePage() {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedLicenseKey, setSelectedLicenseKey] =
useState<LicenseKeyCache | null>(null);
const router = useRouter();
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
const [hostLicense, setHostLicense] = useState<string | null>(null);
const [isPurchaseModalOpen, setIsPurchaseModalOpen] = useState(false);
const [purchaseMode, setPurchaseMode] = useState<
"license" | "additional-sites"
>("license");
const [purchaseMode, setPurchaseMode] = useState<"license">("license");
// Separate loading states for different actions
const [isInitialLoading, setIsInitialLoading] = useState(true);
@@ -90,10 +83,10 @@ export default function LicensePage() {
const formSchema = z.object({
licenseKey: z
.string()
.nonempty({ message: t('licenseKeyRequired') })
.nonempty({ message: t("licenseKeyRequired") })
.max(255),
agreeToTerms: z.boolean().refine((val) => val === true, {
message: t('licenseTermsAgree')
message: t("licenseTermsAgree")
})
});
@@ -122,7 +115,7 @@ export default function LicensePage() {
);
const keys = response.data.data;
setRows(keys);
const hostKey = keys.find((key) => key.type === "HOST");
const hostKey = keys.find((key) => key.type === "host");
if (hostKey) {
setHostLicense(hostKey.licenseKey);
} else {
@@ -130,10 +123,10 @@ export default function LicensePage() {
}
} catch (e) {
toast({
title: t('licenseErrorKeyLoad'),
title: t("licenseErrorKeyLoad"),
description: formatAxiosError(
e,
t('licenseErrorKeyLoadDescription')
t("licenseErrorKeyLoadDescription")
)
});
}
@@ -149,16 +142,16 @@ export default function LicensePage() {
}
await loadLicenseKeys();
toast({
title: t('licenseKeyDeleted'),
description: t('licenseKeyDeletedDescription')
title: t("licenseKeyDeleted"),
description: t("licenseKeyDeletedDescription")
});
setIsDeleteModalOpen(false);
} catch (e) {
toast({
title: t('licenseErrorKeyDelete'),
title: t("licenseErrorKeyDelete"),
description: formatAxiosError(
e,
t('licenseErrorKeyDeleteDescription')
t("licenseErrorKeyDeleteDescription")
)
});
} finally {
@@ -175,15 +168,15 @@ export default function LicensePage() {
}
await loadLicenseKeys();
toast({
title: t('licenseErrorKeyRechecked'),
description: t('licenseErrorKeyRecheckedDescription')
title: t("licenseErrorKeyRechecked"),
description: t("licenseErrorKeyRecheckedDescription")
});
} catch (e) {
toast({
title: t('licenseErrorKeyRecheck'),
title: t("licenseErrorKeyRecheck"),
description: formatAxiosError(
e,
t('licenseErrorKeyRecheckDescription')
t("licenseErrorKeyRecheckDescription")
)
});
} finally {
@@ -202,8 +195,8 @@ export default function LicensePage() {
}
toast({
title: t('licenseKeyActivated'),
description: t('licenseKeyActivatedDescription')
title: t("licenseKeyActivated"),
description: t("licenseKeyActivatedDescription")
});
setIsCreateModalOpen(false);
@@ -212,10 +205,10 @@ export default function LicensePage() {
} catch (e) {
toast({
variant: "destructive",
title: t('licenseErrorKeyActivate'),
title: t("licenseErrorKeyActivate"),
description: formatAxiosError(
e,
t('licenseErrorKeyActivateDescription')
t("licenseErrorKeyActivateDescription")
)
});
} finally {
@@ -246,9 +239,9 @@ export default function LicensePage() {
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t('licenseActivateKey')}</CredenzaTitle>
<CredenzaTitle>{t("licenseActivateKey")}</CredenzaTitle>
<CredenzaDescription>
{t('licenseActivateKeyDescription')}
{t("licenseActivateKeyDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@@ -263,7 +256,9 @@ export default function LicensePage() {
name="licenseKey"
render={({ field }) => (
<FormItem>
<FormLabel>{t('licenseKey')}</FormLabel>
<FormLabel>
{t("licenseKey")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -286,16 +281,7 @@ export default function LicensePage() {
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
{t('licenseAgreement')}
{/* <br /> */}
{/* <Link */}
{/* href="https://fossorial.io/license.html" */}
{/* target="_blank" */}
{/* rel="noopener noreferrer" */}
{/* className="text-primary hover:underline" */}
{/* > */}
{/* {t('fossorialLicense')} */}
{/* </Link> */}
{t("licenseAgreement")}
</FormLabel>
<FormMessage />
</div>
@@ -307,7 +293,7 @@ export default function LicensePage() {
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="submit"
@@ -315,7 +301,7 @@ export default function LicensePage() {
loading={isActivatingLicense}
disabled={isActivatingLicense}
>
{t('licenseActivate')}
{t("licenseActivate")}
</Button>
</CredenzaFooter>
</CredenzaContent>
@@ -331,187 +317,98 @@ export default function LicensePage() {
dialog={
<div className="space-y-4">
<p>
{t('licenseQuestionRemove', {selectedKey: obfuscateLicenseKey(selectedLicenseKey.licenseKey)})}
{t("licenseQuestionRemove", {
selectedKey: obfuscateLicenseKey(
selectedLicenseKey.licenseKey
)
})}
</p>
<p>
<b>
{t('licenseMessageRemove')}
</b>
</p>
<p>
{t('licenseMessageConfirm')}
<b>{t("licenseMessageRemove")}</b>
</p>
<p>{t("licenseMessageConfirm")}</p>
</div>
}
buttonText={t('licenseKeyDeleteConfirm')}
buttonText={t("licenseKeyDeleteConfirm")}
onConfirm={async () =>
deleteLicenseKey(selectedLicenseKey.licenseKeyEncrypted)
}
string={selectedLicenseKey.licenseKey}
title={t('licenseKeyDelete')}
title={t("licenseKeyDelete")}
/>
)}
<SettingsSectionTitle
title={t('licenseTitle')}
description={t('licenseTitleDescription')}
title={t("licenseTitle")}
description={t("licenseTitleDescription")}
/>
<Alert variant="neutral" className="mb-6">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('licenseAbout')}
</AlertTitle>
<AlertDescription>
{t('licenseAboutDescription')}
</AlertDescription>
</Alert>
{/* <Alert variant="neutral" className="mb-6"> */}
{/* <InfoIcon className="h-4 w-4" /> */}
{/* <AlertTitle className="font-semibold"> */}
{/* {t("licenseAbout")} */}
{/* </AlertTitle> */}
{/* <AlertDescription> */}
{/* {t("licenseAboutDescription")} */}
{/* </AlertDescription> */}
{/* </Alert> */}
<SettingsContainer>
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>{t('licenseHost')}</SSTitle>
<SettingsSectionDescription>
{t('licenseHostDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-4">
<div className="flex items-center space-x-4">
{licenseStatus?.isLicenseValid ? (
<div className="space-y-2 text-green-500">
<div className="text-2xl flex items-center gap-2">
<Check />
{licenseStatus?.tier ===
"PROFESSIONAL"
? t('licenseTierCommercial')
: licenseStatus?.tier ===
"ENTERPRISE"
? t('licenseTierCommercial')
: t('licensed')}
</div>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>{t("licenseHost")}</SSTitle>
<SettingsSectionDescription>
{t("licenseHostDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-4">
<div className="flex items-center space-x-4">
{licenseStatus?.isLicenseValid ? (
<div className="space-y-2 text-green-500">
<div className="text-2xl flex items-center gap-2">
<Check />
{t("licensed")}
</div>
) : (
<div className="space-y-2">
{supporterStatus?.visible ? (
<div className="text-2xl">
{t('communityEdition')}
</div>
) : (
<div className="text-2xl flex items-center gap-2 text-pink-500">
<Heart />
{t('communityEdition')}
</div>
)}
</div>
)}
</div>
{licenseStatus?.hostId && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t('hostId')}
</div>
<CopyTextBox text={licenseStatus.hostId} />
</div>
)}
{hostLicense && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t('licenseKey')}
</div>
<CopyTextBox
text={hostLicense}
displayText={obfuscateLicenseKey(
hostLicense
)}
/>
</div>
)}
</div>
<SettingsSectionFooter>
<Button
variant="outline"
onClick={recheck}
disabled={isRecheckingLicense}
loading={isRecheckingLicense}
>
{t('licenseReckeckAll')}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>{t('licenseSiteUsage')}</SSTitle>
<SettingsSectionDescription>
{t('licenseSiteUsageDecsription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-4">
<div className="space-y-2">
) : (
<div className="text-2xl">
{t('licenseSitesUsed', {count: licenseStatus?.usedSites || 0})}
</div>
</div>
{!licenseStatus?.isHostLicensed && (
<p className="text-sm text-muted-foreground">
{t('licenseNoSiteLimit')}
</p>
)}
{licenseStatus?.maxSites && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t('licenseSitesUsedMax', {usedSites: licenseStatus.usedSites || 0, maxSites: licenseStatus.maxSites})}
</span>
<span className="text-muted-foreground">
{Math.round(
((licenseStatus.usedSites ||
0) /
licenseStatus.maxSites) *
100
)}
%
</span>
</div>
<Progress
value={
((licenseStatus.usedSites || 0) /
licenseStatus.maxSites) *
100
}
className="h-5"
/>
{t("unlicensed")}
</div>
)}
</div>
{/* <SettingsSectionFooter> */}
{/* {!licenseStatus?.isHostLicensed ? ( */}
{/* <> */}
{/* <Button */}
{/* onClick={() => { */}
{/* setPurchaseMode("license"); */}
{/* setIsPurchaseModalOpen(true); */}
{/* }} */}
{/* > */}
{/* {t('licensePurchase')} */}
{/* </Button> */}
{/* </> */}
{/* ) : ( */}
{/* <> */}
{/* <Button */}
{/* variant="outline" */}
{/* onClick={() => { */}
{/* setPurchaseMode("additional-sites"); */}
{/* setIsPurchaseModalOpen(true); */}
{/* }} */}
{/* > */}
{/* {t('licensePurchaseSites')} */}
{/* </Button> */}
{/* </> */}
{/* )} */}
{/* </SettingsSectionFooter> */}
</SettingsSection>
</SettingsSectionGrid>
{licenseStatus?.hostId && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t("hostId")}
</div>
<CopyTextBox text={licenseStatus.hostId} />
</div>
)}
{hostLicense && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t("licenseKey")}
</div>
<CopyTextBox
text={hostLicense}
displayText={obfuscateLicenseKey(
hostLicense
)}
/>
</div>
)}
</div>
<SettingsSectionFooter>
<Button
variant="outline"
onClick={recheck}
disabled={isRecheckingLicense}
loading={isRecheckingLicense}
>
{t("licenseReckeckAll")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<LicenseKeysDataTable
licenseKeys={rows}
onDelete={(key) => {

View File

@@ -1,180 +0,0 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionTitle as SectionTitle,
SettingsSectionBody,
SettingsSectionFooter
} from "@app/components/Settings";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Alert } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
import {
Shield,
Zap,
RefreshCw,
Activity,
Wrench,
CheckCircle,
ExternalLink
} from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
export default function ManagedPage() {
const t = useTranslations();
return (
<>
<SettingsSectionTitle
title={t("managedSelfHosted.title")}
description={t("managedSelfHosted.description")}
/>
<SettingsContainer>
<SettingsSection>
<SettingsSectionBody>
<p className="mb-4">
<strong>{t("managedSelfHosted.introTitle")}</strong>{" "}
{t("managedSelfHosted.introDescription")}
</p>
<p className="mb-6">
{t("managedSelfHosted.introDetail")}
</p>
<div className="grid gap-4 md:grid-cols-2 py-4">
<div className="space-y-3">
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
{t(
"managedSelfHosted.benefitSimplerOperations.title"
)}
</h4>
<p className="text-sm text-muted-foreground">
{t(
"managedSelfHosted.benefitSimplerOperations.description"
)}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<RefreshCw className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
{t(
"managedSelfHosted.benefitAutomaticUpdates.title"
)}
</h4>
<p className="text-sm text-muted-foreground">
{t(
"managedSelfHosted.benefitAutomaticUpdates.description"
)}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Wrench className="w-5 h-5 text-orange-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
{t(
"managedSelfHosted.benefitLessMaintenance.title"
)}
</h4>
<p className="text-sm text-muted-foreground">
{t(
"managedSelfHosted.benefitLessMaintenance.description"
)}
</p>
</div>
</div>
</div>
<div className="space-y-3">
<div className="flex items-start gap-3">
<Activity className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
{t(
"managedSelfHosted.benefitCloudFailover.title"
)}
</h4>
<p className="text-sm text-muted-foreground">
{t(
"managedSelfHosted.benefitCloudFailover.description"
)}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Shield className="w-5 h-5 text-indigo-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
{t(
"managedSelfHosted.benefitHighAvailability.title"
)}
</h4>
<p className="text-sm text-muted-foreground">
{t(
"managedSelfHosted.benefitHighAvailability.description"
)}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Zap className="w-5 h-5 text-yellow-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
{t(
"managedSelfHosted.benefitFutureEnhancements.title"
)}
</h4>
<p className="text-sm text-muted-foreground">
{t(
"managedSelfHosted.benefitFutureEnhancements.description"
)}
</p>
</div>
</div>
</div>
</div>
<Alert
variant="neutral"
className="flex items-center gap-1"
>
{t("managedSelfHosted.docsAlert.text")}{" "}
<Link
href="https://docs.digpangolin.com/manage/managed"
target="_blank"
rel="noopener noreferrer"
className="hover:underline text-primary flex items-center gap-1"
>
{t("managedSelfHosted.docsAlert.documentation")}
<ExternalLink className="w-4 h-4" />
</Link>
.
</Alert>
</SettingsSectionBody>
<SettingsSectionFooter>
<Link
href="https://docs.digpangolin.com/self-host/convert-managed"
target="_blank"
rel="noopener noreferrer"
className="hover:underline text-primary flex items-center gap-1"
>
<Button>
{t("managedSelfHosted.convertButton")}
</Button>
</Link>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
</>
);
}

View File

@@ -74,16 +74,21 @@ export default async function OrgAuthPage(props: {
}
let subscriptionStatus: GetOrgTierResponse | null = null;
try {
const getSubscription = cache(() =>
priv.get<AxiosResponse<GetOrgTierResponse>>(
`/org/${loginPage!.orgId}/billing/tier`
)
);
const subRes = await getSubscription();
subscriptionStatus = subRes.data.data;
} catch {}
const subscribed = subscriptionStatus?.tier === TierId.STANDARD;
if (build === "saas") {
try {
const getSubscription = cache(() =>
priv.get<AxiosResponse<GetOrgTierResponse>>(
`/org/${loginPage!.orgId}/billing/tier`
)
);
const subRes = await getSubscription();
subscriptionStatus = subRes.data.data;
} catch {}
}
const subscribed =
build === "enterprise"
? true
: subscriptionStatus?.tier === TierId.STANDARD;
if (build === "saas" && !subscribed) {
redirect(env.app.dashboardUrl);

View File

@@ -1,15 +1,5 @@
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { Separator } from "@app/components/ui/separator";
import { priv } from "@app/lib/api";
import { verifySession } from "@app/lib/auth/verifySession";
import UserProvider from "@app/providers/UserProvider";
import { GetLicenseStatusResponse } from "@server/routers/license";
import { AxiosResponse } from "axios";
import { ExternalLink } from "lucide-react";
import { Metadata } from "next";
import { cache } from "react";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: `Auth - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
@@ -21,19 +11,6 @@ type AuthLayoutProps = {
};
export default async function AuthLayout({ children }: AuthLayoutProps) {
const getUser = cache(verifySession);
const user = await getUser();
const t = await getTranslations();
const hideFooter = true;
const licenseStatusRes = await cache(
async () =>
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
"/license/status"
)
)();
const licenseStatus = licenseStatusRes.data.data;
return (
<div className="h-full flex flex-col">
<div className="flex justify-end items-center p-3 space-x-2">
@@ -43,49 +20,6 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
<div className="flex-1 flex items-center justify-center">
<div className="w-full max-w-md p-3">{children}</div>
</div>
{!(
hideFooter || (
licenseStatus.isHostLicensed &&
licenseStatus.isLicenseValid)
) && (
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600">
<div className="flex items-center space-x-2 whitespace-nowrap">
<span>Pangolin</span>
</div>
<Separator orientation="vertical" />
<a
href="https://fossorial.io/"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>Fossorial</span>
<ExternalLink className="w-3 h-3" />
</a>
<Separator orientation="vertical" />
<a
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t("communityEdition")}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-3 h-3"
>
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
</svg>
</a>
</div>
</footer>
)}
</div>
);
}

View File

@@ -73,7 +73,10 @@ export default async function ResourceAuthPage(props: {
subscriptionStatus = subRes.data.data;
} catch {}
}
const subscribed = subscriptionStatus?.tier === TierId.STANDARD;
const subscribed =
build === "enterprise"
? true
: subscriptionStatus?.tier === TierId.STANDARD;
const allHeaders = await headers();
const host = allHeaders.get("host");
@@ -207,7 +210,12 @@ export default async function ResourceAuthPage(props: {
})) as LoginFormIDP[];
}
if (!userIsUnauthorized && isSSOOnly && authInfo.skipToIdpId && authInfo.skipToIdpId !== null) {
if (
!userIsUnauthorized &&
isSSOOnly &&
authInfo.skipToIdpId &&
authInfo.skipToIdpId !== null
) {
const idp = loginIdps.find((idp) => idp.idpId === authInfo.skipToIdpId);
if (idp) {
return (

View File

@@ -11,12 +11,13 @@ import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey";
import LicenseStatusProvider from "@app/providers/LicenseStatusProvider";
import { GetLicenseStatusResponse } from "@server/routers/license";
import { GetLicenseStatusResponse } from "#private/routers/license";
import LicenseViolation from "@app/components/LicenseViolation";
import { cache } from "react";
import { NextIntlClientProvider } from "next-intl";
import { getLocale } from "next-intl/server";
import { Toaster } from "@app/components/ui/toaster";
import { build } from "@server/build";
export const metadata: Metadata = {
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
@@ -57,13 +58,22 @@ export default async function RootLayout({
supporterData.visible = res.data.data.visible;
supporterData.tier = res.data.data.tier;
const licenseStatusRes = await cache(
async () =>
let licenseStatus: GetLicenseStatusResponse;
if (build === "enterprise") {
const licenseStatusRes = await cache(
async () =>
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
"/license/status"
)
)();
const licenseStatus = licenseStatusRes.data.data;
)();
licenseStatus = licenseStatusRes.data.data;
} else {
licenseStatus = {
isHostLicensed: false,
isLicenseValid: false,
hostId: ""
};
}
return (
<html suppressHydrationWarning lang={locale}>

View File

@@ -54,7 +54,8 @@ export const orgNavSections = (
{
title: "sidebarClients",
href: "/{orgId}/settings/clients",
icon: <MonitorUp className="h-4 w-4" />
icon: <MonitorUp className="h-4 w-4" />,
isBeta: true
}
]
: []),
@@ -63,7 +64,8 @@ export const orgNavSections = (
{
title: "sidebarRemoteExitNodes",
href: "/{orgId}/settings/remote-exit-nodes",
icon: <Server className="h-4 w-4" />
icon: <Server className="h-4 w-4" />,
showEE: true
}
]
: []),
@@ -97,7 +99,8 @@ export const orgNavSections = (
{
title: "sidebarIdentityProviders",
href: "/{orgId}/settings/idp",
icon: <Fingerprint className="h-4 w-4" />
icon: <Fingerprint className="h-4 w-4" />,
showEE: true
}
]
: []),
@@ -116,15 +119,6 @@ export const orgNavSections = (
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="h-4 w-4" />
},
...(build == "saas"
? [
{
title: "sidebarBilling",
href: "/{orgId}/settings/billing",
icon: <TicketCheck className="h-4 w-4" />
}
]
: []),
{
title: "sidebarSettings",
href: "/{orgId}/settings/general",
@@ -138,15 +132,6 @@ export const adminNavSections: SidebarNavSection[] = [
{
heading: "Admin",
items: [
...(build == "oss"
? [
{
title: "managedSelfhosted",
href: "/admin/managed",
icon: <Zap className="h-4 w-4" />
}
]
: []),
{
title: "sidebarAllUsers",
href: "/admin/users",