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

@@ -387,6 +387,47 @@ Each office runs its own `cloudflared` tunnel on its own PC. Ports never conflic
--- ---
## License Key Generator
The license key generator is a private tool that lives only on your dev PC. Use it to generate a new license key for any office every 3 months.
**Location:** `/home/ff/Desktop/LicenseGenerator/`
**Generate a 3-month key (default):**
```bash
node /home/ff/Desktop/LicenseGenerator/generate-license.js
```
**Generate a key with a custom duration:**
```bash
node /home/ff/Desktop/LicenseGenerator/generate-license.js --months=6
```
**Example output:**
```
=== Dental App License Key ===
License Key: DENTAL-8ED7AAEF3E0CA008D98CC1E0-2026-08-26
Expires: 2026-08-26
Duration: 3 month(s)
Paste this key into the Activation page in the app.
```
**Workflow:**
1. Office pays renewal fee
2. Run the generator script above
3. Copy the License Key
4. Paste it into the **Activation** page in the app (via RustDesk or in person)
5. Record the key, office name, expiry, and payment in your records
**Important — `secret.key`:**
- `/home/ff/Desktop/LicenseGenerator/secret.key` is the private secret used to sign all keys
- Back it up on a USB drive or password manager
- If lost, all existing keys become invalid and new keys must be issued to all offices
---
## Claude Code Memory ## Claude Code Memory
Claude Code (the AI assistant used to build this project) stores its memory locally on the PC. This memory contains project context, architecture decisions, feature history, and working preferences — allowing Claude to pick up where it left off in new sessions. Claude Code (the AI assistant used to build this project) stores its memory locally on the PC. This memory contains project context, architecture decisions, feature history, and working preferences — allowing Claude to pick up where it left off in new sessions.

View File

@@ -1,6 +1,7 @@
NODE_ENV="development" NODE_ENV="development"
HOST=0.0.0.0 HOST=0.0.0.0
PORT=5000 PORT=5000
CLOUDFLARE_HOST=communitydentistsoflowell.mydentalofficemanagement.com
FRONTEND_URLS=http://localhost:3000,http://communitydentistsoflowell.mydentalofficemanagement.com,https://communitydentistsoflowell.mydentalofficemanagement.com FRONTEND_URLS=http://localhost:3000,http://communitydentistsoflowell.mydentalofficemanagement.com,https://communitydentistsoflowell.mydentalofficemanagement.com
SELENIUM_AGENT_BASE_URL=http://localhost:5002 SELENIUM_AGENT_BASE_URL=http://localhost:5002
JWT_SECRET = 'dentalsecret' JWT_SECRET = 'dentalsecret'
@@ -9,3 +10,4 @@ DB_USER=postgres
DB_PASSWORD=mypassword DB_PASSWORD=mypassword
DB_NAME=dentalapp 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 commissionsRoutes from "./commissions";
import shoppingVendorsRoutes from "./shopping-vendors"; import shoppingVendorsRoutes from "./shopping-vendors";
import feeScheduleRoutes from "./feeSchedule"; import feeScheduleRoutes from "./feeSchedule";
import licenseRoutes from "./license";
const router = Router(); const router = Router();
@@ -82,5 +83,6 @@ router.use("/insurance-contacts", insuranceContactsRoutes);
router.use("/commissions", commissionsRoutes); router.use("/commissions", commissionsRoutes);
router.use("/shopping-vendors", shoppingVendorsRoutes); router.use("/shopping-vendors", shoppingVendorsRoutes);
router.use("/fee-schedule", feeScheduleRoutes); router.use("/fee-schedule", feeScheduleRoutes);
router.use("/license", licenseRoutes);
export default router; 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 ChartPage = lazy(() => import("./pages/chart-page"));
const DentalShoppingSearchTagPage = lazy(() => import("./pages/dental-shopping-search-tag-page")); const DentalShoppingSearchTagPage = lazy(() => import("./pages/dental-shopping-search-tag-page"));
const DentalShoppingLoginInfoPage = lazy(() => import("./pages/dental-shopping-login-info-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")); const NotFound = lazy(() => import("./pages/not-found"));
function Router() { function Router() {
@@ -65,6 +66,7 @@ function Router() {
<ProtectedRoute path="/cloud-storage" component={() => <CloudStoragePage />} /> <ProtectedRoute path="/cloud-storage" component={() => <CloudStoragePage />} />
<ProtectedRoute path="/dental-shopping/search-tag" component={() => <DentalShoppingSearchTagPage />} /> <ProtectedRoute path="/dental-shopping/search-tag" component={() => <DentalShoppingSearchTagPage />} />
<ProtectedRoute path="/dental-shopping/login-info" component={() => <DentalShoppingLoginInfoPage />} /> <ProtectedRoute path="/dental-shopping/login-info" component={() => <DentalShoppingLoginInfoPage />} />
<ProtectedRoute path="/activation" component={() => <ActivationPage />} adminOnly />
<ProtectedRoute <ProtectedRoute
path="/job-monitor" path="/job-monitor"
component={() => <JobMonitorPage />} component={() => <JobMonitorPage />}

View File

@@ -93,6 +93,7 @@ interface ClaimFormProps {
onHandleForUnitedDHSeleniumClaim: (data: ClaimFormData) => void; onHandleForUnitedDHSeleniumClaim: (data: ClaimFormData) => void;
onHandleForTuftsSCOSeleniumClaim: (data: ClaimFormData) => void; onHandleForTuftsSCOSeleniumClaim: (data: ClaimFormData) => void;
onClose: () => void; onClose: () => void;
isLicensed?: boolean;
} }
export function ClaimForm({ export function ClaimForm({
@@ -111,6 +112,7 @@ export function ClaimForm({
onHandleForTuftsSCOSeleniumClaim, onHandleForTuftsSCOSeleniumClaim,
onSubmit, onSubmit,
onClose, onClose,
isLicensed = false,
}: ClaimFormProps) { }: ClaimFormProps) {
const { toast } = useToast(); const { toast } = useToast();
const { user } = useAuth(); const { user } = useAuth();
@@ -2196,24 +2198,32 @@ export function ClaimForm({
<Button <Button
className="w-32 bg-blue-600 hover:bg-blue-700 text-white" className="w-32 bg-blue-600 hover:bg-blue-700 text-white"
onClick={() => runWithPriceCheck(handleCCAClaim)} onClick={() => runWithPriceCheck(handleCCAClaim)}
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
> >
CCA Claim CCA Claim
</Button> </Button>
<Button <Button
className="w-36 bg-violet-600 hover:bg-violet-700 text-white" className="w-36 bg-violet-600 hover:bg-violet-700 text-white"
onClick={() => runWithPriceCheck(handleDDMAClaim)} onClick={() => runWithPriceCheck(handleDDMAClaim)}
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
> >
Delta MA Claim Delta MA Claim
</Button> </Button>
<Button <Button
className="w-44 bg-orange-600 hover:bg-orange-700 text-white" className="w-44 bg-orange-600 hover:bg-orange-700 text-white"
onClick={() => runWithPriceCheck(handleUnitedDHClaim)} onClick={() => runWithPriceCheck(handleUnitedDHClaim)}
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
> >
United/DentalHub Claim United/DentalHub Claim
</Button> </Button>
<Button <Button
className="w-32 bg-teal-600 hover:bg-teal-700 text-white" className="w-32 bg-teal-600 hover:bg-teal-700 text-white"
onClick={() => runWithPriceCheck(handleTuftsSCOClaim)} onClick={() => runWithPriceCheck(handleTuftsSCOClaim)}
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
> >
Tufts Claim Tufts Claim
</Button> </Button>
@@ -2723,24 +2733,32 @@ export function ClaimForm({
<Button <Button
className="w-32 bg-blue-600 hover:bg-blue-700 text-white" className="w-32 bg-blue-600 hover:bg-blue-700 text-white"
onClick={() => handleMHPreAuth()} onClick={() => handleMHPreAuth()}
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
> >
MH PreAuth MH PreAuth
</Button> </Button>
<Button <Button
className="w-32 bg-blue-600 hover:bg-blue-700 text-white" className="w-32 bg-blue-600 hover:bg-blue-700 text-white"
onClick={handleCCAPreAuth} onClick={handleCCAPreAuth}
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
> >
CCA PreAuth CCA PreAuth
</Button> </Button>
<Button <Button
className="w-44" className="w-44"
variant="secondary" variant="secondary"
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
> >
United/DentalHub PreAuth United/DentalHub PreAuth
</Button> </Button>
<Button <Button
className="w-32" className="w-32"
variant="secondary" variant="secondary"
disabled={!isLicensed}
title={!isLicensed ? "License required" : undefined}
> >
Tufts PreAuth Tufts PreAuth
</Button> </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", name: "Database Management",
path: "/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 { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal"; import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal";
import { socket } from "@/lib/socket"; import { socket } from "@/lib/socket";
import { useLicense } from "@/hooks/use-license";
export default function ClaimsPage() { export default function ClaimsPage() {
const [isClaimFormOpen, setIsClaimFormOpen] = useState(false); const [isClaimFormOpen, setIsClaimFormOpen] = useState(false);
@@ -52,6 +53,7 @@ export default function ClaimsPage() {
const { status, message, show } = useAppSelector( const { status, message, show } = useAppSelector(
(state) => state.seleniumTasks.claimSubmit (state) => state.seleniumTasks.claimSubmit
); );
const { isLicensed } = useLicense();
// Track pending selenium jobs so we can react to completion via socket // Track pending selenium jobs so we can react to completion via socket
const [pendingClaimJobId, setPendingClaimJobId] = useState<string | null>(null); const [pendingClaimJobId, setPendingClaimJobId] = useState<string | null>(null);
@@ -911,6 +913,7 @@ export default function ClaimsPage() {
onHandleForDDMASeleniumClaim={handleDDMAClaimSubmitSelenium} onHandleForDDMASeleniumClaim={handleDDMAClaimSubmitSelenium}
onHandleForUnitedDHSeleniumClaim={handleUnitedDHClaimSubmitSelenium} onHandleForUnitedDHSeleniumClaim={handleUnitedDHClaimSubmitSelenium}
onHandleForTuftsSCOSeleniumClaim={handleTuftsSCOClaimSubmitSelenium} 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 { TuftsSCOEligibilityButton } from "@/components/insurance-status/tufts-sco-button-modal";
import { UnitedSCOEligibilityButton } from "@/components/insurance-status/united-sco-button-modal"; import { UnitedSCOEligibilityButton } from "@/components/insurance-status/united-sco-button-modal";
import { CCAEligibilityButton } from "@/components/insurance-status/cca-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 * Waits for a Selenium job to complete by racing socket.io events against
@@ -95,6 +96,7 @@ function waitForSeleniumJob(
export default function InsuranceStatusPage() { export default function InsuranceStatusPage() {
const { user } = useAuth(); const { user } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const { isLicensed } = useLicense();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { status, message, show } = useAppSelector( const { status, message, show } = useAppSelector(
(state) => state.seleniumTasks.eligibilityCheck, (state) => state.seleniumTasks.eligibilityCheck,
@@ -868,6 +870,7 @@ export default function InsuranceStatusPage() {
{/* Row 1 */} {/* Row 1 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
<DdmaEligibilityButton <DdmaEligibilityButton
memberId={memberId} memberId={memberId}
dateOfBirth={dateOfBirth} dateOfBirth={dateOfBirth}
@@ -884,7 +887,9 @@ export default function InsuranceStatusPage() {
setPreviewOpen(true); setPreviewOpen(true);
}} }}
/> />
</div>
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
<DeltaInsEligibilityButton <DeltaInsEligibilityButton
memberId={memberId} memberId={memberId}
dateOfBirth={dateOfBirth} dateOfBirth={dateOfBirth}
@@ -901,6 +906,7 @@ export default function InsuranceStatusPage() {
setPreviewOpen(true); setPreviewOpen(true);
}} }}
/> />
</div>
<Button <Button
className="w-full" className="w-full"
@@ -914,6 +920,7 @@ export default function InsuranceStatusPage() {
{/* Row 2 */} {/* Row 2 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
<TuftsSCOEligibilityButton <TuftsSCOEligibilityButton
memberId={memberId} memberId={memberId}
dateOfBirth={dateOfBirth} dateOfBirth={dateOfBirth}
@@ -930,7 +937,9 @@ export default function InsuranceStatusPage() {
setPreviewOpen(true); setPreviewOpen(true);
}} }}
/> />
</div>
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
<UnitedSCOEligibilityButton <UnitedSCOEligibilityButton
memberId={memberId} memberId={memberId}
dateOfBirth={dateOfBirth} dateOfBirth={dateOfBirth}
@@ -947,7 +956,9 @@ export default function InsuranceStatusPage() {
setPreviewOpen(true); setPreviewOpen(true);
}} }}
/> />
</div>
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
<CCAEligibilityButton <CCAEligibilityButton
memberId={memberId} memberId={memberId}
dateOfBirth={dateOfBirth} dateOfBirth={dateOfBirth}
@@ -965,6 +976,7 @@ export default function InsuranceStatusPage() {
}} }}
/> />
</div> </div>
</div>
{/* Row 3 */} {/* Row 3 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">