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",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,192 @@
"use client";
import { useTranslations } from "next-intl";
import { ColumnDef } from "@tanstack/react-table";
import { Button } from "./ui/button";
import { ArrowUpDown } from "lucide-react";
import CopyToClipboard from "./CopyToClipboard";
import { Badge } from "./ui/badge";
import moment from "moment";
import { DataTable } from "./ui/data-table";
import { GeneratedLicenseKey } from "@server/private/routers/generatedLicense";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { AxiosResponse } from "axios";
import { GenerateNewLicenseResponse } from "@server/private/routers/generatedLicense/generateNewLicense";
import GenerateLicenseKeyForm from "./GenerateLicenseKeyForm";
type GnerateLicenseKeysTableProps = {
licenseKeys: GeneratedLicenseKey[];
orgId: string;
};
function obfuscateLicenseKey(key: string): string {
if (key.length <= 8) return key;
const firstPart = key.substring(0, 4);
const lastPart = key.substring(key.length - 4);
return `${firstPart}••••••••••••••••••••${lastPart}`;
}
export default function GenerateLicenseKeysTable({
licenseKeys,
orgId
}: GnerateLicenseKeysTableProps) {
const t = useTranslations();
const router = useRouter();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [isRefreshing, setIsRefreshing] = useState(false);
const [showGenerateForm, setShowGenerateForm] = useState(false);
const handleLicenseGenerated = () => {
// Refresh the data after license is generated
refreshData();
};
const refreshData = async () => {
console.log("Data refreshed");
setIsRefreshing(true);
try {
await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
} finally {
setIsRefreshing(false);
}
};
const columns: ColumnDef<GeneratedLicenseKey>[] = [
{
accessorKey: "licenseKey",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("licenseKey")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const licenseKey = row.original.licenseKey;
return (
<CopyToClipboard
text={licenseKey}
displayText={obfuscateLicenseKey(licenseKey)}
/>
);
}
},
{
accessorKey: "instanceName",
cell: ({ row }) => {
return row.original.instanceName || "-";
}
},
{
accessorKey: "valid",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("valid")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return row.original.isValid ? (
<Badge variant="green">{t("yes")}</Badge>
) : (
<Badge variant="red">{t("no")}</Badge>
);
}
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const tier = row.original.tier;
return tier === "enterprise"
? t("licenseTierEnterprise")
: t("licenseTierPersonal");
}
},
{
accessorKey: "terminateAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("licenseTableValidUntil")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const termianteAt = row.original.expiresAt;
return moment(termianteAt).format("lll");
}
}
];
return (
<>
<DataTable
columns={columns}
data={licenseKeys}
persistPageSize="licenseKeys-table"
title={t("licenseKeys")}
searchPlaceholder={t("licenseKeySearch")}
searchColumn="licenseKey"
onRefresh={refreshData}
isRefreshing={isRefreshing}
addButtonText={t("generateLicenseKey")}
onAdd={() => {
setShowGenerateForm(true);
}}
/>
<GenerateLicenseKeyForm
open={showGenerateForm}
setOpen={setShowGenerateForm}
orgId={orgId}
onGenerated={handleLicenseGenerated}
/>
</>
);
}

View File

@@ -6,7 +6,15 @@ import { OrgSelector } from "@app/components/OrgSelector";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import SupporterStatus from "@app/components/SupporterStatus";
import { ExternalLink, Server, BookOpenText, Zap } from "lucide-react";
import {
ExternalLink,
Server,
BookOpenText,
Zap,
CreditCard,
FileText,
TicketCheck
} from "lucide-react";
import { FaDiscord, FaGithub } from "react-icons/fa";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -22,6 +30,7 @@ import {
TooltipTrigger
} from "@app/components/ui/tooltip";
import { build } from "@server/build";
import SidebarLicenseButton from "./SidebarLicenseButton";
interface LayoutSidebarProps {
orgId?: string;
@@ -119,8 +128,78 @@ export function LayoutSidebar({
/>
</div>
</div>
<div className="p-4 space-y-4 shrink-0">
<SupporterStatus isCollapsed={isSidebarCollapsed} />
{build === "saas" && (
<div className="mb-3 pt-4">
<div className="space-y-1">
<Link
href={`/${orgId}/settings/billing`}
className={cn(
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed
? t("sidebarBilling")
: undefined
}
>
<span
className={cn(
"flex-shrink-0",
!isSidebarCollapsed && "mr-2"
)}
>
<CreditCard className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<span>{t("sidebarBilling")}</span>
)}
</Link>
<Link
href={`/${orgId}/settings/license`}
className={cn(
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed
? t("sidebarEnterpriseLicenses")
: undefined
}
>
<span
className={cn(
"flex-shrink-0",
!isSidebarCollapsed && "mr-2"
)}
>
<TicketCheck className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<span>{t("sidebarEnterpriseLicenses")}</span>
)}
</Link>
</div>
</div>
)}
{build === "enterprise" && (
<div className="mb-3">
<SidebarLicenseButton
isCollapsed={isSidebarCollapsed}
/>
</div>
)}
{build === "oss" && (
<div className="mb-3">
<SupporterStatus isCollapsed={isSidebarCollapsed} />
</div>
)}
{!isSidebarCollapsed && (
<div className="space-y-2">
{loadFooterLinks() ? (
@@ -159,9 +238,9 @@ export function LayoutSidebar({
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
{!isUnlocked()
{build === "oss"
? t("communityEdition")
: t("commercialEdition")}
: t("enterpriseEdition")}
<FaGithub size={12} />
</Link>
</div>

View File

@@ -6,9 +6,9 @@ import { Button } from "@app/components/ui/button";
import { Badge } from "@app/components/ui/badge";
import { LicenseKeyCache } from "@server/license/license";
import { ArrowUpDown } from "lucide-react";
import moment from "moment";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { useTranslations } from "next-intl";
import moment from "moment";
type LicenseKeysDataTableProps = {
licenseKeys: LicenseKeyCache[];
@@ -28,7 +28,6 @@ export function LicenseKeysDataTable({
onDelete,
onCreate
}: LicenseKeysDataTableProps) {
const t = useTranslations();
const columns: ColumnDef<LicenseKeyCache>[] = [
@@ -42,7 +41,7 @@ export function LicenseKeysDataTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('licenseKey')}
{t("licenseKey")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -67,13 +66,17 @@ export function LicenseKeysDataTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('valid')}
{t("valid")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return row.original.valid ? t('yes') : t('no');
return row.original.valid ? (
<Badge variant="green">{t("yes")}</Badge>
) : (
<Badge variant="red">{t("no")}</Badge>
);
}
},
{
@@ -86,23 +89,20 @@ export function LicenseKeysDataTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('type')}
{t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const type = row.original.type;
const label =
type === "SITES" ? t('sitesAdditional') : t('licenseHost');
const variant = type === "SITES" ? "secondary" : "default";
return row.original.valid ? (
<Badge variant={variant}>{label}</Badge>
) : null;
const tier = row.original.tier;
tier === "enterprise"
? t("licenseTierEnterprise")
: t("licenseTierPersonal");
}
},
{
accessorKey: "numSites",
accessorKey: "terminateAt",
header: ({ column }) => {
return (
<Button
@@ -111,10 +111,14 @@ export function LicenseKeysDataTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('numberOfSites')}
{t("licenseTableValidUntil")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const termianteAt = row.original.terminateAt;
return moment(termianteAt).format("lll");
}
},
{
@@ -125,7 +129,7 @@ export function LicenseKeysDataTable({
variant="secondary"
onClick={() => onDelete(row.original)}
>
{t('delete')}
{t("delete")}
</Button>
</div>
)
@@ -137,11 +141,11 @@ export function LicenseKeysDataTable({
columns={columns}
data={licenseKeys}
persistPageSize="licenseKeys-table"
title={t('licenseKeys')}
searchPlaceholder={t('licenseKeySearch')}
title={t("licenseKeys")}
searchPlaceholder={t("licenseKeySearch")}
searchColumn="licenseKey"
onAdd={onCreate}
addButtonText={t('licenseKeyAdd')}
addButtonText={t("licenseKeyAdd")}
/>
);
}

View File

@@ -32,29 +32,5 @@ export default function LicenseViolation() {
);
}
// Show usage violation banner
if (
licenseStatus.maxSites &&
licenseStatus.usedSites &&
licenseStatus.usedSites > licenseStatus.maxSites
) {
return (
<div className="fixed bottom-0 left-0 right-0 w-full bg-yellow-500 text-black p-4 text-center z-50">
<div className="flex justify-between items-center">
<p>
{t('componentsLicenseViolation', {usedSites: licenseStatus.usedSites, maxSites: licenseStatus.maxSites})}
</p>
<Button
variant={"ghost"}
className="hover:bg-yellow-500"
onClick={() => setIsDismissed(true)}
>
{t('dismiss')}
</Button>
</div>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,54 @@
"use client";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import { Button } from "./ui/button";
import { TicketCheck } from "lucide-react";
import { useTranslations } from "next-intl";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import Link from "next/link";
interface SidebarLicenseButtonProps {
isCollapsed?: boolean;
}
export default function SidebarLicenseButton({
isCollapsed = false
}: SidebarLicenseButtonProps) {
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
const t = useTranslations();
return (
<>
{!licenseStatus?.isHostLicensed ? (
isCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link href="https://docs.digpangolin.com/">
<Button size="icon" className="w-8 h-8">
<TicketCheck className="h-4 w-4" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Enable Enterprise License
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Link href="https://docs.digpangolin.com/">
<Button size="sm" className="gap-2 w-full">
Enable Enterprise License
</Button>
</Link>
)
) : null}
</>
);
}

View File

@@ -14,12 +14,14 @@ import {
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import { build } from "@server/build";
export type SidebarNavItem = {
href: string;
title: string;
icon?: React.ReactNode;
showProfessional?: boolean;
showEE?: boolean;
isBeta?: boolean;
};
export type SidebarNavSection = {
@@ -71,7 +73,7 @@ export function SidebarNav({
isDisabled: boolean
) => {
const tooltipText =
item.showProfessional && !isUnlocked()
item.showEE && !isUnlocked()
? `${t(item.title)} (${t("licenseBadge")})`
: t(item.title);
@@ -106,11 +108,24 @@ export function SidebarNav({
{!isCollapsed && (
<>
<span>{t(item.title)}</span>
{item.showProfessional && !isUnlocked() && (
<Badge variant="outlinePrimary" className="ml-2">
{t("licenseBadge")}
{item.isBeta && (
<Badge
variant="outline"
className="ml-2 text-muted-foreground"
>
{t("beta")}
</Badge>
)}
{build === "enterprise" &&
item.showEE &&
!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
{t("licenseBadge")}
</Badge>
)}
</>
)}
</Link>
@@ -154,9 +169,11 @@ export function SidebarNav({
{section.items.map((item) => {
const hydratedHref = hydrateHref(item.href);
const isActive = pathname.startsWith(hydratedHref);
const isProfessional =
item.showProfessional && !isUnlocked();
const isDisabled = disabled || isProfessional;
const isEE =
build === "enterprise" &&
item.showEE &&
!isUnlocked();
const isDisabled = disabled || isEE;
return renderNavItem(
item,
hydratedHref,

View File

@@ -303,7 +303,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
return (
<div className="flex items-center space-x-2">
<span>{originalRow.exitNodeName}</span>
{build == "saas" && originalRow.exitNodeName &&
{build == "saas" && originalRow.exitNodeName &&
['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'].includes(originalRow.exitNodeName.toLowerCase()) && (
<Badge variant="secondary">Cloud</Badge>
)}

View File

@@ -7,7 +7,7 @@ import { useState, ReactNode } from "react";
export interface StrategyOption<TValue extends string> {
id: TValue;
title: string;
description: string;
description: string | ReactNode;
disabled?: boolean;
icon?: ReactNode;
}
@@ -68,7 +68,7 @@ export function StrategySelect<TValue extends string>({
<div className="flex-1">
<div className="font-medium">{option.title}</div>
<div className="text-sm text-muted-foreground">
{option.description}
{typeof option.description === 'string' ? option.description : option.description}
</div>
</div>
</div>

View File

@@ -72,454 +72,475 @@ export interface AuthPageSettingsRef {
hasUnsavedChanges: () => boolean;
}
const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(({
onSaveSuccess,
onSaveError
}, ref) => {
const { org } = useOrgContext();
const api = createApiClient(useEnvContext());
const router = useRouter();
const t = useTranslations();
const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
({ onSaveSuccess, onSaveError }, ref) => {
const { org } = useOrgContext();
const api = createApiClient(useEnvContext());
const router = useRouter();
const t = useTranslations();
const subscription = useSubscriptionStatusContext();
const subscribed = subscription?.getTier() === TierId.STANDARD;
const subscription = useSubscriptionStatusContext();
// Auth page domain state
const [loginPage, setLoginPage] = useState<GetLoginPageResponse | null>(
null
);
const [loginPageExists, setLoginPageExists] = useState(false);
const [editDomainOpen, setEditDomainOpen] = useState(false);
const [baseDomains, setBaseDomains] = useState<DomainRow[]>([]);
const [selectedDomain, setSelectedDomain] = useState<{
domainId: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
} | null>(null);
const [loadingLoginPage, setLoadingLoginPage] = useState(true);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [loadingSave, setLoadingSave] = useState(false);
// Auth page domain state
const [loginPage, setLoginPage] = useState<GetLoginPageResponse | null>(
null
);
const [loginPageExists, setLoginPageExists] = useState(false);
const [editDomainOpen, setEditDomainOpen] = useState(false);
const [baseDomains, setBaseDomains] = useState<DomainRow[]>([]);
const [selectedDomain, setSelectedDomain] = useState<{
domainId: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
} | null>(null);
const [loadingLoginPage, setLoadingLoginPage] = useState(true);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [loadingSave, setLoadingSave] = useState(false);
const form = useForm({
resolver: zodResolver(AuthPageFormSchema),
defaultValues: {
authPageDomainId: loginPage?.domainId || "",
authPageSubdomain: loginPage?.subdomain || ""
},
mode: "onChange"
});
const form = useForm({
resolver: zodResolver(AuthPageFormSchema),
defaultValues: {
authPageDomainId: loginPage?.domainId || "",
authPageSubdomain: loginPage?.subdomain || ""
},
mode: "onChange"
});
// Expose save function to parent component
useImperativeHandle(ref, () => ({
saveAuthSettings: async () => {
await form.handleSubmit(onSubmit)();
},
hasUnsavedChanges: () => hasUnsavedChanges
}), [form, hasUnsavedChanges]);
// Expose save function to parent component
useImperativeHandle(
ref,
() => ({
saveAuthSettings: async () => {
await form.handleSubmit(onSubmit)();
},
hasUnsavedChanges: () => hasUnsavedChanges
}),
[form, hasUnsavedChanges]
);
// Fetch login page and domains data
useEffect(() => {
if (build !== "saas") {
return;
}
const fetchLoginPage = async () => {
try {
const res = await api.get<AxiosResponse<GetLoginPageResponse>>(
`/org/${org?.org.orgId}/login-page`
);
if (res.status === 200) {
setLoginPage(res.data.data);
setLoginPageExists(true);
// Update form with login page data
form.setValue(
"authPageDomainId",
res.data.data.domainId || ""
);
form.setValue(
"authPageSubdomain",
res.data.data.subdomain || ""
);
}
} catch (err) {
// Login page doesn't exist yet, that's okay
setLoginPage(null);
setLoginPageExists(false);
} finally {
setLoadingLoginPage(false);
}
};
const fetchDomains = async () => {
try {
const res = await api.get<AxiosResponse<ListDomainsResponse>>(
`/org/${org?.org.orgId}/domains/`
);
if (res.status === 200) {
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain)
}));
setBaseDomains(domains);
}
} catch (err) {
console.error("Failed to fetch domains:", err);
}
};
if (org?.org.orgId) {
fetchLoginPage();
fetchDomains();
}
}, []);
// Handle domain selection from modal
function handleDomainSelection(domain: {
domainId: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
}) {
form.setValue("authPageDomainId", domain.domainId);
form.setValue("authPageSubdomain", domain.subdomain || "");
setEditDomainOpen(false);
// Update loginPage state to show the selected domain immediately
const sanitizedSubdomain = domain.subdomain
? finalizeSubdomainSanitize(domain.subdomain)
: "";
const sanitizedFullDomain = sanitizedSubdomain
? `${sanitizedSubdomain}.${domain.baseDomain}`
: domain.baseDomain;
// Only update loginPage state if a login page already exists
if (loginPageExists && loginPage) {
setLoginPage({
...loginPage,
domainId: domain.domainId,
subdomain: sanitizedSubdomain,
fullDomain: sanitizedFullDomain
});
}
setHasUnsavedChanges(true);
}
// Clear auth page domain
function clearAuthPageDomain() {
form.setValue("authPageDomainId", "");
form.setValue("authPageSubdomain", "");
setLoginPage(null);
setHasUnsavedChanges(true);
}
async function onSubmit(data: AuthPageFormValues) {
setLoadingSave(true);
try {
// Handle auth page domain
if (data.authPageDomainId) {
if (build !== "saas" || (build === "saas" && subscribed)) {
const sanitizedSubdomain = data.authPageSubdomain
? finalizeSubdomainSanitize(data.authPageSubdomain)
: "";
if (loginPageExists) {
// Login page exists on server - need to update it
// First, we need to get the loginPageId from the server since loginPage might be null locally
let loginPageId: number;
if (loginPage) {
// We have the loginPage data locally
loginPageId = loginPage.loginPageId;
} else {
// User cleared selection locally, but login page still exists on server
// We need to fetch it to get the loginPageId
const fetchRes = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
loginPageId = fetchRes.data.data.loginPageId;
}
// Update existing auth page domain
const updateRes = await api.post(
`/org/${org?.org.orgId}/login-page/${loginPageId}`,
{
domainId: data.authPageDomainId,
subdomain: sanitizedSubdomain || null
}
);
if (updateRes.status === 201) {
setLoginPage(updateRes.data.data);
setLoginPageExists(true);
}
} else {
// No login page exists on server - create new one
const createRes = await api.put(
`/org/${org?.org.orgId}/login-page`,
{
domainId: data.authPageDomainId,
subdomain: sanitizedSubdomain || null
}
);
if (createRes.status === 201) {
setLoginPage(createRes.data.data);
setLoginPageExists(true);
}
}
}
} else if (loginPageExists) {
// Delete existing auth page domain if no domain selected
let loginPageId: number;
if (loginPage) {
// We have the loginPage data locally
loginPageId = loginPage.loginPageId;
} else {
// User cleared selection locally, but login page still exists on server
// We need to fetch it to get the loginPageId
const fetchRes = await api.get<
// Fetch login page and domains data
useEffect(() => {
const fetchLoginPage = async () => {
try {
const res = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
loginPageId = fetchRes.data.data.loginPageId;
if (res.status === 200) {
setLoginPage(res.data.data);
setLoginPageExists(true);
// Update form with login page data
form.setValue(
"authPageDomainId",
res.data.data.domainId || ""
);
form.setValue(
"authPageSubdomain",
res.data.data.subdomain || ""
);
}
} catch (err) {
// Login page doesn't exist yet, that's okay
setLoginPage(null);
setLoginPageExists(false);
} finally {
setLoadingLoginPage(false);
}
};
await api.delete(
`/org/${org?.org.orgId}/login-page/${loginPageId}`
);
setLoginPage(null);
setLoginPageExists(false);
const fetchDomains = async () => {
try {
const res = await api.get<
AxiosResponse<ListDomainsResponse>
>(`/org/${org?.org.orgId}/domains/`);
if (res.status === 200) {
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain)
}));
setBaseDomains(domains);
}
} catch (err) {
console.error("Failed to fetch domains:", err);
}
};
if (org?.org.orgId) {
fetchLoginPage();
fetchDomains();
}
}, []);
// Handle domain selection from modal
function handleDomainSelection(domain: {
domainId: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
}) {
form.setValue("authPageDomainId", domain.domainId);
form.setValue("authPageSubdomain", domain.subdomain || "");
setEditDomainOpen(false);
// Update loginPage state to show the selected domain immediately
const sanitizedSubdomain = domain.subdomain
? finalizeSubdomainSanitize(domain.subdomain)
: "";
const sanitizedFullDomain = sanitizedSubdomain
? `${sanitizedSubdomain}.${domain.baseDomain}`
: domain.baseDomain;
// Only update loginPage state if a login page already exists
if (loginPageExists && loginPage) {
setLoginPage({
...loginPage,
domainId: domain.domainId,
subdomain: sanitizedSubdomain,
fullDomain: sanitizedFullDomain
});
}
setHasUnsavedChanges(false);
router.refresh();
onSaveSuccess?.();
} catch (e) {
toast({
variant: "destructive",
title: t("authPageErrorUpdate"),
description: formatAxiosError(e, t("authPageErrorUpdateMessage"))
});
onSaveError?.(e);
} finally {
setLoadingSave(false);
setHasUnsavedChanges(true);
}
}
return (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("authPage")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("authPageDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{build === "saas" && !subscribed ? (
<Alert variant="info" className="mb-6">
<AlertDescription>
{t("orgAuthPageDisabled")}{" "}
{t("subscriptionRequiredToUse")}
</AlertDescription>
</Alert>
) : null}
// Clear auth page domain
function clearAuthPageDomain() {
form.setValue("authPageDomainId", "");
form.setValue("authPageSubdomain", "");
setLoginPage(null);
setHasUnsavedChanges(true);
}
<SettingsSectionForm>
{loadingLoginPage ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">
{t("loading")}
async function onSubmit(data: AuthPageFormValues) {
setLoadingSave(true);
try {
// Handle auth page domain
if (data.authPageDomainId) {
if (
build === "enterprise" ||
(build === "saas" && subscription?.subscribed)
) {
const sanitizedSubdomain = data.authPageSubdomain
? finalizeSubdomainSanitize(data.authPageSubdomain)
: "";
if (loginPageExists) {
// Login page exists on server - need to update it
// First, we need to get the loginPageId from the server since loginPage might be null locally
let loginPageId: number;
if (loginPage) {
// We have the loginPage data locally
loginPageId = loginPage.loginPageId;
} else {
// User cleared selection locally, but login page still exists on server
// We need to fetch it to get the loginPageId
const fetchRes = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
loginPageId = fetchRes.data.data.loginPageId;
}
// Update existing auth page domain
const updateRes = await api.post(
`/org/${org?.org.orgId}/login-page/${loginPageId}`,
{
domainId: data.authPageDomainId,
subdomain: sanitizedSubdomain || null
}
);
if (updateRes.status === 201) {
setLoginPage(updateRes.data.data);
setLoginPageExists(true);
}
} else {
// No login page exists on server - create new one
const createRes = await api.put(
`/org/${org?.org.orgId}/login-page`,
{
domainId: data.authPageDomainId,
subdomain: sanitizedSubdomain || null
}
);
if (createRes.status === 201) {
setLoginPage(createRes.data.data);
setLoginPageExists(true);
}
}
}
} else if (loginPageExists) {
// Delete existing auth page domain if no domain selected
let loginPageId: number;
if (loginPage) {
// We have the loginPage data locally
loginPageId = loginPage.loginPageId;
} else {
// User cleared selection locally, but login page still exists on server
// We need to fetch it to get the loginPageId
const fetchRes = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
loginPageId = fetchRes.data.data.loginPageId;
}
await api.delete(
`/org/${org?.org.orgId}/login-page/${loginPageId}`
);
setLoginPage(null);
setLoginPageExists(false);
}
setHasUnsavedChanges(false);
router.refresh();
onSaveSuccess?.();
} catch (e) {
toast({
variant: "destructive",
title: t("authPageErrorUpdate"),
description: formatAxiosError(
e,
t("authPageErrorUpdateMessage")
)
});
onSaveError?.(e);
} finally {
setLoadingSave(false);
}
}
return (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("authPage")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("authPageDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{build === "saas" && !subscription?.subscribed ? (
<Alert variant="info" className="mb-6">
<AlertDescription>
{t("orgAuthPageDisabled")}{" "}
{t("subscriptionRequiredToUse")}
</AlertDescription>
</Alert>
) : null}
<SettingsSectionForm>
{loadingLoginPage ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">
{t("loading")}
</div>
</div>
</div>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="auth-page-settings-form"
>
<div className="space-y-3">
<Label>{t("authPageDomain")}</Label>
<div className="border p-2 rounded-md flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-2">
<Globe size="14" />
{loginPage &&
!loginPage.domainId ? (
<InfoPopup
info={t(
"domainNotFoundDescription"
)}
text={t("domainNotFound")}
/>
) : loginPage?.fullDomain ? (
<a
href={`${window.location.protocol}//${loginPage.fullDomain}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{`${window.location.protocol}//${loginPage.fullDomain}`}
</a>
) : form.watch(
"authPageDomainId"
) ? (
// Show selected domain from form state when no loginPage exists yet
(() => {
const selectedDomainId =
form.watch(
"authPageDomainId"
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="auth-page-settings-form"
>
<div className="space-y-3">
<Label>{t("authPageDomain")}</Label>
<div className="border p-2 rounded-md flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-2">
<Globe size="14" />
{loginPage &&
!loginPage.domainId ? (
<InfoPopup
info={t(
"domainNotFoundDescription"
)}
text={t(
"domainNotFound"
)}
/>
) : loginPage?.fullDomain ? (
<a
href={`${window.location.protocol}//${loginPage.fullDomain}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{`${window.location.protocol}//${loginPage.fullDomain}`}
</a>
) : form.watch(
"authPageDomainId"
) ? (
// Show selected domain from form state when no loginPage exists yet
(() => {
const selectedDomainId =
form.watch(
"authPageDomainId"
);
const selectedSubdomain =
form.watch(
"authPageSubdomain"
);
const domain =
baseDomains.find(
(d) =>
d.domainId ===
selectedDomainId
);
if (domain) {
const sanitizedSubdomain =
selectedSubdomain
? finalizeSubdomainSanitize(
selectedSubdomain
)
: "";
const fullDomain =
sanitizedSubdomain
? `${sanitizedSubdomain}.${domain.baseDomain}`
: domain.baseDomain;
return fullDomain;
}
return t(
"noDomainSet"
);
const selectedSubdomain =
form.watch(
"authPageSubdomain"
);
const domain =
baseDomains.find(
(d) =>
d.domainId ===
selectedDomainId
);
if (domain) {
const sanitizedSubdomain =
selectedSubdomain
? finalizeSubdomainSanitize(
selectedSubdomain
)
: "";
const fullDomain =
sanitizedSubdomain
? `${sanitizedSubdomain}.${domain.baseDomain}`
: domain.baseDomain;
return fullDomain;
}
return t("noDomainSet");
})()
) : (
t("noDomainSet")
)}
</span>
<div className="flex items-center gap-2">
<Button
variant="secondary"
type="button"
size="sm"
onClick={() =>
setEditDomainOpen(true)
}
>
{form.watch("authPageDomainId")
? t("changeDomain")
: t("selectDomain")}
</Button>
{form.watch("authPageDomainId") && (
})()
) : (
t("noDomainSet")
)}
</span>
<div className="flex items-center gap-2">
<Button
variant="destructive"
variant="secondary"
type="button"
size="sm"
onClick={
clearAuthPageDomain
onClick={() =>
setEditDomainOpen(
true
)
}
>
<Trash2 size="14" />
{form.watch(
"authPageDomainId"
)
? t("changeDomain")
: t("selectDomain")}
</Button>
)}
{form.watch(
"authPageDomainId"
) && (
<Button
variant="destructive"
type="button"
size="sm"
onClick={
clearAuthPageDomain
}
>
<Trash2 size="14" />
</Button>
)}
</div>
</div>
</div>
{/* Certificate Status */}
{(build !== "saas" ||
(build === "saas" && subscribed)) &&
loginPage?.domainId &&
loginPage?.fullDomain &&
!hasUnsavedChanges && (
<CertificateStatus
orgId={org?.org.orgId || ""}
domainId={loginPage.domainId}
fullDomain={
loginPage.fullDomain
}
autoFetch={true}
showLabel={true}
polling={true}
/>
{/* Certificate Status */}
{(build === "enterprise" ||
(build === "saas" &&
subscription?.subscribed)) &&
loginPage?.domainId &&
loginPage?.fullDomain &&
!hasUnsavedChanges && (
<CertificateStatus
orgId={
org?.org.orgId || ""
}
domainId={
loginPage.domainId
}
fullDomain={
loginPage.fullDomain
}
autoFetch={true}
showLabel={true}
polling={true}
/>
)}
{!form.watch(
"authPageDomainId"
) && (
<div className="text-sm text-muted-foreground">
{t(
"addDomainToEnableCustomAuthPages"
)}
</div>
)}
</div>
</form>
</Form>
)}
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{!form.watch("authPageDomainId") && (
<div className="text-sm text-muted-foreground">
{t(
"addDomainToEnableCustomAuthPages"
)}
</div>
)}
</div>
</form>
</Form>
)}
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{/* Domain Picker Modal */}
<Credenza
open={editDomainOpen}
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{loginPage
? t("editAuthPageDomain")
: t("setAuthPageDomain")}
</CredenzaTitle>
<CredenzaDescription>
{t("selectDomainForOrgAuthPage")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<DomainPicker
hideFreeDomain={true}
orgId={org?.org.orgId as string}
cols={1}
onDomainChange={(res) => {
const selected = {
domainId: res.domainId,
subdomain: res.subdomain,
fullDomain: res.fullDomain,
baseDomain: res.baseDomain
};
setSelectedDomain(selected);
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</CredenzaClose>
<Button
onClick={() => {
if (selectedDomain) {
handleDomainSelection(selectedDomain);
}
}}
disabled={!selectedDomain}
>
{t("selectDomain")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}
);
{/* Domain Picker Modal */}
<Credenza
open={editDomainOpen}
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{loginPage
? t("editAuthPageDomain")
: t("setAuthPageDomain")}
</CredenzaTitle>
<CredenzaDescription>
{t("selectDomainForOrgAuthPage")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<DomainPicker
hideFreeDomain={true}
orgId={org?.org.orgId as string}
cols={1}
onDomainChange={(res) => {
const selected = {
domainId: res.domainId,
subdomain: res.subdomain,
fullDomain: res.fullDomain,
baseDomain: res.baseDomain
};
setSelectedDomain(selected);
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</CredenzaClose>
<Button
onClick={() => {
if (selectedDomain) {
handleDomainSelection(selectedDomain);
}
}}
disabled={!selectedDomain}
>
{t("selectDomain")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
});
AuthPageSettings.displayName = "AuthPageSettings";
AuthPageSettings.displayName = 'AuthPageSettings';
export default AuthPageSettings;
export default AuthPageSettings;

View File

@@ -6,6 +6,8 @@ type SubscriptionStatusContextType = {
updateSubscriptionStatus: (updatedSite: GetOrgSubscriptionResponse) => void;
isActive: () => boolean;
getTier: () => string | null;
isSubscribed: () => boolean;
subscribed: boolean;
};
const SubscriptionStatusContext = createContext<

View File

@@ -40,13 +40,6 @@ export function LicenseStatusProvider({
) {
return true;
}
if (
licenseStatusState?.maxSites &&
licenseStatusState?.usedSites &&
licenseStatusState.usedSites > licenseStatusState.maxSites
) {
return true;
}
return false;
};

View File

@@ -1,9 +1,10 @@
"use client";
import SubscriptionStatusContext from "@app/contexts/subscriptionStatusContext";
import { getTierPriceSet } from "@server/lib/billing/tiers";
import { getTierPriceSet, TierId } from "@server/lib/billing/tiers";
import { GetOrgSubscriptionResponse } from "#private/routers/billing";
import { useState } from "react";
import { build } from "@server/build";
interface ProviderProps {
children: React.ReactNode;
@@ -12,7 +13,7 @@ interface ProviderProps {
sandbox_mode: boolean;
}
export function PrivateSubscriptionStatusProvider({
export function SubscriptionStatusProvider({
children,
subscriptionStatus,
env,
@@ -21,7 +22,9 @@ export function PrivateSubscriptionStatusProvider({
const [subscriptionStatusState, setSubscriptionStatusState] =
useState<GetOrgSubscriptionResponse | null>(subscriptionStatus);
const updateSubscriptionStatus = (updatedSubscriptionStatus: GetOrgSubscriptionResponse) => {
const updateSubscriptionStatus = (
updatedSubscriptionStatus: GetOrgSubscriptionResponse
) => {
setSubscriptionStatusState((prev) => {
return {
...updatedSubscriptionStatus
@@ -43,7 +46,9 @@ export function PrivateSubscriptionStatusProvider({
// 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);
const matchingItem = subscriptionStatus.items.find(
(item) => item.priceId === priceId
);
if (matchingItem) {
return tierId;
}
@@ -54,13 +59,24 @@ export function PrivateSubscriptionStatusProvider({
return null;
};
const isSubscribed = () => {
if (build === "enterprise") {
return true;
}
return getTier() === TierId.STANDARD;
};
const [subscribed, setSubscribed] = useState<boolean>(isSubscribed());
return (
<SubscriptionStatusContext.Provider
value={{
subscriptionStatus: subscriptionStatusState,
updateSubscriptionStatus,
isActive,
getTier
getTier,
isSubscribed,
subscribed
}}
>
{children}
@@ -68,4 +84,4 @@ export function PrivateSubscriptionStatusProvider({
);
}
export default PrivateSubscriptionStatusProvider;
export default SubscriptionStatusProvider;