mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
feat: add Community/Pro licensing, fleet view, and UI reorganization (#145)
* feat: add license gating system with Lemon Squeezy integration Add Community/Pro tier infrastructure: - LicenseService singleton with Lemon Squeezy license API integration - /api/license endpoints (GET info, POST activate/deactivate/validate) - 14-day Pro trial activated automatically on first boot - 72-hour periodic validation with 30-day offline grace period - LicenseContext provider for frontend tier awareness - License settings tab with activation UI and status display - ProBadge and ProGate reusable components for feature gating - requirePro per-route guard for backend Pro-only endpoints - Proxy bypass for /api/license routes (local-only, never proxied) * feat: add user profile dropdown and reorganize top navigation - Create UserProfileDropdown component with settings, billing, theme toggle (System/Light/Dark), documentation links, and logout button - Remove logout button from sidebar header - Remove standalone settings button from top bar - Move theme toggle from Settings modal to profile dropdown - Inject app version via Vite define from root package.json - Add globals.d.ts for __APP_VERSION__ type declaration * refactor(settings): remove appearance tab from settings modal Theme toggle was moved to the User Profile Dropdown in the previous commit. Remove the now-redundant Appearance section, its nav button, and the unused theme/setTheme props from SettingsModal. * feat: add fleet view dashboard and about settings section Fleet Overview: aggregates all nodes into a card grid showing status, container counts, CPU/RAM/disk usage bars. Pro tier unlocks stack drill-down with auto-refresh (30s). Backend endpoints /api/fleet/overview and /api/fleet/node/:nodeId/stacks query nodes in parallel. About section in Settings: displays version, license tier, status, instance ID, and links to docs/changelog/issues. Sidebar perf fix: stack status fetches now run in parallel via Promise.allSettled instead of sequential for-loop, significantly reducing load time for nodes with many stacks. Also removes version number from User Profile Dropdown (now in About). * fix(ci): resolve Docker build and E2E test failures - Copy root package.json into frontend build stage so vite.config.ts can read the app version during Docker multi-stage build. - Update auth E2E test: logout button moved into User Profile Dropdown. - Update nodes E2E test: Settings button moved into User Profile Dropdown.
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
import crypto from 'crypto';
|
||||
import axios from 'axios';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
|
||||
export type LicenseTier = 'community' | 'pro';
|
||||
export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disabled';
|
||||
|
||||
export interface LicenseInfo {
|
||||
tier: LicenseTier;
|
||||
status: LicenseStatus;
|
||||
customerName: string | null;
|
||||
productName: string | null;
|
||||
maskedKey: string | null;
|
||||
validUntil: string | null;
|
||||
trialDaysRemaining: number | null;
|
||||
instanceId: string;
|
||||
}
|
||||
|
||||
interface LemonSqueezyActivationResponse {
|
||||
activated: boolean;
|
||||
error?: string;
|
||||
license_key?: {
|
||||
id: number;
|
||||
status: string;
|
||||
key: string;
|
||||
activation_limit: number;
|
||||
activation_usage: number;
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
};
|
||||
instance?: {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
};
|
||||
meta?: {
|
||||
store_id: number;
|
||||
order_id: number;
|
||||
order_item_id: number;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
variant_id: number;
|
||||
variant_name: string;
|
||||
customer_id: number;
|
||||
customer_name: string;
|
||||
customer_email: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LemonSqueezyValidationResponse {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
license_key?: {
|
||||
id: number;
|
||||
status: string;
|
||||
key: string;
|
||||
activation_limit: number;
|
||||
activation_usage: number;
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
};
|
||||
meta?: {
|
||||
store_id: number;
|
||||
order_id: number;
|
||||
order_item_id: number;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
variant_id: number;
|
||||
variant_name: string;
|
||||
customer_id: number;
|
||||
customer_name: string;
|
||||
customer_email: string;
|
||||
};
|
||||
}
|
||||
|
||||
const LEMON_SQUEEZY_API = 'https://api.lemonsqueezy.com/v1/licenses';
|
||||
const VALIDATION_INTERVAL_MS = 72 * 60 * 60 * 1000; // 72 hours
|
||||
const OFFLINE_GRACE_DAYS = 30;
|
||||
const TRIAL_DURATION_DAYS = 14;
|
||||
|
||||
export class LicenseService {
|
||||
private static instance: LicenseService;
|
||||
private validationTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): LicenseService {
|
||||
if (!LicenseService.instance) {
|
||||
LicenseService.instance = new LicenseService();
|
||||
}
|
||||
return LicenseService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the license service on startup.
|
||||
* Ensures an instance ID exists and starts the 14-day trial on first boot.
|
||||
* Also starts periodic validation for active licenses.
|
||||
*/
|
||||
public initialize(): void {
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
// Generate persistent instance ID on first boot
|
||||
if (!db.getSystemState('instance_id')) {
|
||||
db.setSystemState('instance_id', crypto.randomUUID());
|
||||
}
|
||||
|
||||
// Start 14-day trial on first boot (no license_status means fresh install)
|
||||
const currentStatus = db.getSystemState('license_status');
|
||||
if (!currentStatus) {
|
||||
const trialEnd = new Date();
|
||||
trialEnd.setDate(trialEnd.getDate() + TRIAL_DURATION_DAYS);
|
||||
db.setSystemState('license_status', 'trial');
|
||||
db.setSystemState('license_valid_until', trialEnd.toISOString());
|
||||
console.log(`[License] 14-day Pro trial started. Expires: ${trialEnd.toISOString()}`);
|
||||
}
|
||||
|
||||
this.startPeriodicValidation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current license tier. Synchronous — reads from cached DB state only.
|
||||
*/
|
||||
public getTier(): LicenseTier {
|
||||
const db = DatabaseService.getInstance();
|
||||
const status = db.getSystemState('license_status') as LicenseStatus | null;
|
||||
|
||||
if (!status || status === 'community') return 'community';
|
||||
if (status === 'disabled' || status === 'expired') return 'community';
|
||||
|
||||
if (status === 'trial') {
|
||||
const validUntil = db.getSystemState('license_valid_until');
|
||||
if (validUntil && new Date(validUntil) > new Date()) {
|
||||
return 'pro';
|
||||
}
|
||||
// Trial expired — update status
|
||||
db.setSystemState('license_status', 'community');
|
||||
return 'community';
|
||||
}
|
||||
|
||||
if (status === 'active') {
|
||||
// Check offline grace period
|
||||
const lastValidated = db.getSystemState('license_last_validated');
|
||||
if (lastValidated) {
|
||||
const daysSinceValidation = (Date.now() - parseInt(lastValidated, 10)) / (1000 * 60 * 60 * 24);
|
||||
if (daysSinceValidation > OFFLINE_GRACE_DAYS) {
|
||||
console.warn('[License] Offline grace period exceeded. Degrading to community.');
|
||||
db.setSystemState('license_status', 'community');
|
||||
return 'community';
|
||||
}
|
||||
}
|
||||
|
||||
// Check expiry for subscription licenses
|
||||
const validUntil = db.getSystemState('license_valid_until');
|
||||
if (validUntil && new Date(validUntil) < new Date()) {
|
||||
db.setSystemState('license_status', 'expired');
|
||||
return 'community';
|
||||
}
|
||||
|
||||
return 'pro';
|
||||
}
|
||||
|
||||
return 'community';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full license information for the API response.
|
||||
*/
|
||||
public getLicenseInfo(): LicenseInfo {
|
||||
const db = DatabaseService.getInstance();
|
||||
const status = (db.getSystemState('license_status') || 'community') as LicenseStatus;
|
||||
const key = db.getSystemState('license_key');
|
||||
const validUntil = db.getSystemState('license_valid_until');
|
||||
const instanceId = db.getSystemState('instance_id') || '';
|
||||
|
||||
let trialDaysRemaining: number | null = null;
|
||||
if (status === 'trial' && validUntil) {
|
||||
const remaining = (new Date(validUntil).getTime() - Date.now()) / (1000 * 60 * 60 * 24);
|
||||
trialDaysRemaining = Math.max(0, Math.ceil(remaining));
|
||||
}
|
||||
|
||||
return {
|
||||
tier: this.getTier(),
|
||||
status,
|
||||
customerName: db.getSystemState('license_customer_name'),
|
||||
productName: db.getSystemState('license_product_name'),
|
||||
maskedKey: key ? `****-****-****-${key.slice(-4)}` : null,
|
||||
validUntil,
|
||||
trialDaysRemaining,
|
||||
instanceId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a license key with Lemon Squeezy.
|
||||
*/
|
||||
public async activate(licenseKey: string): Promise<{ success: boolean; error?: string }> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const instanceId = db.getSystemState('instance_id') || crypto.randomUUID();
|
||||
|
||||
try {
|
||||
const response = await axios.post<LemonSqueezyActivationResponse>(
|
||||
`${LEMON_SQUEEZY_API}/activate`,
|
||||
{
|
||||
license_key: licenseKey,
|
||||
instance_name: instanceId,
|
||||
},
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
if (!data.activated) {
|
||||
return { success: false, error: data.error || 'Activation failed' };
|
||||
}
|
||||
|
||||
// Store license data
|
||||
db.setSystemState('license_key', licenseKey);
|
||||
db.setSystemState('license_instance_id', data.instance?.id || '');
|
||||
db.setSystemState('license_status', 'active');
|
||||
db.setSystemState('license_last_validated', Date.now().toString());
|
||||
|
||||
if (data.license_key?.expires_at) {
|
||||
db.setSystemState('license_valid_until', data.license_key.expires_at);
|
||||
} else {
|
||||
// Lifetime license — no expiry
|
||||
db.setSystemState('license_valid_until', '');
|
||||
}
|
||||
|
||||
if (data.meta?.customer_name) {
|
||||
db.setSystemState('license_customer_name', data.meta.customer_name);
|
||||
}
|
||||
if (data.meta?.product_name) {
|
||||
db.setSystemState('license_product_name', data.meta.product_name);
|
||||
}
|
||||
|
||||
console.log('[License] Activated successfully.');
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
// Handle Lemon Squeezy error responses (4xx)
|
||||
if (axios.isAxiosError(err) && err.response?.data) {
|
||||
const errorMsg = err.response.data.error || 'Activation failed';
|
||||
console.error('[License] Activation error:', errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
console.error('[License] Activation network error:', (err as Error).message);
|
||||
return { success: false, error: 'Unable to reach license server. Check your internet connection.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate the current license, reverting to community.
|
||||
*/
|
||||
public async deactivate(): Promise<{ success: boolean; error?: string }> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const licenseKey = db.getSystemState('license_key');
|
||||
const instanceId = db.getSystemState('license_instance_id');
|
||||
|
||||
if (licenseKey && instanceId) {
|
||||
try {
|
||||
await axios.post(
|
||||
`${LEMON_SQUEEZY_API}/deactivate`,
|
||||
{
|
||||
license_key: licenseKey,
|
||||
instance_id: instanceId,
|
||||
},
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[License] Deactivation API call failed (proceeding with local cleanup):', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all license state
|
||||
const keysToRemove = [
|
||||
'license_key',
|
||||
'license_instance_id',
|
||||
'license_status',
|
||||
'license_valid_until',
|
||||
'license_last_validated',
|
||||
'license_customer_name',
|
||||
'license_product_name',
|
||||
];
|
||||
for (const key of keysToRemove) {
|
||||
db.setSystemState(key, '');
|
||||
}
|
||||
db.setSystemState('license_status', 'community');
|
||||
|
||||
console.log('[License] Deactivated. Reverted to Community tier.');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the current license against Lemon Squeezy.
|
||||
*/
|
||||
public async validate(): Promise<{ success: boolean; error?: string }> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const licenseKey = db.getSystemState('license_key');
|
||||
const instanceId = db.getSystemState('license_instance_id');
|
||||
|
||||
if (!licenseKey || !instanceId) {
|
||||
return { success: false, error: 'No active license to validate' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post<LemonSqueezyValidationResponse>(
|
||||
`${LEMON_SQUEEZY_API}/validate`,
|
||||
{
|
||||
license_key: licenseKey,
|
||||
instance_id: instanceId,
|
||||
},
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
db.setSystemState('license_last_validated', Date.now().toString());
|
||||
|
||||
if (!data.valid) {
|
||||
// License revoked or invalid
|
||||
db.setSystemState('license_status', 'disabled');
|
||||
console.warn('[License] Validation failed: license is no longer valid.');
|
||||
return { success: false, error: data.error || 'License is no longer valid' };
|
||||
}
|
||||
|
||||
// Update status based on key status
|
||||
const keyStatus = data.license_key?.status;
|
||||
if (keyStatus === 'expired') {
|
||||
db.setSystemState('license_status', 'expired');
|
||||
return { success: false, error: 'License has expired' };
|
||||
}
|
||||
if (keyStatus === 'disabled') {
|
||||
db.setSystemState('license_status', 'disabled');
|
||||
return { success: false, error: 'License has been disabled' };
|
||||
}
|
||||
|
||||
db.setSystemState('license_status', 'active');
|
||||
|
||||
// Update expiry if changed
|
||||
if (data.license_key?.expires_at) {
|
||||
db.setSystemState('license_valid_until', data.license_key.expires_at);
|
||||
}
|
||||
|
||||
// Update customer/product info if available
|
||||
if (data.meta?.customer_name) {
|
||||
db.setSystemState('license_customer_name', data.meta.customer_name);
|
||||
}
|
||||
if (data.meta?.product_name) {
|
||||
db.setSystemState('license_product_name', data.meta.product_name);
|
||||
}
|
||||
|
||||
console.log('[License] Validation successful.');
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
// Network failure — don't change status, just log
|
||||
console.warn('[License] Validation network error (keeping current status):', (err as Error).message);
|
||||
return { success: false, error: 'Unable to reach license server' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic background validation every 72 hours.
|
||||
*/
|
||||
public startPeriodicValidation(): void {
|
||||
if (this.validationTimer) {
|
||||
clearInterval(this.validationTimer);
|
||||
}
|
||||
|
||||
this.validationTimer = setInterval(async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const status = db.getSystemState('license_status');
|
||||
// Only validate active licenses (not trial, community, etc.)
|
||||
if (status === 'active') {
|
||||
await this.validate();
|
||||
}
|
||||
}, VALIDATION_INTERVAL_MS);
|
||||
|
||||
// Run an initial validation on startup for active licenses (after a short delay)
|
||||
const db = DatabaseService.getInstance();
|
||||
if (db.getSystemState('license_status') === 'active') {
|
||||
setTimeout(() => this.validate(), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup on shutdown.
|
||||
*/
|
||||
public destroy(): void {
|
||||
if (this.validationTimer) {
|
||||
clearInterval(this.validationTimer);
|
||||
this.validationTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user