mirror of
https://github.com/Rohithgilla12/data-peek
synced 2026-05-23 17:38:26 +00:00
feat: Add licensing system for commercial use
Implements a complete licensing system following the Yaak-inspired model: - Open source code (MIT), licensed binaries for commercial use - Personal use is always free - Individual ($6/mo) and Team ($10/user/mo) plans - Perpetual fallback: users keep their version when subscription ends - Honor system enforcement with 30-day offline grace period Key components: - License service in main process with encrypted local storage - License IPC handlers for check, activate, deactivate operations - License store (Zustand) for state management - UI components: status indicator, activation modal, settings modal - Integration with app header and settings page The implementation includes offline activation for development/testing and prepares for integration with a license server (Stripe/Lemon Squeezy).
This commit is contained in:
parent
8cf74ae473
commit
3b392f2f50
11 changed files with 1309 additions and 4 deletions
|
|
@ -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 () {
|
||||
|
|
|
|||
330
apps/desktop/src/main/license-service.ts
Normal file
330
apps/desktop/src/main/license-service.ts
Normal file
|
|
@ -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<void> {
|
||||
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<boolean> {
|
||||
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<LicenseStatus> {
|
||||
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 }
|
||||
}
|
||||
16
apps/desktop/src/preload/index.d.ts
vendored
16
apps/desktop/src/preload/index.d.ts
vendored
|
|
@ -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<IpcResponse<LicenseStatus>>
|
||||
activate: (request: LicenseActivationRequest) => Promise<IpcResponse<LicenseStatus>>
|
||||
deactivate: () => Promise<IpcResponse<void>>
|
||||
activateOffline: (
|
||||
key: string,
|
||||
email: string,
|
||||
type?: LicenseType,
|
||||
daysValid?: number
|
||||
) => Promise<IpcResponse<LicenseStatus>>
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
|
|||
|
|
@ -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<IpcResponse<LicenseStatus>> => ipcRenderer.invoke('license:check'),
|
||||
activate: (request: LicenseActivationRequest): Promise<IpcResponse<LicenseStatus>> =>
|
||||
ipcRenderer.invoke('license:activate', request),
|
||||
deactivate: (): Promise<IpcResponse<void>> => ipcRenderer.invoke('license:deactivate'),
|
||||
activateOffline: (
|
||||
key: string,
|
||||
email: string,
|
||||
type?: LicenseType,
|
||||
daysValid?: number
|
||||
): Promise<IpcResponse<LicenseStatus>> =>
|
||||
ipcRenderer.invoke('license:activate-offline', { key, email, type, daysValid })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Key className="size-5" />
|
||||
Activate License
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter your license key and email to activate data-peek for commercial use.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@company.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="license-key" className="text-sm font-medium">
|
||||
License Key
|
||||
</label>
|
||||
<Input
|
||||
id="license-key"
|
||||
placeholder="DP-XXXX-XXXX-XXXX-XXXX"
|
||||
value={licenseKey}
|
||||
onChange={(e) => setLicenseKey(e.target.value.toUpperCase())}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(localError || error) && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{localError || error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Don't have a license?{' '}
|
||||
<a
|
||||
href="https://data-peek.dev/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-primary hover:underline"
|
||||
>
|
||||
Purchase one
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Need to manage your license?{' '}
|
||||
<a
|
||||
href="https://data-peek.dev/dashboard"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-primary hover:underline"
|
||||
>
|
||||
License Dashboard
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleActivate} disabled={!isValid || isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Activating...
|
||||
</>
|
||||
) : (
|
||||
'Activate'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>License Settings</DialogTitle>
|
||||
<DialogDescription>Manage your data-peek license.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
{/* License Status Card */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`mt-0.5 rounded-full p-1.5 ${
|
||||
isExpired
|
||||
? 'bg-amber-500/10 text-amber-500'
|
||||
: isExpiringSoon
|
||||
? 'bg-amber-500/10 text-amber-500'
|
||||
: 'bg-green-500/10 text-green-500'
|
||||
}`}
|
||||
>
|
||||
{isExpired || isExpiringSoon ? (
|
||||
<AlertCircle className="size-4" />
|
||||
) : (
|
||||
<Check className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">
|
||||
{isExpired
|
||||
? 'License Expired'
|
||||
: `${status.type === 'team' ? 'Team' : 'Individual'} License Active`}
|
||||
</h3>
|
||||
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{status.email && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Email</span>
|
||||
<span>{status.email}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Type</span>
|
||||
<span className="capitalize">{status.type}</span>
|
||||
</div>
|
||||
|
||||
{status.expiresAt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{isExpired ? 'Expired' : 'Renews'}
|
||||
</span>
|
||||
<span>
|
||||
{formatDate(status.expiresAt)}
|
||||
{!isExpired && status.daysUntilExpiry && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({status.daysUntilExpiry} days)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.devicesUsed !== undefined && status.devicesAllowed !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Devices</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Monitor className="size-3" />
|
||||
{status.devicesUsed} of {status.devicesAllowed} used
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpired && (
|
||||
<div className="mt-4 rounded-md bg-amber-500/10 p-3 text-sm text-amber-500">
|
||||
Your subscription has ended.
|
||||
{status.perpetualVersion && (
|
||||
<>
|
||||
{' '}
|
||||
You can continue using data-peek v{status.perpetualVersion} for commercial use.
|
||||
</>
|
||||
)}{' '}
|
||||
Renew to access the latest updates.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Perpetual Version Info */}
|
||||
{status.perpetualVersion && !isExpired && (
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
If your subscription ends, you can continue using data-peek v{status.perpetualVersion}{' '}
|
||||
(your perpetual version) for commercial use.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() => window.open('https://data-peek.dev/dashboard', '_blank')}
|
||||
>
|
||||
Manage License
|
||||
<ExternalLink className="size-3" />
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-2 text-muted-foreground hover:text-destructive"
|
||||
onClick={handleDeactivate}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="size-4" />
|
||||
)}
|
||||
Deactivate
|
||||
</Button>
|
||||
|
||||
{isExpired && (
|
||||
<Button
|
||||
onClick={() => window.open('https://data-peek.dev/pricing', '_blank')}
|
||||
className="gap-2"
|
||||
>
|
||||
Renew License
|
||||
<ExternalLink className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1.5 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<User className="size-3" />
|
||||
Personal Use
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Using data-peek for personal, non-commercial use.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Click to activate a license for commercial use.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Expired license
|
||||
if (status.daysUntilExpiry !== null && status.daysUntilExpiry <= 0) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1.5 px-2 text-xs text-amber-500 hover:text-amber-400"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<AlertCircle className="size-3" />
|
||||
License Expired
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Your license has expired.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{status.perpetualVersion
|
||||
? `You can continue using v${status.perpetualVersion} for commercial use.`
|
||||
: 'Click to renew your license.'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Expiring soon (within 14 days)
|
||||
if (status.daysUntilExpiry !== null && status.daysUntilExpiry <= 14) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1.5 px-2 text-xs text-amber-500 hover:text-amber-400"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<AlertCircle className="size-3" />
|
||||
{status.daysUntilExpiry} days left
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Your license expires in {status.daysUntilExpiry} days.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Click to manage your license.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Active license
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1.5 px-2 text-xs text-green-500 hover:text-green-400"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Check className="size-3" />
|
||||
Pro License
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{status.type === 'team' ? 'Team' : 'Individual'} license active
|
||||
{status.email && ` (${status.email})`}
|
||||
</p>
|
||||
{status.daysUntilExpiry && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Renews in {status.daysUntilExpiry} days
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -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() {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="titlebar-no-drag ml-auto px-3">
|
||||
<div className="titlebar-no-drag ml-auto flex items-center gap-2 px-3">
|
||||
<LicenseStatusIndicator />
|
||||
<Separator orientation="vertical" className="data-[orientation=vertical]:h-4" />
|
||||
<NavActions />
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -101,6 +112,10 @@ function RootLayout() {
|
|||
|
||||
{/* Global Connection Picker */}
|
||||
<ConnectionPicker open={isConnectionPickerOpen} onOpenChange={setIsConnectionPickerOpen} />
|
||||
|
||||
{/* License Modals */}
|
||||
<LicenseActivationModal open={isActivationModalOpen} onOpenChange={closeActivationModal} />
|
||||
<LicenseSettingsModal open={isSettingsModalOpen} onOpenChange={closeSettingsModal} />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex flex-1 flex-col p-6 overflow-auto">
|
||||
|
|
@ -176,6 +194,46 @@ function SettingsPage() {
|
|||
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||
</div>
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{/* License */}
|
||||
<div className="rounded-lg border border-border/50 bg-card p-4">
|
||||
<h2 className="text-lg font-medium mb-2">License</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Manage your data-peek license for commercial use.
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{licenseStatus?.type === 'personal' ? (
|
||||
<>
|
||||
<span className="size-2 rounded-full bg-muted-foreground" />
|
||||
<span className="text-sm">Personal Use (Free)</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className={`size-2 rounded-full ${
|
||||
licenseStatus?.daysUntilExpiry && licenseStatus.daysUntilExpiry <= 0
|
||||
? 'bg-amber-500'
|
||||
: 'bg-green-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm capitalize">
|
||||
{licenseStatus?.type} License
|
||||
{licenseStatus?.email && ` (${licenseStatus.email})`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
licenseStatus?.type === 'personal' ? openActivationModal() : openSettingsModal()
|
||||
}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{licenseStatus?.type === 'personal' ? 'Activate License' : 'Manage License'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Appearance */}
|
||||
<div className="rounded-lg border border-border/50 bg-card p-4">
|
||||
<h2 className="text-lg font-medium mb-2">Appearance</h2>
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ export * from './connection-store'
|
|||
export * from './query-store'
|
||||
export * from './tab-store'
|
||||
export * from './ddl-store'
|
||||
export * from './license-store'
|
||||
|
|
|
|||
207
apps/desktop/src/renderer/src/stores/license-store.ts
Normal file
207
apps/desktop/src/renderer/src/stores/license-store.ts
Normal file
|
|
@ -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<void>
|
||||
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<LicenseState>((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
|
||||
}
|
||||
}))
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue