mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-11 15:36:38 +00:00
add enterprise license system
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
42
src/app/[orgId]/settings/(private)/license/layout.tsx
Normal file
42
src/app/[orgId]/settings/(private)/license/layout.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
src/app/[orgId]/settings/(private)/license/page.tsx
Normal file
25
src/app/[orgId]/settings/(private)/license/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
17
src/app/admin/license/layout.tsx
Normal file
17
src/app/admin/license/layout.tsx
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
1384
src/components/GenerateLicenseKeyForm.tsx
Normal file
1384
src/components/GenerateLicenseKeyForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
192
src/components/GenerateLicenseKeysTable.tsx
Normal file
192
src/components/GenerateLicenseKeysTable.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
54
src/components/SidebarLicenseButton.tsx
Normal file
54
src/components/SidebarLicenseButton.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,8 @@ type SubscriptionStatusContextType = {
|
||||
updateSubscriptionStatus: (updatedSite: GetOrgSubscriptionResponse) => void;
|
||||
isActive: () => boolean;
|
||||
getTier: () => string | null;
|
||||
isSubscribed: () => boolean;
|
||||
subscribed: boolean;
|
||||
};
|
||||
|
||||
const SubscriptionStatusContext = createContext<
|
||||
|
||||
@@ -40,13 +40,6 @@ export function LicenseStatusProvider({
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
licenseStatusState?.maxSites &&
|
||||
licenseStatusState?.usedSites &&
|
||||
licenseStatusState.usedSites > licenseStatusState.maxSites
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user