From 4f26f22ccef89441be032a266723cf6fca0a488a Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 25 Mar 2026 08:32:07 -0400 Subject: [PATCH] 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. --- CHANGELOG.md | 7 + Dockerfile | 2 + backend/src/index.ts | 294 ++++++++++++- backend/src/services/LicenseService.ts | 391 ++++++++++++++++++ e2e/auth.spec.ts | 6 +- e2e/nodes.spec.ts | 3 +- frontend/src/App.tsx | 5 +- frontend/src/components/EditorLayout.tsx | 82 ++-- frontend/src/components/FleetView.tsx | 336 +++++++++++++++ frontend/src/components/ProBadge.tsx | 15 + frontend/src/components/ProGate.tsx | 73 ++++ frontend/src/components/SettingsModal.tsx | 265 +++++++++--- .../src/components/UserProfileDropdown.tsx | 144 +++++++ frontend/src/context/LicenseContext.tsx | 102 +++++ frontend/src/globals.d.ts | 1 + frontend/vite.config.ts | 6 + 16 files changed, 1636 insertions(+), 96 deletions(-) create mode 100644 backend/src/services/LicenseService.ts create mode 100644 frontend/src/components/FleetView.tsx create mode 100644 frontend/src/components/ProBadge.tsx create mode 100644 frontend/src/components/ProGate.tsx create mode 100644 frontend/src/components/UserProfileDropdown.tsx create mode 100644 frontend/src/context/LicenseContext.tsx create mode 100644 frontend/src/globals.d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b6f9379..5553d4f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +* **licensing:** Community/Pro tier system with Lemon Squeezy integration. Includes `LicenseService` backend singleton, `/api/license` endpoints (activate, deactivate, validate), `LicenseContext` frontend provider, License settings tab, `ProBadge` and `ProGate` reusable components, and 14-day Pro trial on first install. License data stored in `system_state` table with 72-hour periodic validation and 30-day offline grace period. +* **fleet:** Fleet Overview dashboard showing all nodes in a card grid with status, container counts, CPU/RAM/disk usage bars. Pro tier unlocks stack drill-down, auto-refresh (30s), and per-node stack details via `/api/fleet/overview` and `/api/fleet/node/:nodeId/stacks` endpoints. +* **ui:** User Profile Dropdown replacing scattered Settings/LogOut buttons. Includes theme toggle (System/Light/Dark), billing link (Pro only), documentation, and version display. Appearance tab removed from Settings. +* **settings:** About section displaying version, license tier, status, instance ID, and links to docs/changelog/issues. + ### Fixed * **charts:** suppress Recharts `width(-1) height(-1)` warnings by removing conflicting `aspect-video` default and adding `minWidth={0}` + `minHeight={0}` on `ResponsiveContainer`. diff --git a/Dockerfile b/Dockerfile index 4a4a2076..8e650ab3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,8 @@ RUN npm config set fetch-retry-maxtimeout 120000 && \ npm install COPY frontend/ ./ +# vite.config.ts reads the root package.json for the app version +COPY package.json /app/package.json RUN npm run build # Stage 2: Compile TypeScript diff --git a/backend/src/index.ts b/backend/src/index.ts index e99513c6..836e9004 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -18,13 +18,14 @@ import httpProxy from 'http-proxy'; import { createProxyMiddleware } from 'http-proxy-middleware'; import path from 'path'; import { HostTerminalService } from './services/HostTerminalService'; -import { DatabaseService } from './services/DatabaseService'; +import { DatabaseService, Node } from './services/DatabaseService'; import { NotificationService } from './services/NotificationService'; import { MonitorService } from './services/MonitorService'; import { ImageUpdateService } from './services/ImageUpdateService'; import { templateService } from './services/TemplateService'; import { ErrorParser } from './utils/ErrorParser'; import { NodeRegistry } from './services/NodeRegistry'; +import { LicenseService } from './services/LicenseService'; import { isValidStackName, isValidRemoteUrl } from './utils/validation'; import YAML from 'yaml'; import fs, { promises as fsPromises } from 'fs'; @@ -139,7 +140,9 @@ app.use((req: Request, res: Response, next: NextFunction): void => { node?.type === 'remote' && req.path.startsWith('/api/') && !req.path.startsWith('/api/auth/') && - !req.path.startsWith('/api/nodes') + !req.path.startsWith('/api/nodes') && + !req.path.startsWith('/api/license') && + !req.path.startsWith('/api/fleet') ) { // Preserve body stream for proxy piping next(); @@ -169,7 +172,9 @@ const nodeContextMiddleware = (req: Request, res: Response, next: NextFunction) if ( req.path.startsWith('/api/') && !req.path.startsWith('/api/auth/') && - !req.path.startsWith('/api/nodes') + !req.path.startsWith('/api/nodes') && + !req.path.startsWith('/api/license') && + !req.path.startsWith('/api/fleet') ) { const node = DatabaseService.getInstance().getNode(req.nodeId); if (!node) { @@ -422,6 +427,283 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => { authMiddleware(req, res, next); }); +// --- License Routes (local-only, never proxied) --- + +// Pro feature guard: returns false and sends 403 if not Pro tier. +const requirePro = (_req: Request, res: Response): boolean => { + if (LicenseService.getInstance().getTier() !== 'pro') { + res.status(403).json({ error: 'This feature requires Sencho Pro.', code: 'PRO_REQUIRED' }); + return false; + } + return true; +}; + +app.get('/api/license', (_req: Request, res: Response): void => { + try { + const info = LicenseService.getInstance().getLicenseInfo(); + res.json(info); + } catch (error) { + console.error('[License] Error getting license info:', error); + res.status(500).json({ error: 'Failed to retrieve license information' }); + } +}); + +app.post('/api/license/activate', async (req: Request, res: Response): Promise => { + try { + const { license_key } = req.body; + if (!license_key || typeof license_key !== 'string') { + res.status(400).json({ error: 'A valid license key is required' }); + return; + } + const result = await LicenseService.getInstance().activate(license_key.trim()); + if (result.success) { + res.json({ success: true, license: LicenseService.getInstance().getLicenseInfo() }); + } else { + res.status(400).json({ error: result.error }); + } + } catch (error) { + console.error('[License] Activation error:', error); + res.status(500).json({ error: 'License activation failed' }); + } +}); + +app.post('/api/license/deactivate', async (_req: Request, res: Response): Promise => { + try { + const result = await LicenseService.getInstance().deactivate(); + if (result.success) { + res.json({ success: true, license: LicenseService.getInstance().getLicenseInfo() }); + } else { + res.status(500).json({ error: result.error }); + } + } catch (error) { + console.error('[License] Deactivation error:', error); + res.status(500).json({ error: 'License deactivation failed' }); + } +}); + +app.post('/api/license/validate', async (_req: Request, res: Response): Promise => { + try { + const result = await LicenseService.getInstance().validate(); + res.json({ ...result, license: LicenseService.getInstance().getLicenseInfo() }); + } catch (error) { + console.error('[License] Validation error:', error); + res.status(500).json({ error: 'License validation failed' }); + } +}); + +// --- Fleet Overview (local-only, aggregates all nodes) --- + +interface FleetNodeOverview { + id: number; + name: string; + type: 'local' | 'remote'; + status: 'online' | 'offline' | 'unknown'; + stats: { + active: number; + managed: number; + unmanaged: number; + exited: number; + total: number; + } | null; + systemStats: { + cpu: { usage: string; cores: number }; + memory: { total: number; used: number; free: number; usagePercent: string }; + disk: { total: number; used: number; free: number; usagePercent: string } | null; + } | null; + stacks: string[] | null; +} + +app.get('/api/fleet/overview', async (_req: Request, res: Response): Promise => { + try { + const db = DatabaseService.getInstance(); + const nodes = db.getNodes(); + + const results = await Promise.allSettled( + nodes.map(async (node): Promise => { + if (node.type === 'remote') { + return fetchRemoteNodeOverview(node); + } + return fetchLocalNodeOverview(node); + }) + ); + + const overview: FleetNodeOverview[] = results.map((result, i) => { + if (result.status === 'fulfilled') return result.value; + console.error(`[Fleet] Failed to fetch node ${nodes[i].name}:`, result.reason); + return { + id: nodes[i].id, + name: nodes[i].name, + type: nodes[i].type, + status: 'offline' as const, + stats: null, + systemStats: null, + stacks: null, + }; + }); + + res.json(overview); + } catch (error) { + console.error('[Fleet] Overview error:', error); + res.status(500).json({ error: 'Failed to fetch fleet overview' }); + } +}); + +// Pro-gated: detailed stack info per node +app.get('/api/fleet/node/:nodeId/stacks', async (req: Request, res: Response): Promise => { + if (!requirePro(req, res)) return; + + try { + const nodeId = parseInt(req.params.nodeId as string, 10); + const node = DatabaseService.getInstance().getNode(nodeId); + if (!node) { + res.status(404).json({ error: 'Node not found' }); + return; + } + + if (node.type === 'remote') { + if (!node.api_url || !node.api_token) { + res.status(503).json({ error: 'Remote node not configured' }); + return; + } + const response = await fetch(`${node.api_url.replace(/\/$/, '')}/api/stacks`, { + headers: { Authorization: `Bearer ${node.api_token}` }, + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) { + res.status(502).json({ error: 'Failed to fetch stacks from remote node' }); + return; + } + const stacks = await response.json(); + res.json(stacks); + return; + } + + const stacks = await FileSystemService.getInstance(nodeId).getStacks(); + res.json(stacks); + } catch (error) { + console.error('[Fleet] Node stacks error:', error); + res.status(500).json({ error: 'Failed to fetch node stacks' }); + } +}); + +async function fetchLocalNodeOverview(node: Node): Promise { + try { + const composeDir = path.resolve(NodeRegistry.getInstance().getComposeDir(node.id)); + const [allContainers, stacks, currentLoad, mem, fsSize] = await Promise.all([ + DockerController.getInstance(node.id).getAllContainers(), + FileSystemService.getInstance(node.id).getStacks(), + si.currentLoad(), + si.mem(), + si.fsSize(), + ]); + + const isManagedByComposeDir = (c: any): boolean => { + const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir']; + if (!workingDir) return false; + const resolved = path.resolve(workingDir); + return resolved === composeDir || resolved.startsWith(composeDir + path.sep); + }; + + const active = allContainers.filter((c: any) => c.State === 'running').length; + const exited = allContainers.filter((c: any) => c.State === 'exited').length; + const total = allContainers.length; + const managed = allContainers.filter((c: any) => c.State === 'running' && isManagedByComposeDir(c)).length; + const unmanaged = allContainers.filter((c: any) => c.State === 'running' && !isManagedByComposeDir(c)).length; + + const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0]; + + return { + id: node.id, + name: node.name, + type: node.type, + status: 'online', + stats: { active, managed, unmanaged, exited, total }, + systemStats: { + cpu: { usage: currentLoad.currentLoad.toFixed(1), cores: currentLoad.cpus.length }, + memory: { + total: mem.total, + used: mem.used, + free: mem.free, + usagePercent: ((mem.used / mem.total) * 100).toFixed(1), + }, + disk: mainDisk ? { + total: mainDisk.size, + used: mainDisk.used, + free: mainDisk.available, + usagePercent: mainDisk.use ? mainDisk.use.toFixed(1) : '0', + } : null, + }, + stacks, + }; + } catch (error) { + console.error(`[Fleet] Local node ${node.name} error:`, error); + return { + id: node.id, name: node.name, type: node.type, status: 'offline', + stats: null, systemStats: null, stacks: null, + }; + } +} + +async function fetchRemoteNodeOverview(node: Node): Promise { + if (!node.api_url || !node.api_token) { + return { + id: node.id, name: node.name, type: node.type, status: 'offline', + stats: null, systemStats: null, stacks: null, + }; + } + + const baseUrl = node.api_url.replace(/\/$/, ''); + const headers = { Authorization: `Bearer ${node.api_token}` }; + + try { + const [statsRes, systemStatsRes, stacksRes] = await Promise.allSettled([ + fetch(`${baseUrl}/api/stats`, { headers, signal: AbortSignal.timeout(10000) }), + fetch(`${baseUrl}/api/system/stats`, { headers, signal: AbortSignal.timeout(10000) }), + fetch(`${baseUrl}/api/stacks`, { headers, signal: AbortSignal.timeout(10000) }), + ]); + + interface RemoteSystemStats { + cpu: { usage: string; cores: number }; + memory: { total: number; used: number; free: number; usagePercent: string }; + disk?: { total: number; used: number; free: number; usagePercent: string } | null; + } + + const stats: FleetNodeOverview['stats'] | null = statsRes.status === 'fulfilled' && statsRes.value.ok + ? await statsRes.value.json() as FleetNodeOverview['stats'] : null; + const systemStatsRaw: RemoteSystemStats | null = systemStatsRes.status === 'fulfilled' && systemStatsRes.value.ok + ? await systemStatsRes.value.json() as RemoteSystemStats : null; + const stacks: string[] | null = stacksRes.status === 'fulfilled' && stacksRes.value.ok + ? await stacksRes.value.json() as string[] : null; + + const systemStats: FleetNodeOverview['systemStats'] | null = systemStatsRaw ? { + cpu: systemStatsRaw.cpu, + memory: systemStatsRaw.memory, + disk: systemStatsRaw.disk ? { + total: systemStatsRaw.disk.total, + used: systemStatsRaw.disk.used, + free: systemStatsRaw.disk.free, + usagePercent: systemStatsRaw.disk.usagePercent, + } : null, + } : null; + + return { + id: node.id, + name: node.name, + type: node.type, + status: stats || systemStats ? 'online' : 'offline', + stats, + systemStats, + stacks, + }; + } catch (error) { + console.error(`[Fleet] Remote node ${node.name} error:`, error); + return { + id: node.id, name: node.name, type: node.type, status: 'offline', + stats: null, systemStats: null, stacks: null, + }; + } +} + // Remote Node HTTP Proxy - single global instance. // Previously, createProxyMiddleware was called inside the request handler on every API // call, spawning a new proxy instance (and http-proxy server) each time. This caused: @@ -497,7 +779,7 @@ const remoteNodeProxy = createProxyMiddleware({ // Intercepts all /api/ requests for remote Distributed API nodes and forwards them // to the target Sencho instance. Node management and auth routes always execute locally. app.use('/api/', (req: Request, res: Response, next: NextFunction): void => { - if (req.path.startsWith('/auth/') || req.path.startsWith('/nodes')) { + if (req.path.startsWith('/auth/') || req.path.startsWith('/nodes') || req.path.startsWith('/license') || req.path.startsWith('/fleet')) { next(); return; } @@ -2088,6 +2370,9 @@ async function startServer() { // Continue starting server even if migration fails } + // Initialize License Service (starts trial on first boot, periodic validation) + LicenseService.getInstance().initialize(); + // Start Background Watchdog MonitorService.getInstance().start(); @@ -2115,6 +2400,7 @@ const gracefulShutdown = (signal: string) => { server.close(() => { console.log('[Shutdown] HTTP server closed'); + try { LicenseService.getInstance().destroy(); } catch { /* already stopped */ } try { MonitorService.getInstance().stop(); } catch { /* already stopped */ } try { ImageUpdateService.getInstance().stop(); } catch { /* already stopped */ } try { DatabaseService.getInstance().getDb().close(); } catch { /* already closed */ } diff --git a/backend/src/services/LicenseService.ts b/backend/src/services/LicenseService.ts new file mode 100644 index 00000000..adac4f0f --- /dev/null +++ b/backend/src/services/LicenseService.ts @@ -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 | 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( + `${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( + `${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; + } + } +} diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts index b2863720..68f0eed6 100644 --- a/e2e/auth.spec.ts +++ b/e2e/auth.spec.ts @@ -49,9 +49,9 @@ test.describe('Authentication', () => { test('logout returns to the login screen', async ({ page }) => { await loginAs(page); - // The logout button renders a Lucide LogOut icon — Lucide adds a CSS class matching the icon name - const logoutBtn = page.locator('button:has(.lucide-log-out)'); - await logoutBtn.click(); + // Log Out is inside the User Profile Dropdown — open it first + await page.getByRole('button', { name: /profile/i }).click(); + await page.getByRole('button', { name: /log out/i }).click(); await page.waitForTimeout(1_000); expect(await isDashboard(page)).toBe(false); }); diff --git a/e2e/nodes.spec.ts b/e2e/nodes.spec.ts index a2251501..b6884d82 100644 --- a/e2e/nodes.spec.ts +++ b/e2e/nodes.spec.ts @@ -8,7 +8,8 @@ import { loginAs } from './helpers'; test.describe('Node management', () => { test.beforeEach(async ({ page }) => { await loginAs(page); - // Open Settings modal then navigate to the Nodes section + // Settings is inside the User Profile Dropdown — open it first + await page.getByRole('button', { name: /profile/i }).click(); await page.getByRole('button', { name: /settings/i }).click(); await page.getByRole('button', { name: /^nodes$/i }).click(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d1c03e58..fdd4f8ae 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { AuthProvider, useAuth } from './context/AuthContext'; import { NodeProvider } from './context/NodeContext'; +import { LicenseProvider } from './context/LicenseContext'; import { Login } from './components/Login'; import { Setup } from './components/Setup'; import EditorLayout from './components/EditorLayout'; @@ -33,7 +34,9 @@ function AppContent() { return ( - + + + ); } diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index a065ff77..2dcd357c 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -16,8 +16,8 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { Tabs, TabsList, TabsTrigger } from './ui/tabs'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; -import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, LogOut, ExternalLink, Bell, Settings, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server } from 'lucide-react'; -import { useAuth } from '@/context/AuthContext'; +import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar } from 'lucide-react'; +import { UserProfileDropdown } from './UserProfileDropdown'; import { apiFetch, fetchForNode } from '@/lib/api'; import { toast } from 'sonner'; import { Label } from './ui/label'; @@ -34,6 +34,7 @@ import { StackAlertSheet } from './StackAlertSheet'; import { AppStoreView } from './AppStoreView'; import { LogViewer } from './LogViewer'; import { GlobalObservabilityView } from './GlobalObservabilityView'; +import { FleetView } from './FleetView'; import { useNodes } from '@/context/NodeContext'; import type { Node } from '@/context/NodeContext'; @@ -68,7 +69,6 @@ const formatBytes = (bytes: number) => { }; export default function EditorLayout() { - const { logout } = useAuth(); const { nodes, activeNode, setActiveNode } = useNodes(); // Stable ref so notification callbacks always read the latest nodes list // without needing nodes in their dependency arrays (which would cause loops). @@ -112,7 +112,7 @@ export default function EditorLayout() { window.matchMedia('(prefers-color-scheme: dark)').matches ); const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark); - const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability'>('dashboard'); + const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet'>('dashboard'); const [isEditing, setIsEditing] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [stackStatuses, setStackStatuses] = useState({}); @@ -181,16 +181,19 @@ export default function EditorLayout() { const fileList: string[] = Array.isArray(data) ? data : []; setFiles(fileList); - // Fetch status for each stack - const statuses: StackStatus = {}; - for (const file of fileList) { - try { + // Fetch status for all stacks in parallel + const statusResults = await Promise.allSettled( + fileList.map(async (file) => { const containersRes = await apiFetch(`/stacks/${file}/containers`); const containers = await containersRes.json(); const hasRunning = Array.isArray(containers) && containers.some((c: ContainerInfo) => c.State === 'running'); - statuses[file] = hasRunning ? 'running' : (Array.isArray(containers) && containers.length > 0 ? 'exited' : 'unknown'); - } catch { - statuses[file] = 'unknown'; + return { file, status: hasRunning ? 'running' as const : (Array.isArray(containers) && containers.length > 0 ? 'exited' as const : 'unknown' as const) }; + }) + ); + const statuses: StackStatus = {}; + for (const result of statusResults) { + if (result.status === 'fulfilled') { + statuses[result.value.file] = result.value.status; } } setStackStatuses(statuses); @@ -926,26 +929,11 @@ export default function EditorLayout() { {/* Left Sidebar (Stacks) */}
{/* Branding Header */} -
+
Sencho Logo

Sencho

- - - - - - Logout - -
{/* Node Switcher */} @@ -1117,6 +1105,17 @@ export default function EditorLayout() { Home + {/* Fleet Overview Toggle */} + {/* Console Toggle */} - {/* Notifications Popover */} @@ -1244,6 +1231,13 @@ export default function EditorLayout() { + + {/* User Profile Dropdown */} + setSettingsModalOpen(true)} + />
{/* end right-side buttons */}
@@ -1537,6 +1531,14 @@ export default function EditorLayout() { ) : activeView === 'global-observability' ? ( + ) : activeView === 'fleet' ? ( + { + const node = nodes.find(n => n.id === nodeId); + if (node) { + setActiveNode(node); + setActiveView('dashboard'); + } + }} /> ) : ( )} @@ -1584,8 +1586,6 @@ export default function EditorLayout() { setSettingsModalOpen(false)} - theme={theme} - setTheme={setTheme} /> {/* Stack Alert Sheet */} diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx new file mode 100644 index 00000000..aafada4b --- /dev/null +++ b/frontend/src/components/FleetView.tsx @@ -0,0 +1,336 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Server, Cpu, MemoryStick, HardDrive, Container, RefreshCw, ChevronDown, ChevronRight, Layers, Wifi, WifiOff } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { apiFetch } from '@/lib/api'; +import { useLicense } from '@/context/LicenseContext'; +import { ProGate } from './ProGate'; + +interface FleetNodeStats { + active: number; + managed: number; + unmanaged: number; + exited: number; + total: number; +} + +interface FleetNodeSystemStats { + cpu: { usage: string; cores: number }; + memory: { total: number; used: number; free: number; usagePercent: string }; + disk: { total: number; used: number; free: number; usagePercent: string } | null; +} + +interface FleetNode { + id: number; + name: string; + type: 'local' | 'remote'; + status: 'online' | 'offline' | 'unknown'; + stats: FleetNodeStats | null; + systemStats: FleetNodeSystemStats | null; + stacks: string[] | null; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} + +function UsageBar({ percent, color }: { percent: number; color: string }) { + return ( +
+
+
+ ); +} + +function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId: number) => void }) { + const { isPro } = useLicense(); + const [expanded, setExpanded] = useState(false); + const [stacks, setStacks] = useState(node.stacks); + const [loadingStacks, setLoadingStacks] = useState(false); + + const isOnline = node.status === 'online'; + const cpuPercent = node.systemStats ? parseFloat(node.systemStats.cpu.usage) : 0; + const memPercent = node.systemStats ? parseFloat(node.systemStats.memory.usagePercent) : 0; + const diskPercent = node.systemStats?.disk ? parseFloat(node.systemStats.disk.usagePercent) : 0; + + const handleExpand = async () => { + if (!isPro) return; + const next = !expanded; + setExpanded(next); + + if (next && !stacks) { + setLoadingStacks(true); + try { + const res = await apiFetch(`/fleet/node/${node.id}/stacks`, { localOnly: true }); + if (res.ok) { + setStacks(await res.json()); + } + } catch { + // silently fail + } finally { + setLoadingStacks(false); + } + } + }; + + return ( +
+ {/* Card Header */} +
+
+
+
+ +
+
+

{node.name}

+
+ + {isOnline ? ( + <> Online + ) : ( + <> Offline + )} + + + {node.type} + +
+
+
+
+ + {/* Container Stats */} + {isOnline && node.stats && ( +
+
+
{node.stats.active}
+
Running
+
+
+
{node.stats.exited}
+
Stopped
+
+
+
{node.stacks?.length ?? '—'}
+
Stacks
+
+
+ )} + + {/* Resource Usage Bars */} + {isOnline && node.systemStats && ( +
+
+
+ + CPU + + {node.systemStats.cpu.usage}% +
+ 80 ? 'bg-red-500' : cpuPercent > 60 ? 'bg-amber-500' : 'bg-emerald-500'} /> +
+
+
+ + RAM + + {formatBytes(node.systemStats.memory.used)} / {formatBytes(node.systemStats.memory.total)} +
+ 80 ? 'bg-red-500' : memPercent > 60 ? 'bg-amber-500' : 'bg-blue-500'} /> +
+ {node.systemStats.disk && ( +
+
+ + Disk + + {formatBytes(node.systemStats.disk.used)} / {formatBytes(node.systemStats.disk.total)} +
+ 90 ? 'bg-red-500' : diskPercent > 75 ? 'bg-amber-500' : 'bg-violet-500'} /> +
+ )} +
+ )} + + {/* Offline placeholder */} + {!isOnline && ( +
+ Node unreachable +
+ )} +
+ + {/* Pro Expandable Stack List */} + {isOnline && isPro && ( +
+ + {expanded && ( +
+ {loadingStacks ? ( +
+ + +
+ ) : stacks && stacks.length > 0 ? ( +
+ {stacks.map(stack => ( + + ))} +
+ ) : ( +

No stacks found

+ )} +
+ )} +
+ )} +
+ ); +} + +interface FleetViewProps { + onNavigateToNode: (nodeId: number) => void; +} + +export function FleetView({ onNavigateToNode }: FleetViewProps) { + const [nodes, setNodes] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const { isPro } = useLicense(); + + const fetchOverview = useCallback(async (showRefresh = false) => { + if (showRefresh) setRefreshing(true); + try { + const res = await apiFetch('/fleet/overview', { localOnly: true }); + if (res.ok) { + setNodes(await res.json()); + } + } catch (error) { + console.error('Failed to fetch fleet overview:', error); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + fetchOverview(); + }, [fetchOverview]); + + // Pro: auto-refresh every 30s + useEffect(() => { + if (!isPro) return; + const interval = setInterval(() => fetchOverview(), 30000); + return () => clearInterval(interval); + }, [isPro, fetchOverview]); + + const onlineCount = nodes.filter(n => n.status === 'online').length; + const totalContainers = nodes.reduce((sum, n) => sum + (n.stats?.active ?? 0), 0); + const totalStacks = nodes.reduce((sum, n) => sum + (n.stacks?.length ?? 0), 0); + + return ( +
+ {/* Header */} +
+
+

Fleet Overview

+

+ {loading ? 'Loading...' : `${onlineCount} of ${nodes.length} nodes online · ${totalContainers} containers · ${totalStacks} stacks`} +

+
+ +
+ + {/* Loading State */} + {loading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + + +
+ + + +
+ ))} +
+ )} + + {/* Empty State */} + {!loading && nodes.length === 0 && ( +
+ +

No nodes configured

+

Add nodes in Settings to see your fleet here.

+
+ )} + + {/* Node Grid */} + {!loading && nodes.length > 0 && ( + <> +
+ {nodes.map(node => ( + + ))} +
+ + {/* Pro auto-refresh indicator */} + {isPro && ( +

+ Auto-refreshing every 30 seconds +

+ )} + + {/* Free tier upgrade prompt for advanced features */} + {!isPro && nodes.length > 0 && ( +
+ + <> + +
+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/ProBadge.tsx b/frontend/src/components/ProBadge.tsx new file mode 100644 index 00000000..3238037a --- /dev/null +++ b/frontend/src/components/ProBadge.tsx @@ -0,0 +1,15 @@ +import { Crown } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; + +interface ProBadgeProps { + className?: string; +} + +export function ProBadge({ className }: ProBadgeProps) { + return ( + + + Pro + + ); +} diff --git a/frontend/src/components/ProGate.tsx b/frontend/src/components/ProGate.tsx new file mode 100644 index 00000000..8378e729 --- /dev/null +++ b/frontend/src/components/ProGate.tsx @@ -0,0 +1,73 @@ +import { type ReactNode, useState } from 'react'; +import { Crown } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useLicense } from '@/context/LicenseContext'; + +interface ProGateProps { + children: ReactNode; + featureName?: string; +} + +const DISMISS_KEY = 'sencho-upgrade-prompt-dismissed'; +const DISMISS_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours (session-like) + +function isDismissedFromStorage(): boolean { + const dismissedAt = localStorage.getItem(DISMISS_KEY); + return !!dismissedAt && Date.now() - parseInt(dismissedAt, 10) < DISMISS_DURATION_MS; +} + +export function ProGate({ children, featureName = 'This feature' }: ProGateProps) { + const { isPro } = useLicense(); + const [dismissed, setDismissed] = useState(isDismissedFromStorage); + + if (isPro) return <>{children}; + + if (dismissed) { + return ( +
+
+ {children} +
+
+
+ + Upgrade to Pro to unlock +
+
+
+ ); + } + + return ( +
+
+ +
+
+

{featureName} requires Sencho Pro

+

+ Unlock advanced features like fleet management, viewer accounts, and more with a Sencho Pro license. +

+
+
+ + +
+
+ ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index c300e48d..5ff750a7 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -18,10 +18,12 @@ import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Shield, Activity, Bell, Palette, Moon, Sun, Monitor, Code, Server, Package, RefreshCw, Database, Info } from 'lucide-react'; +import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { NodeManager } from './NodeManager'; import { useNodes } from '@/context/NodeContext'; +import { useLicense } from '@/context/LicenseContext'; +import { ProBadge } from './ProBadge'; interface Agent { type: 'discord' | 'slack' | 'webhook'; @@ -43,15 +45,11 @@ interface PatchableSettings { log_retention_days?: string; } -type SectionId = 'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes' | 'appstore'; - -type Theme = 'light' | 'dark' | 'auto'; +type SectionId = 'account' | 'license' | 'system' | 'notifications' | 'developer' | 'nodes' | 'appstore' | 'about'; interface SettingsModalProps { isOpen: boolean; onClose: () => void; - theme: Theme; - setTheme: (theme: Theme) => void; } const DEFAULT_SETTINGS: PatchableSettings = { @@ -67,14 +65,18 @@ const DEFAULT_SETTINGS: PatchableSettings = { log_retention_days: '30', }; -export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModalProps) { +export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { const { activeNode } = useNodes(); + const { license, activate, deactivate } = useLicense(); const isRemote = activeNode?.type === 'remote'; const [activeSection, setActiveSection] = useState('account'); + const [licenseKeyInput, setLicenseKeyInput] = useState(''); + const [isActivating, setIsActivating] = useState(false); + const [isDeactivating, setIsDeactivating] = useState(false); // When switching to a remote node, reset to a node-scoped section if on a global-only one useEffect(() => { - if (isRemote && (activeSection === 'account' || activeSection === 'notifications' || activeSection === 'appearance' || activeSection === 'nodes' || activeSection === 'appstore')) { + if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'notifications' || activeSection === 'nodes' || activeSection === 'appstore')) { setActiveSection('system'); } }, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps @@ -409,6 +411,9 @@ export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModa {!isRemote && ( } label="Account" /> )} + {!isRemote && ( + } label="License" /> + )} } @@ -416,9 +421,6 @@ export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModa showDot={hasSystemChanges} /> } label="Notifications" /> - {!isRemote && ( - } label="Appearance" /> - )} } @@ -431,6 +433,7 @@ export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModa {!isRemote && ( } label="App Store" /> )} + } label="About" />
@@ -478,6 +481,151 @@ export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModa )} + {activeSection === 'license' && ( +
+
+

License

+

Manage your Sencho Pro license.

+
+ + {/* Current Tier Display */} +
+
+
+ {license?.tier === 'pro' ? ( + + ) : ( + + )} + + {license?.tier === 'pro' ? 'Sencho Pro' : 'Sencho Community'} + +
+ {license?.tier === 'pro' && } +
+ + {license?.status === 'trial' && license.trialDaysRemaining !== null && ( +
+ + Trial: {license.trialDaysRemaining} day{license.trialDaysRemaining !== 1 ? 's' : ''} remaining +
+ )} + + {license?.status === 'active' && ( +
+ {license.customerName && ( +
+ Customer + {license.customerName} +
+ )} + {license.productName && ( +
+ Plan + {license.productName} +
+ )} + {license.maskedKey && ( +
+ License Key + {license.maskedKey} +
+ )} + {license.validUntil && ( +
+ Renews + {new Date(license.validUntil).toLocaleDateString()} +
+ )} +
+ )} + + {license?.status === 'expired' && ( +
+ + Your Pro license has expired. Renew to restore Pro features. +
+ )} + + {license?.status === 'disabled' && ( +
+ + Your license has been disabled. Contact support for assistance. +
+ )} +
+ + {/* Activation / Deactivation */} + {license?.status === 'active' ? ( +
+

+ Deactivating will revert to Community features. +

+ +
+ ) : ( +
+
+ +

+ Enter your Sencho Pro license key to unlock all features. +

+
+
+ setLicenseKeyInput(e.target.value)} + className="font-mono" + /> + +
+

+ Don't have a license? Get Sencho Pro +

+
+ )} +
+ )} + {activeSection === 'system' && (
@@ -630,41 +778,6 @@ export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModa
)} - {activeSection === 'appearance' && ( -
-
-

Appearance

-

Customize Sencho's visual theme.

-
-
- - - -
-
- )} - {activeSection === 'developer' && (
@@ -791,6 +904,66 @@ export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModa )} + {activeSection === 'about' && ( +
+
+

About Sencho

+

Version and instance information.

+
+ +
+
+ Version + v{__APP_VERSION__} +
+
+ Tier +
{license?.tier === 'pro' ? : Community}
+
+
+ License Status + {license?.status ?? 'community'} +
+ {license?.instanceId && ( +
+ Instance ID + {license.instanceId.slice(0, 8)} +
+ )} +
+ + +
+ )} + {activeSection === 'appstore' && (
diff --git a/frontend/src/components/UserProfileDropdown.tsx b/frontend/src/components/UserProfileDropdown.tsx new file mode 100644 index 00000000..d5cabc97 --- /dev/null +++ b/frontend/src/components/UserProfileDropdown.tsx @@ -0,0 +1,144 @@ +import { Settings, LogOut, ExternalLink, Monitor, Sun, Moon, User } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Separator } from '@/components/ui/separator'; +import { useAuth } from '@/context/AuthContext'; +import { useLicense } from '@/context/LicenseContext'; +import { ProBadge } from './ProBadge'; + +type Theme = 'light' | 'dark' | 'auto'; + +interface UserProfileDropdownProps { + theme: Theme; + setTheme: (theme: Theme) => void; + onOpenSettings: () => void; +} + +export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserProfileDropdownProps) { + const { logout } = useAuth(); + const { license, isPro } = useLicense(); + + return ( + + + + + + {/* User Info */} +
+
+
+ +
+
+

admin

+
+ {isPro ? : Community} +
+
+
+
+ + + + {/* Navigation Links */} +
+ + {license?.status === 'active' && ( + + + Billing + + )} +
+ + + + {/* Theme Toggle */} +
+

Theme

+
+ + + +
+
+ + + + {/* Documentation Links */} + + + + + {/* Logout */} +
+ +
+
+
+ ); +} diff --git a/frontend/src/context/LicenseContext.tsx b/frontend/src/context/LicenseContext.tsx new file mode 100644 index 00000000..e0aafd5f --- /dev/null +++ b/frontend/src/context/LicenseContext.tsx @@ -0,0 +1,102 @@ +import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'; +import { apiFetch } from '@/lib/api'; + +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 LicenseContextType { + license: LicenseInfo | null; + isPro: boolean; + loading: boolean; + refresh: () => Promise; + activate: (licenseKey: string) => Promise<{ success: boolean; error?: string }>; + deactivate: () => Promise<{ success: boolean; error?: string }>; +} + +const LicenseContext = createContext(undefined); + +export function LicenseProvider({ children }: { children: ReactNode }) { + const [license, setLicense] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + const res = await apiFetch('/license', { localOnly: true }); + if (res.ok) { + const data = await res.json(); + setLicense(data); + } + } catch { + // Silently fail — license info is non-critical + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const activate = useCallback(async (licenseKey: string): Promise<{ success: boolean; error?: string }> => { + try { + const res = await apiFetch('/license/activate', { + method: 'POST', + localOnly: true, + body: JSON.stringify({ license_key: licenseKey }), + }); + const data = await res.json(); + if (res.ok && data.success) { + setLicense(data.license); + return { success: true }; + } + return { success: false, error: data.error || 'Activation failed' }; + } catch { + return { success: false, error: 'Network error. Please try again.' }; + } + }, []); + + const deactivate = useCallback(async (): Promise<{ success: boolean; error?: string }> => { + try { + const res = await apiFetch('/license/deactivate', { + method: 'POST', + localOnly: true, + }); + const data = await res.json(); + if (res.ok && data.success) { + setLicense(data.license); + return { success: true }; + } + return { success: false, error: data.error || 'Deactivation failed' }; + } catch { + return { success: false, error: 'Network error. Please try again.' }; + } + }, []); + + const isPro = license?.tier === 'pro'; + + return ( + + {children} + + ); +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useLicense(): LicenseContextType { + const context = useContext(LicenseContext); + if (context === undefined) { + throw new Error('useLicense must be used within a LicenseProvider'); + } + return context; +} diff --git a/frontend/src/globals.d.ts b/frontend/src/globals.d.ts new file mode 100644 index 00000000..41fad5b5 --- /dev/null +++ b/frontend/src/globals.d.ts @@ -0,0 +1 @@ +declare const __APP_VERSION__: string; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 27884d82..592fafa3 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,10 +2,16 @@ import { defineConfig } from 'vite' import tailwindcss from '@tailwindcss/vite' import react from '@vitejs/plugin-react' import path from 'path' +import { readFileSync } from 'fs' + +const rootPkg = JSON.parse(readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8')); // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + define: { + __APP_VERSION__: JSON.stringify(rootPkg.version), + }, resolve: { alias: { "@": path.resolve(__dirname, "./src"),