From c328b7f49a2132525bc9fe26ae16c5fbb2f2c6e4 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 29 Mar 2026 18:00:29 -0400 Subject: [PATCH] refactor: rename Personal Pro to Skipper and Team Pro to Admiral (#256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align paid tier names with Sencho's nautical identity. Internal variant values ('personal'/'team') remain unchanged in code, database, and Lemon Squeezy integration — only user-facing display names updated. - Backend: requireTeamPro → requireAdmiral, TEAM_PRO_REQUIRED → ADMIRAL_REQUIRED - Frontend: TeamProGate.tsx → AdmiralGate.tsx, TierBadge labels updated - Website: PricingSection tier names and nautical descriptions - Docs: all 11 affected pages renamed, nautical footnote added to licensing --- .env.example | 2 +- backend/src/__tests__/sso.test.ts | 4 +- backend/src/index.ts | 86 +++++++++---------- backend/src/services/LicenseService.ts | 2 +- docs/features/api-tokens.mdx | 4 +- docs/features/audit-log.mdx | 6 +- docs/features/licensing.mdx | 18 ++-- docs/features/overview.mdx | 4 +- docs/features/private-registries.mdx | 6 +- docs/features/rbac.mdx | 22 ++--- docs/features/scheduled-operations.mdx | 8 +- docs/features/sso.mdx | 6 +- docs/getting-started/configuration.mdx | 2 +- docs/getting-started/sso-quickstart.mdx | 2 +- docs/reference/settings.mdx | 6 +- .../{TeamProGate.tsx => AdmiralGate.tsx} | 14 +-- frontend/src/components/ApiTokensSection.tsx | 6 +- frontend/src/components/RegistriesSection.tsx | 6 +- frontend/src/components/SSOSection.tsx | 8 +- frontend/src/components/SettingsModal.tsx | 18 ++-- frontend/src/components/TierBadge.tsx | 4 +- frontend/src/context/AuthContext.tsx | 2 +- 22 files changed, 120 insertions(+), 116 deletions(-) rename frontend/src/components/{TeamProGate.tsx => AdmiralGate.tsx} (88%) diff --git a/.env.example b/.env.example index 8d6303fb..8a805f5a 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ JWT_SECRET=your-secure-jwt-secret-here # Directory containing docker-compose files COMPOSE_DIR=/path/to/your/compose/files -# ─── SSO / LDAP Configuration (Team Pro) ─────────────────────────── +# ─── SSO / LDAP Configuration (Admiral) ─────────────────────────── # LDAP / Active Directory SSO_LDAP_ENABLED=false diff --git a/backend/src/__tests__/sso.test.ts b/backend/src/__tests__/sso.test.ts index 1c31b8c2..258cb93e 100644 --- a/backend/src/__tests__/sso.test.ts +++ b/backend/src/__tests__/sso.test.ts @@ -50,11 +50,11 @@ describe('SSO Config Endpoints (Protected)', () => { expect(res.status).toBe(401); }); - it('GET /api/sso/config returns 403 without Team Pro', async () => { + it('GET /api/sso/config returns 403 without Admiral', async () => { const res = await supertest(app) .get('/api/sso/config') .set('Authorization', `Bearer ${adminToken}`); - // Without a Team Pro license, this should be 403 + // Without an Admiral license, this should be 403 expect(res.status).toBe(403); expect(res.body.code).toBe('PRO_REQUIRED'); }); diff --git a/backend/src/index.ts b/backend/src/index.ts index 97ef9c94..fedf85a6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -656,7 +656,7 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => { authMiddleware(req, res, next); }); -// Audit logging middleware - records all mutating API actions for Team Pro accountability. +// Audit logging middleware - records all mutating API actions for Admiral accountability. // Runs for POST/PUT/DELETE/PATCH on /api/* routes. Uses res.on('finish') to capture status code. const AUDIT_ROUTE_SUMMARIES: Record = { 'POST /stacks': 'Created stack', @@ -785,15 +785,15 @@ const requirePro = (_req: Request, res: Response): boolean => { return true; }; -// Team Pro feature guard: requires Pro tier with team variant. -const requireTeamPro = (_req: Request, res: Response): boolean => { +// Admiral feature guard: requires Pro tier with team variant. +const requireAdmiral = (_req: Request, res: Response): boolean => { const ls = LicenseService.getInstance(); if (ls.getTier() !== 'pro') { res.status(403).json({ error: 'This feature requires Sencho Pro.', code: 'PRO_REQUIRED' }); return false; } if (ls.getVariant() !== 'team') { - res.status(403).json({ error: 'This feature requires Sencho Team Pro.', code: 'TEAM_PRO_REQUIRED' }); + res.status(403).json({ error: 'This feature requires Sencho Admiral.', code: 'ADMIRAL_REQUIRED' }); return false; } return true; @@ -807,7 +807,7 @@ const requireAdmin = (req: Request, res: Response): boolean => { return true; }; -// --- Scoped RBAC Permission Engine (Team Pro) --- +// --- Scoped RBAC Permission Engine (Admiral) --- type PermissionAction = | 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete' @@ -838,7 +838,7 @@ const ROLE_PERMISSIONS: Record = { * Core permission resolver. Checks if the current user can perform `action` on an optional resource. * 1. Admin → always true (backward compat) * 2. Check global role permissions - * 3. If resource specified AND Team Pro → check scoped role_assignments + * 3. If resource specified AND Admiral → check scoped role_assignments */ function checkPermission( req: Request, @@ -856,7 +856,7 @@ function checkPermission( // Check if the user's global role grants this action if (ROLE_PERMISSIONS[globalRole]?.includes(action)) return true; - // Scoped assignments only apply when a resource is specified and license is Team Pro + // 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; @@ -1770,7 +1770,7 @@ app.post('/api/users', authMiddleware, async (req: Request, res: Response): Prom res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", or "node-admin"' }); return; } - if ((role === 'deployer' || role === 'node-admin') && !requireTeamPro(req, res)) return; + if ((role === 'deployer' || role === 'node-admin') && !requireAdmiral(req, res)) return; const db = DatabaseService.getInstance(); const existing = db.getUserByUsername(username); @@ -1782,11 +1782,11 @@ app.post('/api/users', authMiddleware, async (req: Request, res: Response): Prom // Enforce seat limits based on license variant const seatLimits = LicenseService.getInstance().getSeatLimits(); if (role === 'admin' && seatLimits.maxAdmins !== null && db.getAdminCount() >= seatLimits.maxAdmins) { - res.status(403).json({ error: `Your license allows a maximum of ${seatLimits.maxAdmins} admin account${seatLimits.maxAdmins === 1 ? '' : 's'}. Upgrade to Team Pro for unlimited accounts.` }); + res.status(403).json({ error: `Your license allows a maximum of ${seatLimits.maxAdmins} admin account${seatLimits.maxAdmins === 1 ? '' : 's'}. Upgrade to Admiral for unlimited accounts.` }); return; } if (role !== 'admin' && seatLimits.maxViewers !== null && db.getNonAdminCount() >= seatLimits.maxViewers) { - res.status(403).json({ error: `Your license allows a maximum of ${seatLimits.maxViewers} viewer account${seatLimits.maxViewers === 1 ? '' : 's'}. Upgrade to Team Pro for unlimited accounts.` }); + res.status(403).json({ error: `Your license allows a maximum of ${seatLimits.maxViewers} viewer account${seatLimits.maxViewers === 1 ? '' : 's'}. Upgrade to Admiral for unlimited accounts.` }); return; } @@ -1837,7 +1837,7 @@ app.put('/api/users/:id', authMiddleware, async (req: Request, res: Response): P res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", or "node-admin"' }); return; } - if ((role === 'deployer' || role === 'node-admin') && !requireTeamPro(req, res)) return; + if ((role === 'deployer' || role === 'node-admin') && !requireAdmiral(req, res)) return; // Prevent demoting yourself if (user.username === req.user!.username && role !== user.role) { res.status(400).json({ error: 'Cannot change your own role' }); @@ -1902,7 +1902,7 @@ app.delete('/api/users/:id', authMiddleware, async (req: Request, res: Response) } }); -// --- Scoped Role Assignments (Team Pro) --- +// --- Scoped Role Assignments (Admiral) --- app.get('/api/users/:id/roles', authMiddleware, (req: Request, res: Response): void => { if (req.apiTokenScope) { @@ -1910,7 +1910,7 @@ app.get('/api/users/:id/roles', authMiddleware, (req: Request, res: Response): v return; } if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { const userId = parseInt(req.params.id as string, 10); const db = DatabaseService.getInstance(); @@ -1932,7 +1932,7 @@ app.post('/api/users/:id/roles', authMiddleware, (req: Request, res: Response): return; } if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { const userId = parseInt(req.params.id as string, 10); const { role, resource_type, resource_id } = req.body; @@ -1980,7 +1980,7 @@ app.delete('/api/users/:id/roles/:assignId', authMiddleware, (req: Request, res: return; } if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { const userId = parseInt(req.params.id as string, 10); const assignId = parseInt(req.params.assignId as string, 10); @@ -2025,7 +2025,7 @@ app.get('/api/permissions/me', authMiddleware, (req: Request, res: Response): vo globalRole, globalPermissions, scopedPermissions, - isTeamPro: LicenseService.getInstance().getVariant() === 'team', + isAdmiral: LicenseService.getInstance().getVariant() === 'team', }); } catch (error) { console.error('[Permissions] Error:', error); @@ -3372,7 +3372,7 @@ app.post('/api/system/console-token', authMiddleware, (req: Request, res: Respon } }); -// --- SSO Config Routes (admin + Team Pro, local-only) --- +// --- SSO Config Routes (admin + Admiral, local-only) --- app.get('/api/sso/config', (req: Request, res: Response): void => { if (req.apiTokenScope) { @@ -3380,7 +3380,7 @@ app.get('/api/sso/config', (req: Request, res: Response): void => { return; } if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { const configs = DatabaseService.getInstance().getSSOConfigs(); const result = configs.map(c => { @@ -3403,7 +3403,7 @@ app.get('/api/sso/config/:provider', (req: Request, res: Response): void => { return; } if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { const config = SSOService.getInstance().getProviderConfig(String(req.params.provider)); if (!config) { @@ -3427,7 +3427,7 @@ app.put('/api/sso/config/:provider', (req: Request, res: Response): void => { return; } if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { const provider = String(req.params.provider); const validProviders = ['ldap', 'oidc_google', 'oidc_github', 'oidc_okta']; @@ -3450,7 +3450,7 @@ app.delete('/api/sso/config/:provider', (req: Request, res: Response): void => { return; } if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { SSOService.getInstance().deleteProviderConfig(String(req.params.provider)); res.json({ success: true, message: 'SSO configuration deleted' }); @@ -3466,7 +3466,7 @@ app.post('/api/sso/config/:provider/test', async (req: Request, res: Response): return; } if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { const provider = String(req.params.provider); if (provider === 'ldap') { @@ -3482,11 +3482,11 @@ app.post('/api/sso/config/:provider/test', async (req: Request, res: Response): } }); -// --- Audit Log Routes (Team Pro, local-only) --- +// --- Audit Log Routes (Admiral, local-only) --- app.get('/api/audit-log', async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { const page = parseInt(req.query.page as string) || 1; @@ -3504,7 +3504,7 @@ app.get('/api/audit-log', async (req: Request, res: Response): Promise => } }); -// --- API Token Routes (Team Pro, admin-only, local-only) --- +// --- API Token Routes (Admiral, admin-only, local-only) --- app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response): Promise => { if (req.apiTokenScope) { @@ -3512,7 +3512,7 @@ app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response): return; } if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { const { name, scope, expires_in } = req.body; if (!name || typeof name !== 'string' || !name.trim()) { @@ -3573,7 +3573,7 @@ app.get('/api/api-tokens', authMiddleware, async (req: Request, res: Response): return; } if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { const user = DatabaseService.getInstance().getUserByUsername(req.user!.username); if (!user) { res.status(500).json({ error: 'User not found.' }); return; } @@ -3593,7 +3593,7 @@ app.delete('/api/api-tokens/:id', authMiddleware, async (req: Request, res: Resp return; } if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { const id = parseInt(req.params.id as string, 10); if (isNaN(id)) { res.status(400).json({ error: 'Invalid token ID.' }); return; } @@ -3615,11 +3615,11 @@ app.delete('/api/api-tokens/:id', authMiddleware, async (req: Request, res: Resp } }); -// --- Scheduled Operations Routes (Team Pro, admin-only, local-only) --- +// --- Scheduled Operations Routes (Admiral, admin-only, local-only) --- app.get('/api/scheduled-tasks', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { const tasks = DatabaseService.getInstance().getScheduledTasks(); res.json(tasks); @@ -3631,7 +3631,7 @@ app.get('/api/scheduled-tasks', (req: Request, res: Response): void => { app.post('/api/scheduled-tasks', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets } = req.body; @@ -3701,7 +3701,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 (!requireTeamPro(req, res)) return; + if (!requireAdmiral(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; } @@ -3716,7 +3716,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 (!requireTeamPro(req, res)) return; + if (!requireAdmiral(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; } @@ -3790,7 +3790,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 (!requireTeamPro(req, res)) return; + if (!requireAdmiral(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; } @@ -3809,7 +3809,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 (!requireTeamPro(req, res)) return; + if (!requireAdmiral(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; } @@ -3837,7 +3837,7 @@ app.patch('/api/scheduled-tasks/:id/toggle', (req: Request, res: Response): void app.post('/api/scheduled-tasks/:id/run', async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(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; } @@ -3859,7 +3859,7 @@ app.post('/api/scheduled-tasks/:id/run', async (req: Request, res: Response): Pr app.get('/api/scheduled-tasks/:id/runs', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(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; } @@ -3878,14 +3878,14 @@ app.get('/api/scheduled-tasks/:id/runs', (req: Request, res: Response): void => } }); -// --- Private Registry Routes (Team Pro, admin-only, local-only) --- +// --- Private Registry Routes (Admiral, admin-only, local-only) --- const VALID_REGISTRY_TYPES = ['dockerhub', 'ghcr', 'ecr', 'custom'] as const; app.get('/api/registries', (req: Request, res: Response): void => { if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; } if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { res.json(RegistryService.getInstance().getAll()); } catch (error) { @@ -3897,7 +3897,7 @@ app.get('/api/registries', (req: Request, res: Response): void => { app.post('/api/registries', (req: Request, res: Response): void => { if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; } if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { const { name, url, type, username, secret, aws_region } = req.body; @@ -3931,7 +3931,7 @@ app.post('/api/registries', (req: Request, res: Response): void => { app.put('/api/registries/:id', (req: Request, res: Response): void => { if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; } if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { const id = parseInt(req.params.id as string, 10); if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; } @@ -3966,7 +3966,7 @@ app.put('/api/registries/:id', (req: Request, res: Response): void => { app.delete('/api/registries/:id', (req: Request, res: Response): void => { if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; } if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { const id = parseInt(req.params.id as string, 10); if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; } @@ -3985,7 +3985,7 @@ app.delete('/api/registries/:id', (req: Request, res: Response): void => { app.post('/api/registries/:id/test', async (req: Request, res: Response): Promise => { if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; } if (!requireAdmin(req, res)) return; - if (!requireTeamPro(req, res)) return; + if (!requireAdmiral(req, res)) return; try { const id = parseInt(req.params.id as string, 10); if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; } diff --git a/backend/src/services/LicenseService.ts b/backend/src/services/LicenseService.ts index c0aa50f3..5ea83aeb 100644 --- a/backend/src/services/LicenseService.ts +++ b/backend/src/services/LicenseService.ts @@ -179,7 +179,7 @@ export class LicenseService { /** * Get the license variant (personal or team) from stored metadata. - * Trial licenses default to "personal" - Team Pro features require a Team Pro license. + * Trial licenses default to "personal" - Admiral features require an Admiral license. */ public getVariant(): LicenseVariant { const db = DatabaseService.getInstance(); diff --git a/docs/features/api-tokens.mdx b/docs/features/api-tokens.mdx index b56acc02..59171287 100644 --- a/docs/features/api-tokens.mdx +++ b/docs/features/api-tokens.mdx @@ -4,7 +4,7 @@ description: Generate scoped API tokens for CI/CD pipelines, scripts, and automa --- - API Tokens require a Sencho **Team Pro** license. Personal Pro and Community Edition do not include this feature. + API Tokens require a Sencho **Admiral** license. Skipper and Community Edition do not include this feature. API tokens let you authenticate external tools - CI/CD pipelines, deployment scripts, monitoring integrations - without sharing user credentials. Each token is scoped to a specific permission level so you can follow the principle of least privilege. @@ -39,7 +39,7 @@ These restrictions ensure that API tokens cannot escalate privileges or modify t ## Creating a token -1. Open **Settings Hub** and navigate to the **API Tokens** tab (visible to Team Pro admins only). +1. Open **Settings Hub** and navigate to the **API Tokens** tab (visible to Admiral admins only). 2. Click **Create Token**. 3. Enter a descriptive name (e.g., "GitHub Actions deploy"), select a permission scope, and optionally choose an expiration period (30, 60, 90 days, or 1 year). Tokens without an expiration must be revoked manually. 4. Click **Create**. The raw token is displayed **once** - copy it immediately. diff --git a/docs/features/audit-log.mdx b/docs/features/audit-log.mdx index 7377f671..44bebbe7 100644 --- a/docs/features/audit-log.mdx +++ b/docs/features/audit-log.mdx @@ -4,10 +4,10 @@ description: Track all mutating actions across your Sencho instance with a searc --- - The Audit Log requires a Sencho **Team Pro** license. Personal Pro and Community Edition do not include this feature. + The Audit Log requires a Sencho **Admiral** license. Skipper and Community Edition do not include this feature. -Sencho Team Pro records every mutating action (deploy, stop, delete, settings changes, user management) with full attribution. The audit log answers the question every team eventually asks: **"Who changed what, and when?"** +Sencho Admiral records every mutating action (deploy, stop, delete, settings changes, user management) with full attribution. The audit log answers the question every team eventually asks: **"Who changed what, and when?"** ## What gets logged @@ -36,7 +36,7 @@ Every `POST`, `PUT`, `DELETE`, and `PATCH` request to the Sencho API is automati ## Viewing the audit log -Navigate to the **Audit** tab in the sidebar (visible to Team Pro admins only). +Navigate to the **Audit** tab in the sidebar (visible to Admiral admins only). Audit Log view showing a timeline of actions diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index 052cff4f..36c83c95 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -5,19 +5,23 @@ description: How Sencho Pro licensing works - trials, activation, and subscripti Sencho uses an open-core model. The **Community** tier is free forever with unlimited nodes. **Pro** unlocks advanced features like fleet management, RBAC, webhooks, atomic deployments, and fleet-wide backups. + + *Our tier names are inspired by the meaning of Sencho (船長) — because you're the captain of your container fleet.* + + ## Plans | Tier | Price | Accounts | |------|-------|----------| | **Community** | Free | 1 admin | -| **Personal Pro** | $7.99/month, $69.99/year, or $249 lifetime | 1 admin + 3 viewers | -| **Team Pro** | $49.99/month, $499.99/year, or $1,499 lifetime | Unlimited | +| **Skipper** | $7.99/month, $69.99/year, or $249 lifetime | 1 admin + 3 viewers | +| **Admiral** | $49.99/month, $499.99/year, or $1,499 lifetime | Unlimited | Lifetime pricing is an early-adopter offer available for a limited time only. ## Free trial -Every new Sencho installation starts with a **14-day Personal Pro trial** - no license key or credit card required. Personal Pro features like fleet management, RBAC viewer accounts, webhooks, and atomic deployments are unlocked during the trial so you can evaluate them with your real infrastructure. Team Pro features (SSO, audit log, unlimited accounts) require a Team Pro license. +Every new Sencho installation starts with a **14-day Skipper trial** - no license key or credit card required. Skipper features like fleet management, RBAC viewer accounts, webhooks, and atomic deployments are unlocked during the trial so you can evaluate them with your real infrastructure. Admiral features (SSO, audit log, unlimited accounts) require an Admiral license. When the trial expires, Sencho automatically reverts to the Community tier. No data is lost. @@ -25,14 +29,14 @@ When the trial expires, Sencho automatically reverts to the Community tier. No d You can upgrade directly from **Settings > License** in your Sencho dashboard. The upgrade cards shown depend on your current tier: -- **Community users** see both **Personal Pro** and **Team Pro** options with feature highlights and direct checkout links. -- **Personal Pro users** see a **Team Pro** upgrade card for when you need unlimited accounts. -- **Team Pro users** are on the highest tier - no upgrade cards are shown. +- **Community users** see both **Skipper** and **Admiral** options with feature highlights and direct checkout links. +- **Skipper users** see an **Admiral** upgrade card for when you need unlimited accounts. +- **Admiral users** are on the highest tier - no upgrade cards are shown. Clicking an upgrade button opens the Lemon Squeezy checkout in a new tab. After completing the purchase, you'll receive a license key by email. - License settings showing upgrade cards for Personal Pro and Team Pro + License settings showing upgrade cards for Skipper and Admiral ## Activating your license diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index 995985d7..e09d3853 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -69,11 +69,11 @@ Create point-in-time snapshots of every compose file and environment file across ## Private registries -Store credentials for private Docker registries - Docker Hub organizations, GHCR, AWS ECR, and self-hosted registries. Sencho injects them automatically during deploy and pull operations. ECR short-lived tokens are refreshed on every operation. Team Pro only. [Learn more →](/features/private-registries) +Store credentials for private Docker registries - Docker Hub organizations, GHCR, AWS ECR, and self-hosted registries. Sencho injects them automatically during deploy and pull operations. ECR short-lived tokens are refreshed on every operation. Admiral only. [Learn more →](/features/private-registries) ## Audit log -Track every mutating action across your Sencho instance with a searchable audit trail. See who deployed, stopped, deleted, or changed settings - with timestamps, user attribution, and node context. Team Pro only. [Learn more →](/features/audit-log) +Track every mutating action across your Sencho instance with a searchable audit trail. See who deployed, stopped, deleted, or changed settings - with timestamps, user attribution, and node context. Admiral only. [Learn more →](/features/audit-log) ## Licensing & billing diff --git a/docs/features/private-registries.mdx b/docs/features/private-registries.mdx index 23585737..dc187249 100644 --- a/docs/features/private-registries.mdx +++ b/docs/features/private-registries.mdx @@ -4,7 +4,7 @@ description: Store credentials for private Docker registries so Sencho can autom --- - Private Registry Management requires a Sencho **Team Pro** license. Personal Pro and Community Edition do not include this feature. + Private Registry Management requires a Sencho **Admiral** license. Skipper and Community Edition do not include this feature. Sencho can store credentials for your private Docker registries and inject them automatically whenever it runs `docker compose pull` or `docker compose up`. This means your stacks can reference private images without needing to manually `docker login` on the host. @@ -20,7 +20,7 @@ Sencho can store credentials for your private Docker registries and inject them ## Adding a registry -1. Open **Settings Hub** and navigate to the **Registries** tab (visible to Team Pro admins only). +1. Open **Settings Hub** and navigate to the **Registries** tab (visible to Admiral admins only). 2. Click **Add Registry**. 3. Select the registry type. The URL field auto-fills with the standard endpoint for Docker Hub and GHCR. 4. Enter a descriptive name, the registry URL, and your credentials. @@ -107,7 +107,7 @@ AWS ECR uses short-lived authentication tokens (valid for 12 hours) derived from - **No persistent Docker login** - Credentials are written to a temporary file for the duration of each operation and immediately deleted afterward. - **Secrets never exposed** - The API never returns decrypted secrets. The UI shows only whether a secret is stored. - **Audit trail** - Registry create, update, and delete operations are recorded in the [Audit Log](/features/audit-log). -- **Admin-only access** - Only admin users with a Team Pro license can manage registry credentials. +- **Admin-only access** - Only admin users with a Admiral license can manage registry credentials. ## Multi-node behavior diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx index 3ab8ce6d..0fe0d8bf 100644 --- a/docs/features/rbac.mdx +++ b/docs/features/rbac.mdx @@ -4,10 +4,10 @@ description: Role-based access control for Sencho - manage admin, viewer, deploy --- - Multi-user support requires a Sencho Pro license. Community Edition supports a single admin account only. Intermediate roles (Deployer, Node Admin) and scoped permissions require **Team Pro**. + Multi-user support requires a Sencho Pro license. Community Edition supports a single admin account only. Intermediate roles (Deployer, Node Admin) and scoped permissions require **Admiral**. -Sencho supports role-based access control with four distinct roles. The classic **Admin** and **Viewer** roles are available on all Pro tiers, while **Deployer** and **Node Admin** are exclusive to Team Pro and support scoped permissions per stack or node. +Sencho supports role-based access control with four distinct roles. The classic **Admin** and **Viewer** roles are available on all Pro tiers, while **Deployer** and **Node Admin** are exclusive to Admiral and support scoped permissions per stack or node. ## Roles @@ -15,8 +15,8 @@ Sencho supports role-based access control with four distinct roles. The classic |------|-------------|------| | **Admin** | Full access to all features — deploy, edit, manage users, configure nodes, and more | Pro | | **Viewer** | Read-only access to dashboards, logs, stats, and file contents | Pro | -| **Deployer** | Can deploy, restart, stop, and start stacks — but cannot edit compose files, delete stacks, or access system settings | Team Pro | -| **Node Admin** | Full stack and node management within their scope — cannot access system settings, users, or license management | Team Pro | +| **Deployer** | Can deploy, restart, stop, and start stacks — but cannot edit compose files, delete stacks, or access system settings | Admiral | +| **Node Admin** | Full stack and node management within their scope — cannot access system settings, users, or license management | Admiral | ### Permission matrix @@ -39,7 +39,7 @@ Sencho supports role-based access control with four distinct roles. The classic ## Scoped permissions - Scoped permissions require a **Team Pro** license. + Scoped permissions require a **Admiral** license. Roles can be scoped to specific stacks or nodes. This means you can grant a user the **Deployer** role globally, but also give them **Node Admin** access on a specific node — or limit a viewer to deploy access on only certain stacks. @@ -64,15 +64,15 @@ Admins can manage accounts in **Settings → Users**. From there you can: - **Edit** an existing user's password or role - **Delete** a user account -When creating or editing a user on Team Pro, you'll see all four role options in the role selector. On Personal Pro, only Admin and Viewer are available. +When creating or editing a user on Admiral, you'll see all four role options in the role selector. On Skipper, only Admin and Viewer are available. - Role selector showing all four roles on Team Pro + Role selector showing all four roles on Admiral ## Managing scoped permissions -When editing a user on Team Pro, a **Scoped Permissions** section appears below the user form. Here you can: +When editing a user on Admiral, a **Scoped Permissions** section appears below the user form. Here you can: 1. **View** the user's current scoped assignments 2. **Add** a new scope by selecting a role, resource type (stack or node), and specific resource @@ -82,7 +82,7 @@ Each scoped assignment grants the specified role's permissions on the specified ## SSO auto-provisioning -With a Team Pro license, users can also be created automatically when they log in via SSO (LDAP, Google, GitHub, or Okta). SSO users appear in the Users list alongside local accounts. They are assigned a role based on identity provider group membership or claim mapping. +With a Admiral license, users can also be created automatically when they log in via SSO (LDAP, Google, GitHub, or Okta). SSO users appear in the Users list alongside local accounts. They are assigned a role based on identity provider group membership or claim mapping. SSO users cannot log in with a password — they must always authenticate through their identity provider. After SSO provisioning, an admin can add scoped permissions to SSO users just like local accounts. @@ -97,5 +97,5 @@ When you upgrade to Sencho Pro, your existing single-admin credentials are autom | Tier | Admin accounts | Non-admin accounts | Intermediate roles | Scoped permissions | |------|---------------|-------------------|-------------------|-------------------| | **Community** | 1 | 0 | ❌ | ❌ | -| **Personal Pro** | 1 | 3 | ❌ | ❌ | -| **Team Pro** | Unlimited | Unlimited | ✅ | ✅ | +| **Skipper** | 1 | 3 | ❌ | ❌ | +| **Admiral** | Unlimited | Unlimited | ✅ | ✅ | diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index 24073f0d..66950a9b 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -4,8 +4,8 @@ description: Automate recurring Docker operations like stack restarts, fleet sna --- - Scheduled Operations requires a Sencho **Team Pro** license. - Personal Pro and Community Edition do not include this feature. + Scheduled Operations requires a Sencho **Admiral** license. + Skipper and Community Edition do not include this feature. ## Overview @@ -26,7 +26,7 @@ Scheduled Operations lets you automate recurring maintenance tasks across your i ## Creating a Scheduled Task -1. Navigate to the **Schedules** tab in the top navigation bar (visible to Team Pro admins). +1. Navigate to the **Schedules** tab in the top navigation bar (visible to Admiral admins). 2. Click **New Schedule**. 3. Fill in the form: - **Name** - a descriptive label (e.g. "Nightly staging restart"). @@ -92,7 +92,7 @@ Execution history is retained for 30 days. The Scheduler Service runs in the background and checks for due tasks every 60 seconds. When a task's next run time has passed: -1. The scheduler verifies your Team Pro license is active. +1. The scheduler verifies your Admiral license is active. 2. It executes the configured action using the same internal services that power the UI buttons (restart, snapshot, prune). 3. Results are logged to the execution history. 4. The next run time is recalculated from the cron expression. diff --git a/docs/features/sso.mdx b/docs/features/sso.mdx index 57bafe7a..9772ad31 100644 --- a/docs/features/sso.mdx +++ b/docs/features/sso.mdx @@ -4,10 +4,10 @@ description: Authenticate with your existing identity provider - LDAP, Google, G --- - SSO requires a Sencho **Team Pro** license. Personal Pro and Community Edition do not include this feature. + SSO requires a Sencho **Admiral** license. Skipper and Community Edition do not include this feature. -Sencho Team Pro lets your team sign in using existing identity providers instead of managing separate credentials. SSO works **alongside** password authentication - it does not replace it. +Sencho Admiral lets your team sign in using existing identity providers instead of managing separate credentials. SSO works **alongside** password authentication - it does not replace it. ## Supported providers @@ -173,7 +173,7 @@ If not set, Sencho auto-detects the URL from the request's `Host` header and pro - **State parameter** - A cryptographic random value protects against CSRF attacks on the OAuth callback - **Encrypted secrets** - LDAP bind passwords and OIDC client secrets are encrypted at rest with AES-256-GCM - **No local password** - SSO users are created with an unusable password hash. They cannot bypass SSO by using the password login form -- **Admin-only configuration** - Only Team Pro administrators can enable or configure SSO providers +- **Admin-only configuration** - Only Admiral administrators can enable or configure SSO providers ## Troubleshooting diff --git a/docs/getting-started/configuration.mdx b/docs/getting-started/configuration.mdx index 6b81d8c8..5965f072 100644 --- a/docs/getting-started/configuration.mdx +++ b/docs/getting-started/configuration.mdx @@ -29,7 +29,7 @@ When you point `COMPOSE_DIR` at a directory, Sencho expects each stack to live i ## SSO environment variables -If you use SSO (Team Pro), configure your identity providers via environment variables: +If you use SSO (Admiral), configure your identity providers via environment variables: | Variable | Description | |----------|-------------| diff --git a/docs/getting-started/sso-quickstart.mdx b/docs/getting-started/sso-quickstart.mdx index 9dd10053..b2cb69d3 100644 --- a/docs/getting-started/sso-quickstart.mdx +++ b/docs/getting-started/sso-quickstart.mdx @@ -4,7 +4,7 @@ description: Step-by-step instructions for connecting Sencho to your identity pr --- - SSO requires a Sencho **Team Pro** license. You can configure SSO via environment variables (shown below) or from the Settings UI after first boot. + SSO requires a Sencho **Admiral** license. You can configure SSO via environment variables (shown below) or from the Settings UI after first boot. ## Google OIDC diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index ff103ca4..f23f4e83 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -39,7 +39,7 @@ Manage your Sencho Pro license from this section. | **Trial status** | If on a trial, shows remaining days | | **Customer / Plan / Key** | Displayed when a Pro license is active | | **Renews** | Next renewal date for active subscriptions | -| **Upgrade cards** | Dynamic plan cards with feature highlights and direct Lemon Squeezy checkout links. Community users see Personal Pro and Team Pro; Personal Pro users see Team Pro only | +| **Upgrade cards** | Dynamic plan cards with feature highlights and direct Lemon Squeezy checkout links. Community users see Skipper and Admiral; Skipper users see Admiral only | | **Activate** | Enter a license key to activate Pro | | **Manage Subscription** | Opens the billing portal (active Pro only) | | **Deactivate License** | Reverts to Community features (active Pro only) | @@ -196,8 +196,8 @@ Pro license holders get additional support channels: | Channel | Description | |---------|-------------| -| **Email Support** | Direct email support (Personal Pro) | -| **Priority Email Support** | Responses within 24 hours (Team Pro) | +| **Email Support** | Direct email support (Skipper) | +| **Priority Email Support** | Responses within 24 hours (Admiral) | --- diff --git a/frontend/src/components/TeamProGate.tsx b/frontend/src/components/AdmiralGate.tsx similarity index 88% rename from frontend/src/components/TeamProGate.tsx rename to frontend/src/components/AdmiralGate.tsx index 85b016fd..c6417012 100644 --- a/frontend/src/components/TeamProGate.tsx +++ b/frontend/src/components/AdmiralGate.tsx @@ -3,12 +3,12 @@ import { Crown } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useLicense } from '@/context/LicenseContext'; -interface TeamProGateProps { +interface AdmiralGateProps { children: ReactNode; featureName?: string; } -const DISMISS_KEY = 'sencho-team-upgrade-prompt-dismissed'; +const DISMISS_KEY = 'sencho-admiral-upgrade-prompt-dismissed'; const DISMISS_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours function isDismissedFromStorage(): boolean { @@ -16,7 +16,7 @@ function isDismissedFromStorage(): boolean { return !!dismissedAt && Date.now() - parseInt(dismissedAt, 10) < DISMISS_DURATION_MS; } -export function TeamProGate({ children, featureName = 'This feature' }: TeamProGateProps) { +export function AdmiralGate({ children, featureName = 'This feature' }: AdmiralGateProps) { const { isPro, license } = useLicense(); const [dismissed, setDismissed] = useState(isDismissedFromStorage); @@ -31,7 +31,7 @@ export function TeamProGate({ children, featureName = 'This feature' }: TeamProG
- Upgrade to Team Pro to unlock + Upgrade to Admiral to unlock
@@ -44,9 +44,9 @@ export function TeamProGate({ children, featureName = 'This feature' }: TeamProG
-

{featureName} requires Sencho Team Pro

+

{featureName} requires Sencho Admiral

- Unlock team features like SSO authentication, audit logging, API tokens, and unlimited user accounts with a Sencho Team Pro license. + Unlock team features like SSO authentication, audit logging, API tokens, and unlimited user accounts with a Sencho Admiral license.

@@ -65,7 +65,7 @@ export function TeamProGate({ children, featureName = 'This feature' }: TeamProG onClick={() => window.open('https://sencho.io/pricing', '_blank')} > - Get Team Pro + Get Admiral
diff --git a/frontend/src/components/ApiTokensSection.tsx b/frontend/src/components/ApiTokensSection.tsx index b558b303..deead18b 100644 --- a/frontend/src/components/ApiTokensSection.tsx +++ b/frontend/src/components/ApiTokensSection.tsx @@ -8,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; -import { TeamProGate } from './TeamProGate'; +import { AdmiralGate } from './AdmiralGate'; import { TierBadge } from './TierBadge'; import { Zap, Plus, Copy, Trash2, CheckCircle, RefreshCw, Clock } from 'lucide-react'; @@ -123,7 +123,7 @@ export function ApiTokensSection() { }; return ( - +
@@ -267,6 +267,6 @@ export function ApiTokensSection() {
))}
- + ); } diff --git a/frontend/src/components/RegistriesSection.tsx b/frontend/src/components/RegistriesSection.tsx index ccd16b86..30c30bb7 100644 --- a/frontend/src/components/RegistriesSection.tsx +++ b/frontend/src/components/RegistriesSection.tsx @@ -8,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; -import { TeamProGate } from './TeamProGate'; +import { AdmiralGate } from './AdmiralGate'; import { TierBadge } from './TierBadge'; import { Database, Plus, Trash2, Pencil, RefreshCw, CheckCircle, XCircle, Clock } from 'lucide-react'; @@ -205,7 +205,7 @@ export function RegistriesSection() { }; return ( - +
@@ -379,6 +379,6 @@ export function RegistriesSection() {
))}
- + ); } diff --git a/frontend/src/components/SSOSection.tsx b/frontend/src/components/SSOSection.tsx index 69d275d9..d2d8b4f5 100644 --- a/frontend/src/components/SSOSection.tsx +++ b/frontend/src/components/SSOSection.tsx @@ -7,7 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; -import { TeamProGate } from './TeamProGate'; +import { AdmiralGate } from './AdmiralGate'; import { Shield, Loader2, CheckCircle, XCircle } from 'lucide-react'; interface SSOProviderConfig { @@ -320,7 +320,7 @@ export function SSOSection() { try { const res = await apiFetch('/sso/config'); if (res.ok) setConfigs(await res.json()); - } catch { /* ignore - TeamProGate will handle non-pro */ } + } catch { /* ignore - AdmiralGate will handle non-pro */ } }; // eslint-disable-next-line react-hooks/set-state-in-effect @@ -329,7 +329,7 @@ export function SSOSection() { const getConfig = (provider: string) => configs.find(c => c.provider === provider) || null; return ( - +

@@ -360,6 +360,6 @@ export function SSOSection() {

For OIDC providers, set the OAuth callback URL to: {'https:///api/auth/sso/oidc//callback'}

-
+ ); } diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 6935bf43..1968ec65 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -663,7 +663,7 @@ function UsersSection() {
- {/* Scoped Permissions (Team Pro, editing only) */} + {/* Scoped Permissions (Admiral, editing only) */} {editingUser && isPro && license?.variant === 'team' && (

Scoped Permissions

@@ -1365,17 +1365,17 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
)} - {/* Upgrade Cards - Community: show both, Personal Pro: show Team only, Team Pro: none */} + {/* Upgrade Cards - Community: show both, Skipper: show Admiral only, Admiral: none */} {(license?.tier !== 'pro' || (license?.variant === 'personal' && license?.status === 'active')) && (
- {/* Personal Pro Card - only for Community users */} + {/* Skipper Card - only for Community users */} {license?.tier !== 'pro' && (
- Personal Pro + Skipper Popular

Professional tools for solo operators.

@@ -1393,22 +1393,22 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { onClick={() => window.open('https://saelix.lemonsqueezy.com/checkout/buy/f75bfb65-443a-46a0-abb1-981e0ff4b382', '_blank')} > - Get Personal Pro + Get Skipper
)} - {/* Team Pro Card */} + {/* Admiral Card */}
- Team Pro + Admiral

For teams managing shared infrastructure.

    {[ - ...(license?.variant === 'personal' ? ['Everything in Personal Pro'] : ['Everything in Community']), + ...(license?.variant === 'personal' ? ['Everything in Skipper'] : ['Everything in Community']), 'Unlimited admin accounts', 'Unlimited viewer accounts', ...(license?.variant !== 'personal' ? ['Fleet View & webhooks', 'Atomic deployment & backups'] : []), @@ -1427,7 +1427,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { onClick={() => window.open('https://saelix.lemonsqueezy.com/checkout/buy/b049b824-176a-408d-a9d3-9365c979a61f', '_blank')} > - Get Team Pro + Get Admiral
diff --git a/frontend/src/components/TierBadge.tsx b/frontend/src/components/TierBadge.tsx index 0d15fb93..3e71bb23 100644 --- a/frontend/src/components/TierBadge.tsx +++ b/frontend/src/components/TierBadge.tsx @@ -11,8 +11,8 @@ interface TierBadgeProps { const tierConfig = { community: { icon: Globe, label: 'Community' }, - pro: { icon: Crown, label: 'Pro' }, - team: { icon: Users, label: 'Team' }, + pro: { icon: Crown, label: 'Skipper' }, + team: { icon: Users, label: 'Admiral' }, } as const; function resolveTier(tier: LicenseTier, variant: LicenseVariant, status: LicenseStatus) { diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index eb2c0229..1841e8fa 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -19,7 +19,7 @@ interface PermissionsData { globalRole: UserRole; globalPermissions: PermissionAction[]; scopedPermissions: Record; - isTeamPro: boolean; + isAdmiral: boolean; } interface AuthContextType {