mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-17 02:16:38 +00:00
add license system and ui
This commit is contained in:
@@ -42,6 +42,7 @@ import {
|
||||
} from "@app/components/InfoSection";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
|
||||
@@ -67,6 +68,7 @@ export default function GeneralPage() {
|
||||
const { idpId } = useParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
|
||||
const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
|
||||
|
||||
@@ -230,7 +232,7 @@ export default function GeneralPage() {
|
||||
defaultChecked={form.getValues(
|
||||
"autoProvision"
|
||||
)}
|
||||
disabled={true}
|
||||
disabled={!isUnlocked()}
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue(
|
||||
"autoProvision",
|
||||
@@ -238,12 +240,14 @@ export default function GeneralPage() {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Badge
|
||||
variant="outlinePrimary"
|
||||
className="ml-2"
|
||||
>
|
||||
Professional
|
||||
</Badge>
|
||||
{!isUnlocked() && (
|
||||
<Badge
|
||||
variant="outlinePrimary"
|
||||
className="ml-2"
|
||||
>
|
||||
Professional
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
When enabled, users will be
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AxiosResponse } from "axios";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import { ProfessionalContentOverlay } from "@app/components/ProfessionalContentOverlay";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
@@ -35,7 +36,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
redirect("/admin/idp");
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
const navItems: HorizontalTabs = [
|
||||
{
|
||||
title: "General",
|
||||
href: `/admin/idp/${params.idpId}/general`
|
||||
|
||||
@@ -36,6 +36,7 @@ import { InfoIcon, ExternalLink } from "lucide-react";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
|
||||
const createIdpFormSchema = z.object({
|
||||
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
|
||||
@@ -74,6 +75,7 @@ export default function Page() {
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
|
||||
const form = useForm<CreateIdpFormValues>({
|
||||
resolver: zodResolver(createIdpFormSchema),
|
||||
@@ -190,7 +192,7 @@ export default function Page() {
|
||||
defaultChecked={form.getValues(
|
||||
"autoProvision"
|
||||
)}
|
||||
disabled={true}
|
||||
disabled={!isUnlocked()}
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue(
|
||||
"autoProvision",
|
||||
@@ -198,12 +200,14 @@ export default function Page() {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Badge
|
||||
variant="outlinePrimary"
|
||||
className="ml-2"
|
||||
>
|
||||
Professional
|
||||
</Badge>
|
||||
{!isUnlocked() && (
|
||||
<Badge
|
||||
variant="outlinePrimary"
|
||||
className="ml-2"
|
||||
>
|
||||
Professional
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
When enabled, users will be
|
||||
|
||||
147
src/app/admin/license/LicenseKeysDataTable.tsx
Normal file
147
src/app/admin/license/LicenseKeysDataTable.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
// This file is licensed under the Fossorial Commercial License.
|
||||
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
//
|
||||
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
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";
|
||||
|
||||
type LicenseKeysDataTableProps = {
|
||||
licenseKeys: LicenseKeyCache[];
|
||||
onDelete: (key: string) => void;
|
||||
onCreate: () => void;
|
||||
};
|
||||
|
||||
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 function LicenseKeysDataTable({
|
||||
licenseKeys,
|
||||
onDelete,
|
||||
onCreate
|
||||
}: LicenseKeysDataTableProps) {
|
||||
const columns: ColumnDef<LicenseKeyCache>[] = [
|
||||
{
|
||||
accessorKey: "licenseKey",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
License Key
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const licenseKey = row.original.licenseKey;
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={licenseKey}
|
||||
displayText={obfuscateLicenseKey(licenseKey)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "valid",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Valid
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return row.original.valid ? "Yes" : "No";
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Type
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const type = row.original.type;
|
||||
const label =
|
||||
type === "SITES" ? "Additional Sites" : "Host License";
|
||||
const variant = type === "SITES" ? "secondary" : "default";
|
||||
return row.original.valid ? (
|
||||
<Badge variant={variant}>{label}</Badge>
|
||||
) : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "numSites",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Number of Sites
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
variant="outlinePrimary"
|
||||
onClick={() => onDelete(row.original.licenseKey)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={licenseKeys}
|
||||
title="License Keys"
|
||||
searchPlaceholder="Search license keys..."
|
||||
searchColumn="licenseKey"
|
||||
onAdd={onCreate}
|
||||
addButtonText="Add License Key"
|
||||
/>
|
||||
);
|
||||
}
|
||||
131
src/app/admin/license/components/SitePriceCalculator.tsx
Normal file
131
src/app/admin/license/components/SitePriceCalculator.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
// This file is licensed under the Fossorial Commercial License.
|
||||
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
//
|
||||
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { MinusCircle, PlusCircle } from "lucide-react";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
|
||||
type SitePriceCalculatorProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: "license" | "additional-sites";
|
||||
};
|
||||
|
||||
export function SitePriceCalculator({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
mode
|
||||
}: SitePriceCalculatorProps) {
|
||||
const [siteCount, setSiteCount] = useState(1);
|
||||
const pricePerSite = 5;
|
||||
const licenseFlatRate = 125;
|
||||
|
||||
const incrementSites = () => {
|
||||
setSiteCount((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const decrementSites = () => {
|
||||
setSiteCount((prev) => (prev > 1 ? prev - 1 : 1));
|
||||
};
|
||||
|
||||
const totalCost = mode === "license"
|
||||
? licenseFlatRate + (siteCount * pricePerSite)
|
||||
: siteCount * pricePerSite;
|
||||
|
||||
return (
|
||||
<Credenza open={isOpen} onOpenChange={onOpenChange}>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{mode === "license" ? "Purchase License" : "Purchase Additional Sites"}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Choose how many sites you want to {mode === "license" ? "purchase a license for" : "add to your existing license"}.
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Number of Sites
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={decrementSites}
|
||||
disabled={siteCount <= 1}
|
||||
aria-label="Decrease site count"
|
||||
>
|
||||
<MinusCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
<span className="text-3xl w-12 text-center">
|
||||
{siteCount}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={incrementSites}
|
||||
aria-label="Increase site count"
|
||||
>
|
||||
<PlusCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
{mode === "license" && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
License fee:
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
${licenseFlatRate.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-sm font-medium">
|
||||
Price per site:
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
${pricePerSite.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-sm font-medium">
|
||||
Number of sites:
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{siteCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-4 text-lg font-bold">
|
||||
<span>Total:</span>
|
||||
<span>${totalCost.toFixed(2)} / mo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</CredenzaClose>
|
||||
<Button>Continue to Payment</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
471
src/app/admin/license/page.tsx
Normal file
471
src/app/admin/license/page.tsx
Normal file
@@ -0,0 +1,471 @@
|
||||
// This file is licensed under the Fossorial Commercial License.
|
||||
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
//
|
||||
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { LicenseKeyCache } from "@server/license/license";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { LicenseKeysDataTable } from "./LicenseKeysDataTable";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSectionTitle as SSTitle,
|
||||
SettingsSection,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionGrid,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Check, ShieldCheck, ShieldOff } 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";
|
||||
|
||||
const formSchema = z.object({
|
||||
licenseKey: z
|
||||
.string()
|
||||
.nonempty({ message: "License key is required" })
|
||||
.max(255)
|
||||
});
|
||||
|
||||
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 LicensePage() {
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [rows, setRows] = useState<LicenseKeyCache[]>([]);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedLicenseKey, setSelectedLicenseKey] = useState<string | 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");
|
||||
|
||||
// Separate loading states for different actions
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [isActivatingLicense, setIsActivatingLicense] = useState(false);
|
||||
const [isDeletingLicense, setIsDeletingLicense] = useState(false);
|
||||
const [isRecheckingLicense, setIsRecheckingLicense] = useState(false);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
licenseKey: ""
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setIsInitialLoading(true);
|
||||
await loadLicenseKeys();
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function loadLicenseKeys() {
|
||||
try {
|
||||
const response =
|
||||
await api.get<AxiosResponse<LicenseKeyCache[]>>(
|
||||
"/license/keys"
|
||||
);
|
||||
const keys = response.data.data;
|
||||
setRows(keys);
|
||||
const hostKey = keys.find((key) => key.type === "LICENSE");
|
||||
if (hostKey) {
|
||||
setHostLicense(hostKey.licenseKey);
|
||||
} else {
|
||||
setHostLicense(null);
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Failed to load license keys",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred loading license keys"
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLicenseKey(key: string) {
|
||||
try {
|
||||
setIsDeletingLicense(true);
|
||||
const res = await api.delete(`/license/${key}`);
|
||||
if (res.data.data) {
|
||||
updateLicenseStatus(res.data.data);
|
||||
}
|
||||
await loadLicenseKeys();
|
||||
toast({
|
||||
title: "License key deleted",
|
||||
description: "The license key has been deleted"
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Failed to delete license key",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred deleting license key"
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setIsDeletingLicense(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function recheck() {
|
||||
try {
|
||||
setIsRecheckingLicense(true);
|
||||
const res = await api.post(`/license/recheck`);
|
||||
if (res.data.data) {
|
||||
updateLicenseStatus(res.data.data);
|
||||
}
|
||||
await loadLicenseKeys();
|
||||
toast({
|
||||
title: "License keys rechecked",
|
||||
description: "All license keys have been rechecked"
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Failed to recheck license keys",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred rechecking license keys"
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setIsRecheckingLicense(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
try {
|
||||
setIsActivatingLicense(true);
|
||||
const res = await api.post("/license/activate", {
|
||||
licenseKey: values.licenseKey
|
||||
});
|
||||
if (res.data.data) {
|
||||
updateLicenseStatus(res.data.data);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "License key activated",
|
||||
description: "The license key has been successfully activated."
|
||||
});
|
||||
|
||||
setIsCreateModalOpen(false);
|
||||
form.reset();
|
||||
await loadLicenseKeys();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to activate license key",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while activating the license key."
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setIsActivatingLicense(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isInitialLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SitePriceCalculator
|
||||
isOpen={isPurchaseModalOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsPurchaseModalOpen(val);
|
||||
}}
|
||||
mode={purchaseMode}
|
||||
/>
|
||||
|
||||
<Credenza
|
||||
open={isCreateModalOpen}
|
||||
onOpenChange={(val) => {
|
||||
setIsCreateModalOpen(val);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Activate License Key</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Enter a license key to activate it.
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="activate-license-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="licenseKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>License Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="activate-license-form"
|
||||
loading={isActivatingLicense}
|
||||
disabled={isActivatingLicense}
|
||||
>
|
||||
Activate License
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
{selectedLicenseKey && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedLicenseKey(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to delete the license key{" "}
|
||||
<b>{obfuscateLicenseKey(selectedLicenseKey)}</b>
|
||||
?
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
This will remove the license key and all
|
||||
associated permissions. Any sites using this
|
||||
license key will no longer be accessible.
|
||||
</b>
|
||||
</p>
|
||||
<p>
|
||||
To confirm, please type the license key below.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete License Key"
|
||||
onConfirm={async () => deleteLicenseKey(selectedLicenseKey)}
|
||||
string={selectedLicenseKey}
|
||||
title="Delete License Key"
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsSectionTitle
|
||||
title="Manage License Status"
|
||||
description="View and manage license keys in the system"
|
||||
/>
|
||||
|
||||
<SettingsContainer>
|
||||
<SettingsSectionGrid cols={2}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SSTitle>Host License</SSTitle>
|
||||
<SettingsSectionDescription>
|
||||
Manage the main license key for the host.
|
||||
</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 />
|
||||
Licensed
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-2xl">
|
||||
Not Licensed
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{licenseStatus?.hostId && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
Host ID
|
||||
</div>
|
||||
<CopyTextBox text={licenseStatus.hostId} />
|
||||
</div>
|
||||
)}
|
||||
{hostLicense && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
License Key
|
||||
</div>
|
||||
<CopyTextBox
|
||||
text={hostLicense}
|
||||
displayText={obfuscateLicenseKey(
|
||||
hostLicense
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={recheck}
|
||||
disabled={isRecheckingLicense}
|
||||
loading={isRecheckingLicense}
|
||||
>
|
||||
Recheck All Keys
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SSTitle>Sites Usage</SSTitle>
|
||||
<SettingsSectionDescription>
|
||||
View the number of sites using this license.
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-2xl">
|
||||
{licenseStatus?.usedSites || 0}{" "}
|
||||
{licenseStatus?.usedSites === 1
|
||||
? "site"
|
||||
: "sites"}{" "}
|
||||
in system
|
||||
</div>
|
||||
</div>
|
||||
{licenseStatus?.maxSites && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{licenseStatus.usedSites || 0} of{" "}
|
||||
{licenseStatus.maxSites} sites used
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SettingsSectionFooter>
|
||||
{!licenseStatus?.isHostLicensed ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {}}
|
||||
>
|
||||
View License Portal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setPurchaseMode("license");
|
||||
setIsPurchaseModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Purchase License
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setPurchaseMode("additional-sites");
|
||||
setIsPurchaseModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Purchase Additional Sites
|
||||
</Button>
|
||||
)}
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsSectionGrid>
|
||||
<LicenseKeysDataTable
|
||||
licenseKeys={rows}
|
||||
onDelete={(key) => {
|
||||
setSelectedLicenseKey(key);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
onCreate={() => setIsCreateModalOpen(true)}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
|
||||
type ValidateOidcTokenParams = {
|
||||
orgId: string;
|
||||
@@ -33,6 +34,8 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { licenseStatus, isLicenseViolation } = useLicenseStatusContext();
|
||||
|
||||
useEffect(() => {
|
||||
async function validate() {
|
||||
setLoading(true);
|
||||
@@ -43,6 +46,10 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
stateCookie: props.stateCookie
|
||||
});
|
||||
|
||||
if (isLicenseViolation()) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<ValidateOidcUrlCallbackResponse>
|
||||
|
||||
45
src/app/components/LicenseViolation.tsx
Normal file
45
src/app/components/LicenseViolation.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
// This file is licensed under the Fossorial Commercial License.
|
||||
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
//
|
||||
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
|
||||
export default function LicenseViolation() {
|
||||
const { licenseStatus } = useLicenseStatusContext();
|
||||
|
||||
if (!licenseStatus) return null;
|
||||
|
||||
// Show invalid license banner
|
||||
if (licenseStatus.isHostLicensed && !licenseStatus.isLicenseValid) {
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 w-full bg-red-500 text-white p-4 text-center z-50">
|
||||
<p>
|
||||
Invalid or expired license keys detected. Follow license
|
||||
terms to continue using all features.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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">
|
||||
<p>
|
||||
License Violation: Using {licenseStatus.usedSites} sites
|
||||
exceeds your licensed limit of {licenseStatus.maxSites}{" "}
|
||||
sites. Follow license terms to continue using all features.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,25 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import {
|
||||
Figtree,
|
||||
Inter,
|
||||
Red_Hat_Display,
|
||||
Red_Hat_Mono,
|
||||
Red_Hat_Text,
|
||||
Space_Grotesk
|
||||
} from "next/font/google";
|
||||
import { Inter } from "next/font/google";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
||||
import EnvProvider from "@app/providers/EnvProvider";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { BookOpenText, ExternalLink } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import SupportStatusProvider from "@app/providers/SupporterStatusProvider";
|
||||
import { createApiClient, internal, priv } from "@app/lib/api";
|
||||
import { priv } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey";
|
||||
import SupporterMessage from "./components/SupporterMessage";
|
||||
import LicenseStatusProvider from "@app/providers/LicenseStatusProvider";
|
||||
import { GetLicenseStatusResponse } from "@server/routers/license";
|
||||
import LicenseViolation from "./components/LicenseViolation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Dashboard - Pangolin`,
|
||||
@@ -48,6 +40,12 @@ export default async function RootLayout({
|
||||
supporterData.visible = res.data.data.visible;
|
||||
supporterData.tier = res.data.data.tier;
|
||||
|
||||
const licenseStatusRes =
|
||||
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
|
||||
"/license/status"
|
||||
);
|
||||
const licenseStatus = licenseStatusRes.data.data;
|
||||
|
||||
return (
|
||||
<html suppressHydrationWarning>
|
||||
<body className={`${font.className} h-screen overflow-hidden`}>
|
||||
@@ -58,14 +56,19 @@ export default async function RootLayout({
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<EnvProvider env={pullEnv()}>
|
||||
<SupportStatusProvider supporterStatus={supporterData}>
|
||||
{/* Main content */}
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 overflow-auto">
|
||||
{children}
|
||||
<LicenseStatusProvider licenseStatus={licenseStatus}>
|
||||
<SupportStatusProvider
|
||||
supporterStatus={supporterData}
|
||||
>
|
||||
{/* Main content */}
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<LicenseViolation />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SupportStatusProvider>
|
||||
</SupportStatusProvider>
|
||||
</LicenseStatusProvider>
|
||||
</EnvProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
Waypoints,
|
||||
Combine,
|
||||
Fingerprint,
|
||||
KeyRound
|
||||
KeyRound,
|
||||
TicketCheck
|
||||
} from "lucide-react";
|
||||
|
||||
export const orgLangingNavItems: SidebarNavItem[] = [
|
||||
@@ -93,5 +94,10 @@ export const adminNavItems: SidebarNavItem[] = [
|
||||
title: "Identity Providers",
|
||||
href: "/admin/idp",
|
||||
icon: <Fingerprint className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: "License",
|
||||
href: "/admin/license",
|
||||
icon: <TicketCheck className="h-4 w-4" />
|
||||
}
|
||||
];
|
||||
|
||||
@@ -4,20 +4,26 @@ import { useState, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
|
||||
type CopyTextBoxProps = {
|
||||
text?: string;
|
||||
displayText?: string;
|
||||
wrapText?: boolean;
|
||||
outline?: boolean;
|
||||
};
|
||||
|
||||
export default function CopyTextBox({
|
||||
text = "",
|
||||
displayText,
|
||||
wrapText = false,
|
||||
outline = true
|
||||
}) {
|
||||
}: CopyTextBoxProps) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const textRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (textRef.current) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(
|
||||
textRef.current.textContent || ""
|
||||
);
|
||||
await navigator.clipboard.writeText(text);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} catch (err) {
|
||||
@@ -38,7 +44,7 @@ export default function CopyTextBox({
|
||||
: "overflow-x-auto"
|
||||
}`}
|
||||
>
|
||||
<code className="block w-full">{text}</code>
|
||||
<code className="block w-full">{displayText || text}</code>
|
||||
</pre>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -4,10 +4,11 @@ import { useState } from "react";
|
||||
|
||||
type CopyToClipboardProps = {
|
||||
text: string;
|
||||
displayText?: string;
|
||||
isLink?: boolean;
|
||||
};
|
||||
|
||||
const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
|
||||
const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
@@ -19,6 +20,8 @@ const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const displayValue = displayText ?? text;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2 max-w-full">
|
||||
{isLink ? (
|
||||
@@ -30,7 +33,7 @@ const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
|
||||
style={{ maxWidth: "100%" }} // Ensures truncation works within parent
|
||||
title={text} // Shows full text on hover
|
||||
>
|
||||
{text}
|
||||
{displayValue}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
@@ -44,7 +47,7 @@ const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
|
||||
}}
|
||||
title={text} // Full text tooltip
|
||||
>
|
||||
{text}
|
||||
{displayValue}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -5,14 +5,19 @@ import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
|
||||
export type HorizontalTabs = Array<{
|
||||
title: string;
|
||||
href: string;
|
||||
icon?: React.ReactNode;
|
||||
showProfessional?: boolean;
|
||||
}>;
|
||||
|
||||
interface HorizontalTabsProps {
|
||||
children: React.ReactNode;
|
||||
items: Array<{
|
||||
title: string;
|
||||
href: string;
|
||||
icon?: React.ReactNode;
|
||||
}>;
|
||||
items: HorizontalTabs;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -23,6 +28,7 @@ export function HorizontalTabs({
|
||||
}: HorizontalTabsProps) {
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
||||
|
||||
function hydrateHref(href: string) {
|
||||
return href
|
||||
@@ -42,34 +48,47 @@ export function HorizontalTabs({
|
||||
const isActive =
|
||||
pathname.startsWith(hydratedHref) &&
|
||||
!pathname.includes("create");
|
||||
const isProfessional =
|
||||
item.showProfessional && !isUnlocked();
|
||||
const isDisabled =
|
||||
disabled || (isProfessional && !isUnlocked());
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={hydratedHref}
|
||||
href={hydratedHref}
|
||||
href={isProfessional ? "#" : hydratedHref}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap",
|
||||
isActive
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
disabled && "cursor-not-allowed"
|
||||
isDisabled && "cursor-not-allowed"
|
||||
)}
|
||||
onClick={
|
||||
disabled
|
||||
? (e) => e.preventDefault()
|
||||
: undefined
|
||||
}
|
||||
tabIndex={disabled ? -1 : undefined}
|
||||
aria-disabled={disabled}
|
||||
onClick={(e) => {
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
tabIndex={isDisabled ? -1 : undefined}
|
||||
aria-disabled={isDisabled}
|
||||
>
|
||||
{item.icon ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
{item.icon}
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
) : (
|
||||
item.title
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center space-x-2",
|
||||
isDisabled && "opacity-60"
|
||||
)}
|
||||
>
|
||||
{item.icon && item.icon}
|
||||
<span>{item.title}</span>
|
||||
{isProfessional && (
|
||||
<Badge
|
||||
variant="outlinePrimary"
|
||||
className="ml-2"
|
||||
>
|
||||
Professional
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -161,14 +161,6 @@ export function Layout({
|
||||
>
|
||||
Documentation
|
||||
</Link>
|
||||
<Link
|
||||
href="mailto:support@fossorial.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Support
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<ProfileIcon />
|
||||
|
||||
42
src/components/ProfessionalContentOverlay.tsx
Normal file
42
src/components/ProfessionalContentOverlay.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
// This file is licensed under the Fossorial Commercial License.
|
||||
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
//
|
||||
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||
|
||||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
type ProfessionalContentOverlayProps = {
|
||||
children: React.ReactNode;
|
||||
isProfessional?: boolean;
|
||||
};
|
||||
|
||||
export function ProfessionalContentOverlay({
|
||||
children,
|
||||
isProfessional = false
|
||||
}: ProfessionalContentOverlayProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
isProfessional && "opacity-60 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
{isProfessional && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-50">
|
||||
<div className="text-center p-6 bg-primary/10 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
Professional Edition Required
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
This feature is only available in the Professional
|
||||
Edition.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ export function SettingsContainer({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
export function SettingsSection({ children }: { children: React.ReactNode }) {
|
||||
return <div className="border rounded-lg bg-card p-5">{children}</div>;
|
||||
return <div className="border rounded-lg bg-card p-5 flex flex-col min-h-[200px]">{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSectionHeader({
|
||||
@@ -47,7 +47,7 @@ export function SettingsSectionBody({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="space-y-5">{children}</div>;
|
||||
return <div className="space-y-5 flex-grow">{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSectionFooter({
|
||||
@@ -55,7 +55,7 @@ export function SettingsSectionFooter({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="flex justify-end space-x-4 mt-8">{children}</div>;
|
||||
return <div className="flex justify-end space-x-2 mt-auto pt-8">{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSectionGrid({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cn } from "@app/lib/cn";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
|
||||
export interface SidebarNavItem {
|
||||
href: string;
|
||||
@@ -37,6 +38,7 @@ export function SidebarNav({
|
||||
const resourceId = params.resourceId as string;
|
||||
const userId = params.userId as string;
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
||||
|
||||
const { user } = useUserContext();
|
||||
|
||||
@@ -98,7 +100,7 @@ export function SidebarNav({
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isExpanded = expandedItems.has(hydratedHref);
|
||||
const indent = level * 28; // Base indent for each level
|
||||
const isProfessional = item.showProfessional;
|
||||
const isProfessional = item.showProfessional && !isUnlocked();
|
||||
const isDisabled = disabled || isProfessional;
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,8 +16,8 @@ const badgeVariants = cva(
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground",
|
||||
outline: "text-foreground",
|
||||
green: "border-transparent bg-green-300",
|
||||
yellow: "border-transparent bg-yellow-300",
|
||||
green: "border-transparent bg-green-500",
|
||||
yellow: "border-transparent bg-yellow-500",
|
||||
red: "border-transparent bg-red-300",
|
||||
},
|
||||
},
|
||||
|
||||
30
src/components/ui/progress.tsx
Normal file
30
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"border relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
||||
20
src/contexts/licenseStatusContext.ts
Normal file
20
src/contexts/licenseStatusContext.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// This file is licensed under the Fossorial Commercial License.
|
||||
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
//
|
||||
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||
|
||||
import { LicenseStatus } from "@server/license/license";
|
||||
import { createContext } from "react";
|
||||
|
||||
type LicenseStatusContextType = {
|
||||
licenseStatus: LicenseStatus | null;
|
||||
updateLicenseStatus: (updatedSite: LicenseStatus) => void;
|
||||
isLicenseViolation: () => boolean;
|
||||
isUnlocked: () => boolean;
|
||||
};
|
||||
|
||||
const LicenseStatusContext = createContext<
|
||||
LicenseStatusContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export default LicenseStatusContext;
|
||||
17
src/hooks/useLicenseStatusContext.ts
Normal file
17
src/hooks/useLicenseStatusContext.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// This file is licensed under the Fossorial Commercial License.
|
||||
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
//
|
||||
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||
|
||||
import LicenseStatusContext from "@app/contexts/licenseStatusContext";
|
||||
import { useContext } from "react";
|
||||
|
||||
export function useLicenseStatusContext() {
|
||||
const context = useContext(LicenseStatusContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useLicenseStatusContext must be used within an LicenseStatusProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
72
src/providers/LicenseStatusProvider.tsx
Normal file
72
src/providers/LicenseStatusProvider.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
// This file is licensed under the Fossorial Commercial License.
|
||||
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
//
|
||||
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||
|
||||
"use client";
|
||||
|
||||
import LicenseStatusContext from "@app/contexts/licenseStatusContext";
|
||||
import { LicenseStatus } from "@server/license/license";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ProviderProps {
|
||||
children: React.ReactNode;
|
||||
licenseStatus: LicenseStatus | null;
|
||||
}
|
||||
|
||||
export function LicenseStatusProvider({
|
||||
children,
|
||||
licenseStatus
|
||||
}: ProviderProps) {
|
||||
const [licenseStatusState, setLicenseStatusState] =
|
||||
useState<LicenseStatus | null>(licenseStatus);
|
||||
|
||||
const updateLicenseStatus = (updatedLicenseStatus: LicenseStatus) => {
|
||||
setLicenseStatusState((prev) => {
|
||||
return {
|
||||
...updatedLicenseStatus
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const isUnlocked = () => {
|
||||
if (licenseStatusState?.isHostLicensed) {
|
||||
if (licenseStatusState?.isLicenseValid) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isLicenseViolation = () => {
|
||||
if (
|
||||
licenseStatusState?.isHostLicensed &&
|
||||
!licenseStatusState?.isLicenseValid
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
licenseStatusState?.maxSites &&
|
||||
licenseStatusState?.usedSites &&
|
||||
licenseStatusState.usedSites > licenseStatusState.maxSites
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<LicenseStatusContext.Provider
|
||||
value={{
|
||||
licenseStatus: licenseStatusState,
|
||||
updateLicenseStatus,
|
||||
isLicenseViolation,
|
||||
isUnlocked
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LicenseStatusContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default LicenseStatusProvider;
|
||||
Reference in New Issue
Block a user