diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 2b9c58d..ec0e4c5 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -22,6 +22,14 @@ import { createMenu } from './menu' import { setupContextMenu } from './context-menu' import { getWindowState, trackWindowState } from './window-state' import { getAdapter } from './db-adapter' +import { + initLicenseStore, + checkLicense, + activateLicense, + deactivateLicense, + activateLicenseOffline +} from './license-service' +import type { LicenseActivationRequest } from '@shared/index' // electron-store v11 is ESM-only, use dynamic import type StoreType = import('electron-store').default<{ connections: ConnectionConfig[] }> @@ -123,6 +131,9 @@ app.whenReady().then(async () => { // Initialize electron-store (ESM module) await initStore() + // Initialize license store + await initLicenseStore() + // Create native application menu createMenu() @@ -538,6 +549,77 @@ app.whenReady().then(async () => { } }) + // ============================================ + // License Handlers + // ============================================ + + // Check current license status + ipcMain.handle('license:check', async () => { + try { + const status = await checkLicense() + return { success: true, data: status } + } catch (error: unknown) { + console.error('[main:license:check] Error:', error) + const errorMessage = error instanceof Error ? error.message : String(error) + return { success: false, error: errorMessage } + } + }) + + // Activate a license + ipcMain.handle('license:activate', async (_, request: LicenseActivationRequest) => { + console.log('[main:license:activate] Activating license for:', request.email) + try { + const result = await activateLicense(request.key, request.email) + if (result.success) { + const status = await checkLicense() + return { success: true, data: status } + } + return { success: false, error: result.error } + } catch (error: unknown) { + console.error('[main:license:activate] Error:', error) + const errorMessage = error instanceof Error ? error.message : String(error) + return { success: false, error: errorMessage } + } + }) + + // Deactivate the current license + ipcMain.handle('license:deactivate', async () => { + console.log('[main:license:deactivate] Deactivating license') + try { + const result = await deactivateLicense() + return { success: result.success, error: result.error } + } catch (error: unknown) { + console.error('[main:license:deactivate] Error:', error) + const errorMessage = error instanceof Error ? error.message : String(error) + return { success: false, error: errorMessage } + } + }) + + // Offline activation (for development/testing) + ipcMain.handle( + 'license:activate-offline', + async ( + _, + { + key, + email, + type, + daysValid + }: { key: string; email: string; type?: 'individual' | 'team'; daysValid?: number } + ) => { + console.log('[main:license:activate-offline] Offline activation for:', email) + try { + activateLicenseOffline(key, email, type, daysValid) + const status = await checkLicense() + return { success: true, data: status } + } catch (error: unknown) { + console.error('[main:license:activate-offline] Error:', error) + const errorMessage = error instanceof Error ? error.message : String(error) + return { success: false, error: errorMessage } + } + } + ) + await createWindow() app.on('activate', function () { diff --git a/apps/desktop/src/main/license-service.ts b/apps/desktop/src/main/license-service.ts new file mode 100644 index 0000000..450023d --- /dev/null +++ b/apps/desktop/src/main/license-service.ts @@ -0,0 +1,330 @@ +import { app } from 'electron' +import * as crypto from 'crypto' +import * as os from 'os' +import type { LicenseData, LicenseStatus, LicenseType } from '@shared/index' + +// electron-store v11 is ESM-only, use dynamic import +type LicenseStore = import('electron-store').default<{ license?: LicenseData }> + +let store: LicenseStore | null = null + +// License server API URL (placeholder - configure for your server) +const API_URL = process.env.LICENSE_API_URL || 'https://api.data-peek.dev' + +// Validation intervals +const VALIDATION_INTERVAL_DAYS = 7 +const OFFLINE_GRACE_DAYS = 30 + +/** + * Get the app version from package.json + */ +function getAppVersion(): string { + return app.getVersion() +} + +/** + * Generate a device ID based on machine-specific information + * Uses hostname, platform, arch, and CPU info + */ +function getDeviceId(): string { + const machineInfo = [ + os.hostname(), + os.platform(), + os.arch(), + os.cpus()[0]?.model || 'unknown-cpu', + os.homedir() + ].join('|') + + return crypto.createHash('sha256').update(machineInfo).digest('hex') +} + +/** + * Get a human-readable device name + */ +function getDeviceName(): string { + return `${os.hostname()} (${os.platform()})` +} + +/** + * Get encryption key derived from device ID + */ +function getEncryptionKey(): string { + const deviceId = getDeviceId() + return crypto.createHash('sha256').update(deviceId).digest('hex').slice(0, 32) +} + +/** + * Calculate days between two dates + */ +function daysBetween(date1: Date, date2: Date): number { + const diff = Math.abs(date2.getTime() - date1.getTime()) + return Math.ceil(diff / (1000 * 60 * 60 * 24)) +} + +/** + * Compare version strings (semver format) + * Returns true if current <= perpetual + */ +function isVersionLessOrEqual(current: string, perpetual: string): boolean { + const parse = (v: string): number[] => + v + .replace(/[^0-9.]/g, '') + .split('.') + .map(Number) + const [c1 = 0, c2 = 0, c3 = 0] = parse(current) + const [p1 = 0, p2 = 0, p3 = 0] = parse(perpetual) + + if (c1 !== p1) return c1 < p1 + if (c2 !== p2) return c2 < p2 + return c3 <= p3 +} + +/** + * Initialize the license store + */ +export async function initLicenseStore(): Promise { + if (store) return + + const Store = (await import('electron-store')).default + store = new Store<{ license?: LicenseData }>({ + name: 'data-peek-license', + encryptionKey: getEncryptionKey(), + defaults: { + license: undefined + } + }) +} + +/** + * Get the stored license data + */ +export function getStoredLicense(): LicenseData | undefined { + if (!store) return undefined + return store.get('license') +} + +/** + * Save license data to storage + */ +function saveLicense(license: LicenseData): void { + if (!store) return + store.set('license', license) +} + +/** + * Clear the stored license + */ +function clearLicense(): void { + if (!store) return + store.delete('license') +} + +/** + * Update the last validated timestamp + */ +function updateLastValidated(): void { + const stored = getStoredLicense() + if (stored) { + stored.lastValidated = new Date().toISOString() + saveLicense(stored) + } +} + +/** + * Get the expired status based on perpetual version + */ +function getExpiredStatus(stored: LicenseData): LicenseStatus { + const currentVersion = getAppVersion() + const isPerpetualValid = isVersionLessOrEqual(currentVersion, stored.perpetualVersion) + + return { + isValid: isPerpetualValid, + isCommercial: isPerpetualValid, + type: stored.type, + expiresAt: stored.expiresAt, + daysUntilExpiry: 0, + perpetualVersion: stored.perpetualVersion, + needsRevalidation: !isPerpetualValid, + email: stored.email + } +} + +/** + * Validate license online + */ +async function validateOnline(key: string): Promise { + try { + const response = await fetch(`${API_URL}/api/licenses/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + key, + deviceId: getDeviceId() + }) + }) + + return response.ok + } catch { + // Network error - return false to trigger offline handling + return false + } +} + +/** + * Check the current license status + */ +export async function checkLicense(): Promise { + const stored = getStoredLicense() + + // No license stored = personal use + if (!stored) { + return { + isValid: true, + isCommercial: false, + type: 'personal', + expiresAt: null, + daysUntilExpiry: null, + perpetualVersion: null, + needsRevalidation: false + } + } + + const lastValidated = new Date(stored.lastValidated) + const now = new Date() + const daysSinceValidation = daysBetween(lastValidated, now) + const needsRevalidation = daysSinceValidation >= VALIDATION_INTERVAL_DAYS + + if (needsRevalidation) { + try { + const validated = await validateOnline(stored.key) + if (validated) { + updateLastValidated() + } + } catch { + // Offline - check grace period + if (daysSinceValidation > OFFLINE_GRACE_DAYS) { + return getExpiredStatus(stored) + } + // Within grace period, allow continued use + } + } + + const expiresAt = new Date(stored.expiresAt) + + // Active subscription + if (expiresAt > now) { + return { + isValid: true, + isCommercial: true, + type: stored.type, + expiresAt: stored.expiresAt, + daysUntilExpiry: daysBetween(now, expiresAt), + perpetualVersion: stored.perpetualVersion, + needsRevalidation: false, + email: stored.email + } + } + + // Expired - check perpetual version + return getExpiredStatus(stored) +} + +/** + * Activate a license + */ +export async function activateLicense( + licenseKey: string, + email: string +): Promise<{ success: boolean; error?: string }> { + try { + const response = await fetch(`${API_URL}/api/licenses/activate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + key: licenseKey, + email, + deviceId: getDeviceId(), + deviceName: getDeviceName(), + appVersion: getAppVersion() + }) + }) + + const data = await response.json() + + if (!response.ok) { + return { success: false, error: data.message || 'Activation failed' } + } + + // Store license data + const licenseData: LicenseData = { + key: licenseKey, + type: data.type as LicenseType, + email, + expiresAt: data.expiresAt, + perpetualVersion: data.perpetualVersion || getAppVersion(), + activatedAt: new Date().toISOString(), + lastValidated: new Date().toISOString() + } + + saveLicense(licenseData) + return { success: true } + } catch (error) { + console.error('[license] Activation error:', error) + return { success: false, error: 'Network error. Please check your connection and try again.' } + } +} + +/** + * Deactivate the current license + */ +export async function deactivateLicense(): Promise<{ success: boolean; error?: string }> { + const stored = getStoredLicense() + + if (!stored) { + return { success: true } + } + + try { + await fetch(`${API_URL}/api/licenses/deactivate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + key: stored.key, + deviceId: getDeviceId() + }) + }) + } catch { + // Continue anyway - allow offline deactivation + console.log('[license] Could not reach server, deactivating locally') + } + + clearLicense() + return { success: true } +} + +/** + * Activate a license offline (for demo/testing purposes) + * This simulates a successful activation without server + */ +export function activateLicenseOffline( + licenseKey: string, + email: string, + type: LicenseType = 'individual', + daysValid: number = 365 +): { success: boolean } { + const expiresAt = new Date() + expiresAt.setDate(expiresAt.getDate() + daysValid) + + const licenseData: LicenseData = { + key: licenseKey, + type, + email, + expiresAt: expiresAt.toISOString(), + perpetualVersion: getAppVersion(), + activatedAt: new Date().toISOString(), + lastValidated: new Date().toISOString() + } + + saveLicense(licenseData) + return { success: true } +} diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts index 57e9c5e..bd3f29e 100644 --- a/apps/desktop/src/preload/index.d.ts +++ b/apps/desktop/src/preload/index.d.ts @@ -9,7 +9,10 @@ import type { AlterTableBatch, DDLResult, SequenceInfo, - CustomTypeInfo + CustomTypeInfo, + LicenseStatus, + LicenseActivationRequest, + LicenseType } from '@shared/index' interface DataPeekApi { @@ -64,6 +67,17 @@ interface DataPeekApi { onFormatSql: (callback: () => void) => () => void onClearResults: (callback: () => void) => () => void } + license: { + check: () => Promise> + activate: (request: LicenseActivationRequest) => Promise> + deactivate: () => Promise> + activateOffline: ( + key: string, + email: string, + type?: LicenseType, + daysValid?: number + ) => Promise> + } } declare global { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 95c61a5..1ccc55a 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -10,7 +10,10 @@ import type { AlterTableBatch, DDLResult, SequenceInfo, - CustomTypeInfo + CustomTypeInfo, + LicenseStatus, + LicenseActivationRequest, + LicenseType } from '@shared/index' // Custom APIs for renderer @@ -104,6 +107,20 @@ const api = { ipcRenderer.on('menu:clear-results', handler) return () => ipcRenderer.removeListener('menu:clear-results', handler) } + }, + // License management + license: { + check: (): Promise> => ipcRenderer.invoke('license:check'), + activate: (request: LicenseActivationRequest): Promise> => + ipcRenderer.invoke('license:activate', request), + deactivate: (): Promise> => ipcRenderer.invoke('license:deactivate'), + activateOffline: ( + key: string, + email: string, + type?: LicenseType, + daysValid?: number + ): Promise> => + ipcRenderer.invoke('license:activate-offline', { key, email, type, daysValid }) } } diff --git a/apps/desktop/src/renderer/src/components/license-activation-modal.tsx b/apps/desktop/src/renderer/src/components/license-activation-modal.tsx new file mode 100644 index 0000000..39441d2 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/license-activation-modal.tsx @@ -0,0 +1,174 @@ +'use client' + +import { useState } from 'react' +import { Loader2, Key, ExternalLink } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { useLicenseStore } from '@/stores/license-store' + +interface LicenseActivationModalProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function LicenseActivationModal({ open, onOpenChange }: LicenseActivationModalProps) { + const { activateLicense, activateLicenseOffline, isLoading, error } = useLicenseStore() + + const [email, setEmail] = useState('') + const [licenseKey, setLicenseKey] = useState('') + const [localError, setLocalError] = useState(null) + + const resetForm = () => { + setEmail('') + setLicenseKey('') + setLocalError(null) + } + + const handleClose = () => { + resetForm() + onOpenChange(false) + } + + const handleActivate = async () => { + if (!email || !licenseKey) { + setLocalError('Please enter both email and license key') + return + } + + // Basic email validation + if (!email.includes('@')) { + setLocalError('Please enter a valid email address') + return + } + + setLocalError(null) + + // Try online activation first, fall back to offline for development + const result = await activateLicense(licenseKey, email) + + if (result.success) { + handleClose() + } else if (result.error?.includes('Network error')) { + // For development/testing: try offline activation + const offlineResult = await activateLicenseOffline(licenseKey, email, 'individual', 365) + if (offlineResult.success) { + handleClose() + } else { + setLocalError(offlineResult.error || 'Activation failed') + } + } else { + setLocalError(result.error || 'Activation failed') + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && email && licenseKey) { + handleActivate() + } + } + + const isValid = email && licenseKey + + return ( + + + + + + Activate License + + + Enter your license key and email to activate data-peek for commercial use. + + + +
+
+ + setEmail(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+ +
+ + setLicenseKey(e.target.value.toUpperCase())} + onKeyDown={handleKeyDown} + className="font-mono" + /> +
+ + {(localError || error) && ( +
+ {localError || error} +
+ )} + +
+

+ Don't have a license?{' '} + + Purchase one + + +

+

+ Need to manage your license?{' '} + + License Dashboard + + +

+
+
+ + + + + +
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/license-settings-modal.tsx b/apps/desktop/src/renderer/src/components/license-settings-modal.tsx new file mode 100644 index 0000000..009c4fd --- /dev/null +++ b/apps/desktop/src/renderer/src/components/license-settings-modal.tsx @@ -0,0 +1,189 @@ +'use client' + +import { Loader2, Check, AlertCircle, ExternalLink, LogOut, Monitor } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { useLicenseStore } from '@/stores/license-store' + +interface LicenseSettingsModalProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function LicenseSettingsModal({ open, onOpenChange }: LicenseSettingsModalProps) { + const { status, deactivateLicense, isLoading, error } = useLicenseStore() + + if (!status) { + return null + } + + const handleDeactivate = async () => { + const result = await deactivateLicense() + if (result.success) { + onOpenChange(false) + } + } + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) + } + + const isExpired = status.daysUntilExpiry !== null && status.daysUntilExpiry <= 0 + const isExpiringSoon = status.daysUntilExpiry !== null && status.daysUntilExpiry <= 14 + + return ( + + + + License Settings + Manage your data-peek license. + + +
+ {/* License Status Card */} +
+
+
+ {isExpired || isExpiringSoon ? ( + + ) : ( + + )} +
+
+

+ {isExpired + ? 'License Expired' + : `${status.type === 'team' ? 'Team' : 'Individual'} License Active`} +

+ +
+ {status.email && ( +
+ Email + {status.email} +
+ )} + +
+ Type + {status.type} +
+ + {status.expiresAt && ( +
+ + {isExpired ? 'Expired' : 'Renews'} + + + {formatDate(status.expiresAt)} + {!isExpired && status.daysUntilExpiry && ( + + ({status.daysUntilExpiry} days) + + )} + +
+ )} + + {status.devicesUsed !== undefined && status.devicesAllowed !== undefined && ( +
+ Devices + + + {status.devicesUsed} of {status.devicesAllowed} used + +
+ )} +
+
+
+ + {isExpired && ( +
+ Your subscription has ended. + {status.perpetualVersion && ( + <> + {' '} + You can continue using data-peek v{status.perpetualVersion} for commercial use. + + )}{' '} + Renew to access the latest updates. +
+ )} +
+ + {/* Perpetual Version Info */} + {status.perpetualVersion && !isExpired && ( +

+ If your subscription ends, you can continue using data-peek v{status.perpetualVersion}{' '} + (your perpetual version) for commercial use. +

+ )} + + {error && ( +
+ {error} +
+ )} +
+ + + + +
+ + + {isExpired && ( + + )} +
+
+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/license-status-indicator.tsx b/apps/desktop/src/renderer/src/components/license-status-indicator.tsx new file mode 100644 index 0000000..614a1db --- /dev/null +++ b/apps/desktop/src/renderer/src/components/license-status-indicator.tsx @@ -0,0 +1,149 @@ +'use client' + +import { useEffect } from 'react' +import { Check, AlertCircle, User } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from '@/components/ui/tooltip' +import { useLicenseStore } from '@/stores/license-store' + +export function LicenseStatusIndicator() { + const { status, isLoading, checkLicense, openActivationModal, openSettingsModal } = + useLicenseStore() + + // Check license on mount + useEffect(() => { + checkLicense() + }, [checkLicense]) + + if (isLoading && !status) { + return null + } + + if (!status) { + return null + } + + const handleClick = () => { + if (status.type === 'personal') { + openActivationModal() + } else { + openSettingsModal() + } + } + + // Personal use + if (status.type === 'personal') { + return ( + + + + + + +

Using data-peek for personal, non-commercial use.

+

+ Click to activate a license for commercial use. +

+
+
+
+ ) + } + + // Expired license + if (status.daysUntilExpiry !== null && status.daysUntilExpiry <= 0) { + return ( + + + + + + +

Your license has expired.

+

+ {status.perpetualVersion + ? `You can continue using v${status.perpetualVersion} for commercial use.` + : 'Click to renew your license.'} +

+
+
+
+ ) + } + + // Expiring soon (within 14 days) + if (status.daysUntilExpiry !== null && status.daysUntilExpiry <= 14) { + return ( + + + + + + +

Your license expires in {status.daysUntilExpiry} days.

+

Click to manage your license.

+
+
+
+ ) + } + + // Active license + return ( + + + + + + +

+ {status.type === 'team' ? 'Team' : 'Individual'} license active + {status.email && ` (${status.email})`} +

+ {status.daysUntilExpiry && ( +

+ Renews in {status.daysUntilExpiry} days +

+ )} +
+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/router.tsx b/apps/desktop/src/renderer/src/router.tsx index 5a4017f..4436516 100644 --- a/apps/desktop/src/renderer/src/router.tsx +++ b/apps/desktop/src/renderer/src/router.tsx @@ -15,7 +15,10 @@ import { Separator } from '@/components/ui/separator' import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar' import { TabContainer } from '@/components/tab-container' import { ConnectionPicker } from '@/components/connection-picker' -import { useConnectionStore } from '@/stores' +import { LicenseStatusIndicator } from '@/components/license-status-indicator' +import { LicenseActivationModal } from '@/components/license-activation-modal' +import { LicenseSettingsModal } from '@/components/license-settings-modal' +import { useConnectionStore, useLicenseStore } from '@/stores' import { cn } from '@/lib/utils' // Root Layout @@ -26,6 +29,12 @@ function RootLayout() { const setConnectionStatus = useConnectionStore((s) => s.setConnectionStatus) const [isConnectionPickerOpen, setIsConnectionPickerOpen] = useState(false) + // License modal states from store + const isActivationModalOpen = useLicenseStore((s) => s.isActivationModalOpen) + const closeActivationModal = useLicenseStore((s) => s.closeActivationModal) + const isSettingsModalOpen = useLicenseStore((s) => s.isSettingsModalOpen) + const closeSettingsModal = useLicenseStore((s) => s.closeSettingsModal) + // Handle connection switching const handleSelectConnection = useCallback( (connectionId: string) => { @@ -90,7 +99,9 @@ function RootLayout() { )} -
+
+ +
@@ -101,6 +112,10 @@ function RootLayout() { {/* Global Connection Picker */} + + {/* License Modals */} + + ) } @@ -166,6 +181,9 @@ function ShortcutRow({ keys, description }: { keys: string[]; description: strin // Settings Page function SettingsPage() { const { theme, setTheme } = useTheme() + const licenseStatus = useLicenseStore((s) => s.status) + const openSettingsModal = useLicenseStore((s) => s.openSettingsModal) + const openActivationModal = useLicenseStore((s) => s.openActivationModal) return (
@@ -176,6 +194,46 @@ function SettingsPage() {

Settings

+ {/* License */} +
+

License

+

+ Manage your data-peek license for commercial use. +

+
+
+ {licenseStatus?.type === 'personal' ? ( + <> + + Personal Use (Free) + + ) : ( + <> + + + {licenseStatus?.type} License + {licenseStatus?.email && ` (${licenseStatus.email})`} + + + )} +
+ +
+
+ {/* Appearance */}

Appearance

diff --git a/apps/desktop/src/renderer/src/stores/index.ts b/apps/desktop/src/renderer/src/stores/index.ts index 52b3495..07cfbde 100644 --- a/apps/desktop/src/renderer/src/stores/index.ts +++ b/apps/desktop/src/renderer/src/stores/index.ts @@ -2,3 +2,4 @@ export * from './connection-store' export * from './query-store' export * from './tab-store' export * from './ddl-store' +export * from './license-store' diff --git a/apps/desktop/src/renderer/src/stores/license-store.ts b/apps/desktop/src/renderer/src/stores/license-store.ts new file mode 100644 index 0000000..86765b2 --- /dev/null +++ b/apps/desktop/src/renderer/src/stores/license-store.ts @@ -0,0 +1,207 @@ +import { create } from 'zustand' +import type { LicenseStatus, LicenseType } from '@shared/index' + +interface LicenseState { + // License status + status: LicenseStatus | null + isLoading: boolean + error: string | null + + // Modal states + isActivationModalOpen: boolean + isSettingsModalOpen: boolean + + // Actions + checkLicense: () => Promise + activateLicense: (key: string, email: string) => Promise<{ success: boolean; error?: string }> + activateLicenseOffline: ( + key: string, + email: string, + type?: LicenseType, + daysValid?: number + ) => Promise<{ success: boolean; error?: string }> + deactivateLicense: () => Promise<{ success: boolean; error?: string }> + + // Modal actions + openActivationModal: () => void + closeActivationModal: () => void + openSettingsModal: () => void + closeSettingsModal: () => void + + // Computed + isPersonal: () => boolean + isCommercial: () => boolean + isExpired: () => boolean + isExpiringSoon: () => boolean +} + +export const useLicenseStore = create((set, get) => ({ + // Initial state + status: null, + isLoading: false, + error: null, + isActivationModalOpen: false, + isSettingsModalOpen: false, + + // Actions + checkLicense: async () => { + set({ isLoading: true, error: null }) + + try { + const result = await window.api.license.check() + + if (result.success && result.data) { + set({ + status: result.data, + isLoading: false, + error: null + }) + } else { + set({ + isLoading: false, + error: result.error || 'Failed to check license' + }) + } + } catch (error) { + console.error('Failed to check license:', error) + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Unknown error' + }) + } + }, + + activateLicense: async (key: string, email: string) => { + set({ isLoading: true, error: null }) + + try { + const result = await window.api.license.activate({ key, email }) + + if (result.success && result.data) { + set({ + status: result.data, + isLoading: false, + error: null, + isActivationModalOpen: false + }) + return { success: true } + } else { + const error = result.error || 'Failed to activate license' + set({ + isLoading: false, + error + }) + return { success: false, error } + } + } catch (error) { + console.error('Failed to activate license:', error) + const errorMsg = error instanceof Error ? error.message : 'Unknown error' + set({ + isLoading: false, + error: errorMsg + }) + return { success: false, error: errorMsg } + } + }, + + activateLicenseOffline: async ( + key: string, + email: string, + type?: LicenseType, + daysValid?: number + ) => { + set({ isLoading: true, error: null }) + + try { + const result = await window.api.license.activateOffline(key, email, type, daysValid) + + if (result.success && result.data) { + set({ + status: result.data, + isLoading: false, + error: null, + isActivationModalOpen: false + }) + return { success: true } + } else { + const error = result.error || 'Failed to activate license' + set({ + isLoading: false, + error + }) + return { success: false, error } + } + } catch (error) { + console.error('Failed to activate license offline:', error) + const errorMsg = error instanceof Error ? error.message : 'Unknown error' + set({ + isLoading: false, + error: errorMsg + }) + return { success: false, error: errorMsg } + } + }, + + deactivateLicense: async () => { + set({ isLoading: true, error: null }) + + try { + const result = await window.api.license.deactivate() + + if (result.success) { + // Recheck license to get personal status + await get().checkLicense() + set({ + isLoading: false, + error: null, + isSettingsModalOpen: false + }) + return { success: true } + } else { + const error = result.error || 'Failed to deactivate license' + set({ + isLoading: false, + error + }) + return { success: false, error } + } + } catch (error) { + console.error('Failed to deactivate license:', error) + const errorMsg = error instanceof Error ? error.message : 'Unknown error' + set({ + isLoading: false, + error: errorMsg + }) + return { success: false, error: errorMsg } + } + }, + + // Modal actions + openActivationModal: () => set({ isActivationModalOpen: true, error: null }), + closeActivationModal: () => set({ isActivationModalOpen: false, error: null }), + openSettingsModal: () => set({ isSettingsModalOpen: true, error: null }), + closeSettingsModal: () => set({ isSettingsModalOpen: false, error: null }), + + // Computed + isPersonal: () => { + const status = get().status + return status?.type === 'personal' + }, + + isCommercial: () => { + const status = get().status + return status?.isCommercial ?? false + }, + + isExpired: () => { + const status = get().status + if (!status?.expiresAt) return false + return new Date(status.expiresAt) < new Date() + }, + + isExpiringSoon: () => { + const status = get().status + if (!status?.daysUntilExpiry) return false + return status.daysUntilExpiry <= 14 + } +})) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 984fcf3..9de1874 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -540,3 +540,87 @@ export interface CustomTypeInfo { /** Enum values (for enum types) */ values?: string[]; } + +// ============================================ +// License Types +// ============================================ + +/** + * License type enumeration + */ +export type LicenseType = 'personal' | 'individual' | 'team'; + +/** + * Stored license data (encrypted locally) + */ +export interface LicenseData { + /** License key */ + key: string; + /** Type of license */ + type: LicenseType; + /** Email address of license owner */ + email: string; + /** Subscription expiry date (ISO string) */ + expiresAt: string; + /** Last version the user is entitled to use perpetually */ + perpetualVersion: string; + /** When this license was activated (ISO string) */ + activatedAt: string; + /** Last time the license was validated online (ISO string) */ + lastValidated: string; +} + +/** + * License status returned to the frontend + */ +export interface LicenseStatus { + /** Whether the license is valid */ + isValid: boolean; + /** Whether commercial use is allowed */ + isCommercial: boolean; + /** Type of license */ + type: LicenseType; + /** Expiry date (null for personal) */ + expiresAt: string | null; + /** Days until expiry (null for personal or expired) */ + daysUntilExpiry: number | null; + /** Perpetual version the user can use after expiry */ + perpetualVersion: string | null; + /** Whether revalidation is needed */ + needsRevalidation: boolean; + /** Email associated with the license */ + email?: string; + /** Number of devices activated */ + devicesUsed?: number; + /** Maximum devices allowed */ + devicesAllowed?: number; +} + +/** + * License activation request + */ +export interface LicenseActivationRequest { + key: string; + email: string; +} + +/** + * License activation response + */ +export interface LicenseActivationResponse { + success: boolean; + error?: string; + type?: LicenseType; + expiresAt?: string; + perpetualVersion?: string; + devicesUsed?: number; + devicesAllowed?: number; +} + +/** + * License deactivation response + */ +export interface LicenseDeactivationResponse { + success: boolean; + error?: string; +}