diff --git a/README.md b/README.md index 008e5caf..88f31c09 100644 --- a/README.md +++ b/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 (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. diff --git a/apps/Backend/.env b/apps/Backend/.env index bd5325ca..b65e40e7 100755 --- a/apps/Backend/.env +++ b/apps/Backend/.env @@ -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 \ No newline at end of file +DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp +LICENSE_SECRET=3aa4ab937e46c6863b9e3c2b591a595b31ea3af1060bf5e7961ad722a8b54f92 \ No newline at end of file diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 82fc8fc1..2b7b20f9 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -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; diff --git a/apps/Backend/src/routes/license.ts b/apps/Backend/src/routes/license.ts new file mode 100644 index 00000000..000b354f --- /dev/null +++ b/apps/Backend/src/routes/license.ts @@ -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; diff --git a/apps/Frontend/src/App.tsx b/apps/Frontend/src/App.tsx index 824e27ea..7ebaf185 100755 --- a/apps/Frontend/src/App.tsx +++ b/apps/Frontend/src/App.tsx @@ -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() { } /> } /> } /> + } adminOnly /> } diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index e2ac4988..c97f5c9d 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -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({ @@ -2723,24 +2733,32 @@ export function ClaimForm({ diff --git a/apps/Frontend/src/components/layout/sidebar.tsx b/apps/Frontend/src/components/layout/sidebar.tsx index 532d8c9e..7fe6cca5 100755 --- a/apps/Frontend/src/components/layout/sidebar.tsx +++ b/apps/Frontend/src/components/layout/sidebar.tsx @@ -186,6 +186,12 @@ export function Sidebar() { }, ], }, + { + name: "Activation", + path: "/activation", + icon: , + adminOnly: true, + }, { name: "Database Management", path: "/database-management", diff --git a/apps/Frontend/src/hooks/use-license.ts b/apps/Frontend/src/hooks/use-license.ts new file mode 100644 index 00000000..bfe22002 --- /dev/null +++ b/apps/Frontend/src/hooks/use-license.ts @@ -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({ + 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 }; +} diff --git a/apps/Frontend/src/pages/activation-page.tsx b/apps/Frontend/src/pages/activation-page.tsx new file mode 100644 index 00000000..1c303a5d --- /dev/null +++ b/apps/Frontend/src/pages/activation-page.tsx @@ -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 ( +
+
+ +

License Activation

+
+ + {/* Status Card */} + + + License Status + + + {isLoading ? ( +

Checking license...

+ ) : isActive ? ( +
+
+ + Active +
+

+ Expires: {formatExpiry(status!.expiry!)} +

+ {isWarning && ( +
+ +

+ Expires in {status!.daysLeft} day{status!.daysLeft !== 1 ? "s" : ""}. Please renew soon. +

+
+ )} +
+ ) : isExpired ? ( +
+ +
+ Expired +

+ Expired on {formatExpiry(status!.expiry!)}. Enter a new key to reactivate. +

+
+
+ ) : ( +
+ + Not activated +
+ )} +
+
+ + {/* Activate Card */} + + + Enter License Key + + + setKeyInput(e.target.value)} + className="font-mono text-sm" + /> + + + + + {/* Features Card */} + + + Features + + +
+

Free

+
+ {[ + "MassHealth Eligibility", + "MassHealth Claim", + "Documents", + "Payments", + "Database Backups", + "Reports", + ].map((f) => ( +
+ + {f} +
+ ))} +
+
+ +
+

Premium

+
+ {[ + "CCA, DDMA, United, Tufts Eligibility", + "CCA, DDMA, United, Tufts Claims", + "Pre-Authorizations", + "AI SMS", + ].map((f) => ( +
+ {isActive ? ( + + ) : ( + + )} + + {f} + +
+ ))} +
+
+
+
+
+ ); +} diff --git a/apps/Frontend/src/pages/claims-page.tsx b/apps/Frontend/src/pages/claims-page.tsx index f5ec9aa1..2d718733 100755 --- a/apps/Frontend/src/pages/claims-page.tsx +++ b/apps/Frontend/src/pages/claims-page.tsx @@ -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(null); @@ -911,6 +913,7 @@ export default function ClaimsPage() { onHandleForDDMASeleniumClaim={handleDDMAClaimSubmitSelenium} onHandleForUnitedDHSeleniumClaim={handleUnitedDHClaimSubmitSelenium} onHandleForTuftsSCOSeleniumClaim={handleTuftsSCOClaimSubmitSelenium} + isLicensed={isLicensed} /> )} diff --git a/apps/Frontend/src/pages/insurance-status-page.tsx b/apps/Frontend/src/pages/insurance-status-page.tsx index 60692218..402ddbb6 100755 --- a/apps/Frontend/src/pages/insurance-status-page.tsx +++ b/apps/Frontend/src/pages/insurance-status-page.tsx @@ -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 */}
- setTriggerTarget(null)} - onPdfReady={(pdfId, fallbackFilename) => { - setPreviewPdfId(pdfId); - setPreviewFallbackFilename( - fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`, - ); - setPreviewOpen(true); - }} - /> +
+ setTriggerTarget(null)} + onPdfReady={(pdfId, fallbackFilename) => { + setPreviewPdfId(pdfId); + setPreviewFallbackFilename( + fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`, + ); + setPreviewOpen(true); + }} + /> +
- setTriggerTarget(null)} - onPdfReady={(pdfId, fallbackFilename) => { - setPreviewPdfId(pdfId); - setPreviewFallbackFilename( - fallbackFilename ?? `eligibility_deltains_${memberId}.pdf`, - ); - setPreviewOpen(true); - }} - /> +
+ setTriggerTarget(null)} + onPdfReady={(pdfId, fallbackFilename) => { + setPreviewPdfId(pdfId); + setPreviewFallbackFilename( + fallbackFilename ?? `eligibility_deltains_${memberId}.pdf`, + ); + setPreviewOpen(true); + }} + /> +
{/* Row 3 */}