From bede5162d3d9f1c248d4298e33912aa67def620e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 19 Dec 2025 17:01:57 +0000 Subject: [PATCH] feat(license): add initial license implementation structure to fix build --- cmd/pulse/main.go | 4 + frontend-modern/src/api/license.ts | 48 ++ .../components/Settings/ProLicensePanel.tsx | 301 +++++++++++ .../src/components/Settings/Settings.tsx | 18 + internal/ai/patrol.go | 40 +- internal/ai/service.go | 94 +++- internal/api/license_handlers.go | 214 ++++++++ internal/license/features.go | 132 +++++ internal/license/license.go | 480 ++++++++++++++++++ internal/license/license_test.go | 382 ++++++++++++++ internal/license/persistence.go | 227 +++++++++ internal/license/pubkey.go | 79 +++ 12 files changed, 1999 insertions(+), 20 deletions(-) create mode 100644 frontend-modern/src/api/license.ts create mode 100644 frontend-modern/src/components/Settings/ProLicensePanel.tsx create mode 100644 internal/api/license_handlers.go create mode 100644 internal/license/features.go create mode 100644 internal/license/license.go create mode 100644 internal/license/license_test.go create mode 100644 internal/license/persistence.go create mode 100644 internal/license/pubkey.go diff --git a/cmd/pulse/main.go b/cmd/pulse/main.go index a199085..6c59820 100644 --- a/cmd/pulse/main.go +++ b/cmd/pulse/main.go @@ -15,6 +15,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/api" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/license" "github.com/rcourtman/pulse-go-rewrite/internal/logging" "github.com/rcourtman/pulse-go-rewrite/internal/metrics" _ "github.com/rcourtman/pulse-go-rewrite/internal/mock" // Import for init() to run @@ -101,6 +102,9 @@ func runServer() { Component: "pulse", }) + // Initialize license public key for Pro feature validation + license.InitPublicKey() + log.Info().Msg("Starting Pulse monitoring server") // Validate agent binaries are available for download diff --git a/frontend-modern/src/api/license.ts b/frontend-modern/src/api/license.ts new file mode 100644 index 0000000..c4c3c83 --- /dev/null +++ b/frontend-modern/src/api/license.ts @@ -0,0 +1,48 @@ +import { apiFetchJSON } from '@/utils/apiClient'; + +export interface LicenseStatus { + valid: boolean; + tier: string; + email?: string; + expires_at?: string | null; + is_lifetime: boolean; + days_remaining: number; + features: string[]; + max_nodes?: number; + max_guests?: number; + in_grace_period?: boolean; + grace_period_end?: string | null; +} + +export interface ActivateLicenseResponse { + success: boolean; + message?: string; + status?: LicenseStatus; +} + +export interface ClearLicenseResponse { + success: boolean; + message?: string; +} + +export class LicenseAPI { + private static baseUrl = '/api/license'; + + static async getStatus(): Promise { + return apiFetchJSON(`${this.baseUrl}/status`) as Promise; + } + + static async activateLicense(licenseKey: string): Promise { + return apiFetchJSON(`${this.baseUrl}/activate`, { + method: 'POST', + body: JSON.stringify({ license_key: licenseKey }), + }) as Promise; + } + + static async clearLicense(): Promise { + return apiFetchJSON(`${this.baseUrl}/clear`, { + method: 'POST', + body: JSON.stringify({}), + }) as Promise; + } +} diff --git a/frontend-modern/src/components/Settings/ProLicensePanel.tsx b/frontend-modern/src/components/Settings/ProLicensePanel.tsx new file mode 100644 index 0000000..da0657d --- /dev/null +++ b/frontend-modern/src/components/Settings/ProLicensePanel.tsx @@ -0,0 +1,301 @@ +import { Component, Show, createMemo, createSignal, onMount, For } from 'solid-js'; +import SettingsPanel from '@/components/shared/SettingsPanel'; +import { formField, formHelpText, labelClass, controlClass } from '@/components/shared/Form'; +import { showError, showSuccess } from '@/utils/toast'; +import { LicenseAPI, type LicenseStatus } from '@/api/license'; +import RefreshCw from 'lucide-solid/icons/refresh-cw'; + +const PULSE_PRO_URL = 'https://pulsemonitor.app/pro'; + +const TIER_LABELS: Record = { + free: 'Free', + pro: 'Pro', + pro_annual: 'Pro Annual', + lifetime: 'Lifetime', + msp: 'MSP', + enterprise: 'Enterprise', +}; + +const FEATURE_LABELS: Record = { + ai_patrol: 'AI Patrol', + ai_alerts: 'AI Alert Analysis', + ai_autofix: 'AI Auto-Fix', + kubernetes_ai: 'Kubernetes AI', + multi_user: 'Multi-user / RBAC', + white_label: 'White-label Branding', + multi_tenant: 'Multi-tenant Mode', + unlimited: 'Unlimited Instances', +}; + +const formatTitleCase = (value: string) => + value.replace(/_/g, ' ').replace(/\b\w/g, (match) => match.toUpperCase()); + +const formatDate = (value?: string | null) => { + if (!value) return 'Never'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleDateString(); +}; + +export const ProLicensePanel: Component = () => { + const [status, setStatus] = createSignal(null); + const [licenseKey, setLicenseKey] = createSignal(''); + const [loading, setLoading] = createSignal(false); + const [activating, setActivating] = createSignal(false); + const [clearing, setClearing] = createSignal(false); + + const loadStatus = async () => { + setLoading(true); + try { + const nextStatus = await LicenseAPI.getStatus(); + setStatus(nextStatus); + } catch (err) { + showError(err instanceof Error ? err.message : 'Failed to load license status'); + } finally { + setLoading(false); + } + }; + + onMount(() => { + void loadStatus(); + }); + + const statusLabel = createMemo(() => { + const current = status(); + if (!current) return 'Unknown'; + if (current.valid) { + return current.in_grace_period ? 'Grace Period' : 'Active'; + } + if (current.expires_at) { + return 'Expired'; + } + return 'No License'; + }); + + const statusTone = createMemo(() => { + const current = status(); + if (!current) return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'; + if (current.valid && current.in_grace_period) { + return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'; + } + if (current.valid) { + return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'; + } + if (current.expires_at) { + return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'; + } + return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'; + }); + + const hasLicenseDetails = createMemo(() => { + const current = status(); + if (!current) return false; + return Boolean(current.email || current.expires_at || current.tier !== 'free'); + }); + + const formattedTier = createMemo(() => { + const current = status(); + if (!current) return 'Unknown'; + return TIER_LABELS[current.tier] ?? formatTitleCase(current.tier); + }); + + const formattedFeatures = createMemo(() => { + const current = status(); + if (!current?.features?.length) return []; + return current.features.map((feature) => FEATURE_LABELS[feature] ?? formatTitleCase(feature)); + }); + + const handleActivate = async () => { + const trimmedKey = licenseKey().trim(); + if (!trimmedKey) { + showError('License key is required'); + return; + } + setActivating(true); + try { + const result = await LicenseAPI.activateLicense(trimmedKey); + if (!result.success) { + showError(result.message || 'Failed to activate license'); + return; + } + showSuccess(result.message || 'License activated'); + setLicenseKey(''); + if (result.status) { + setStatus(result.status); + } else { + await loadStatus(); + } + } catch (err) { + showError(err instanceof Error ? err.message : 'Failed to activate license'); + } finally { + setActivating(false); + } + }; + + const handleClear = async () => { + if (!confirm('Clear the current Pulse Pro license?')) { + return; + } + setClearing(true); + try { + const result = await LicenseAPI.clearLicense(); + showSuccess(result.message || 'License cleared'); + await loadStatus(); + } catch (err) { + showError(err instanceof Error ? err.message : 'Failed to clear license'); + } finally { + setClearing(false); + } + }; + + return ( +
+ + + Refresh + + } + > +
+ +