Files
pangolin/src/app/admin/license/page.tsx

527 lines
22 KiB
TypeScript

"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, Heart, InfoIcon, 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";
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";
import { useTranslations } from "next-intl";
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<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");
// 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 { supporterStatus } = useSupporterStatusContext();
const t = useTranslations();
const formSchema = z.object({
licenseKey: z
.string()
.nonempty({ message: t('licenseKeyRequired') })
.max(255),
agreeToTerms: z.boolean().refine((val) => val === true, {
message: t('licenseTermsAgree')
})
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
licenseKey: "",
agreeToTerms: false
}
});
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 === "HOST");
if (hostKey) {
setHostLicense(hostKey.licenseKey);
} else {
setHostLicense(null);
}
} catch (e) {
toast({
title: t('licenseErrorKeyLoad'),
description: formatAxiosError(
e,
t('licenseErrorKeyLoadDescription')
)
});
}
}
async function deleteLicenseKey(key: string) {
try {
setIsDeletingLicense(true);
const encodedKey = encodeURIComponent(key);
const res = await api.delete(`/license/${encodedKey}`);
if (res.data.data) {
updateLicenseStatus(res.data.data);
}
await loadLicenseKeys();
toast({
title: t('licenseKeyDeleted'),
description: t('licenseKeyDeletedDescription')
});
setIsDeleteModalOpen(false);
} catch (e) {
toast({
title: t('licenseErrorKeyDelete'),
description: formatAxiosError(
e,
t('licenseErrorKeyDeleteDescription')
)
});
} 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: t('licenseErrorKeyRechecked'),
description: t('licenseErrorKeyRecheckedDescription')
});
} catch (e) {
toast({
title: t('licenseErrorKeyRecheck'),
description: formatAxiosError(
e,
t('licenseErrorKeyRecheckDescription')
)
});
} 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: t('licenseKeyActivated'),
description: t('licenseKeyActivatedDescription')
});
setIsCreateModalOpen(false);
form.reset();
await loadLicenseKeys();
} catch (e) {
toast({
variant: "destructive",
title: t('licenseErrorKeyActivate'),
description: formatAxiosError(
e,
t('licenseErrorKeyActivateDescription')
)
});
} 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>{t('licenseActivateKey')}</CredenzaTitle>
<CredenzaDescription>
{t('licenseActivateKeyDescription')}
</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>{t('licenseKey')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={
field.onChange
}
/>
</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> */}
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
type="submit"
form="activate-license-form"
loading={isActivatingLicense}
disabled={isActivatingLicense}
>
{t('licenseActivate')}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
{selectedLicenseKey && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedLicenseKey(null);
}}
dialog={
<div className="space-y-4">
<p>
{t('licenseQuestionRemove', {selectedKey: obfuscateLicenseKey(selectedLicenseKey.licenseKey)})}
</p>
<p>
<b>
{t('licenseMessageRemove')}
</b>
</p>
<p>
{t('licenseMessageConfirm')}
</p>
</div>
}
buttonText={t('licenseKeyDeleteConfirm')}
onConfirm={async () =>
deleteLicenseKey(selectedLicenseKey.licenseKeyEncrypted)
}
string={selectedLicenseKey.licenseKey}
title={t('licenseKeyDelete')}
/>
)}
<SettingsSectionTitle
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>
<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>
</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"
/>
</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>
<LicenseKeysDataTable
licenseKeys={rows}
onDelete={(key) => {
setSelectedLicenseKey(key);
setIsDeleteModalOpen(true);
}}
onCreate={() => setIsCreateModalOpen(true)}
/>
</SettingsContainer>
</>
);
}