feat: add license activation system with feature gates

- License key generator tool at ~/Desktop/LicenseGenerator
- Backend validator route (GET /api/license/status, POST /api/license/activate)
- Activation page in sidebar with status, key input, and free/premium feature list
- useLicense hook for frontend license state
- Feature gates: premium eligibility buttons (DDMA, DeltaIns, Tufts, United, CCA) disabled without license
- Feature gates: premium claim buttons (CCA, Delta MA, United, Tufts) and all PreAuth buttons disabled without license
- Free features always active: MassHealth eligibility/claim, Documents, Payments, Backups, Reports
- README: license key generator usage instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-05-26 21:22:34 -04:00
parent 594df39741
commit 070752380d
11 changed files with 472 additions and 81 deletions

View File

@@ -1,6 +1,7 @@
NODE_ENV="development"
HOST=0.0.0.0
PORT=5000
CLOUDFLARE_HOST=communitydentistsoflowell.mydentalofficemanagement.com
FRONTEND_URLS=http://localhost:3000,http://communitydentistsoflowell.mydentalofficemanagement.com,https://communitydentistsoflowell.mydentalofficemanagement.com
SELENIUM_AGENT_BASE_URL=http://localhost:5002
JWT_SECRET = 'dentalsecret'
@@ -8,4 +9,5 @@ DB_HOST=localhost
DB_USER=postgres
DB_PASSWORD=mypassword
DB_NAME=dentalapp
DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp
DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp
LICENSE_SECRET=3aa4ab937e46c6863b9e3c2b591a595b31ea3af1060bf5e7961ad722a8b54f92

View File

@@ -39,6 +39,7 @@ import insuranceContactsRoutes from "./insurance-contacts";
import commissionsRoutes from "./commissions";
import shoppingVendorsRoutes from "./shopping-vendors";
import feeScheduleRoutes from "./feeSchedule";
import licenseRoutes from "./license";
const router = Router();
@@ -82,5 +83,6 @@ router.use("/insurance-contacts", insuranceContactsRoutes);
router.use("/commissions", commissionsRoutes);
router.use("/shopping-vendors", shoppingVendorsRoutes);
router.use("/fee-schedule", feeScheduleRoutes);
router.use("/license", licenseRoutes);
export default router;

View File

@@ -0,0 +1,97 @@
import { Router } from "express";
import crypto from "crypto";
import fs from "fs";
import path from "path";
const router = Router();
const LICENSE_FILE = path.join(process.cwd(), "license.json");
const SECRET = process.env.LICENSE_SECRET || "";
function validateKey(key: string): { valid: boolean; expiry?: string; error?: string } {
// Format: DENTAL-{24-char-signature}-{YYYY-MM-DD}
const parts = key.trim().split("-");
if (parts.length !== 6 || parts[0] !== "DENTAL") {
return { valid: false, error: "Invalid license key format" };
}
const signature = parts[1];
const expiryStr = `${parts[2]}-${parts[3]}-${parts[4]}`;
// Verify signature
const payload = `DENTAL:${expiryStr}`;
const expectedSig = crypto
.createHmac("sha256", SECRET)
.update(payload)
.digest("hex")
.substring(0, 24)
.toUpperCase();
if (signature !== expectedSig) {
return { valid: false, error: "Invalid license key" };
}
// Check expiry
const expiry = new Date(expiryStr);
if (isNaN(expiry.getTime())) {
return { valid: false, error: "Invalid expiry date in key" };
}
if (new Date() > expiry) {
return { valid: false, expiry: expiryStr, error: "License key has expired" };
}
return { valid: true, expiry: expiryStr };
}
function loadLicense(): { key: string; expiry: string } | null {
try {
if (!fs.existsSync(LICENSE_FILE)) return null;
return JSON.parse(fs.readFileSync(LICENSE_FILE, "utf8"));
} catch {
return null;
}
}
function saveLicense(key: string, expiry: string) {
fs.writeFileSync(LICENSE_FILE, JSON.stringify({ key, expiry }, null, 2));
}
// GET /api/license/status
router.get("/status", (req, res) => {
const stored = loadLicense();
if (!stored) {
return res.json({ activated: false, expired: false, expiry: null, daysLeft: null });
}
const result = validateKey(stored.key);
if (!result.valid && result.expiry) {
return res.json({ activated: true, expired: true, expiry: stored.expiry, daysLeft: 0 });
}
if (!result.valid) {
return res.json({ activated: false, expired: false, expiry: null, daysLeft: null });
}
const expiry = new Date(stored.expiry);
const daysLeft = Math.ceil((expiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
return res.json({ activated: true, expired: false, expiry: stored.expiry, daysLeft });
});
// POST /api/license/activate
router.post("/activate", (req, res) => {
const { key } = req.body;
if (!key) {
return res.status(400).json({ error: "License key is required" });
}
const result = validateKey(key);
if (!result.valid) {
return res.status(400).json({ error: result.error });
}
saveLicense(key.trim(), result.expiry!);
return res.json({ success: true, expiry: result.expiry });
});
export default router;

View File

@@ -31,6 +31,7 @@ const JobMonitorPage = lazy(() => import("./pages/job-monitor-page"));
const ChartPage = lazy(() => import("./pages/chart-page"));
const DentalShoppingSearchTagPage = lazy(() => import("./pages/dental-shopping-search-tag-page"));
const DentalShoppingLoginInfoPage = lazy(() => import("./pages/dental-shopping-login-info-page"));
const ActivationPage = lazy(() => import("./pages/activation-page"));
const NotFound = lazy(() => import("./pages/not-found"));
function Router() {
@@ -65,6 +66,7 @@ function Router() {
<ProtectedRoute path="/cloud-storage" component={() => <CloudStoragePage />} />
<ProtectedRoute path="/dental-shopping/search-tag" component={() => <DentalShoppingSearchTagPage />} />
<ProtectedRoute path="/dental-shopping/login-info" component={() => <DentalShoppingLoginInfoPage />} />
<ProtectedRoute path="/activation" component={() => <ActivationPage />} adminOnly />
<ProtectedRoute
path="/job-monitor"
component={() => <JobMonitorPage />}

View File

@@ -93,6 +93,7 @@ interface ClaimFormProps {
onHandleForUnitedDHSeleniumClaim: (data: ClaimFormData) => void;
onHandleForTuftsSCOSeleniumClaim: (data: ClaimFormData) => void;
onClose: () => void;
isLicensed?: boolean;
}
export function ClaimForm({
@@ -111,6 +112,7 @@ export function ClaimForm({
onHandleForTuftsSCOSeleniumClaim,
onSubmit,
onClose,
isLicensed = false,
}: ClaimFormProps) {
const { toast } = useToast();
const { user } = useAuth();
@@ -2196,24 +2198,32 @@ export function ClaimForm({
<Button
className="w-32 bg-blue-600 hover:bg-blue-700 text-white"
onClick={() => runWithPriceCheck(handleCCAClaim)}
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
>
CCA Claim
</Button>
<Button
className="w-36 bg-violet-600 hover:bg-violet-700 text-white"
onClick={() => runWithPriceCheck(handleDDMAClaim)}
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
>
Delta MA Claim
</Button>
<Button
className="w-44 bg-orange-600 hover:bg-orange-700 text-white"
onClick={() => runWithPriceCheck(handleUnitedDHClaim)}
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
>
United/DentalHub Claim
</Button>
<Button
className="w-32 bg-teal-600 hover:bg-teal-700 text-white"
onClick={() => runWithPriceCheck(handleTuftsSCOClaim)}
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
>
Tufts Claim
</Button>
@@ -2723,24 +2733,32 @@ export function ClaimForm({
<Button
className="w-32 bg-blue-600 hover:bg-blue-700 text-white"
onClick={() => handleMHPreAuth()}
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
>
MH PreAuth
</Button>
<Button
className="w-32 bg-blue-600 hover:bg-blue-700 text-white"
onClick={handleCCAPreAuth}
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
>
CCA PreAuth
</Button>
<Button
className="w-44"
variant="secondary"
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
>
United/DentalHub PreAuth
</Button>
<Button
className="w-32"
variant="secondary"
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
>
Tufts PreAuth
</Button>

View File

@@ -186,6 +186,12 @@ export function Sidebar() {
},
],
},
{
name: "Activation",
path: "/activation",
icon: <KeyRound className="h-5 w-5 text-amber-500" />,
adminOnly: true,
},
{
name: "Database Management",
path: "/database-management",

View File

@@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
interface LicenseStatus {
activated: boolean;
expired: boolean;
expiry: string | null;
daysLeft: number | null;
}
export function useLicense() {
const { data, isLoading } = useQuery<LicenseStatus>({
queryKey: ["/api/license/status"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/license/status");
return res.json();
},
staleTime: 1000 * 60 * 5, // cache for 5 minutes
});
const isLicensed = !isLoading && !!data?.activated && !data?.expired;
return { isLicensed, isLoading, licenseData: data };
}

View File

@@ -0,0 +1,184 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import { KeyRound, CheckCircle2, XCircle, AlertCircle, Shield, ShieldOff } from "lucide-react";
import { cn } from "@/lib/utils";
export default function ActivationPage() {
const { toast } = useToast();
const [keyInput, setKeyInput] = useState("");
const { data: status, isLoading } = useQuery({
queryKey: ["/api/license/status"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/license/status");
return res.json() as Promise<{
activated: boolean;
expired: boolean;
expiry: string | null;
daysLeft: number | null;
}>;
},
});
const activateMutation = useMutation({
mutationFn: async (key: string) => {
const res = await apiRequest("POST", "/api/license/activate", { key });
if (!res.ok) {
const body = await res.json();
throw new Error(body.error || "Activation failed");
}
return res.json();
},
onSuccess: () => {
toast({ title: "License activated successfully" });
setKeyInput("");
queryClient.invalidateQueries({ queryKey: ["/api/license/status"] });
},
onError: (err: Error) => {
toast({ title: err.message, variant: "destructive" });
},
});
const formatExpiry = (expiry: string) => {
return new Date(expiry).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
};
const isActive = status?.activated && !status?.expired;
const isExpired = status?.activated && status?.expired;
const isWarning = isActive && status?.daysLeft !== null && status.daysLeft <= 30;
return (
<div className="p-6 max-w-2xl mx-auto space-y-6">
<div className="flex items-center space-x-3">
<KeyRound className="h-6 w-6 text-gray-600" />
<h1 className="text-2xl font-semibold text-gray-800">License Activation</h1>
</div>
{/* Status Card */}
<Card>
<CardHeader>
<CardTitle className="text-base">License Status</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-gray-500 text-sm">Checking license...</p>
) : isActive ? (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<CheckCircle2 className="h-5 w-5 text-green-500" />
<span className="font-medium text-green-700">Active</span>
</div>
<p className="text-sm text-gray-600">
Expires: <span className="font-medium">{formatExpiry(status!.expiry!)}</span>
</p>
{isWarning && (
<div className="flex items-center space-x-2 mt-2 p-2 bg-yellow-50 rounded-md border border-yellow-200">
<AlertCircle className="h-4 w-4 text-yellow-600 flex-shrink-0" />
<p className="text-sm text-yellow-700">
Expires in {status!.daysLeft} day{status!.daysLeft !== 1 ? "s" : ""}. Please renew soon.
</p>
</div>
)}
</div>
) : isExpired ? (
<div className="flex items-center space-x-2">
<XCircle className="h-5 w-5 text-red-500" />
<div>
<span className="font-medium text-red-700">Expired</span>
<p className="text-sm text-gray-500">
Expired on {formatExpiry(status!.expiry!)}. Enter a new key to reactivate.
</p>
</div>
</div>
) : (
<div className="flex items-center space-x-2">
<XCircle className="h-5 w-5 text-gray-400" />
<span className="text-gray-500">Not activated</span>
</div>
)}
</CardContent>
</Card>
{/* Activate Card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Enter License Key</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Input
placeholder="DENTAL-XXXXXXXXXXXXXXXXXXXXXXXX-YYYY-MM-DD"
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
className="font-mono text-sm"
/>
<Button
onClick={() => activateMutation.mutate(keyInput)}
disabled={!keyInput.trim() || activateMutation.isPending}
className="w-full"
>
{activateMutation.isPending ? "Activating..." : "Activate"}
</Button>
</CardContent>
</Card>
{/* Features Card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Features</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2">Free</p>
<div className="space-y-1">
{[
"MassHealth Eligibility",
"MassHealth Claim",
"Documents",
"Payments",
"Database Backups",
"Reports",
].map((f) => (
<div key={f} className="flex items-center space-x-2">
<CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" />
<span className="text-sm text-gray-700">{f}</span>
</div>
))}
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2">Premium</p>
<div className="space-y-1">
{[
"CCA, DDMA, United, Tufts Eligibility",
"CCA, DDMA, United, Tufts Claims",
"Pre-Authorizations",
"AI SMS",
].map((f) => (
<div key={f} className="flex items-center space-x-2">
{isActive ? (
<Shield className="h-4 w-4 text-blue-500 flex-shrink-0" />
) : (
<ShieldOff className="h-4 w-4 text-gray-300 flex-shrink-0" />
)}
<span className={cn("text-sm", isActive ? "text-gray-700" : "text-gray-400")}>
{f}
</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -34,6 +34,7 @@ import ClaimDocumentsUploadMultiple from "@/components/claims/claim-document-upl
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal";
import { socket } from "@/lib/socket";
import { useLicense } from "@/hooks/use-license";
export default function ClaimsPage() {
const [isClaimFormOpen, setIsClaimFormOpen] = useState(false);
@@ -52,6 +53,7 @@ export default function ClaimsPage() {
const { status, message, show } = useAppSelector(
(state) => state.seleniumTasks.claimSubmit
);
const { isLicensed } = useLicense();
// Track pending selenium jobs so we can react to completion via socket
const [pendingClaimJobId, setPendingClaimJobId] = useState<string | null>(null);
@@ -911,6 +913,7 @@ export default function ClaimsPage() {
onHandleForDDMASeleniumClaim={handleDDMAClaimSubmitSelenium}
onHandleForUnitedDHSeleniumClaim={handleUnitedDHClaimSubmitSelenium}
onHandleForTuftsSCOSeleniumClaim={handleTuftsSCOClaimSubmitSelenium}
isLicensed={isLicensed}
/>
)}

View File

@@ -41,6 +41,7 @@ import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltain
import { TuftsSCOEligibilityButton } from "@/components/insurance-status/tufts-sco-button-modal";
import { UnitedSCOEligibilityButton } from "@/components/insurance-status/united-sco-button-modal";
import { CCAEligibilityButton } from "@/components/insurance-status/cca-button-modal";
import { useLicense } from "@/hooks/use-license";
/**
* Waits for a Selenium job to complete by racing socket.io events against
@@ -95,6 +96,7 @@ function waitForSeleniumJob(
export default function InsuranceStatusPage() {
const { user } = useAuth();
const { toast } = useToast();
const { isLicensed } = useLicense();
const dispatch = useAppDispatch();
const { status, message, show } = useAppSelector(
(state) => state.seleniumTasks.eligibilityCheck,
@@ -868,39 +870,43 @@ export default function InsuranceStatusPage() {
{/* Row 1 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<DdmaEligibilityButton
memberId={memberId}
dateOfBirth={dateOfBirth}
firstName={firstName}
lastName={lastName}
isFormIncomplete={isFormIncomplete}
autoTrigger={triggerTarget === "ddma"}
onAutoTriggered={() => setTriggerTarget(null)}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`,
);
setPreviewOpen(true);
}}
/>
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
<DdmaEligibilityButton
memberId={memberId}
dateOfBirth={dateOfBirth}
firstName={firstName}
lastName={lastName}
isFormIncomplete={isFormIncomplete}
autoTrigger={triggerTarget === "ddma"}
onAutoTriggered={() => setTriggerTarget(null)}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`,
);
setPreviewOpen(true);
}}
/>
</div>
<DeltaInsEligibilityButton
memberId={memberId}
dateOfBirth={dateOfBirth}
firstName={firstName}
lastName={lastName}
isFormIncomplete={isFormIncomplete}
autoTrigger={triggerTarget === "delta-ins"}
onAutoTriggered={() => setTriggerTarget(null)}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_deltains_${memberId}.pdf`,
);
setPreviewOpen(true);
}}
/>
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
<DeltaInsEligibilityButton
memberId={memberId}
dateOfBirth={dateOfBirth}
firstName={firstName}
lastName={lastName}
isFormIncomplete={isFormIncomplete}
autoTrigger={triggerTarget === "delta-ins"}
onAutoTriggered={() => setTriggerTarget(null)}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_deltains_${memberId}.pdf`,
);
setPreviewOpen(true);
}}
/>
</div>
<Button
className="w-full"
@@ -914,56 +920,62 @@ export default function InsuranceStatusPage() {
{/* Row 2 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<TuftsSCOEligibilityButton
memberId={memberId}
dateOfBirth={dateOfBirth}
firstName={firstName}
lastName={lastName}
isFormIncomplete={isFormIncomplete}
autoTrigger={triggerTarget === "tufts-sco"}
onAutoTriggered={() => setTriggerTarget(null)}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_tuftssco_${memberId}.pdf`,
);
setPreviewOpen(true);
}}
/>
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
<TuftsSCOEligibilityButton
memberId={memberId}
dateOfBirth={dateOfBirth}
firstName={firstName}
lastName={lastName}
isFormIncomplete={isFormIncomplete}
autoTrigger={triggerTarget === "tufts-sco"}
onAutoTriggered={() => setTriggerTarget(null)}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_tuftssco_${memberId}.pdf`,
);
setPreviewOpen(true);
}}
/>
</div>
<UnitedSCOEligibilityButton
memberId={memberId}
dateOfBirth={dateOfBirth}
firstName={firstName}
lastName={lastName}
isFormIncomplete={isFormIncomplete}
autoTrigger={triggerTarget === "united-sco"}
onAutoTriggered={() => setTriggerTarget(null)}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_unitedsco_${memberId}.pdf`,
);
setPreviewOpen(true);
}}
/>
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
<UnitedSCOEligibilityButton
memberId={memberId}
dateOfBirth={dateOfBirth}
firstName={firstName}
lastName={lastName}
isFormIncomplete={isFormIncomplete}
autoTrigger={triggerTarget === "united-sco"}
onAutoTriggered={() => setTriggerTarget(null)}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_unitedsco_${memberId}.pdf`,
);
setPreviewOpen(true);
}}
/>
</div>
<CCAEligibilityButton
memberId={memberId}
dateOfBirth={dateOfBirth}
firstName={firstName}
lastName={lastName}
isFormIncomplete={isFormIncomplete}
autoTrigger={triggerTarget === "cca"}
onAutoTriggered={() => setTriggerTarget(null)}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_cca_${memberId}.pdf`,
);
setPreviewOpen(true);
}}
/>
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
<CCAEligibilityButton
memberId={memberId}
dateOfBirth={dateOfBirth}
firstName={firstName}
lastName={lastName}
isFormIncomplete={isFormIncomplete}
autoTrigger={triggerTarget === "cca"}
onAutoTriggered={() => setTriggerTarget(null)}
onPdfReady={(pdfId, fallbackFilename) => {
setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_cca_${memberId}.pdf`,
);
setPreviewOpen(true);
}}
/>
</div>
</div>
{/* Row 3 */}