mirror of
https://github.com/Rohithgilla12/data-peek
synced 2026-05-24 09:58: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 { setupContextMenu } from './context-menu'
|
||||||
import { getWindowState, trackWindowState } from './window-state'
|
import { getWindowState, trackWindowState } from './window-state'
|
||||||
import { getAdapter } from './db-adapter'
|
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
|
// electron-store v11 is ESM-only, use dynamic import
|
||||||
type StoreType = import('electron-store').default<{ connections: ConnectionConfig[] }>
|
type StoreType = import('electron-store').default<{ connections: ConnectionConfig[] }>
|
||||||
|
|
@ -123,6 +131,9 @@ app.whenReady().then(async () => {
|
||||||
// Initialize electron-store (ESM module)
|
// Initialize electron-store (ESM module)
|
||||||
await initStore()
|
await initStore()
|
||||||
|
|
||||||
|
// Initialize license store
|
||||||
|
await initLicenseStore()
|
||||||
|
|
||||||
// Create native application menu
|
// Create native application menu
|
||||||
createMenu()
|
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()
|
await createWindow()
|
||||||
|
|
||||||
app.on('activate', function () {
|
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,
|
AlterTableBatch,
|
||||||
DDLResult,
|
DDLResult,
|
||||||
SequenceInfo,
|
SequenceInfo,
|
||||||
CustomTypeInfo
|
CustomTypeInfo,
|
||||||
|
LicenseStatus,
|
||||||
|
LicenseActivationRequest,
|
||||||
|
LicenseType
|
||||||
} from '@shared/index'
|
} from '@shared/index'
|
||||||
|
|
||||||
interface DataPeekApi {
|
interface DataPeekApi {
|
||||||
|
|
@ -64,6 +67,17 @@ interface DataPeekApi {
|
||||||
onFormatSql: (callback: () => void) => () => void
|
onFormatSql: (callback: () => void) => () => void
|
||||||
onClearResults: (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 {
|
declare global {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,10 @@ import type {
|
||||||
AlterTableBatch,
|
AlterTableBatch,
|
||||||
DDLResult,
|
DDLResult,
|
||||||
SequenceInfo,
|
SequenceInfo,
|
||||||
CustomTypeInfo
|
CustomTypeInfo,
|
||||||
|
LicenseStatus,
|
||||||
|
LicenseActivationRequest,
|
||||||
|
LicenseType
|
||||||
} from '@shared/index'
|
} from '@shared/index'
|
||||||
|
|
||||||
// Custom APIs for renderer
|
// Custom APIs for renderer
|
||||||
|
|
@ -104,6 +107,20 @@ const api = {
|
||||||
ipcRenderer.on('menu:clear-results', handler)
|
ipcRenderer.on('menu:clear-results', handler)
|
||||||
return () => ipcRenderer.removeListener('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 { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
|
||||||
import { TabContainer } from '@/components/tab-container'
|
import { TabContainer } from '@/components/tab-container'
|
||||||
import { ConnectionPicker } from '@/components/connection-picker'
|
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'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
// Root Layout
|
// Root Layout
|
||||||
|
|
@ -26,6 +29,12 @@ function RootLayout() {
|
||||||
const setConnectionStatus = useConnectionStore((s) => s.setConnectionStatus)
|
const setConnectionStatus = useConnectionStore((s) => s.setConnectionStatus)
|
||||||
const [isConnectionPickerOpen, setIsConnectionPickerOpen] = useState(false)
|
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
|
// Handle connection switching
|
||||||
const handleSelectConnection = useCallback(
|
const handleSelectConnection = useCallback(
|
||||||
(connectionId: string) => {
|
(connectionId: string) => {
|
||||||
|
|
@ -90,7 +99,9 @@ function RootLayout() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 />
|
<NavActions />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -101,6 +112,10 @@ function RootLayout() {
|
||||||
|
|
||||||
{/* Global Connection Picker */}
|
{/* Global Connection Picker */}
|
||||||
<ConnectionPicker open={isConnectionPickerOpen} onOpenChange={setIsConnectionPickerOpen} />
|
<ConnectionPicker open={isConnectionPickerOpen} onOpenChange={setIsConnectionPickerOpen} />
|
||||||
|
|
||||||
|
{/* License Modals */}
|
||||||
|
<LicenseActivationModal open={isActivationModalOpen} onOpenChange={closeActivationModal} />
|
||||||
|
<LicenseSettingsModal open={isSettingsModalOpen} onOpenChange={closeSettingsModal} />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -166,6 +181,9 @@ function ShortcutRow({ keys, description }: { keys: string[]; description: strin
|
||||||
// Settings Page
|
// Settings Page
|
||||||
function SettingsPage() {
|
function SettingsPage() {
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
const licenseStatus = useLicenseStore((s) => s.status)
|
||||||
|
const openSettingsModal = useLicenseStore((s) => s.openSettingsModal)
|
||||||
|
const openActivationModal = useLicenseStore((s) => s.openActivationModal)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col p-6 overflow-auto">
|
<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>
|
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6 max-w-2xl">
|
<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 */}
|
{/* Appearance */}
|
||||||
<div className="rounded-lg border border-border/50 bg-card p-4">
|
<div className="rounded-lg border border-border/50 bg-card p-4">
|
||||||
<h2 className="text-lg font-medium mb-2">Appearance</h2>
|
<h2 className="text-lg font-medium mb-2">Appearance</h2>
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@ export * from './connection-store'
|
||||||
export * from './query-store'
|
export * from './query-store'
|
||||||
export * from './tab-store'
|
export * from './tab-store'
|
||||||
export * from './ddl-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) */
|
/** Enum values (for enum types) */
|
||||||
values?: string[];
|
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