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:
41
README.md
41
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -8,4 +9,5 @@ DB_HOST=localhost
|
|||||||
DB_USER=postgres
|
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
|
||||||
@@ -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;
|
||||||
|
|||||||
97
apps/Backend/src/routes/license.ts
Normal file
97
apps/Backend/src/routes/license.ts
Normal 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;
|
||||||
@@ -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 />}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
24
apps/Frontend/src/hooks/use-license.ts
Normal file
24
apps/Frontend/src/hooks/use-license.ts
Normal 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 };
|
||||||
|
}
|
||||||
184
apps/Frontend/src/pages/activation-page.tsx
Normal file
184
apps/Frontend/src/pages/activation-page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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,39 +870,43 @@ 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">
|
||||||
<DdmaEligibilityButton
|
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
|
||||||
memberId={memberId}
|
<DdmaEligibilityButton
|
||||||
dateOfBirth={dateOfBirth}
|
memberId={memberId}
|
||||||
firstName={firstName}
|
dateOfBirth={dateOfBirth}
|
||||||
lastName={lastName}
|
firstName={firstName}
|
||||||
isFormIncomplete={isFormIncomplete}
|
lastName={lastName}
|
||||||
autoTrigger={triggerTarget === "ddma"}
|
isFormIncomplete={isFormIncomplete}
|
||||||
onAutoTriggered={() => setTriggerTarget(null)}
|
autoTrigger={triggerTarget === "ddma"}
|
||||||
onPdfReady={(pdfId, fallbackFilename) => {
|
onAutoTriggered={() => setTriggerTarget(null)}
|
||||||
setPreviewPdfId(pdfId);
|
onPdfReady={(pdfId, fallbackFilename) => {
|
||||||
setPreviewFallbackFilename(
|
setPreviewPdfId(pdfId);
|
||||||
fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`,
|
setPreviewFallbackFilename(
|
||||||
);
|
fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`,
|
||||||
setPreviewOpen(true);
|
);
|
||||||
}}
|
setPreviewOpen(true);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DeltaInsEligibilityButton
|
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
|
||||||
memberId={memberId}
|
<DeltaInsEligibilityButton
|
||||||
dateOfBirth={dateOfBirth}
|
memberId={memberId}
|
||||||
firstName={firstName}
|
dateOfBirth={dateOfBirth}
|
||||||
lastName={lastName}
|
firstName={firstName}
|
||||||
isFormIncomplete={isFormIncomplete}
|
lastName={lastName}
|
||||||
autoTrigger={triggerTarget === "delta-ins"}
|
isFormIncomplete={isFormIncomplete}
|
||||||
onAutoTriggered={() => setTriggerTarget(null)}
|
autoTrigger={triggerTarget === "delta-ins"}
|
||||||
onPdfReady={(pdfId, fallbackFilename) => {
|
onAutoTriggered={() => setTriggerTarget(null)}
|
||||||
setPreviewPdfId(pdfId);
|
onPdfReady={(pdfId, fallbackFilename) => {
|
||||||
setPreviewFallbackFilename(
|
setPreviewPdfId(pdfId);
|
||||||
fallbackFilename ?? `eligibility_deltains_${memberId}.pdf`,
|
setPreviewFallbackFilename(
|
||||||
);
|
fallbackFilename ?? `eligibility_deltains_${memberId}.pdf`,
|
||||||
setPreviewOpen(true);
|
);
|
||||||
}}
|
setPreviewOpen(true);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -914,56 +920,62 @@ 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">
|
||||||
<TuftsSCOEligibilityButton
|
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
|
||||||
memberId={memberId}
|
<TuftsSCOEligibilityButton
|
||||||
dateOfBirth={dateOfBirth}
|
memberId={memberId}
|
||||||
firstName={firstName}
|
dateOfBirth={dateOfBirth}
|
||||||
lastName={lastName}
|
firstName={firstName}
|
||||||
isFormIncomplete={isFormIncomplete}
|
lastName={lastName}
|
||||||
autoTrigger={triggerTarget === "tufts-sco"}
|
isFormIncomplete={isFormIncomplete}
|
||||||
onAutoTriggered={() => setTriggerTarget(null)}
|
autoTrigger={triggerTarget === "tufts-sco"}
|
||||||
onPdfReady={(pdfId, fallbackFilename) => {
|
onAutoTriggered={() => setTriggerTarget(null)}
|
||||||
setPreviewPdfId(pdfId);
|
onPdfReady={(pdfId, fallbackFilename) => {
|
||||||
setPreviewFallbackFilename(
|
setPreviewPdfId(pdfId);
|
||||||
fallbackFilename ?? `eligibility_tuftssco_${memberId}.pdf`,
|
setPreviewFallbackFilename(
|
||||||
);
|
fallbackFilename ?? `eligibility_tuftssco_${memberId}.pdf`,
|
||||||
setPreviewOpen(true);
|
);
|
||||||
}}
|
setPreviewOpen(true);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<UnitedSCOEligibilityButton
|
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
|
||||||
memberId={memberId}
|
<UnitedSCOEligibilityButton
|
||||||
dateOfBirth={dateOfBirth}
|
memberId={memberId}
|
||||||
firstName={firstName}
|
dateOfBirth={dateOfBirth}
|
||||||
lastName={lastName}
|
firstName={firstName}
|
||||||
isFormIncomplete={isFormIncomplete}
|
lastName={lastName}
|
||||||
autoTrigger={triggerTarget === "united-sco"}
|
isFormIncomplete={isFormIncomplete}
|
||||||
onAutoTriggered={() => setTriggerTarget(null)}
|
autoTrigger={triggerTarget === "united-sco"}
|
||||||
onPdfReady={(pdfId, fallbackFilename) => {
|
onAutoTriggered={() => setTriggerTarget(null)}
|
||||||
setPreviewPdfId(pdfId);
|
onPdfReady={(pdfId, fallbackFilename) => {
|
||||||
setPreviewFallbackFilename(
|
setPreviewPdfId(pdfId);
|
||||||
fallbackFilename ?? `eligibility_unitedsco_${memberId}.pdf`,
|
setPreviewFallbackFilename(
|
||||||
);
|
fallbackFilename ?? `eligibility_unitedsco_${memberId}.pdf`,
|
||||||
setPreviewOpen(true);
|
);
|
||||||
}}
|
setPreviewOpen(true);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CCAEligibilityButton
|
<div title={!isLicensed ? "License required" : undefined} className={!isLicensed ? "opacity-40 pointer-events-none" : ""}>
|
||||||
memberId={memberId}
|
<CCAEligibilityButton
|
||||||
dateOfBirth={dateOfBirth}
|
memberId={memberId}
|
||||||
firstName={firstName}
|
dateOfBirth={dateOfBirth}
|
||||||
lastName={lastName}
|
firstName={firstName}
|
||||||
isFormIncomplete={isFormIncomplete}
|
lastName={lastName}
|
||||||
autoTrigger={triggerTarget === "cca"}
|
isFormIncomplete={isFormIncomplete}
|
||||||
onAutoTriggered={() => setTriggerTarget(null)}
|
autoTrigger={triggerTarget === "cca"}
|
||||||
onPdfReady={(pdfId, fallbackFilename) => {
|
onAutoTriggered={() => setTriggerTarget(null)}
|
||||||
setPreviewPdfId(pdfId);
|
onPdfReady={(pdfId, fallbackFilename) => {
|
||||||
setPreviewFallbackFilename(
|
setPreviewPdfId(pdfId);
|
||||||
fallbackFilename ?? `eligibility_cca_${memberId}.pdf`,
|
setPreviewFallbackFilename(
|
||||||
);
|
fallbackFilename ?? `eligibility_cca_${memberId}.pdf`,
|
||||||
setPreviewOpen(true);
|
);
|
||||||
}}
|
setPreviewOpen(true);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 3 */}
|
{/* Row 3 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user