diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f28805..a8c5ed93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +* **licensing:** trial users can now see Skipper/Admiral purchase cards in Settings > License (previously hidden due to a condition bug) + +### Changed + +* **licensing:** rename internal tier value from `pro` to `paid` across backend and frontend; no user-facing behavior change beyond corrected branding +* **licensing:** all user-facing text now uses actual tier names (Community, Skipper, Admiral) instead of the generic "Pro" label +* **licensing:** `ProGate` component renamed to `PaidGate`, `isPro` renamed to `isPaid` throughout the codebase +* **licensing:** backend error code `PRO_REQUIRED` renamed to `PAID_REQUIRED`; `requirePro` guard renamed to `requirePaid` +* **docs:** all documentation updated to replace "Sencho Pro" references with proper tier names + ## [0.38.0](https://github.com/AnsoCode/Sencho/compare/v0.37.0...v0.38.0) (2026-04-04) diff --git a/backend/src/__tests__/auth.test.ts b/backend/src/__tests__/auth.test.ts index 01a4fbfa..c4991546 100644 --- a/backend/src/__tests__/auth.test.ts +++ b/backend/src/__tests__/auth.test.ts @@ -99,7 +99,7 @@ describe('POST /api/system/console-token', () => { // Console-token requires Admiral tier — mock LicenseService for the happy-path test beforeAll(async () => { const { LicenseService } = await import('../services/LicenseService'); - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('pro'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('team'); }); diff --git a/backend/src/__tests__/distributed-license.test.ts b/backend/src/__tests__/distributed-license.test.ts index 404a46ba..a9f76b1f 100644 --- a/backend/src/__tests__/distributed-license.test.ts +++ b/backend/src/__tests__/distributed-license.test.ts @@ -23,10 +23,10 @@ afterAll(() => { const signToken = (payload: Record, expiresIn: string | number = '1m') => jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: expiresIn as jwt.SignOptions['expiresIn'] }); -// We need a Pro-gated route that doesn't depend on Docker or remote nodes. -// /api/webhooks is Pro-gated and just reads from the DB — returns an empty array +// We need a Paid-gated route that doesn't depend on Docker or remote nodes. +// /api/webhooks is Paid-gated and just reads from the DB; returns an empty array // if no webhooks exist. -const PRO_ROUTE = '/api/webhooks'; +const PAID_ROUTE = '/api/webhooks'; // For Admiral routes, /api/audit-log is Admiral-gated and reads from the DB. const ADMIRAL_ROUTE = '/api/audit-log'; @@ -36,14 +36,14 @@ const ADMIRAL_ROUTE = '/api/audit-log'; describe('authMiddleware - distributed license headers', () => { it('sets proxyTier/proxyVariant for node_proxy tokens with valid tier headers', async () => { const token = signToken({ scope: 'node_proxy' }); - // Hit a Pro-gated route with tier assertion - should be allowed + // Hit a Paid-gated route with tier assertion - should be allowed const res = await request(app) - .get(PRO_ROUTE) + .get(PAID_ROUTE) .set('Authorization', `Bearer ${token}`) - .set('x-sencho-tier', 'pro') + .set('x-sencho-tier', 'paid') .set('x-sencho-variant', 'personal'); - // Should NOT get 403 PRO_REQUIRED — the proxy tier assertion grants access + // Should NOT get 403 PAID_REQUIRED; the proxy tier assertion grants access expect(res.status).not.toBe(403); }); @@ -51,50 +51,50 @@ describe('authMiddleware - distributed license headers', () => { const token = signToken({ username: TEST_USERNAME, role: 'admin' }); // Even with tier headers set, a user session should use local license (community) const res = await request(app) - .get(PRO_ROUTE) + .get(PAID_ROUTE) .set('Authorization', `Bearer ${token}`) - .set('x-sencho-tier', 'pro') + .set('x-sencho-tier', 'paid') .set('x-sencho-variant', 'team'); // Local license is community in test env → should get 403 expect(res.status).toBe(403); - expect(res.body.code).toBe('PRO_REQUIRED'); + expect(res.body.code).toBe('PAID_REQUIRED'); }); it('ignores tier headers for malformed values on node_proxy tokens', async () => { const token = signToken({ scope: 'node_proxy' }); const res = await request(app) - .get(PRO_ROUTE) + .get(PAID_ROUTE) .set('Authorization', `Bearer ${token}`) .set('x-sencho-tier', 'enterprise') // invalid value .set('x-sencho-variant', 'mega'); // invalid value // Invalid tier header → proxyTier not set → falls back to local (community) → 403 expect(res.status).toBe(403); - expect(res.body.code).toBe('PRO_REQUIRED'); + expect(res.body.code).toBe('PAID_REQUIRED'); }); it('falls back to local tier when no tier headers on node_proxy token', async () => { const token = signToken({ scope: 'node_proxy' }); const res = await request(app) - .get(PRO_ROUTE) + .get(PAID_ROUTE) .set('Authorization', `Bearer ${token}`); // No tier headers → falls back to local (community) → 403 expect(res.status).toBe(403); - expect(res.body.code).toBe('PRO_REQUIRED'); + expect(res.body.code).toBe('PAID_REQUIRED'); }); }); -// ─── requirePro guard ─────────────────────────────────────────────────────── +// ─── requirePaid guard ─────────────────────────────────────────────────────── -describe('requirePro - distributed license', () => { - it('allows access when proxy asserts pro tier', async () => { +describe('requirePaid - distributed license', () => { + it('allows access when proxy asserts paid tier', async () => { const token = signToken({ scope: 'node_proxy' }); const res = await request(app) - .get(PRO_ROUTE) + .get(PAID_ROUTE) .set('Authorization', `Bearer ${token}`) - .set('x-sencho-tier', 'pro') + .set('x-sencho-tier', 'paid') .set('x-sencho-variant', ''); expect(res.status).not.toBe(403); @@ -103,45 +103,45 @@ describe('requirePro - distributed license', () => { it('blocks access when proxy asserts community tier', async () => { const token = signToken({ scope: 'node_proxy' }); const res = await request(app) - .get(PRO_ROUTE) + .get(PAID_ROUTE) .set('Authorization', `Bearer ${token}`) .set('x-sencho-tier', 'community'); expect(res.status).toBe(403); - expect(res.body.code).toBe('PRO_REQUIRED'); + expect(res.body.code).toBe('PAID_REQUIRED'); }); it('blocks access for direct user when local tier is community', async () => { const token = signToken({ username: TEST_USERNAME, role: 'admin' }); const res = await request(app) - .get(PRO_ROUTE) + .get(PAID_ROUTE) .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(403); - expect(res.body.code).toBe('PRO_REQUIRED'); + expect(res.body.code).toBe('PAID_REQUIRED'); }); }); // ─── requireAdmiral guard ─────────────────────────────────────────────────── describe('requireAdmiral - distributed license', () => { - it('allows access when proxy asserts pro tier with team variant', async () => { + it('allows access when proxy asserts paid tier with team variant', async () => { const token = signToken({ scope: 'node_proxy' }); const res = await request(app) .get(ADMIRAL_ROUTE) .set('Authorization', `Bearer ${token}`) - .set('x-sencho-tier', 'pro') + .set('x-sencho-tier', 'paid') .set('x-sencho-variant', 'team'); expect(res.status).not.toBe(403); }); - it('blocks when proxy asserts pro tier with personal variant', async () => { + it('blocks when proxy asserts paid tier with personal variant', async () => { const token = signToken({ scope: 'node_proxy' }); const res = await request(app) .get(ADMIRAL_ROUTE) .set('Authorization', `Bearer ${token}`) - .set('x-sencho-tier', 'pro') + .set('x-sencho-tier', 'paid') .set('x-sencho-variant', 'personal'); expect(res.status).toBe(403); @@ -156,15 +156,15 @@ describe('requireAdmiral - distributed license', () => { .set('x-sencho-tier', 'community'); expect(res.status).toBe(403); - expect(res.body.code).toBe('PRO_REQUIRED'); + expect(res.body.code).toBe('PAID_REQUIRED'); }); - it('blocks when proxy asserts pro tier with empty variant', async () => { + it('blocks when proxy asserts paid tier with empty variant', async () => { const token = signToken({ scope: 'node_proxy' }); const res = await request(app) .get(ADMIRAL_ROUTE) .set('Authorization', `Bearer ${token}`) - .set('x-sencho-tier', 'pro') + .set('x-sencho-tier', 'paid') .set('x-sencho-variant', ''); expect(res.status).toBe(403); @@ -180,7 +180,7 @@ describe('Security - tier header injection', () => { const res = await request(app) .get(ADMIRAL_ROUTE) .set('Authorization', `Bearer ${token}`) - .set('x-sencho-tier', 'pro') + .set('x-sencho-tier', 'paid') .set('x-sencho-variant', 'team'); // User session → tier headers ignored → local community tier → 403 @@ -189,8 +189,8 @@ describe('Security - tier header injection', () => { it('cannot elevate access via tier headers without any auth', async () => { const res = await request(app) - .get(PRO_ROUTE) - .set('x-sencho-tier', 'pro') + .get(PAID_ROUTE) + .set('x-sencho-tier', 'paid') .set('x-sencho-variant', 'team'); expect(res.status).toBe(401); @@ -199,9 +199,9 @@ describe('Security - tier header injection', () => { it('cannot elevate access with expired node_proxy token', async () => { const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '-1s' }); const res = await request(app) - .get(PRO_ROUTE) + .get(PAID_ROUTE) .set('Authorization', `Bearer ${token}`) - .set('x-sencho-tier', 'pro'); + .set('x-sencho-tier', 'paid'); expect(res.status).toBe(401); }); @@ -209,9 +209,9 @@ describe('Security - tier header injection', () => { it('cannot elevate access with token signed by wrong secret', async () => { const token = jwt.sign({ scope: 'node_proxy' }, 'wrong-secret', { expiresIn: '1m' }); const res = await request(app) - .get(PRO_ROUTE) + .get(PAID_ROUTE) .set('Authorization', `Bearer ${token}`) - .set('x-sencho-tier', 'pro'); + .set('x-sencho-tier', 'paid'); expect(res.status).toBe(401); }); diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index 04d276e5..e10b001d 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -28,7 +28,7 @@ const { mockCreateSnapshot: vi.fn().mockReturnValue(1), mockInsertSnapshotFiles: vi.fn(), mockClearStackUpdateStatus: vi.fn(), - mockGetTier: vi.fn().mockReturnValue('pro'), + mockGetTier: vi.fn().mockReturnValue('paid'), mockGetVariant: vi.fn().mockReturnValue('team'), mockGetContainersByStack: vi.fn().mockResolvedValue([]), mockRestartContainer: vi.fn().mockResolvedValue(undefined), @@ -171,7 +171,7 @@ describe('SchedulerService - license gating', () => { }); it('allows update tasks for non-admiral pro', async () => { - mockGetTier.mockReturnValue('pro'); + mockGetTier.mockReturnValue('paid'); mockGetVariant.mockReturnValue('individual'); mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'update' })]); mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }]); @@ -186,7 +186,7 @@ describe('SchedulerService - license gating', () => { }); it('skips non-update tasks for non-admiral pro', async () => { - mockGetTier.mockReturnValue('pro'); + mockGetTier.mockReturnValue('paid'); mockGetVariant.mockReturnValue('individual'); mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'restart' })]); @@ -197,7 +197,7 @@ describe('SchedulerService - license gating', () => { }); it('allows all actions for admiral (pro + team)', async () => { - mockGetTier.mockReturnValue('pro'); + mockGetTier.mockReturnValue('paid'); mockGetVariant.mockReturnValue('team'); mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'restart' })]); mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]); @@ -214,7 +214,7 @@ describe('SchedulerService - license gating', () => { describe('SchedulerService - concurrent task prevention', () => { it('does not execute a task that is already in runningTasks', async () => { - mockGetTier.mockReturnValue('pro'); + mockGetTier.mockReturnValue('paid'); mockGetVariant.mockReturnValue('team'); mockGetDueScheduledTasks.mockReturnValue([{ id: 42, @@ -239,7 +239,7 @@ describe('SchedulerService - concurrent task prevention', () => { }); it('removes task from runningTasks after completion', async () => { - mockGetTier.mockReturnValue('pro'); + mockGetTier.mockReturnValue('paid'); mockGetVariant.mockReturnValue('team'); mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]); @@ -611,7 +611,7 @@ describe('SchedulerService - error handling', () => { describe('SchedulerService - cleanup', () => { it('calls cleanupOldTaskRuns(30) on every tick', async () => { - mockGetTier.mockReturnValue('pro'); + mockGetTier.mockReturnValue('paid'); mockGetVariant.mockReturnValue('team'); mockGetDueScheduledTasks.mockReturnValue([]); @@ -626,7 +626,7 @@ describe('SchedulerService - cleanup', () => { describe('SchedulerService - isProcessing guard', () => { it('skips tick if already processing', async () => { - mockGetTier.mockReturnValue('pro'); + mockGetTier.mockReturnValue('paid'); const svc = SchedulerService.getInstance(); (svc as any).isProcessing = true; diff --git a/backend/src/__tests__/sso.test.ts b/backend/src/__tests__/sso.test.ts index 258cb93e..622381da 100644 --- a/backend/src/__tests__/sso.test.ts +++ b/backend/src/__tests__/sso.test.ts @@ -56,7 +56,7 @@ describe('SSO Config Endpoints (Protected)', () => { .set('Authorization', `Bearer ${adminToken}`); // Without an Admiral license, this should be 403 expect(res.status).toBe(403); - expect(res.body.code).toBe('PRO_REQUIRED'); + expect(res.body.code).toBe('PAID_REQUIRED'); }); it('PUT /api/sso/config/:provider returns 401 without auth', async () => { diff --git a/backend/src/index.ts b/backend/src/index.ts index fb911369..f928872b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -838,31 +838,31 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => { // --- License Routes (local-only, never proxied) --- -// Pro feature guard: returns false and sends 403 if not Pro tier. +// Paid feature guard: returns false and sends 403 if not on a paid tier (Skipper or Admiral). // Checks req.proxyTier first (set by authMiddleware for trusted node proxy requests), // falling back to the local LicenseService tier for direct access. -const requirePro = (req: Request, res: Response): boolean => { +const requirePaid = (req: Request, res: Response): boolean => { const tier = req.proxyTier !== undefined ? req.proxyTier : LicenseService.getInstance().getTier(); - if (tier !== 'pro') { - res.status(403).json({ error: 'This feature requires Sencho Pro.', code: 'PRO_REQUIRED' }); + if (tier !== 'paid') { + res.status(403).json({ error: 'This feature requires a Skipper or Admiral license.', code: 'PAID_REQUIRED' }); return false; } return true; }; -// Admiral feature guard: requires Pro tier with team variant. +// Admiral feature guard: requires paid tier with team variant. // Checks req.proxyTier/proxyVariant first (set by authMiddleware for trusted node proxy // requests), falling back to the local LicenseService for direct access. const requireAdmiral = (req: Request, res: Response): boolean => { const ls = LicenseService.getInstance(); const tier = req.proxyTier !== undefined ? req.proxyTier : ls.getTier(); const variant = req.proxyVariant !== undefined ? req.proxyVariant : ls.getVariant(); - if (tier !== 'pro') { - res.status(403).json({ error: 'This feature requires Sencho Pro.', code: 'PRO_REQUIRED' }); + if (tier !== 'paid') { + res.status(403).json({ error: 'This feature requires a Skipper or Admiral license.', code: 'PAID_REQUIRED' }); return false; } if (variant !== 'team') { - res.status(403).json({ error: 'This feature requires Sencho Admiral.', code: 'ADMIRAL_REQUIRED' }); + res.status(403).json({ error: 'This feature requires a Sencho Admiral license.', code: 'ADMIRAL_REQUIRED' }); return false; } return true; @@ -876,9 +876,9 @@ const requireAdmin = (req: Request, res: Response): boolean => { return true; }; -// Tier gate for scheduled tasks: 'update' action requires Pro, everything else requires Admiral. +// Tier gate for scheduled tasks: 'update' action requires Skipper+, everything else requires Admiral. const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => { - if (action === 'update') return requirePro(req, res); + if (action === 'update') return requirePaid(req, res); return requireAdmiral(req, res); }; @@ -1161,9 +1161,9 @@ app.get('/api/fleet/overview', async (_req: Request, res: Response): Promise => { - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const nodeId = parseInt(req.params.nodeId as string, 10); @@ -1199,9 +1199,9 @@ app.get('/api/fleet/node/:nodeId/stacks', async (req: Request, res: Response): P } }); -// Pro-gated: container details for a specific stack on a specific node +// Paid-gated: container details for a specific stack on a specific node app.get('/api/fleet/node/:nodeId/stacks/:stackName/containers', async (req: Request, res: Response): Promise => { - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const nodeId = parseInt(req.params.nodeId as string, 10); @@ -1245,7 +1245,7 @@ app.get('/api/fleet/node/:nodeId/stacks/:stackName/containers', async (req: Requ // Fleet Update Status — returns version comparison and active update status for all nodes app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promise => { - if (!requirePro(_req, res)) return; + if (!requirePaid(_req, res)) return; try { const db = DatabaseService.getInstance(); const nodes = db.getNodes(); @@ -1309,7 +1309,7 @@ app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promis // Trigger update on a specific node app.post('/api/fleet/nodes/:nodeId/update', async (req: Request, res: Response): Promise => { - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const nodeId = parseInt(req.params.nodeId as string, 10); const db = DatabaseService.getInstance(); @@ -1374,7 +1374,7 @@ app.post('/api/fleet/nodes/:nodeId/update', async (req: Request, res: Response): // Trigger update on all outdated nodes app.post('/api/fleet/update-all', async (req: Request, res: Response): Promise => { - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const db = DatabaseService.getInstance(); const nodes = db.getNodes(); @@ -1537,7 +1537,7 @@ async function fetchRemoteNodeOverview(node: Node): Promise { } } -// ─── Fleet Snapshots (Pro) ─── +// ─── Fleet Snapshots (Skipper+) ─── interface SnapshotNodeData { nodeId: number; @@ -1629,7 +1629,7 @@ async function captureRemoteNodeFiles(node: Node): Promise { // Create fleet snapshot app.post('/api/fleet/snapshots', async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const { description = '' } = req.body; @@ -1702,7 +1702,7 @@ app.post('/api/fleet/snapshots', async (req: Request, res: Response): Promise => { - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100); @@ -1719,7 +1719,7 @@ app.get('/api/fleet/snapshots', async (req: Request, res: Response): Promise => { - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const id = parseInt(req.params.id as string, 10); @@ -1764,7 +1764,7 @@ app.get('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise // Restore a stack from snapshot app.post('/api/fleet/snapshots/:id/restore', async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const snapshotId = parseInt(req.params.id as string, 10); @@ -1869,7 +1869,7 @@ app.post('/api/fleet/snapshots/:id/restore', async (req: Request, res: Response) // Delete snapshot app.delete('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const id = parseInt(req.params.id as string, 10); @@ -1887,11 +1887,11 @@ app.delete('/api/fleet/snapshots/:id', async (req: Request, res: Response): Prom } }); -// ─── Webhooks (Pro) ─── CRUD requires auth + Pro, trigger is public with HMAC ─── +// ─── Webhooks (Skipper+) ─── CRUD requires auth + paid tier, trigger is public with HMAC ─── -// Webhook CRUD (auth + Pro required) +// Webhook CRUD (auth + paid tier required) app.get('/api/webhooks', authMiddleware, async (_req: Request, res: Response): Promise => { - if (!requirePro(_req, res)) return; + if (!requirePaid(_req, res)) return; try { const webhooks = DatabaseService.getInstance().getWebhooks(); const svc = WebhookService.getInstance(); @@ -1904,7 +1904,7 @@ app.get('/api/webhooks', authMiddleware, async (_req: Request, res: Response): P app.post('/api/webhooks', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const { name, stack_name, action, enabled } = req.body; if (!name || !stack_name || !action) { @@ -1933,7 +1933,7 @@ app.post('/api/webhooks', authMiddleware, async (req: Request, res: Response): P app.put('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const id = parseInt(req.params.id as string, 10); const webhook = DatabaseService.getInstance().getWebhook(id); @@ -1956,7 +1956,7 @@ app.put('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response) app.delete('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const id = parseInt(req.params.id as string, 10); DatabaseService.getInstance().deleteWebhook(id); @@ -1968,7 +1968,7 @@ app.delete('/api/webhooks/:id', authMiddleware, async (req: Request, res: Respon }); app.get('/api/webhooks/:id/history', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const id = parseInt(req.params.id as string, 10); const executions = DatabaseService.getInstance().getWebhookExecutions(id); @@ -1991,9 +1991,9 @@ app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promi return; } - // Pro gate - trigger only works with an active Pro license - if (LicenseService.getInstance().getTier() !== 'pro') { - res.status(403).json({ error: 'This feature requires Sencho Pro.', code: 'PRO_REQUIRED' }); + // Paid tier gate - trigger only works with an active Skipper or Admiral license + if (LicenseService.getInstance().getTier() !== 'paid') { + res.status(403).json({ error: 'This feature requires a Skipper or Admiral license.', code: 'PAID_REQUIRED' }); return; } @@ -2018,7 +2018,7 @@ app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promi // Execute asynchronously - return 202 immediately res.status(202).json({ message: 'Webhook accepted', action }); - const atomic = LicenseService.getInstance().getTier() === 'pro'; + const atomic = LicenseService.getInstance().getTier() === 'paid'; svc.execute(id, action, triggerSource, atomic).catch(err => { console.error(`[Webhooks] Execution error for webhook ${id}:`, err); }); @@ -2028,7 +2028,7 @@ app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promi } }); -// --- User Management (local-only, admin + Pro gated for creation) --- +// --- User Management (local-only, admin + paid tier gated for creation) --- app.get('/api/users', authMiddleware, async (req: Request, res: Response): Promise => { if (req.apiTokenScope) { @@ -2051,7 +2051,7 @@ app.post('/api/users', authMiddleware, async (req: Request, res: Response): Prom return; } if (!requireAdmin(req, res)) return; - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const { username, password, role } = req.body; @@ -2626,9 +2626,9 @@ server.on('upgrade', async (req, socket, head) => { socket.destroy(); return; } - // Admiral license gate — host console requires Pro (team variant) + // Admiral license gate: host console requires Admiral (paid + team variant) const ls = LicenseService.getInstance(); - if (ls.getTier() !== 'pro' || ls.getVariant() !== 'team') { + if (ls.getTier() !== 'paid' || ls.getVariant() !== 'team') { socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); socket.destroy(); return; @@ -2736,10 +2736,10 @@ app.get('/api/containers', async (req: Request, res: Response) => { } }); -// --- Label Routes (Pro-gated) --- +// --- Label Routes (Skipper+) --- app.get('/api/labels', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const nodeId = req.nodeId ?? 0; const labels = DatabaseService.getInstance().getLabels(nodeId); @@ -2751,7 +2751,7 @@ app.get('/api/labels', authMiddleware, async (req: Request, res: Response): Prom }); app.post('/api/labels', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const nodeId = req.nodeId ?? 0; const { name, color } = req.body; @@ -2782,7 +2782,7 @@ app.post('/api/labels', authMiddleware, async (req: Request, res: Response): Pro }); app.get('/api/labels/assignments', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const nodeId = req.nodeId ?? 0; const assignments = DatabaseService.getInstance().getLabelsForStacks(nodeId); @@ -2794,7 +2794,7 @@ app.get('/api/labels/assignments', authMiddleware, async (req: Request, res: Res }); app.put('/api/labels/:id', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const id = parseInt(req.params.id as string, 10); if (isNaN(id)) { res.status(400).json({ error: 'Invalid label ID' }); return; } @@ -2836,7 +2836,7 @@ app.put('/api/labels/:id', authMiddleware, async (req: Request, res: Response): }); app.delete('/api/labels/:id', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const id = parseInt(req.params.id as string, 10); if (isNaN(id)) { res.status(400).json({ error: 'Invalid label ID' }); return; } @@ -2850,7 +2850,7 @@ app.delete('/api/labels/:id', authMiddleware, async (req: Request, res: Response }); app.put('/api/stacks/:stackName/labels', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const stackName = req.params.stackName as string; if (!isValidStackName(stackName)) { @@ -2874,7 +2874,7 @@ app.put('/api/stacks/:stackName/labels', authMiddleware, async (req: Request, re }); app.post('/api/labels/:id/action', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; try { const id = parseInt(req.params.id as string, 10); @@ -3275,12 +3275,12 @@ app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) => return res.status(400).json({ error: 'Invalid stack name' }); } try { - const atomic = LicenseService.getInstance().getTier() === 'pro'; + const atomic = LicenseService.getInstance().getTier() === 'paid'; await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic); res.json({ message: 'Deployed successfully' }); } catch (error: any) { console.error('Failed to deploy stack:', error); - const rolledBack = LicenseService.getInstance().getTier() === 'pro'; + const rolledBack = LicenseService.getInstance().getTier() === 'paid'; res.status(500).json({ error: error.message || 'Failed to deploy stack', rolledBack }); } }); @@ -3373,21 +3373,21 @@ app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => return res.status(400).json({ error: 'Invalid stack name' }); } try { - const atomic = LicenseService.getInstance().getTier() === 'pro'; + const atomic = LicenseService.getInstance().getTier() === 'paid'; await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined, atomic); DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName); res.json({ status: 'Update completed' }); } catch (error) { - const rolledBack = LicenseService.getInstance().getTier() === 'pro'; + const rolledBack = LicenseService.getInstance().getTier() === 'paid'; res.status(500).json({ error: 'Failed to update', rolledBack }); } }); -// Manual rollback endpoint (Pro + Admin) +// Manual rollback endpoint (Skipper+ and Admin) app.post('/api/stacks/:stackName/rollback', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; if (!isValidStackName(stackName)) { return res.status(400).json({ error: 'Invalid stack name' }); } @@ -4377,7 +4377,7 @@ app.delete('/api/api-tokens/:id', authMiddleware, async (req: Request, res: Resp app.get('/api/scheduled-tasks', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { let tasks = DatabaseService.getInstance().getScheduledTasks(); // Skipper users only see 'update' tasks; Admiral sees all @@ -4489,7 +4489,7 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => { app.get('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const id = parseInt(req.params.id as string, 10); if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; } @@ -4505,7 +4505,7 @@ app.get('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const id = parseInt(req.params.id as string, 10); if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; } @@ -4604,7 +4604,7 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { app.delete('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const id = parseInt(req.params.id as string, 10); if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; } @@ -4624,7 +4624,7 @@ app.delete('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { app.patch('/api/scheduled-tasks/:id/toggle', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const id = parseInt(req.params.id as string, 10); if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; } @@ -4653,7 +4653,7 @@ app.patch('/api/scheduled-tasks/:id/toggle', (req: Request, res: Response): void app.post('/api/scheduled-tasks/:id/run', async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const id = parseInt(req.params.id as string, 10); if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; } @@ -4676,7 +4676,7 @@ app.post('/api/scheduled-tasks/:id/run', async (req: Request, res: Response): Pr app.get('/api/scheduled-tasks/:id/runs/export', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const id = parseInt(req.params.id as string, 10); if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; } @@ -4719,7 +4719,7 @@ app.get('/api/scheduled-tasks/:id/runs/export', (req: Request, res: Response): v app.get('/api/scheduled-tasks/:id/runs', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; - if (!requirePro(req, res)) return; + if (!requirePaid(req, res)) return; try { const id = parseInt(req.params.id as string, 10); if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; } @@ -5147,7 +5147,7 @@ app.post('/api/templates/deploy', async (req: Request, res: Response) => { // 4. Deploy the stack with atomic rollback try { - const atomic = LicenseService.getInstance().getTier() === 'pro'; + const atomic = LicenseService.getInstance().getTier() === 'paid'; await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic); res.json({ success: true, message: 'Template deployed successfully' }); } catch (deployError: any) { diff --git a/backend/src/services/LicenseService.ts b/backend/src/services/LicenseService.ts index b69a51cd..3f701c6a 100644 --- a/backend/src/services/LicenseService.ts +++ b/backend/src/services/LicenseService.ts @@ -2,12 +2,12 @@ import crypto from 'crypto'; import axios from 'axios'; import { DatabaseService } from './DatabaseService'; -export type LicenseTier = 'community' | 'pro'; +export type LicenseTier = 'community' | 'paid'; export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disabled'; export type LicenseVariant = 'personal' | 'team' | null; -const VALID_TIERS: readonly string[] = ['community', 'pro'] satisfies readonly LicenseTier[]; +const VALID_TIERS: readonly string[] = ['community', 'paid'] satisfies readonly LicenseTier[]; const VALID_VARIANTS: readonly string[] = ['personal', 'team'] satisfies readonly LicenseVariant[]; export function isLicenseTier(value: unknown): value is LicenseTier { @@ -141,7 +141,7 @@ export class LicenseService { 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()}`); + console.log(`[License] 14-day Skipper trial started. Expires: ${trialEnd.toISOString()}`); } this.startPeriodicValidation(); @@ -160,7 +160,7 @@ export class LicenseService { if (status === 'trial') { const validUntil = db.getSystemState('license_valid_until'); if (validUntil && new Date(validUntil) > new Date()) { - return 'pro'; + return 'paid'; } // Trial expired - update status db.setSystemState('license_status', 'community'); @@ -186,7 +186,7 @@ export class LicenseService { return 'community'; } - return 'pro'; + return 'paid'; } return 'community'; diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 33447b45..0238f362 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -49,9 +49,9 @@ export class SchedulerService { this.isProcessing = true; try { const ls = LicenseService.getInstance(); - const isPro = ls.getTier() === 'pro'; - const isAdmiral = isPro && ls.getVariant() === 'team'; - if (!isPro) return; // No scheduled tasks for non-Pro tiers + const isPaid = ls.getTier() === 'paid'; + const isAdmiral = isPaid && ls.getVariant() === 'team'; + if (!isPaid) return; // No scheduled tasks for unpaid tiers const db = DatabaseService.getInstance(); const now = Date.now(); diff --git a/docs/api-reference/overview.mdx b/docs/api-reference/overview.mdx index 5dad2700..8b6cd9a0 100644 --- a/docs/api-reference/overview.mdx +++ b/docs/api-reference/overview.mdx @@ -69,7 +69,7 @@ The `code` field is present for specific error types: | Code | Meaning | |------|---------| -| `PRO_REQUIRED` | Endpoint requires a Pro (Skipper or Admiral) license | +| `PAID_REQUIRED` | Endpoint requires a Skipper or Admiral license | | `ADMIRAL_REQUIRED` | Endpoint requires an Admiral license | | `SCOPE_DENIED` | API token scope does not allow this operation | @@ -104,7 +104,7 @@ Some endpoints are gated by license tier: | Tier | Gated features | |------|---------------| -| **Pro (Skipper+)** | Webhooks, Fleet snapshots, Stack rollback | +| **Skipper+** | Webhooks, Fleet snapshots, Stack rollback | | **Admiral** | API Tokens, Scheduled Tasks | Requests to gated endpoints on a lower tier return `403` with the appropriate error code. diff --git a/docs/features/atomic-deployments.mdx b/docs/features/atomic-deployments.mdx index d0c7e008..4e1e742f 100644 --- a/docs/features/atomic-deployments.mdx +++ b/docs/features/atomic-deployments.mdx @@ -1,13 +1,13 @@ --- title: Atomic Deployments -description: Zero-downtime deployments with automatic rollback for Sencho Pro users. +description: Zero-downtime deployments with automatic rollback for Skipper and Admiral users. --- Atomic Deployments require a **Sencho Skipper** or **Admiral** license. Community Edition uses standard deployments without backup or rollback. -Sencho Pro wraps every deployment in a safety net. Before applying changes, it backs up your current configuration. If the deployment fails, it automatically rolls back to the previous working state. +Sencho wraps every deployment in a safety net on Skipper and Admiral tiers. Before applying changes, it backs up your current configuration. If the deployment fails, it automatically rolls back to the previous working state. ## How it works diff --git a/docs/features/fleet-backups.mdx b/docs/features/fleet-backups.mdx index a3176b7d..f21437cb 100644 --- a/docs/features/fleet-backups.mdx +++ b/docs/features/fleet-backups.mdx @@ -4,7 +4,7 @@ description: Snapshot compose files across all nodes for disaster recovery and a --- - Fleet-Wide Backups require a Sencho Pro license. The feature is available to Pro admins in the Fleet View. + Fleet-Wide Backups require a Skipper or Admiral license. The feature is available to admins in the Fleet View. Create point-in-time snapshots of every `compose.yaml` and `.env` file across your entire fleet, local and remote nodes alike. Snapshots are stored centrally in Sencho's database and can be browsed, previewed, and restored at any time. diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 489ef6e7..31348260 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -3,7 +3,7 @@ title: Fleet View description: Monitor all your nodes from a single dashboard with real-time health metrics, search, filtering, and container drill-down. --- -The **Fleet** tab gives you a bird's-eye view of every node in your Sencho deployment, local and remote, on one screen. It is available to all tiers, with advanced features unlocked by Sencho Pro. +The **Fleet** tab gives you a bird's-eye view of every node in your Sencho deployment, local and remote, on one screen. It is available to all tiers, with advanced features unlocked by Skipper and Admiral. Fleet Overview showing health summary cards, toolbar, node cards, and tabs @@ -20,7 +20,7 @@ The header shows **Fleet Overview** with a subtitle summarising the current stat | Button | What it does | |--------|--------------| -| **Check Updates** | Opens the Node Updates modal to view and apply Sencho version updates across your fleet (Pro) | +| **Check Updates** | Opens the Node Updates modal to view and apply Sencho version updates across your fleet (Skipper+) | | **Refresh** | Re-fetches data from all nodes. Shows a spinner while loading | ## Community features @@ -55,10 +55,10 @@ Click the **Refresh** button in the top-right to re-fetch data from all nodes. T --- -## Pro features +## Paid features - The features below require a Sencho Pro license. Community users see an upgrade prompt in place of these controls. + The features below require a Skipper or Admiral license. Community users see an upgrade prompt in place of these controls. ### Fleet health summary cards diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index c22c6072..ae56ab0a 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -53,7 +53,7 @@ When you manage multiple nodes running different Sencho versions, the dashboard ## Fleet View -Monitor your entire infrastructure from a single screen. The fleet dashboard shows all nodes with health metrics, container counts, and resource usage. Pro users unlock fleet health summary cards, container drill-down, search, sorting, filtering, and critical node detection. [Learn more →](/features/fleet-view) +Monitor your entire infrastructure from a single screen. The fleet dashboard shows all nodes with health metrics, container counts, and resource usage. Skipper and Admiral users unlock fleet health summary cards, container drill-down, search, sorting, filtering, and critical node detection. [Learn more →](/features/fleet-view) ## Remote updates @@ -85,7 +85,7 @@ Automate recurring maintenance tasks like stack restarts, fleet snapshots, and s ## RBAC & user management -Create viewer accounts with read-only access to dashboards, logs, and file contents, while keeping deploy and edit permissions locked to admins. Sencho Pro supports two roles: Admin (full access) and Viewer (read-only). [Learn more →](/features/rbac) +Create viewer accounts with read-only access to dashboards, logs, and file contents, while keeping deploy and edit permissions locked to admins. Sencho supports two roles on Skipper: Admin (full access) and Viewer (read-only), with three additional roles on Admiral. [Learn more →](/features/rbac) ## SSO & LDAP authentication @@ -93,7 +93,7 @@ Authenticate with your existing identity provider. Sencho supports LDAP/Active D ## Atomic deployments -Pro users get automatic backup and rollback on every deployment. Before applying changes, Sencho snapshots your compose and environment files. If containers crash after deploy, the previous configuration is restored automatically. [Learn more →](/features/atomic-deployments) +Skipper and Admiral users get automatic backup and rollback on every deployment. Before applying changes, Sencho snapshots your compose and environment files. If containers crash after deploy, the previous configuration is restored automatically. [Learn more →](/features/atomic-deployments) ## Fleet-wide backups @@ -109,4 +109,4 @@ Track every mutating action across your Sencho instance with a searchable audit ## Licensing & billing -Sencho is free for personal use with the Community tier. Pro unlocks RBAC, webhooks, fleet backups, atomic deployments, and advanced fleet features. Manage your license, view subscription details, and access the billing portal from Settings. [Learn more →](/features/licensing) +Sencho is free for personal use with the Community tier. Skipper and Admiral unlock RBAC, webhooks, fleet backups, atomic deployments, and advanced fleet features. Manage your license, view subscription details, and access the billing portal from Settings. [Learn more →](/features/licensing) diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx index 546967aa..d23b54ef 100644 --- a/docs/features/rbac.mdx +++ b/docs/features/rbac.mdx @@ -7,7 +7,7 @@ description: Role-based access control for Sencho - manage admin, viewer, deploy Multi-user support requires a **Sencho Skipper** or **Admiral** license. Community Edition supports a single admin account only. Intermediate roles (Deployer, Node Admin, Auditor) and scoped permissions require **Admiral**. -Sencho supports role-based access control with five distinct roles. **Admin** and **Viewer** are available on all Pro tiers, while **Deployer**, **Node Admin**, and **Auditor** are exclusive to Admiral. +Sencho supports role-based access control with five distinct roles. **Admin** and **Viewer** are available on all paid tiers, while **Deployer**, **Node Admin**, and **Auditor** are exclusive to Admiral. ## Roles diff --git a/docs/features/remote-updates.mdx b/docs/features/remote-updates.mdx index debcbbc1..2e64fb44 100644 --- a/docs/features/remote-updates.mdx +++ b/docs/features/remote-updates.mdx @@ -6,7 +6,7 @@ description: Check for outdated nodes and trigger over-the-air Sencho updates fr Sencho can update remote nodes directly from the dashboard. When your primary instance is running a newer version than a remote node, a one-click update pulls the latest image and recreates the container automatically. - Remote updates require a **Sencho Pro** license (Skipper or Admiral tier). + Remote updates require a **Skipper** or **Admiral** license. ## Prerequisites diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx index d1e4ed9c..fad2338c 100644 --- a/docs/features/resources.mdx +++ b/docs/features/resources.mdx @@ -103,7 +103,7 @@ Click the eye icon on any network row to open a detail panel showing: Network inspect panel showing IPAM config and connected containers -#### Network topology Pro +#### Network topology Skipper Switch to the **Topology** view to see an interactive graph of your Docker networks and the containers connected to them. System networks (`bridge`, `host`, `none`) are excluded for clarity. @@ -118,7 +118,7 @@ Switch to the **Topology** view to see an interactive graph of your Docker netwo - A mini map in the bottom-right provides an overview - Network Topology requires a Sencho Pro license (Skipper or Admiral). Community users see an upgrade prompt. + Network Topology requires a Skipper or Admiral license. Community users see an upgrade prompt. ### Unmanaged diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 20c81e04..6bda07b3 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -51,7 +51,7 @@ The stack header shows different actions depending on whether the stack is runni | **Stop** | `docker compose stop` | Stops containers without removing them. State is preserved. | | **Restart** | `docker compose restart` | Restarts all containers in the stack. | | **Update** | `docker compose pull` + `up -d` | Pulls the latest image tags and recreates containers. | -| **Rollback** | Restores backup | Reverts compose and env files to the pre-deploy snapshot and redeploys. Only shown when a backup exists (Pro). | +| **Rollback** | Restores backup | Reverts compose and env files to the pre-deploy snapshot and redeploys. Only shown when a backup exists (Skipper+). | | **Delete** | `down --volumes` + removes files | Stops and removes containers and volumes, then deletes the stack directory. | **When stopped:** @@ -89,7 +89,7 @@ Right-click any stack in the sidebar to open the context menu. The menu adapts t ### Available actions - **Alerts** - configure metric-based alerting rules for this stack -- **Labels** - assign organizational labels (Pro) +- **Labels** - assign organizational labels (Skipper+) - **Check for updates** - manually trigger an image update check - **Open App** - open the stack's web interface in a new tab (only shown when the stack is running and exposes a web port) - **Deploy** - start the stack (shown when stopped) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 805c6f8e..d67acac3 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -25,8 +25,8 @@ info: ## License Tiers - Some endpoints require a Pro or Admiral license. Requests to gated endpoints - on Community Edition return `403` with `code: "PRO_REQUIRED"` or `code: "ADMIRAL_REQUIRED"`. + Some endpoints require a Skipper or Admiral license. Requests to gated endpoints + on Community Edition return `403` with `code: "PAID_REQUIRED"` or `code: "ADMIRAL_REQUIRED"`. contact: name: Sencho url: https://sencho.io @@ -59,11 +59,11 @@ tags: - name: API Tokens description: Manage scoped API tokens (Admiral license required) - name: Webhooks - description: Configure and trigger deployment webhooks (Pro license required) + description: Configure and trigger deployment webhooks (Skipper or Admiral license required) - name: Nodes description: Manage local and remote Sencho nodes - name: Fleet - description: Multi-node fleet overview and snapshots (Pro license required) + description: Multi-node fleet overview and snapshots (Skipper or Admiral license required) - name: Scheduled Tasks description: Configure recurring automated operations (Admiral license required) - name: Registries @@ -131,8 +131,8 @@ components: description: Human-readable error message. code: type: string - description: Machine-readable error code (e.g., `PRO_REQUIRED`, `SCOPE_DENIED`). - enum: [PRO_REQUIRED, ADMIRAL_REQUIRED, SCOPE_DENIED] + description: Machine-readable error code (e.g., `PAID_REQUIRED`, `SCOPE_DENIED`). + enum: [PAID_REQUIRED, ADMIRAL_REQUIRED, SCOPE_DENIED] SuccessMessage: type: object @@ -503,8 +503,8 @@ components: schema: $ref: "#/components/schemas/Error" example: - error: "This feature requires Sencho Pro." - code: "PRO_REQUIRED" + error: "This feature requires a Skipper or Admiral license." + code: "PAID_REQUIRED" NotFound: description: Resource not found. content: @@ -931,7 +931,7 @@ paths: tags: [Stacks] summary: Deploy stack description: | - Runs `docker compose up -d` for the stack. On Pro tier, uses atomic deployment + Runs `docker compose up -d` for the stack. On Skipper/Admiral tier, uses atomic deployment with automatic rollback on failure. Requires `stack:deploy` permission. parameters: - $ref: "#/components/parameters/stackName" @@ -959,7 +959,7 @@ paths: type: string rolledBack: type: boolean - description: Whether the stack was automatically rolled back (Pro tier). + description: Whether the stack was automatically rolled back (Skipper/Admiral tier). /api/stacks/{stackName}/down: post: @@ -1075,7 +1075,7 @@ paths: summary: Pull and recreate stack description: | Pulls latest images and recreates containers (`docker compose pull && up -d`). - On Pro tier, uses atomic update with automatic rollback on failure. + On Skipper/Admiral tier, uses atomic update with automatic rollback on failure. Requires `stack:deploy` permission. parameters: - $ref: "#/components/parameters/stackName" @@ -1112,7 +1112,7 @@ paths: operationId: rollbackStack tags: [Stacks] summary: Rollback stack - description: Restores the stack to its previous deployment state. Requires Pro license and `stack:deploy` permission. + description: Restores the stack to its previous deployment state. Requires Skipper or Admiral license and `stack:deploy` permission. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" @@ -1410,7 +1410,7 @@ paths: operationId: listWebhooks tags: [Webhooks] summary: List webhooks - description: Returns all configured webhooks with masked secrets. Requires Pro license. + description: Returns all configured webhooks with masked secrets. Requires Skipper or Admiral license. responses: "200": description: Array of webhook objects. @@ -1430,7 +1430,7 @@ paths: summary: Create webhook description: | Creates a new webhook for a stack. The webhook secret is auto-generated and only - returned in the creation response. Requires Pro license and admin role. + returned in the creation response. Requires Skipper or Admiral license and admin role. requestBody: required: true content: @@ -1483,7 +1483,7 @@ paths: operationId: updateWebhook tags: [Webhooks] summary: Update webhook - description: Updates webhook configuration. Requires Pro license and admin role. + description: Updates webhook configuration. Requires Skipper or Admiral license and admin role. parameters: - $ref: "#/components/parameters/idPath" requestBody: @@ -1525,7 +1525,7 @@ paths: operationId: deleteWebhook tags: [Webhooks] summary: Delete webhook - description: Permanently deletes a webhook. Requires Pro license and admin role. + description: Permanently deletes a webhook. Requires Skipper or Admiral license and admin role. parameters: - $ref: "#/components/parameters/idPath" responses: @@ -1547,7 +1547,7 @@ paths: operationId: getWebhookHistory tags: [Webhooks] summary: Get webhook execution history - description: Returns the execution log for a webhook. Requires Pro license. + description: Returns the execution log for a webhook. Requires Skipper or Admiral license. parameters: - $ref: "#/components/parameters/idPath" responses: @@ -1882,7 +1882,7 @@ paths: operationId: getFleetNodeStacks tags: [Fleet] summary: List stacks on a fleet node - description: Returns stack names from a specific fleet node. Requires Pro license. + description: Returns stack names from a specific fleet node. Requires Skipper or Admiral license. parameters: - name: nodeId in: path @@ -1922,7 +1922,7 @@ paths: operationId: getFleetNodeStackContainers tags: [Fleet] summary: List containers in a fleet node stack - description: Returns containers for a specific stack on a specific fleet node. Requires Pro license. + description: Returns containers for a specific stack on a specific fleet node. Requires Skipper or Admiral license. parameters: - name: nodeId in: path @@ -1992,7 +1992,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "403": - $ref: "#/components/responses/ProRequired" + $ref: "#/components/responses/Forbidden" /api/fleet/nodes/{nodeId}/update: post: @@ -2019,7 +2019,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "403": - $ref: "#/components/responses/ProRequired" + $ref: "#/components/responses/Forbidden" "404": description: Node not found. content: @@ -2066,14 +2066,14 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "403": - $ref: "#/components/responses/ProRequired" + $ref: "#/components/responses/Forbidden" /api/fleet/snapshots: post: operationId: createFleetSnapshot tags: [Fleet] summary: Create fleet snapshot - description: Creates a point-in-time backup of all compose files across all nodes. Requires Pro license and admin role. + description: Creates a point-in-time backup of all compose files across all nodes. Requires Skipper or Admiral license and admin role. requestBody: required: false content: @@ -2100,7 +2100,7 @@ paths: operationId: listFleetSnapshots tags: [Fleet] summary: List fleet snapshots - description: Returns paginated fleet snapshots. Requires Pro license. + description: Returns paginated fleet snapshots. Requires Skipper or Admiral license. parameters: - name: limit in: query @@ -2138,7 +2138,7 @@ paths: operationId: getFleetSnapshot tags: [Fleet] summary: Get snapshot details - description: Returns full snapshot details including all captured files grouped by node and stack. Requires Pro license. + description: Returns full snapshot details including all captured files grouped by node and stack. Requires Skipper or Admiral license. parameters: - $ref: "#/components/parameters/idPath" responses: @@ -2158,7 +2158,7 @@ paths: operationId: deleteFleetSnapshot tags: [Fleet] summary: Delete snapshot - description: Permanently deletes a fleet snapshot. Requires Pro license and admin role. + description: Permanently deletes a fleet snapshot. Requires Skipper or Admiral license and admin role. parameters: - $ref: "#/components/parameters/idPath" responses: @@ -2180,7 +2180,7 @@ paths: operationId: restoreFleetSnapshot tags: [Fleet] summary: Restore from snapshot - description: Restores a specific stack on a specific node from the snapshot. Optionally redeploys after restore. Requires Pro license and admin role. + description: Restores a specific stack on a specific node from the snapshot. Optionally redeploys after restore. Requires Skipper or Admiral license and admin role. parameters: - $ref: "#/components/parameters/idPath" requestBody: diff --git a/frontend/src/components/AdmiralGate.tsx b/frontend/src/components/AdmiralGate.tsx index 99606309..acae7724 100644 --- a/frontend/src/components/AdmiralGate.tsx +++ b/frontend/src/components/AdmiralGate.tsx @@ -17,10 +17,10 @@ function isDismissedFromStorage(): boolean { } export function AdmiralGate({ children, featureName = 'This feature' }: AdmiralGateProps) { - const { isPro, license } = useLicense(); + const { isPaid, license } = useLicense(); const [dismissed, setDismissed] = useState(isDismissedFromStorage); - if (isPro && license?.variant === 'team') return <>{children}; + if (isPaid && license?.variant === 'team') return <>{children}; if (dismissed) { return ( diff --git a/frontend/src/components/AutoUpdatePoliciesView.tsx b/frontend/src/components/AutoUpdatePoliciesView.tsx index 2544a342..e40130c9 100644 --- a/frontend/src/components/AutoUpdatePoliciesView.tsx +++ b/frontend/src/components/AutoUpdatePoliciesView.tsx @@ -14,7 +14,7 @@ import { Label } from '@/components/ui/label'; import { RefreshCw, Plus, Pencil, Trash2, History, Play, ChevronLeft, ChevronRight, Download } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { apiFetch, fetchForNode } from '@/lib/api'; -import { ProGate } from '@/components/ProGate'; +import { PaidGate } from '@/components/PaidGate'; import cronstrue from 'cronstrue'; interface ScheduledTask { @@ -590,8 +590,8 @@ function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePo export default function AutoUpdatePoliciesView({ filterNodeId, onClearFilter }: AutoUpdatePoliciesProps) { return ( - + - + ); } diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 810c51c4..8acef651 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -85,7 +85,7 @@ const formatBytes = (bytes: number) => { export default function EditorLayout() { const { isAdmin, can } = useAuth(); - const { isPro, license } = useLicense(); + const { isPaid, license } = useLicense(); const { nodes, activeNode, setActiveNode, nodeMeta } = useNodes(); // Stable ref so notification callbacks always read the latest nodes list // without needing nodes in their dependency arrays (which would cause loops). @@ -220,16 +220,16 @@ export default function EditorLayout() { { value: 'templates', label: 'App Store', icon: CloudDownload }, { value: 'global-observability', label: 'Logs', icon: Activity }, ); - if (isPro && isAdmin) { + if (isPaid && isAdmin) { items.push({ value: 'auto-updates', label: 'Auto-Update', icon: RefreshCw }); } - if (isPro && license?.variant === 'team') { + if (isPaid && license?.variant === 'team') { if (isAdmin) items.push({ value: 'host-console', label: 'Console', icon: Terminal }); if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText }); if (isAdmin) items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock }); } return items; - }, [isAdmin, isPro, license?.variant, can]); + }, [isAdmin, isPaid, license?.variant, can]); // Only highlight a tab if activeView matches a nav item const navTabValue = navItems.some(i => i.value === activeView) ? activeView : undefined; @@ -393,7 +393,7 @@ export default function EditorLayout() { }; const refreshLabels = async () => { - if (!isPro) return; + if (!isPaid) return; try { const [labelsRes, assignmentsRes] = await Promise.all([ apiFetch('/labels'), @@ -866,8 +866,8 @@ export default function EditorLayout() { setContainers([]); } - // Load backup info (Pro only) - if (isPro) { + // Load backup info (Skipper+ only) + if (isPaid) { try { const backupRes = await apiFetch(`/stacks/${filename}/backup`); if (backupRes.ok) setBackupInfo(await backupRes.json()); @@ -1001,7 +1001,7 @@ export default function EditorLayout() { setContainers(Array.isArray(conts) ? conts : []); } // Refresh backup info - if (isPro) { + if (isPaid) { try { const backupRes = await apiFetch(`/stacks/${stackName}/backup`); if (backupRes.ok) setBackupInfo(await backupRes.json()); @@ -1010,7 +1010,7 @@ export default function EditorLayout() { } catch (error) { console.error('Failed to deploy:', error); const msg = (error as Error).message || 'Failed to deploy stack'; - toast.error(isPro ? `${msg} - automatically rolled back to previous version.` : msg); + toast.error(isPaid ? `${msg} - automatically rolled back to previous version.` : msg); } finally { clearStackAction(stackFile); refreshStacks(true); @@ -1175,7 +1175,7 @@ export default function EditorLayout() { setContainers(Array.isArray(conts) ? conts : []); } if (action === 'update') fetchImageUpdates(); - if (action === 'deploy' && isPro) { + if (action === 'deploy' && isPaid) { try { const backupRes = await apiFetch(`/stacks/${stackName}/backup`); if (backupRes.ok) setBackupInfo(await backupRes.json()); @@ -1184,7 +1184,7 @@ export default function EditorLayout() { } catch (error) { console.error(`Failed to ${action}:`, error); const msg = (error as Error).message || `Failed to ${action} stack`; - toast.error(action === 'deploy' && isPro ? `${msg} - automatically rolled back to previous version.` : msg); + toast.error(action === 'deploy' && isPaid ? `${msg} - automatically rolled back to previous version.` : msg); } finally { clearStackAction(stackFile); refreshStacks(true); @@ -1426,7 +1426,7 @@ export default function EditorLayout() { {typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘' : 'Ctrl+'}K - {isPro && labels.length > 0 && ( + {isPaid && labels.length > 0 && (
{labels.map(label => ( @@ -1500,7 +1500,7 @@ export default function EditorLayout() { : '--'} {getDisplayName(file)} - {isPro && stackLabelMap[file]?.length > 0 && ( + {isPaid && stackLabelMap[file]?.length > 0 && ( {stackLabelMap[file].map(l => ( @@ -1527,7 +1527,7 @@ export default function EditorLayout() { Alerts - {isPro && ( + {isPaid && ( Alerts - {isPro && ( + {isPaid && ( @@ -1931,7 +1931,7 @@ export default function EditorLayout() { {loadingAction === 'update' ? 'Updating...' : 'Update'} - {isPro && backupInfo.exists && ( + {isPaid && backupInfo.exists && ( diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 8bd8b867..0fe64ca2 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -23,7 +23,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightI import { springs } from '@/lib/motion'; import { apiFetch } from '@/lib/api'; import { useLicense } from '@/context/LicenseContext'; -import { ProGate } from './ProGate'; +import { PaidGate } from './PaidGate'; import FleetSnapshots from './FleetSnapshots'; import { toast } from '@/components/ui/toast-store'; import { LabelDot, type Label as StackLabel } from './LabelPill'; @@ -357,7 +357,7 @@ function ReconnectingOverlay() { } function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId }: { node: FleetNode; onNavigate: (nodeId: number, stackName: string) => void; labelMap?: Record; updateStatus?: NodeUpdateStatus; onUpdate?: (nodeId: number) => void; updatingNodeId?: number | null }) { - const { isPro } = useLicense(); + const { isPaid } = useLicense(); const [expanded, setExpanded] = useState(false); const [stacks, setStacks] = useState(node.stacks); const [loadingStacks, setLoadingStacks] = useState(false); @@ -368,7 +368,7 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating const diskPercent = getNodeDisk(node); const handleExpand = async () => { - if (!isPro) return; + if (!isPaid) return; const next = !expanded; setExpanded(next); @@ -512,8 +512,8 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating )}
- {/* Pro Expandable Stack List with Container Drill-Down */} - {isOnline && isPro && ( + {/* Paid: Expandable Stack List with Container Drill-Down */} + {isOnline && isPaid && (
diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index c241031b..779d97b0 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -20,7 +20,7 @@ import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, Alert import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; -import { ProGate } from './ProGate'; +import { PaidGate } from './PaidGate'; import { CapabilityGate } from './CapabilityGate'; import { formatBytes } from '@/lib/utils'; import { cn } from '@/lib/utils'; @@ -347,7 +347,7 @@ function TableSkeleton({ cols, rows = 5 }: { cols: number; rows?: number }) { export default function ResourcesView() { const { isAdmin } = useAuth(); const { activeNode } = useNodes(); - const { isPro } = useLicense(); + const { isPaid } = useLicense(); const [networkViewMode, setNetworkViewMode] = useState<'list' | 'topology'>('list'); const [usage, setUsage] = useState(null); const [images, setImages] = useState([]); @@ -794,7 +794,7 @@ export default function ResourcesView() { )} > Topology - {!isPro && Pro} + {!isPaid && Skipper} {isAdmin && networkViewMode === 'list' && ( @@ -813,7 +813,7 @@ export default function ResourcesView() { {networkViewMode === 'topology' ? (
- + @@ -823,7 +823,7 @@ export default function ResourcesView() { - +
) : ( diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index e0165633..66731d1a 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -47,7 +47,7 @@ interface SettingsModalProps { export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModalProps) { const { activeNode } = useNodes(); const { isAdmin } = useAuth(); - const { license, isPro } = useLicense(); + const { license, isPaid } = useLicense(); const isRemote = activeNode?.type === 'remote'; const [activeSection, setActiveSection] = useState(initialSection || 'account'); @@ -273,7 +273,7 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal case 'notification-routing': return ; case 'webhooks': - return ; + return ; case 'developer': return ( !open && onClose()}> @@ -332,19 +332,19 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal {/* Users / SSO / API Tokens / Registries */} {!isRemote && isAdmin && ( - } label="Users" locked={!isPro} /> + } label="Users" locked={!isPaid} /> )} {!isRemote && isAdmin && ( - } label="SSO" locked={!isTeamPro} /> + } label="SSO" locked={!isAdmiral} /> )} {!isRemote && isAdmin && ( - } label="API Tokens" locked={!isTeamPro} /> + } label="API Tokens" locked={!isAdmiral} /> )} {!isRemote && isAdmin && ( - } label="Registries" locked={!isTeamPro} /> + } label="Registries" locked={!isAdmiral} /> )} {!isRemote && ( - } label="Labels" locked={!isPro} /> + } label="Labels" locked={!isPaid} /> )} {!isRemote && isAdmin && } @@ -358,10 +358,10 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal /> } label="Notifications" /> {!isRemote && isAdmin && ( - } label="Routing" locked={!isTeamPro} /> + } label="Routing" locked={!isAdmiral} /> )} {!isRemote && ( - } label="Webhooks" locked={!isPro} /> + } label="Webhooks" locked={!isPaid} /> )} @@ -143,7 +143,7 @@ export function DeveloperSection({ settings, onSettingChange, onSave, isSaving, - {isPro && license?.variant === 'team' && ( + {isPaid && license?.variant === 'team' && (
diff --git a/frontend/src/components/settings/LabelsSection.tsx b/frontend/src/components/settings/LabelsSection.tsx index 608fc09a..1ffde8c2 100644 --- a/frontend/src/components/settings/LabelsSection.tsx +++ b/frontend/src/components/settings/LabelsSection.tsx @@ -21,7 +21,7 @@ import { import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; -import { ProGate } from '../ProGate'; +import { PaidGate } from '../PaidGate'; import { CapabilityGate } from '../CapabilityGate'; import { LabelDot, type Label, type LabelColor } from '../LabelPill'; @@ -123,7 +123,7 @@ export function LabelsSection() { }; return ( - +
@@ -236,6 +236,6 @@ export function LabelsSection() { - + ); } diff --git a/frontend/src/components/settings/LicenseSection.tsx b/frontend/src/components/settings/LicenseSection.tsx index c02e8655..35f7a4aa 100644 --- a/frontend/src/components/settings/LicenseSection.tsx +++ b/frontend/src/components/settings/LicenseSection.tsx @@ -12,8 +12,14 @@ import { } from 'lucide-react'; import { apiFetch } from '@/lib/api'; +function getTierDisplayName(tier?: string, variant?: string | null, status?: string): string { + if (tier === 'paid' && variant === 'team' && status === 'active') return 'Sencho Admiral'; + if (tier === 'paid') return 'Sencho Skipper'; + return 'Sencho Community'; +} + export function LicenseSection() { - const { license, activate, deactivate } = useLicense(); + const { license, isPaid, activate, deactivate } = useLicense(); const [licenseKeyInput, setLicenseKeyInput] = useState(''); const [isActivating, setIsActivating] = useState(false); const [isDeactivating, setIsDeactivating] = useState(false); @@ -36,24 +42,27 @@ export function LicenseSection() { } }; + const showSkipperCard = !isPaid || license?.status === 'trial'; + const showUpgradeCards = showSkipperCard || (license?.variant === 'personal' && license?.status === 'active'); + return (

License

-

Manage your Sencho Pro license.

+

Manage your Sencho license.

{/* Current Tier Display */}
- {license?.tier === 'pro' ? ( + {isPaid ? ( ) : ( )} - {license?.tier === 'pro' ? 'Sencho Pro' : 'Sencho Community'} + {getTierDisplayName(license?.tier, license?.variant, license?.status)}
@@ -98,7 +107,7 @@ export function LicenseSection() { {license?.status === 'expired' && (
- Your Pro license has expired. Renew to restore Pro features. + Your license has expired. Renew to restore paid features.
)} @@ -110,7 +119,7 @@ export function LicenseSection() { )}
- {/* Manage Subscription (active Pro) */} + {/* Manage Subscription (active paid license) */} {license?.status === 'active' && (
- {/* Pro support channels */} - {isPro && ( + {/* Paid tier support channels */} + {isPaid && (

- Pro Support + Priority Support

Need faster support?

- Upgrade to Pro for direct email support and priority issue handling. + Upgrade to Skipper or Admiral for direct email support and priority issue handling.

diff --git a/frontend/src/components/settings/UsersSection.tsx b/frontend/src/components/settings/UsersSection.tsx index e9885ada..9545e5d3 100644 --- a/frontend/src/components/settings/UsersSection.tsx +++ b/frontend/src/components/settings/UsersSection.tsx @@ -13,7 +13,7 @@ import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { useAuth, type UserRole } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; -import { ProGate } from '@/components/ProGate'; +import { PaidGate } from '@/components/PaidGate'; import { CapabilityGate } from '@/components/CapabilityGate'; import { RefreshCw, Trash2, Plus, Pencil } from 'lucide-react'; @@ -35,7 +35,7 @@ interface RoleAssignmentItem { export function UsersSection() { const { user: currentUser } = useAuth(); - const { isPro, license } = useLicense(); + const { isPaid, license } = useLicense(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); @@ -230,7 +230,7 @@ export function UsersSection() { }; return ( - +
@@ -267,7 +267,7 @@ export function UsersSection() { Admin Viewer - {isPro && license?.variant === 'team' && ( + {isPaid && license?.variant === 'team' && ( <> Deployer Node Admin @@ -306,7 +306,7 @@ export function UsersSection() {
{/* Scoped Permissions (Admiral, editing only) */} - {editingUser && isPro && license?.variant === 'team' && ( + {editingUser && isPaid && license?.variant === 'team' && (

Scoped Permissions

@@ -455,6 +455,6 @@ export function UsersSection() { )}

- + ); } diff --git a/frontend/src/components/settings/WebhooksSection.tsx b/frontend/src/components/settings/WebhooksSection.tsx index d399415f..6b8e60f4 100644 --- a/frontend/src/components/settings/WebhooksSection.tsx +++ b/frontend/src/components/settings/WebhooksSection.tsx @@ -8,7 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; -import { ProGate } from '@/components/ProGate'; +import { PaidGate } from '@/components/PaidGate'; import { CapabilityGate } from '@/components/CapabilityGate'; import { TierBadge } from '@/components/TierBadge'; import { @@ -38,7 +38,7 @@ interface WebhookExecution { executed_at: number; } -export function WebhooksSection({ isPro }: { isPro: boolean }) { +export function WebhooksSection({ isPaid }: { isPaid: boolean }) { const [webhooks, setWebhooks] = useState([]); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); @@ -134,21 +134,21 @@ export function WebhooksSection({ isPro }: { isPro: boolean }) { toast.success(`${label} copied to clipboard.`); }; - if (!isPro) { + if (!isPaid) { return (

Webhooks

Trigger stack actions from CI/CD pipelines via HTTP.

- +
- +
); } diff --git a/frontend/src/context/LicenseContext.tsx b/frontend/src/context/LicenseContext.tsx index cc87208e..33a22a0e 100644 --- a/frontend/src/context/LicenseContext.tsx +++ b/frontend/src/context/LicenseContext.tsx @@ -1,7 +1,7 @@ import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'; import { apiFetch } from '@/lib/api'; -export type LicenseTier = 'community' | 'pro'; +export type LicenseTier = 'community' | 'paid'; export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disabled'; export type LicenseVariant = 'personal' | 'team' | null; @@ -21,7 +21,7 @@ export interface LicenseInfo { interface LicenseContextType { license: LicenseInfo | null; - isPro: boolean; + isPaid: boolean; loading: boolean; refresh: () => Promise; activate: (licenseKey: string) => Promise<{ success: boolean; error?: string }>; @@ -87,10 +87,10 @@ export function LicenseProvider({ children }: { children: ReactNode }) { } }, []); - const isPro = license?.tier === 'pro'; + const isPaid = license?.tier === 'paid'; return ( - + {children} );