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:
Claude 2025-11-29 03:19:40 +00:00
parent 8cf74ae473
commit 3b392f2f50
No known key found for this signature in database
11 changed files with 1309 additions and 4 deletions

View file

@ -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 () {

View 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 }
}

View file

@ -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 {

View file

@ -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 })
}
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>

View file

@ -2,3 +2,4 @@ export * from './connection-store'
export * from './query-store'
export * from './tab-store'
export * from './ddl-store'
export * from './license-store'

View 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
}
}))

View file

@ -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;
}