refactor(licensing): replace Pro branding with Community/Skipper/Admiral tiers (#375)

Eliminate all references to "Pro" across backend, frontend, and docs.
Internal tier value renamed from 'pro' to 'paid'; user-facing text now
uses the thematic tier names (Community, Skipper, Admiral).

- Rename LicenseTier 'pro' to 'paid' in backend and frontend types
- Rename requirePro guard to requirePaid, error code PRO_REQUIRED to PAID_REQUIRED
- Rename ProGate.tsx to PaidGate.tsx with updated copy
- Fix: trial users can now see upgrade/purchase cards in Settings
- Update all docs and openapi.yaml to use correct tier names
This commit is contained in:
Anso
2026-04-05 05:59:36 -04:00
committed by GitHub
parent a1804c8fbe
commit f516275834
33 changed files with 304 additions and 281 deletions
+14
View File
@@ -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)
+1 -1
View File
@@ -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');
});
@@ -23,10 +23,10 @@ afterAll(() => {
const signToken = (payload: Record<string, unknown>, 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);
});
@@ -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;
+1 -1
View File
@@ -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 () => {
+61 -61
View File
@@ -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<voi
}
});
// Pro-gated: detailed stack info per node
// Paid-gated: detailed stack info per node
app.get('/api/fleet/node/:nodeId/stacks', async (req: Request, res: Response): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<FleetNodeOverview> {
}
}
// ─── Fleet Snapshots (Pro) ───
// ─── Fleet Snapshots (Skipper+) ───
interface SnapshotNodeData {
nodeId: number;
@@ -1629,7 +1629,7 @@ async function captureRemoteNodeFiles(node: Node): Promise<SnapshotNodeData> {
// Create fleet snapshot
app.post('/api/fleet/snapshots', async (req: Request, res: Response): Promise<void> => {
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<vo
// List fleet snapshots
app.get('/api/fleet/snapshots', async (req: Request, res: Response): Promise<void> => {
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<voi
// Get snapshot detail
app.get('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise<void> => {
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<void> => {
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<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);
@@ -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<void> => {
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<void> => {
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<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);
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<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);
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<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; }
@@ -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) {
+5 -5
View File
@@ -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';
+3 -3
View File
@@ -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();
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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.
---
<Note>
Atomic Deployments require a **Sencho Skipper** or **Admiral** license. Community Edition uses standard deployments without backup or rollback.
</Note>
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
+1 -1
View File
@@ -4,7 +4,7 @@ description: Snapshot compose files across all nodes for disaster recovery and a
---
<Note>
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.
</Note>
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.
+4 -4
View File
@@ -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.
<Frame>
<img src="/images/fleet-view/fleet-overview.png" alt="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
<Note>
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.
</Note>
### Fleet health summary cards
+4 -4
View File
@@ -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)
+1 -1
View File
@@ -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**.
</Note>
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
+1 -1
View File
@@ -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.
<Note>
Remote updates require a **Sencho Pro** license (Skipper or Admiral tier).
Remote updates require a **Skipper** or **Admiral** license.
</Note>
## Prerequisites
+2 -2
View File
@@ -103,7 +103,7 @@ Click the eye icon on any network row to open a detail panel showing:
<img src="/images/networks/network-inspect.png" alt="Network inspect panel showing IPAM config and connected containers" />
</Frame>
#### Network topology <span style={{fontSize: '0.75em', color: 'var(--brand)'}}>Pro</span>
#### Network topology <span style={{fontSize: '0.75em', color: 'var(--brand)'}}>Skipper</span>
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
<Note>
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.
</Note>
### Unmanaged
+2 -2
View File
@@ -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)
+27 -27
View File
@@ -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:
+2 -2
View File
@@ -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 (
@@ -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 (
<ProGate featureName="Auto-Update Policies">
<PaidGate featureName="Auto-Update Policies">
<AutoUpdatePoliciesContent filterNodeId={filterNodeId} onClearFilter={onClearFilter} />
</ProGate>
</PaidGate>
);
}
+16 -16
View File
@@ -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
</kbd>
</div>
{isPro && labels.length > 0 && (
{isPaid && labels.length > 0 && (
<div className="flex gap-1 px-3 py-1.5 overflow-x-auto scrollbar-none flex-none">
{labels.map(label => (
<ContextMenu key={label.id}>
@@ -1500,7 +1500,7 @@ export default function EditorLayout() {
: '--'}
</span>
<span className="flex-1 truncate font-mono text-[13px]">{getDisplayName(file)}</span>
{isPro && stackLabelMap[file]?.length > 0 && (
{isPaid && stackLabelMap[file]?.length > 0 && (
<span className="flex items-center gap-0.5 shrink-0 ml-1">
{stackLabelMap[file].map(l => (
<LabelDot key={l.id} color={l.color} />
@@ -1527,7 +1527,7 @@ export default function EditorLayout() {
<BellRing className="h-4 w-4 mr-2" />
Alerts
</DropdownMenuItem>
{isPro && (
{isPaid && (
<LabelAssignPopover
stackName={file}
allLabels={labels}
@@ -1610,7 +1610,7 @@ export default function EditorLayout() {
<BellRing className="h-4 w-4 mr-2" />
Alerts
</ContextMenuItem>
{isPro && (
{isPaid && (
<ContextMenuSub>
<ContextMenuSubTrigger>
<Tag className="h-4 w-4 mr-2" strokeWidth={1.5} />
@@ -1931,7 +1931,7 @@ export default function EditorLayout() {
<CloudDownload className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'update' ? 'Updating...' : 'Update'}
</Button>
{isPro && backupInfo.exists && (
{isPaid && backupInfo.exists && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
+32 -32
View File
@@ -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<string, StackLabel[]>; updateStatus?: NodeUpdateStatus; onUpdate?: (nodeId: number) => void; updatingNodeId?: number | null }) {
const { isPro } = useLicense();
const { isPaid } = useLicense();
const [expanded, setExpanded] = useState(false);
const [stacks, setStacks] = useState<string[] | null>(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
)}
</div>
{/* Pro Expandable Stack List with Container Drill-Down */}
{isOnline && isPro && (
{/* Paid: Expandable Stack List with Container Drill-Down */}
{isOnline && isPaid && (
<div className="border-t">
<button
onClick={handleExpand}
@@ -571,7 +571,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
const [fleetLabels, setFleetLabels] = useState<StackLabel[]>([]);
const [fleetStackLabelMap, setFleetStackLabelMap] = useState<Record<string, StackLabel[]>>({});
const [labelFilters, setLabelFilters] = useState<Set<number>>(new Set());
const { isPro } = useLicense();
const { isPaid } = useLicense();
const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]);
const [updatingNodeId, setUpdatingNodeId] = useState<number | null>(null);
const [reconnecting, setReconnecting] = useState(false);
@@ -606,7 +606,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
}, []);
const fetchLabels = useCallback(async () => {
if (!isPro) return;
if (!isPaid) return;
try {
const [labelsRes, assignmentsRes] = await Promise.all([
apiFetch('/labels', { localOnly: true }),
@@ -617,10 +617,10 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
} catch {
// Non-critical
}
}, [isPro]);
}, [isPaid]);
const fetchUpdateStatus = useCallback(async () => {
if (!isPro) return;
if (!isPaid) return;
try {
const res = await apiFetch('/fleet/update-status', { localOnly: true });
if (res.ok) {
@@ -631,7 +631,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
);
}
} catch { /* non-critical */ }
}, [isPro]);
}, [isPaid]);
const triggerNodeUpdate = useCallback(async (nodeId: number) => {
const status = updateStatusesRef.current.find(s => s.nodeId === nodeId);
@@ -704,12 +704,12 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
fetchUpdateStatus();
}, [fetchOverview, fetchLabels, fetchUpdateStatus]);
// Pro: auto-refresh every 30s
// Paid tier: auto-refresh every 30s
useEffect(() => {
if (!isPro) return;
if (!isPaid) return;
const interval = setInterval(() => fetchOverview(), 30000);
return () => clearInterval(interval);
}, [isPro, fetchOverview]);
}, [isPaid, fetchOverview]);
// Fast poll (5s) when any node is actively updating — uses ref to avoid interval thrashing
const hasUpdatingRef = useRef(false);
@@ -756,12 +756,12 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
[updateStatuses]
);
// --- Filtering & Sorting (Pro) ---
// --- Filtering & Sorting (Skipper+) ---
const processedNodes = useMemo(() => {
let filtered = [...nodes];
// Search (Pro only, but harmless if applied - free users won't see the search bar)
// Search (paid only, but harmless if applied - free users won't see the search bar)
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
filtered = filtered.filter(n =>
@@ -770,7 +770,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
);
}
if (isPro) {
if (isPaid) {
// Status filter
if (prefs.filterStatus === 'online') filtered = filtered.filter(n => n.status === 'online');
if (prefs.filterStatus === 'offline') filtered = filtered.filter(n => n.status !== 'online');
@@ -817,7 +817,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
}
return filtered;
}, [nodes, searchQuery, isPro, prefs, labelFilters, fleetStackLabelMap]);
}, [nodes, searchQuery, isPaid, prefs, labelFilters, fleetStackLabelMap]);
return (
<div className="h-full overflow-auto p-6">
@@ -830,7 +830,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
</p>
</div>
<div className="flex items-center gap-2">
{isPro && (
{isPaid && (
<Button
variant="outline"
size="sm"
@@ -865,7 +865,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<TabsHighlightItem value="overview">
<TabsTrigger value="overview">Overview</TabsTrigger>
</TabsHighlightItem>
{isPro && (
{isPaid && (
<TabsHighlightItem value="snapshots">
<TabsTrigger value="snapshots">
<Camera className="w-4 h-4 mr-1.5" />Snapshots
@@ -907,8 +907,8 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
{/* Fleet Content */}
{!loading && nodes.length > 0 && (
<>
{/* Pro: Fleet Health Summary Cards */}
{isPro && onlineNodes.length > 0 && (
{/* Paid: Fleet Health Summary Cards */}
{isPaid && onlineNodes.length > 0 && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
<StatCard
icon={Box}
@@ -938,8 +938,8 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
</div>
)}
{/* Pro: Search, Sort & Filter Toolbar */}
{isPro && (
{/* Paid: Search, Sort & Filter Toolbar */}
{isPaid && (
<div className="flex flex-wrap items-center gap-3 mb-4">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-sm">
@@ -1050,7 +1050,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
onNavigate={onNavigateToNode}
labelMap={fleetStackLabelMap}
updateStatus={updateStatusMap.get(node.id)}
onUpdate={isPro ? triggerNodeUpdate : undefined}
onUpdate={isPaid ? triggerNodeUpdate : undefined}
updatingNodeId={updatingNodeId}
/>
))}
@@ -1076,18 +1076,18 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
</div>
)}
{/* Pro auto-refresh indicator */}
{isPro && (
{/* Paid tier auto-refresh indicator */}
{isPaid && (
<p className="text-xs text-muted-foreground text-center mt-6">
Auto-refreshing every 30 seconds
</p>
)}
{/* Free tier: Pro gate for advanced features */}
{!isPro && nodes.length > 0 && (
{/* Free tier: paid gate for advanced features */}
{!isPaid && nodes.length > 0 && (
<div className="mt-6">
<ProGate featureName="Fleet Management">
{/* Preview of what Pro unlocks */}
<PaidGate featureName="Fleet Management">
{/* Preview of what paid tier unlocks */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<div className="rounded-xl border bg-card p-4 h-24" />
<div className="rounded-xl border bg-card p-4 h-24" />
@@ -1098,14 +1098,14 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<div className="h-9 rounded-md border bg-card flex-1 max-w-sm" />
<div className="h-9 rounded-md border bg-card w-[150px]" />
</div>
</ProGate>
</PaidGate>
</div>
)}
</>
)}
</TabsContent>
{isPro && (
{isPaid && (
<TabsContent value="snapshots">
<FleetSnapshots />
</TabsContent>
@@ -3,7 +3,7 @@ import { Compass } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useLicense } from '@/context/LicenseContext';
interface ProGateProps {
interface PaidGateProps {
children: ReactNode;
featureName?: string;
}
@@ -16,11 +16,11 @@ function isDismissedFromStorage(): boolean {
return !!dismissedAt && Date.now() - parseInt(dismissedAt, 10) < DISMISS_DURATION_MS;
}
export function ProGate({ children, featureName = 'This feature' }: ProGateProps) {
const { isPro } = useLicense();
export function PaidGate({ children, featureName = 'This feature' }: PaidGateProps) {
const { isPaid } = useLicense();
const [dismissed, setDismissed] = useState(isDismissedFromStorage);
if (isPro) return <>{children}</>;
if (isPaid) return <>{children}</>;
if (dismissed) {
return (
@@ -31,7 +31,7 @@ export function ProGate({ children, featureName = 'This feature' }: ProGateProps
<div className="absolute inset-0 flex items-start justify-center pt-8">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-muted/80 border border-border text-muted-foreground text-xs">
<Compass className="w-3 h-3" />
Upgrade to Pro to unlock
Upgrade to unlock more features
</div>
</div>
</div>
@@ -44,9 +44,9 @@ export function ProGate({ children, featureName = 'This feature' }: ProGateProps
<Compass className="w-8 h-8 text-muted-foreground" />
</div>
<div className="text-center max-w-md">
<h3 className="text-lg font-semibold mb-2">{featureName} requires Sencho Pro</h3>
<h3 className="text-lg font-semibold mb-2">{featureName} requires a paid license</h3>
<p className="text-sm text-muted-foreground">
Unlock advanced features like fleet management, viewer accounts, and more with a Sencho Pro license.
Unlock features like fleet management, viewer accounts, and more with a Skipper or Admiral license.
</p>
</div>
<div className="flex gap-3">
@@ -65,7 +65,7 @@ export function ProGate({ children, featureName = 'This feature' }: ProGateProps
onClick={() => window.open('https://sencho.io/pricing', '_blank')}
>
<Compass className="w-4 h-4 mr-2" />
Get Sencho Pro
View Plans
</Button>
</div>
</div>
+5 -5
View File
@@ -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<UsageData | null>(null);
const [images, setImages] = useState<DockerImage[]>([]);
@@ -794,7 +794,7 @@ export default function ResourcesView() {
)}
>
Topology
{!isPro && <Badge variant="outline" className="text-[9px] h-4 px-1 border-brand/30 text-brand">Pro</Badge>}
{!isPaid && <Badge variant="outline" className="text-[9px] h-4 px-1 border-brand/30 text-brand">Skipper</Badge>}
</button>
</div>
{isAdmin && networkViewMode === 'list' && (
@@ -813,7 +813,7 @@ export default function ResourcesView() {
{networkViewMode === 'topology' ? (
<div className="p-4">
<ProGate featureName="Network Topology">
<PaidGate featureName="Network Topology">
<CapabilityGate capability="network-topology" featureName="Network Topology">
<Suspense fallback={
<div className="flex items-center justify-center h-[400px] text-muted-foreground gap-2">
@@ -823,7 +823,7 @@ export default function ResourcesView() {
<NetworkTopologyView />
</Suspense>
</CapabilityGate>
</ProGate>
</PaidGate>
</div>
) : (
<Table>
+10 -10
View File
@@ -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<SectionId>(initialSection || 'account');
@@ -273,7 +273,7 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
case 'notification-routing':
return <NotificationRoutingSection />;
case 'webhooks':
return <WebhooksSection isPro={isPro} />;
return <WebhooksSection isPaid={isPaid} />;
case 'developer':
return (
<DeveloperSection
@@ -303,7 +303,7 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
}
};
const isTeamPro = isPro && license?.variant === 'team';
const isAdmiral = isPaid && license?.variant === 'team';
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
@@ -332,19 +332,19 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
{/* Users / SSO / API Tokens / Registries */}
{!isRemote && isAdmin && (
<NavButton section="users" icon={<Users className="w-4 h-4 mr-2" />} label="Users" locked={!isPro} />
<NavButton section="users" icon={<Users className="w-4 h-4 mr-2" />} label="Users" locked={!isPaid} />
)}
{!isRemote && isAdmin && (
<NavButton section="sso" icon={<Shield className="w-4 h-4 mr-2" />} label="SSO" locked={!isTeamPro} />
<NavButton section="sso" icon={<Shield className="w-4 h-4 mr-2" />} label="SSO" locked={!isAdmiral} />
)}
{!isRemote && isAdmin && (
<NavButton section="api-tokens" icon={<Zap className="w-4 h-4 mr-2" />} label="API Tokens" locked={!isTeamPro} />
<NavButton section="api-tokens" icon={<Zap className="w-4 h-4 mr-2" />} label="API Tokens" locked={!isAdmiral} />
)}
{!isRemote && isAdmin && (
<NavButton section="registries" icon={<Database className="w-4 h-4 mr-2" />} label="Registries" locked={!isTeamPro} />
<NavButton section="registries" icon={<Database className="w-4 h-4 mr-2" />} label="Registries" locked={!isAdmiral} />
)}
{!isRemote && (
<NavButton section="labels" icon={<Tag className="w-4 h-4 mr-2" />} label="Labels" locked={!isPro} />
<NavButton section="labels" icon={<Tag className="w-4 h-4 mr-2" />} label="Labels" locked={!isPaid} />
)}
{!isRemote && isAdmin && <Separator className="my-1.5" />}
@@ -358,10 +358,10 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
/>
<NavButton section="notifications" icon={<Bell className="w-4 h-4 mr-2" />} label="Notifications" />
{!isRemote && isAdmin && (
<NavButton section="notification-routing" icon={<GitBranch className="w-4 h-4 mr-2" />} label="Routing" locked={!isTeamPro} />
<NavButton section="notification-routing" icon={<GitBranch className="w-4 h-4 mr-2" />} label="Routing" locked={!isAdmiral} />
)}
{!isRemote && (
<NavButton section="webhooks" icon={<Webhook className="w-4 h-4 mr-2" />} label="Webhooks" locked={!isPro} />
<NavButton section="webhooks" icon={<Webhook className="w-4 h-4 mr-2" />} label="Webhooks" locked={!isPaid} />
)}
<NavButton
section="developer"
+3 -3
View File
@@ -11,15 +11,15 @@ interface TierBadgeProps {
const tierConfig = {
community: { icon: Globe, label: 'Community' },
pro: { icon: Compass, label: 'Skipper' },
paid: { icon: Compass, label: 'Skipper' },
team: { icon: ShipWheel, label: 'Admiral' },
} as const;
function resolveTier(tier: LicenseTier, variant: LicenseVariant, status: LicenseStatus) {
// Only show Team badge for active team licenses, not trials
// (trials default to team variant to unlock all features)
if (tier === 'pro' && variant === 'team' && status === 'active') return tierConfig.team;
if (tier === 'pro') return tierConfig.pro;
if (tier === 'paid' && variant === 'team' && status === 'active') return tierConfig.team;
if (tier === 'paid') return tierConfig.paid;
return tierConfig.community;
}
@@ -34,7 +34,7 @@ function SettingsSkeleton() {
}
export function DeveloperSection({ settings, onSettingChange, onSave, isSaving, isLoading, isRemote }: DeveloperSectionProps) {
const { isPro, license } = useLicense();
const { isPaid, license } = useLicense();
return (
<div className="space-y-6">
@@ -143,7 +143,7 @@ export function DeveloperSection({ settings, onSettingChange, onSave, isSaving,
</div>
</div>
{isPro && license?.variant === 'team' && (
{isPaid && license?.variant === 'team' && (
<div className="flex items-center justify-between gap-4 pt-4 border-t border-glass-border">
<div className="space-y-0.5">
<Label className="text-base">Audit Log Retention</Label>
@@ -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 (
<ProGate featureName="Stack Labels">
<PaidGate featureName="Stack Labels">
<CapabilityGate capability="labels" featureName="Stack Labels">
<div className="space-y-4">
<div className="flex items-center justify-between pr-8">
@@ -236,6 +236,6 @@ export function LabelsSection() {
</AlertDialogContent>
</AlertDialog>
</CapabilityGate>
</ProGate>
</PaidGate>
);
}
@@ -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 (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight">License</h3>
<p className="text-sm text-muted-foreground">Manage your Sencho Pro license.</p>
<p className="text-sm text-muted-foreground">Manage your Sencho license.</p>
</div>
{/* Current Tier Display */}
<div className="bg-glass border border-glass-border p-4 rounded-lg space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{license?.tier === 'pro' ? (
{isPaid ? (
<CheckCircle className="w-5 h-5 text-success" />
) : (
<Crown className="w-5 h-5 text-muted-foreground" />
)}
<span className="font-medium text-base">
{license?.tier === 'pro' ? 'Sencho Pro' : 'Sencho Community'}
{getTierDisplayName(license?.tier, license?.variant, license?.status)}
</span>
</div>
<TierBadge />
@@ -98,7 +107,7 @@ export function LicenseSection() {
{license?.status === 'expired' && (
<div className="flex items-center gap-2 text-sm text-destructive">
<XCircle className="w-4 h-4" />
<span>Your Pro license has expired. Renew to restore Pro features.</span>
<span>Your license has expired. Renew to restore paid features.</span>
</div>
)}
@@ -110,7 +119,7 @@ export function LicenseSection() {
)}
</div>
{/* Manage Subscription (active Pro) */}
{/* Manage Subscription (active paid license) */}
{license?.status === 'active' && (
<div className="space-y-3">
<Button
@@ -155,13 +164,13 @@ export function LicenseSection() {
</div>
)}
{/* Upgrade Cards */}
{(license?.tier !== 'pro' || (license?.variant === 'personal' && license?.status === 'active')) && (
{/* Upgrade Cards: show for community, trial, or active Skipper users */}
{showUpgradeCards && (
<div className="space-y-3">
<Label className="text-base">Upgrade your plan</Label>
<div className={`grid gap-3 ${license?.tier !== 'pro' ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'}`}>
{/* Skipper Card - only for Community users */}
{license?.tier !== 'pro' && (
<div className={`grid gap-3 ${showSkipperCard ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'}`}>
{/* Skipper Card - only for Community users and trial users */}
{showSkipperCard && (
<div className="relative border border-glass-border rounded-lg p-4 space-y-3 bg-glass flex flex-col">
<div className="flex items-center gap-2">
<Compass className="w-4 h-4 text-amber-500" />
@@ -213,7 +222,7 @@ export function LicenseSection() {
</ul>
<Button
size="sm"
variant={license?.tier !== 'pro' ? 'outline' : 'default'}
variant={showSkipperCard ? 'outline' : 'default'}
className="w-full mt-auto"
onClick={() => window.open('https://saelix.lemonsqueezy.com/checkout/buy/b049b824-176a-408d-a9d3-9365c979a61f', '_blank')}
>
@@ -244,7 +253,7 @@ export function LicenseSection() {
setIsActivating(true);
const result = await activate(licenseKeyInput.trim());
if (result.success) {
toast.success('License activated! Welcome to Sencho Pro.');
toast.success('License activated successfully.');
setLicenseKeyInput('');
} else {
toast.error(result.error || 'Activation failed');
@@ -4,7 +4,7 @@ import { TierBadge } from '@/components/TierBadge';
import { Book, Bug, Mail, ExternalLink, Crown } from 'lucide-react';
export function SupportSection() {
const { isPro, license } = useLicense();
const { isPaid, license } = useLicense();
return (
<div className="space-y-6">
@@ -42,11 +42,11 @@ export function SupportSection() {
</div>
</div>
{/* Pro support channels */}
{isPro && (
{/* Paid tier support channels */}
{isPaid && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
Pro Support <TierBadge />
Priority Support <TierBadge />
</h4>
<div className="grid gap-3">
<a href={license?.variant === 'team' ? 'mailto:support@sencho.io' : 'mailto:licensing@sencho.io'}
@@ -71,17 +71,17 @@ export function SupportSection() {
)}
{/* Upsell for Community */}
{!isPro && (
{!isPaid && (
<div className="rounded-lg border border-glass-border p-4 bg-muted/30">
<div className="flex items-start gap-3">
<Crown className="w-5 h-5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Need faster support?</p>
<p className="text-xs text-muted-foreground mt-1">
Upgrade to Pro for direct email support and priority issue handling.
Upgrade to Skipper or Admiral for direct email support and priority issue handling.
</p>
<Button size="sm" className="mt-3" onClick={() => window.open('https://sencho.io/#pricing', '_blank')}>
Upgrade to Pro
View Plans
</Button>
</div>
</div>
@@ -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<UserItem[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
@@ -230,7 +230,7 @@ export function UsersSection() {
};
return (
<ProGate featureName="User management">
<PaidGate featureName="User management">
<CapabilityGate capability="users" featureName="User Management">
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
@@ -267,7 +267,7 @@ export function UsersSection() {
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
{isPro && license?.variant === 'team' && (
{isPaid && license?.variant === 'team' && (
<>
<SelectItem value="deployer">Deployer</SelectItem>
<SelectItem value="node-admin">Node Admin</SelectItem>
@@ -306,7 +306,7 @@ export function UsersSection() {
</div>
{/* Scoped Permissions (Admiral, editing only) */}
{editingUser && isPro && license?.variant === 'team' && (
{editingUser && isPaid && license?.variant === 'team' && (
<div className="border border-glass-border rounded-lg p-4 space-y-3 mt-4">
<h4 className="text-sm font-medium">Scoped Permissions</h4>
<p className="text-xs text-muted-foreground">
@@ -455,6 +455,6 @@ export function UsersSection() {
)}
</div>
</CapabilityGate>
</ProGate>
</PaidGate>
);
}
@@ -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<WebhookItem[]>([]);
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 (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">Webhooks <TierBadge /></h3>
<p className="text-sm text-muted-foreground">Trigger stack actions from CI/CD pipelines via HTTP.</p>
</div>
<ProGate featureName="Webhooks">
<PaidGate featureName="Webhooks">
<CapabilityGate capability="webhooks" featureName="Webhooks">
<div className="space-y-3">
<div className="h-16 rounded-lg border bg-card" />
<div className="h-16 rounded-lg border bg-card" />
</div>
</CapabilityGate>
</ProGate>
</PaidGate>
</div>
);
}
+4 -4
View File
@@ -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<void>;
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 (
<LicenseContext.Provider value={{ license, isPro, loading, refresh, activate, deactivate }}>
<LicenseContext.Provider value={{ license, isPaid, loading, refresh, activate, deactivate }}>
{children}
</LicenseContext.Provider>
);