add enterprise license system

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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