mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix(licensing): rename variant values to skipper/admiral and store resolved type (#379)
Rename internal variant values from 'personal'/'team' to 'skipper'/'admiral', aligning code with user-facing tier names. Variant type is now resolved once at activation/validation and stored in DB via license_variant_type, instead of string-matching the Lemon Squeezy variant_name on every read. Also captures variant_id for future lookups. Pre-existing installs auto-migrate on first getVariant() call.
This commit is contained in:
+7
-1
@@ -13,10 +13,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
* **licensing:** internal variant values renamed from `personal`/`team` to `skipper`/`admiral`, aligning code with user-facing tier names
|
||||
* **licensing:** variant type is now resolved once at activation/validation and stored in DB, instead of string-matching the Lemon Squeezy variant name on every read; `variant_id` is also captured for future lookups
|
||||
* **licensing:** pre-existing installs auto-migrate on first `getVariant()` call (resolves from stored name, persists the result)
|
||||
|
||||
### Fixed
|
||||
|
||||
* **licensing:** trial users can now see Skipper/Admiral purchase cards in Settings > License (previously hidden due to a condition bug)
|
||||
* **licensing:** Admiral licenses (including lifetime) are now correctly identified as the "team" variant; previously, Lemon Squeezy variant names containing "Admiral" were not matched, causing the tier to display as "Skipper" and Admiral features to remain locked
|
||||
* **licensing:** Admiral licenses (including lifetime) are now correctly identified; previously, Lemon Squeezy variant names containing "Admiral" were not matched, causing the tier to display as "Skipper" and Admiral features to remain locked
|
||||
* **licensing:** "Manage Subscription" button is now hidden for lifetime licenses, which have no billing portal by design
|
||||
* **licensing:** license card now shows "Duration: Lifetime" for lifetime licenses instead of an empty renewal date
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('POST /api/system/console-token', () => {
|
||||
beforeAll(async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('team');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('authMiddleware - distributed license headers', () => {
|
||||
.get(PAID_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'personal');
|
||||
.set('x-sencho-variant', 'skipper');
|
||||
|
||||
// Should NOT get 403 PAID_REQUIRED; the proxy tier assertion grants access
|
||||
expect(res.status).not.toBe(403);
|
||||
@@ -54,7 +54,7 @@ describe('authMiddleware - distributed license headers', () => {
|
||||
.get(PAID_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'team');
|
||||
.set('x-sencho-variant', 'admiral');
|
||||
|
||||
// Local license is community in test env → should get 403
|
||||
expect(res.status).toBe(403);
|
||||
@@ -125,24 +125,24 @@ describe('requirePaid - distributed license', () => {
|
||||
// ─── requireAdmiral guard ───────────────────────────────────────────────────
|
||||
|
||||
describe('requireAdmiral - distributed license', () => {
|
||||
it('allows access when proxy asserts paid tier with team variant', async () => {
|
||||
it('allows access when proxy asserts paid tier with admiral variant', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'team');
|
||||
.set('x-sencho-variant', 'admiral');
|
||||
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it('blocks when proxy asserts paid tier with personal variant', async () => {
|
||||
it('blocks when proxy asserts paid tier with skipper variant', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'personal');
|
||||
.set('x-sencho-variant', 'skipper');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
@@ -181,7 +181,7 @@ describe('Security - tier header injection', () => {
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'team');
|
||||
.set('x-sencho-variant', 'admiral');
|
||||
|
||||
// User session → tier headers ignored → local community tier → 403
|
||||
expect(res.status).toBe(403);
|
||||
@@ -191,7 +191,7 @@ describe('Security - tier header injection', () => {
|
||||
const res = await request(app)
|
||||
.get(PAID_ROUTE)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'team');
|
||||
.set('x-sencho-variant', 'admiral');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ function setLicenseState(overrides: Record<string, string>) {
|
||||
'license_status', 'license_key', 'license_valid_until',
|
||||
'license_last_validated', 'license_customer_name',
|
||||
'license_product_name', 'license_variant_name',
|
||||
'license_variant_type', 'license_variant_id',
|
||||
'billing_portal_url', 'billing_portal_expires',
|
||||
];
|
||||
for (const key of keys) {
|
||||
@@ -37,9 +38,9 @@ function setLicenseState(overrides: Record<string, string>) {
|
||||
}
|
||||
|
||||
describe('LicenseService.getVariant()', () => {
|
||||
it('returns "personal" for trial licenses', () => {
|
||||
it('returns "skipper" for trial licenses', () => {
|
||||
setLicenseState({ license_status: 'trial' });
|
||||
expect(svc.getVariant()).toBe('personal');
|
||||
expect(svc.getVariant()).toBe('skipper');
|
||||
});
|
||||
|
||||
it('returns null when no variant name is stored', () => {
|
||||
@@ -47,39 +48,51 @@ describe('LicenseService.getVariant()', () => {
|
||||
expect(svc.getVariant()).toBeNull();
|
||||
});
|
||||
|
||||
it('maps "Team" variant name to "team"', () => {
|
||||
it('reads pre-resolved variant type from DB (admiral)', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_type: 'admiral' });
|
||||
expect(svc.getVariant()).toBe('admiral');
|
||||
});
|
||||
|
||||
it('reads pre-resolved variant type from DB (skipper)', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_type: 'skipper' });
|
||||
expect(svc.getVariant()).toBe('skipper');
|
||||
});
|
||||
|
||||
it('falls back to name resolution and persists type (Team -> admiral)', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Team' });
|
||||
expect(svc.getVariant()).toBe('team');
|
||||
expect(svc.getVariant()).toBe('admiral');
|
||||
expect(DatabaseService.getInstance().getSystemState('license_variant_type')).toBe('admiral');
|
||||
});
|
||||
|
||||
it('maps "Personal" variant name to "personal"', () => {
|
||||
it('falls back to name resolution and persists type (Personal -> skipper)', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Personal' });
|
||||
expect(svc.getVariant()).toBe('personal');
|
||||
expect(svc.getVariant()).toBe('skipper');
|
||||
expect(DatabaseService.getInstance().getSystemState('license_variant_type')).toBe('skipper');
|
||||
});
|
||||
|
||||
it('maps "Admiral" variant name to "team"', () => {
|
||||
it('maps "Admiral" variant name to "admiral"', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Admiral' });
|
||||
expect(svc.getVariant()).toBe('team');
|
||||
expect(svc.getVariant()).toBe('admiral');
|
||||
});
|
||||
|
||||
it('maps "Admiral Lifetime" variant name to "team"', () => {
|
||||
it('maps "Admiral Lifetime" variant name to "admiral"', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Admiral Lifetime' });
|
||||
expect(svc.getVariant()).toBe('team');
|
||||
expect(svc.getVariant()).toBe('admiral');
|
||||
});
|
||||
|
||||
it('maps "Skipper" variant name to "personal"', () => {
|
||||
it('maps "Skipper" variant name to "skipper"', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Skipper' });
|
||||
expect(svc.getVariant()).toBe('personal');
|
||||
expect(svc.getVariant()).toBe('skipper');
|
||||
});
|
||||
|
||||
it('maps "Skipper Lifetime" variant name to "personal"', () => {
|
||||
it('maps "Skipper Lifetime" variant name to "skipper"', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Skipper Lifetime' });
|
||||
expect(svc.getVariant()).toBe('personal');
|
||||
expect(svc.getVariant()).toBe('skipper');
|
||||
});
|
||||
|
||||
it('defaults unknown variant names to "personal"', () => {
|
||||
it('defaults unknown variant names to "skipper"', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Unknown Variant' });
|
||||
expect(svc.getVariant()).toBe('personal');
|
||||
expect(svc.getVariant()).toBe('skipper');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,7 +216,7 @@ describe('LicenseService.getLicenseInfo() - full scenarios', () => {
|
||||
const info = svc.getLicenseInfo();
|
||||
expect(info.tier).toBe('paid');
|
||||
expect(info.status).toBe('active');
|
||||
expect(info.variant).toBe('team');
|
||||
expect(info.variant).toBe('admiral');
|
||||
expect(info.isLifetime).toBe(true);
|
||||
expect(info.trialDaysRemaining).toBeNull();
|
||||
expect(info.customerName).toBe('Test User');
|
||||
@@ -226,7 +239,7 @@ describe('LicenseService.getLicenseInfo() - full scenarios', () => {
|
||||
const info = svc.getLicenseInfo();
|
||||
expect(info.tier).toBe('paid');
|
||||
expect(info.status).toBe('active');
|
||||
expect(info.variant).toBe('personal');
|
||||
expect(info.variant).toBe('skipper');
|
||||
expect(info.isLifetime).toBe(false);
|
||||
expect(info.trialDaysRemaining).toBeNull();
|
||||
expect(info.customerName).toBe('Another User');
|
||||
|
||||
@@ -29,7 +29,7 @@ const {
|
||||
mockInsertSnapshotFiles: vi.fn(),
|
||||
mockClearStackUpdateStatus: vi.fn(),
|
||||
mockGetTier: vi.fn().mockReturnValue('paid'),
|
||||
mockGetVariant: vi.fn().mockReturnValue('team'),
|
||||
mockGetVariant: vi.fn().mockReturnValue('admiral'),
|
||||
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
|
||||
mockRestartContainer: vi.fn().mockResolvedValue(undefined),
|
||||
mockPruneSystem: vi.fn().mockResolvedValue({ success: true, reclaimedBytes: 0 }),
|
||||
@@ -198,7 +198,7 @@ describe('SchedulerService - license gating', () => {
|
||||
|
||||
it('allows all actions for admiral (pro + team)', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('team');
|
||||
mockGetVariant.mockReturnValue('admiral');
|
||||
mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'restart' })]);
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
|
||||
|
||||
@@ -215,7 +215,7 @@ describe('SchedulerService - license gating', () => {
|
||||
describe('SchedulerService - concurrent task prevention', () => {
|
||||
it('does not execute a task that is already in runningTasks', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('team');
|
||||
mockGetVariant.mockReturnValue('admiral');
|
||||
mockGetDueScheduledTasks.mockReturnValue([{
|
||||
id: 42,
|
||||
name: 'running-task',
|
||||
@@ -240,7 +240,7 @@ describe('SchedulerService - concurrent task prevention', () => {
|
||||
|
||||
it('removes task from runningTasks after completion', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('team');
|
||||
mockGetVariant.mockReturnValue('admiral');
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
@@ -612,7 +612,7 @@ describe('SchedulerService - error handling', () => {
|
||||
describe('SchedulerService - cleanup', () => {
|
||||
it('calls cleanupOldTaskRuns(30) on every tick', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('team');
|
||||
mockGetVariant.mockReturnValue('admiral');
|
||||
mockGetDueScheduledTasks.mockReturnValue([]);
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
|
||||
@@ -861,7 +861,7 @@ const requireAdmiral = (req: Request, res: Response): boolean => {
|
||||
res.status(403).json({ error: 'This feature requires a Skipper or Admiral license.', code: 'PAID_REQUIRED' });
|
||||
return false;
|
||||
}
|
||||
if (variant !== 'team') {
|
||||
if (variant !== 'admiral') {
|
||||
res.status(403).json({ error: 'This feature requires a Sencho Admiral license.', code: 'ADMIRAL_REQUIRED' });
|
||||
return false;
|
||||
}
|
||||
@@ -936,7 +936,7 @@ function checkPermission(
|
||||
|
||||
// Scoped assignments only apply when a resource is specified and license is Admiral
|
||||
if (!resourceType || !resourceId) return false;
|
||||
if (LicenseService.getInstance().getVariant() !== 'team') return false;
|
||||
if (LicenseService.getInstance().getVariant() !== 'admiral') return false;
|
||||
|
||||
const assignments = DatabaseService.getInstance().getRoleAssignments(req.user.userId, resourceType, resourceId);
|
||||
for (const assignment of assignments) {
|
||||
@@ -2327,7 +2327,7 @@ app.get('/api/permissions/me', authMiddleware, (req: Request, res: Response): vo
|
||||
globalRole,
|
||||
globalPermissions,
|
||||
scopedPermissions,
|
||||
isAdmiral: LicenseService.getInstance().getVariant() === 'team',
|
||||
isAdmiral: LicenseService.getInstance().getVariant() === 'admiral',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Permissions] Error:', error);
|
||||
@@ -2628,7 +2628,7 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
}
|
||||
// Admiral license gate: host console requires Admiral (paid + team variant)
|
||||
const ls = LicenseService.getInstance();
|
||||
if (ls.getTier() !== 'paid' || ls.getVariant() !== 'team') {
|
||||
if (ls.getTier() !== 'paid' || ls.getVariant() !== 'admiral') {
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
@@ -4382,7 +4382,7 @@ app.get('/api/scheduled-tasks', (req: Request, res: Response): void => {
|
||||
let tasks = DatabaseService.getInstance().getScheduledTasks();
|
||||
// Skipper users only see 'update' tasks; Admiral sees all
|
||||
const ls = LicenseService.getInstance();
|
||||
if (ls.getVariant() !== 'team') {
|
||||
if (ls.getVariant() !== 'admiral') {
|
||||
tasks = tasks.filter(t => t.action === 'update');
|
||||
}
|
||||
res.json(tasks);
|
||||
|
||||
@@ -5,10 +5,10 @@ import { DatabaseService } from './DatabaseService';
|
||||
export type LicenseTier = 'community' | 'paid';
|
||||
export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disabled';
|
||||
|
||||
export type LicenseVariant = 'personal' | 'team' | null;
|
||||
export type LicenseVariant = 'skipper' | 'admiral' | null;
|
||||
|
||||
const VALID_TIERS: readonly string[] = ['community', 'paid'] satisfies readonly LicenseTier[];
|
||||
const VALID_VARIANTS: readonly string[] = ['personal', 'team'] satisfies readonly LicenseVariant[];
|
||||
const VALID_VARIANTS: readonly string[] = ['skipper', 'admiral'] satisfies readonly LicenseVariant[];
|
||||
|
||||
export function isLicenseTier(value: unknown): value is LicenseTier {
|
||||
return typeof value === 'string' && (VALID_TIERS as readonly string[]).includes(value);
|
||||
@@ -43,8 +43,8 @@ export interface SeatLimits {
|
||||
}
|
||||
|
||||
const SEAT_LIMITS: Record<string, SeatLimits> = {
|
||||
personal: { maxAdmins: 1, maxViewers: 3 },
|
||||
team: { maxAdmins: null, maxViewers: null },
|
||||
skipper: { maxAdmins: 1, maxViewers: 3 },
|
||||
admiral: { maxAdmins: null, maxViewers: null },
|
||||
};
|
||||
|
||||
interface LemonSqueezyActivationResponse {
|
||||
@@ -194,21 +194,49 @@ export class LicenseService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the license variant (personal or team) from stored metadata.
|
||||
* Trial licenses default to "personal"; Admiral features require an Admiral license.
|
||||
* Maps Lemon Squeezy variant names (which may use brand names like "Admiral"
|
||||
* or "Skipper") to the internal 'team' or 'personal' values.
|
||||
* Resolve a Lemon Squeezy variant name string to the internal variant type.
|
||||
* Handles legacy names ("Team", "Personal") and current names ("Admiral", "Skipper").
|
||||
*/
|
||||
private resolveVariantType(variantName: string): 'skipper' | 'admiral' {
|
||||
const lower = variantName.toLowerCase();
|
||||
if (lower.includes('team') || lower.includes('admiral')) return 'admiral';
|
||||
if (lower.includes('personal') || lower.includes('skipper')) return 'skipper';
|
||||
return 'skipper';
|
||||
}
|
||||
|
||||
/** Persist variant metadata from Lemon Squeezy response to DB. */
|
||||
private storeVariantMeta(db: DatabaseService, meta: { variant_name?: string; variant_id?: number }): void {
|
||||
if (meta.variant_name) {
|
||||
db.setSystemState('license_variant_name', meta.variant_name);
|
||||
db.setSystemState('license_variant_type', this.resolveVariantType(meta.variant_name));
|
||||
}
|
||||
if (meta.variant_id) {
|
||||
db.setSystemState('license_variant_id', String(meta.variant_id));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the license variant (skipper or admiral) from stored metadata.
|
||||
* Trial licenses default to "skipper"; Admiral features require an Admiral license.
|
||||
* Reads the pre-resolved `license_variant_type` from DB. Falls back to name-based
|
||||
* resolution for pre-existing installs, then persists the result so the fallback
|
||||
* only runs once.
|
||||
*/
|
||||
public getVariant(): LicenseVariant {
|
||||
const db = DatabaseService.getInstance();
|
||||
const status = db.getSystemState('license_status');
|
||||
if (status === 'trial') return 'personal';
|
||||
if (status === 'trial') return 'skipper';
|
||||
|
||||
// Primary path: read the pre-resolved type stored at activation/validation
|
||||
const storedType = db.getSystemState('license_variant_type');
|
||||
if (isLicenseVariant(storedType)) return storedType;
|
||||
|
||||
// Backward compat: resolve from variant name for pre-existing installs
|
||||
const variantName = db.getSystemState('license_variant_name');
|
||||
if (!variantName) return null;
|
||||
const lower = variantName.toLowerCase();
|
||||
if (lower.includes('team') || lower.includes('admiral')) return 'team';
|
||||
if (lower.includes('personal') || lower.includes('skipper')) return 'personal';
|
||||
return 'personal'; // default activated licenses to personal
|
||||
const resolved = this.resolveVariantType(variantName);
|
||||
db.setSystemState('license_variant_type', resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,7 +245,7 @@ export class LicenseService {
|
||||
public getSeatLimits(): SeatLimits {
|
||||
const variant = this.getVariant();
|
||||
if (!variant) return { maxAdmins: 1, maxViewers: 0 }; // community
|
||||
return SEAT_LIMITS[variant] || SEAT_LIMITS.personal;
|
||||
return SEAT_LIMITS[variant] || SEAT_LIMITS.skipper;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,8 +323,8 @@ export class LicenseService {
|
||||
if (data.meta?.product_name) {
|
||||
db.setSystemState('license_product_name', data.meta.product_name);
|
||||
}
|
||||
if (data.meta?.variant_name) {
|
||||
db.setSystemState('license_variant_name', data.meta.variant_name);
|
||||
if (data.meta) {
|
||||
this.storeVariantMeta(db, data.meta);
|
||||
}
|
||||
if (data.meta?.customer_id) {
|
||||
db.setSystemState('customer_id', String(data.meta.customer_id));
|
||||
@@ -353,6 +381,8 @@ export class LicenseService {
|
||||
'license_customer_name',
|
||||
'license_product_name',
|
||||
'license_variant_name',
|
||||
'license_variant_type',
|
||||
'license_variant_id',
|
||||
'subscription_id',
|
||||
'customer_id',
|
||||
'customer_portal_url',
|
||||
@@ -428,8 +458,8 @@ export class LicenseService {
|
||||
if (data.meta?.product_name) {
|
||||
db.setSystemState('license_product_name', data.meta.product_name);
|
||||
}
|
||||
if (data.meta?.variant_name) {
|
||||
db.setSystemState('license_variant_name', data.meta.variant_name);
|
||||
if (data.meta) {
|
||||
this.storeVariantMeta(db, data.meta);
|
||||
}
|
||||
if (data.meta?.customer_id && !db.getSystemState('customer_id')) {
|
||||
db.setSystemState('customer_id', String(data.meta.customer_id));
|
||||
|
||||
@@ -50,7 +50,7 @@ export class SchedulerService {
|
||||
try {
|
||||
const ls = LicenseService.getInstance();
|
||||
const isPaid = ls.getTier() === 'paid';
|
||||
const isAdmiral = isPaid && ls.getVariant() === 'team';
|
||||
const isAdmiral = isPaid && ls.getVariant() === 'admiral';
|
||||
if (!isPaid) return; // No scheduled tasks for unpaid tiers
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
@@ -20,7 +20,7 @@ export function AdmiralGate({ children, featureName = 'This feature' }: AdmiralG
|
||||
const { isPaid, license } = useLicense();
|
||||
const [dismissed, setDismissed] = useState(isDismissedFromStorage);
|
||||
|
||||
if (isPaid && license?.variant === 'team') return <>{children}</>;
|
||||
if (isPaid && license?.variant === 'admiral') return <>{children}</>;
|
||||
|
||||
if (dismissed) {
|
||||
return (
|
||||
|
||||
@@ -223,7 +223,7 @@ export default function EditorLayout() {
|
||||
if (isPaid && isAdmin) {
|
||||
items.push({ value: 'auto-updates', label: 'Auto-Update', icon: RefreshCw });
|
||||
}
|
||||
if (isPaid && license?.variant === 'team') {
|
||||
if (isPaid && license?.variant === 'admiral') {
|
||||
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 });
|
||||
|
||||
@@ -303,7 +303,7 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
|
||||
}
|
||||
};
|
||||
|
||||
const isAdmiral = isPaid && license?.variant === 'team';
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
|
||||
@@ -11,15 +11,14 @@ interface TierBadgeProps {
|
||||
|
||||
const tierConfig = {
|
||||
community: { icon: Globe, label: 'Community' },
|
||||
paid: { icon: Compass, label: 'Skipper' },
|
||||
team: { icon: ShipWheel, label: 'Admiral' },
|
||||
skipper: { icon: Compass, label: 'Skipper' },
|
||||
admiral: { 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 === 'paid' && variant === 'team' && status === 'active') return tierConfig.team;
|
||||
if (tier === 'paid') return tierConfig.paid;
|
||||
// Only show Admiral badge for active admiral licenses (trials default to skipper)
|
||||
if (tier === 'paid' && variant === 'admiral' && status === 'active') return tierConfig.admiral;
|
||||
if (tier === 'paid') return tierConfig.skipper;
|
||||
return tierConfig.community;
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ export function DeveloperSection({ settings, onSettingChange, onSave, isSaving,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPaid && license?.variant === 'team' && (
|
||||
{isPaid && license?.variant === 'admiral' && (
|
||||
<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>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
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' && variant === 'admiral' && status === 'active') return 'Sencho Admiral';
|
||||
if (tier === 'paid') return 'Sencho Skipper';
|
||||
return 'Sencho Community';
|
||||
}
|
||||
@@ -42,9 +42,9 @@ export function LicenseSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const isAdmiral = isPaid && license?.variant === 'team' && license?.status === 'active';
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral' && license?.status === 'active';
|
||||
const showSkipperCard = !isPaid || license?.status === 'trial';
|
||||
const showUpgradeCards = !isAdmiral && (showSkipperCard || (license?.variant === 'personal' && license?.status === 'active'));
|
||||
const showUpgradeCards = !isAdmiral && (showSkipperCard || (license?.variant === 'skipper' && license?.status === 'active'));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -210,9 +210,9 @@ export function LicenseSection() {
|
||||
<p className="text-xs text-muted-foreground">For teams managing shared infrastructure.</p>
|
||||
<ul className="space-y-1.5">
|
||||
{[
|
||||
...(license?.variant === 'personal' ? ['Everything in Skipper'] : ['Everything in Community']),
|
||||
...(license?.variant === 'skipper' ? ['Everything in Skipper'] : ['Everything in Community']),
|
||||
'Unlimited accounts & scoped RBAC',
|
||||
...(license?.variant !== 'personal' ? ['Fleet View, webhooks & labels', 'Atomic deployments & backups'] : []),
|
||||
...(license?.variant !== 'skipper' ? ['Fleet View, webhooks & labels', 'Atomic deployments & backups'] : []),
|
||||
'SSO, audit log & host console',
|
||||
'API tokens & private registries',
|
||||
'Scheduled operations',
|
||||
|
||||
@@ -49,17 +49,17 @@ export function SupportSection() {
|
||||
Priority Support <TierBadge />
|
||||
</h4>
|
||||
<div className="grid gap-3">
|
||||
<a href={license?.variant === 'team' ? 'mailto:support@sencho.io' : 'mailto:licensing@sencho.io'}
|
||||
<a href={license?.variant === 'admiral' ? 'mailto:support@sencho.io' : 'mailto:licensing@sencho.io'}
|
||||
className="flex items-center gap-3 p-3 rounded-lg border border-glass-border hover:bg-muted/50 transition-colors">
|
||||
<div className="w-9 h-9 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<Mail className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
{license?.variant === 'team' ? 'Priority Email Support' : 'Email Support'}
|
||||
{license?.variant === 'admiral' ? 'Priority Email Support' : 'Email Support'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{license?.variant === 'team'
|
||||
{license?.variant === 'admiral'
|
||||
? 'Direct support with responses within 24 hours'
|
||||
: 'Reach our support team directly'}
|
||||
</p>
|
||||
|
||||
@@ -267,7 +267,7 @@ export function UsersSection() {
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="viewer">Viewer</SelectItem>
|
||||
{isPaid && license?.variant === 'team' && (
|
||||
{isPaid && license?.variant === 'admiral' && (
|
||||
<>
|
||||
<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 && isPaid && license?.variant === 'team' && (
|
||||
{editingUser && isPaid && license?.variant === 'admiral' && (
|
||||
<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">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { apiFetch } from '@/lib/api';
|
||||
export type LicenseTier = 'community' | 'paid';
|
||||
export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disabled';
|
||||
|
||||
export type LicenseVariant = 'personal' | 'team' | null;
|
||||
export type LicenseVariant = 'skipper' | 'admiral' | null;
|
||||
|
||||
export interface LicenseInfo {
|
||||
tier: LicenseTier;
|
||||
|
||||
Reference in New Issue
Block a user