From 8d8118c963a1c3b10872041ea0f645d8f0a65196 Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 28 Mar 2026 15:37:54 -0400 Subject: [PATCH] feat(api-tokens): add scoped API tokens for CI/CD automation (Team Pro) (#220) Add long-lived API tokens with three permission scopes (read-only, deploy-only, full-admin) for CI/CD pipelines, scripts, and automation. - Database: api_tokens table with SHA-256 hashed storage - Auth: extend middleware to authenticate Bearer API tokens - Scope enforcement: middleware restricts actions per token scope - API: CRUD endpoints gated behind Team Pro + admin - UI: ApiTokensSection in Settings Hub with create/revoke/copy flows - Docs: new api-tokens.mdx with usage examples and screenshots --- CHANGELOG.md | 4 + backend/src/index.ts | 157 +++++++++++ backend/src/services/DatabaseService.ts | 69 +++++ docs/docs.json | 1 + docs/features/api-tokens.mdx | 79 ++++++ .../images/api-tokens/api-tokens-overview.png | Bin 0 -> 54895 bytes frontend/src/components/ApiTokensSection.tsx | 252 ++++++++++++++++++ frontend/src/components/SettingsModal.tsx | 12 +- frontend/src/components/TeamProGate.tsx | 2 +- 9 files changed, 573 insertions(+), 3 deletions(-) create mode 100644 docs/features/api-tokens.mdx create mode 100644 docs/images/api-tokens/api-tokens-overview.png create mode 100644 frontend/src/components/ApiTokensSection.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index fdcba7a6..431b7535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +* **API Tokens / Service Accounts (Team Pro):** Generate scoped API tokens for CI/CD pipelines and automation. Three permission levels: read-only, deploy-only, and full-admin. Tokens are hashed at rest (SHA-256) and shown only once at creation. Scope enforcement at middleware level prevents tokens from exceeding their granted permissions. + ### Changed * 14-day trial now defaults to Personal Pro instead of Team Pro — Team Pro features (SSO, audit log, unlimited accounts) require a Team Pro license diff --git a/backend/src/index.ts b/backend/src/index.ts index 45121ad5..a18e0894 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -203,6 +203,7 @@ declare global { interface Request { user?: { username: string; role: 'admin' | 'viewer' }; nodeId: number; + apiTokenScope?: 'read-only' | 'deploy-only' | 'full-admin'; } } } @@ -235,6 +236,32 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction): const jwtSecret = settings.auth_jwt_secret; if (!jwtSecret) throw new Error('No JWT secret'); const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string }; + + // API token path: scope-based programmatic access + if (decoded.scope === 'api_token') { + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + const apiToken = DatabaseService.getInstance().getApiTokenByHash(tokenHash); + if (!apiToken || apiToken.revoked_at) { + res.status(401).json({ error: 'API token not found or revoked' }); + return; + } + if (apiToken.expires_at && apiToken.expires_at < Date.now()) { + res.status(401).json({ error: 'API token has expired' }); + return; + } + DatabaseService.getInstance().updateApiTokenLastUsed(apiToken.id); + const creator = DatabaseService.getInstance().getUserById(apiToken.user_id); + const roleMap: Record = { + 'read-only': 'viewer', + 'deploy-only': 'viewer', + 'full-admin': 'admin', + }; + req.user = { username: creator?.username || `api-token:${apiToken.name}`, role: roleMap[apiToken.scope] || 'viewer' }; + req.apiTokenScope = apiToken.scope as 'read-only' | 'deploy-only' | 'full-admin'; + next(); + return; + } + // Accept both user sessions and node proxy tokens. Default role to 'admin' for backward compat with pre-RBAC tokens. req.user = { username: decoded.username || 'node-proxy', role: (decoded.role as 'admin' | 'viewer') || 'admin' }; next(); @@ -647,6 +674,8 @@ const AUDIT_ROUTE_SUMMARIES: Record = { 'POST /fleet/snapshot/restore': 'Restored fleet backup', 'PUT /sso/config': 'Updated SSO configuration', 'DELETE /sso/config': 'Deleted SSO configuration', + 'POST /api-tokens': 'Created API token', + 'DELETE /api-tokens': 'Revoked API token', }; function getAuditSummary(method: string, apiPath: string): string { @@ -728,6 +757,45 @@ const requireAdmin = (req: Request, res: Response): boolean => { return true; }; +// Scope enforcement for API tokens — restricts which endpoints a token can reach. +const DEPLOY_ALLOWED_PATTERNS: RegExp[] = [ + /^\/api\/stacks\/[^/]+\/up$/, + /^\/api\/stacks\/[^/]+\/down$/, + /^\/api\/stacks\/[^/]+\/restart$/, + /^\/api\/stacks\/[^/]+\/pull$/, + /^\/api\/compose\/(up|down|start|stop|restart|pull)$/, +]; + +const enforceApiTokenScope = (req: Request, res: Response, next: NextFunction): void => { + const scope = req.apiTokenScope; + if (!scope) { next(); return; } // Not an API token request + if (scope === 'full-admin') { next(); return; } + + if (scope === 'read-only') { + if (req.method !== 'GET') { + res.status(403).json({ error: 'API token scope "read-only" only allows GET requests.', code: 'SCOPE_DENIED' }); + return; + } + next(); + return; + } + + if (scope === 'deploy-only') { + if (req.method === 'GET') { next(); return; } + const fullPath = `/api${req.path}`; + if (req.method === 'POST' && DEPLOY_ALLOWED_PATTERNS.some(p => p.test(fullPath))) { + next(); + return; + } + res.status(403).json({ error: 'API token scope "deploy-only" does not allow this action.', code: 'SCOPE_DENIED' }); + return; + } + + res.status(403).json({ error: 'Unknown API token scope.', code: 'SCOPE_DENIED' }); +}; + +app.use('/api', enforceApiTokenScope); + app.get('/api/license', (_req: Request, res: Response): void => { try { const info = LicenseService.getInstance().getLicenseInfo(); @@ -3096,6 +3164,95 @@ app.get('/api/audit-log', async (req: Request, res: Response): Promise => } }); +// --- API Token Routes (Team Pro, admin-only, local-only) --- + +app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + if (!requireTeamPro(req, res)) return; + try { + const { name, scope } = req.body; + if (!name || typeof name !== 'string' || !name.trim()) { + res.status(400).json({ error: 'Token name is required.' }); + return; + } + const validScopes = ['read-only', 'deploy-only', 'full-admin']; + if (!scope || !validScopes.includes(scope)) { + res.status(400).json({ error: `Scope must be one of: ${validScopes.join(', ')}` }); + return; + } + + const settings = DatabaseService.getInstance().getGlobalSettings(); + const jwtSecret = settings.auth_jwt_secret; + if (!jwtSecret) { + res.status(500).json({ error: 'No JWT secret configured.' }); + return; + } + + const user = DatabaseService.getInstance().getUserByUsername(req.user!.username); + if (!user) { + res.status(500).json({ error: 'User not found.' }); + return; + } + + const rawToken = jwt.sign({ scope: 'api_token', sub: user.username, jti: crypto.randomUUID() }, jwtSecret); + const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); + + const id = DatabaseService.getInstance().addApiToken({ + token_hash: tokenHash, + name: name.trim(), + scope: scope as 'read-only' | 'deploy-only' | 'full-admin', + user_id: user.id, + created_at: Date.now(), + expires_at: null, + }); + + res.status(201).json({ id, token: rawToken }); + } catch (error) { + console.error('[ApiTokens] Create error:', error); + res.status(500).json({ error: 'Failed to create API token' }); + } +}); + +app.get('/api/api-tokens', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + if (!requireTeamPro(req, res)) return; + try { + const user = DatabaseService.getInstance().getUserByUsername(req.user!.username); + if (!user) { res.status(500).json({ error: 'User not found.' }); return; } + const tokens = DatabaseService.getInstance().getApiTokensByUser(user.id); + // Never expose token hashes to the client + const sanitized = tokens.map(({ token_hash: _hash, ...rest }) => rest); + res.json(sanitized); + } catch (error) { + console.error('[ApiTokens] List error:', error); + res.status(500).json({ error: 'Failed to list API tokens' }); + } +}); + +app.delete('/api/api-tokens/:id', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + if (!requireTeamPro(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; } + + const apiToken = DatabaseService.getInstance().getApiTokenById(id); + if (!apiToken) { res.status(404).json({ error: 'API token not found.' }); return; } + + const user = DatabaseService.getInstance().getUserByUsername(req.user!.username); + if (!user || apiToken.user_id !== user.id) { + res.status(403).json({ error: 'You can only revoke your own tokens.' }); + return; + } + + DatabaseService.getInstance().revokeApiToken(id); + res.json({ success: true }); + } catch (error) { + console.error('[ApiTokens] Revoke error:', error); + res.status(500).json({ error: 'Failed to revoke API token' }); + } +}); + // --- System Maintenance Routes (The System Janitor) --- app.get('/api/system/orphans', async (req: Request, res: Response) => { diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 1e0a0b23..a377caf5 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -123,6 +123,20 @@ export interface AuditLogEntry { summary: string; } +export type ApiTokenScope = 'read-only' | 'deploy-only' | 'full-admin'; + +export interface ApiToken { + id: number; + token_hash: string; + name: string; + scope: ApiTokenScope; + user_id: number; + created_at: number; + last_used_at: number | null; + expires_at: number | null; + revoked_at: number | null; +} + export class DatabaseService { private static instance: DatabaseService; private db: Database.Database; @@ -294,6 +308,22 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp); CREATE INDEX IF NOT EXISTS idx_audit_log_username ON audit_log(username); + + CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'read-only', + user_id INTEGER NOT NULL, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + expires_at INTEGER, + revoked_at INTEGER, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); + CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id); `); // Apply migrations safely (ignore if columns already exist) @@ -767,6 +797,10 @@ export class DatabaseService { return this.db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined; } + public getUserById(id: number): User | undefined { + return this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined; + } + public getUserByProviderIdentity(authProvider: string, providerId: string): User | undefined { return this.db.prepare('SELECT * FROM users WHERE auth_provider = ? AND provider_id = ?').get(authProvider, providerId) as User | undefined; } @@ -947,4 +981,39 @@ export class DatabaseService { const cutoff = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000); this.db.prepare('DELETE FROM audit_log WHERE timestamp < ?').run(cutoff); } + + // --- API Tokens --- + + public addApiToken(token: Omit): number { + const result = this.db.prepare( + 'INSERT INTO api_tokens (token_hash, name, scope, user_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(token.token_hash, token.name, token.scope, token.user_id, token.created_at, token.expires_at); + return result.lastInsertRowid as number; + } + + public getApiTokensByUser(userId: number): ApiToken[] { + return this.db.prepare( + 'SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC' + ).all(userId) as ApiToken[]; + } + + public getApiTokenByHash(tokenHash: string): ApiToken | undefined { + return this.db.prepare( + 'SELECT * FROM api_tokens WHERE token_hash = ?' + ).get(tokenHash) as ApiToken | undefined; + } + + public getApiTokenById(id: number): ApiToken | undefined { + return this.db.prepare( + 'SELECT * FROM api_tokens WHERE id = ?' + ).get(id) as ApiToken | undefined; + } + + public revokeApiToken(id: number): void { + this.db.prepare('UPDATE api_tokens SET revoked_at = ? WHERE id = ?').run(Date.now(), id); + } + + public updateApiTokenLastUsed(id: number): void { + this.db.prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(Date.now(), id); + } } diff --git a/docs/docs.json b/docs/docs.json index 1d1a0fa0..843bf4e7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -97,6 +97,7 @@ "features/atomic-deployments", "features/fleet-backups", "features/audit-log", + "features/api-tokens", "features/sso", "features/licensing" ] diff --git a/docs/features/api-tokens.mdx b/docs/features/api-tokens.mdx new file mode 100644 index 00000000..6f54be68 --- /dev/null +++ b/docs/features/api-tokens.mdx @@ -0,0 +1,79 @@ +--- +title: API Tokens +description: Generate scoped API tokens for CI/CD pipelines, scripts, and automation workflows with granular permission control. +--- + + + API Tokens require a Sencho **Team Pro** license. Personal Pro 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. + +## Permission scopes + +Every token is created with one of three permission levels: + +| Scope | Allowed actions | +|-------|----------------| +| **Read Only** | `GET` requests only — view stacks, containers, metrics, and settings | +| **Deploy Only** | Everything in Read Only, plus deploy-related actions (up, down, restart, pull) | +| **Full Admin** | Unrestricted access — equivalent to an admin user session | + +Choose the narrowest scope that fits your use case. A CI pipeline that only deploys stacks should use **Deploy Only**, not Full Admin. + +## Creating a token + +1. Open **Settings Hub** and navigate to the **API Tokens** tab (visible to Team Pro admins only). +2. Click **Create Token**. +3. Enter a descriptive name (e.g., "GitHub Actions deploy") and select a permission scope. +4. Click **Create**. The raw token is displayed **once** — copy it immediately. + + + API Tokens management view in Settings Hub + + + + The token value is shown only at creation time. Sencho stores a SHA-256 hash of the token, not the token itself. If you lose it, revoke and create a new one. + + +## Using a token + +Pass the token as a Bearer token in the `Authorization` header: + + +```bash curl +curl -H "Authorization: Bearer YOUR_TOKEN" \ + https://your-sencho-instance/api/stacks +``` + +```yaml GitHub Actions +- name: Deploy stack + run: | + curl -X POST \ + -H "Authorization: Bearer ${{ secrets.SENCHO_TOKEN }}" \ + https://your-sencho-instance/api/compose/up \ + -d '{"stack": "my-app"}' +``` + + +### Scope enforcement + +If a token attempts an action outside its scope, Sencho returns a `403` response with a `SCOPE_DENIED` error code: + +```json +{ + "error": "API token scope \"read-only\" only allows GET requests.", + "code": "SCOPE_DENIED" +} +``` + +## Revoking a token + +Click the trash icon next to any token in the API Tokens settings tab. Revocation is immediate — any in-flight or future requests using the revoked token will receive a `401` response. + +## Security model + +- **Hashed storage** — Only a SHA-256 hash of the token is stored in the database. The raw token is never persisted. +- **Audit trail** — All actions performed via API tokens are recorded in the [Audit Log](/features/audit-log) under the creating user's username. +- **No expiry by default** — Tokens do not expire automatically. Revoke tokens manually when they are no longer needed. +- **Scope enforcement** — Permission checks happen at the middleware level before any route handler executes, ensuring consistent enforcement across all endpoints. diff --git a/docs/images/api-tokens/api-tokens-overview.png b/docs/images/api-tokens/api-tokens-overview.png new file mode 100644 index 0000000000000000000000000000000000000000..6bc52fa2d995235f61fe8186078a5fac9d9d1982 GIT binary patch literal 54895 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 z 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 = { + 'read-only': 'Read Only', + 'deploy-only': 'Deploy Only', + 'full-admin': 'Full Admin', +}; + +const SCOPE_BADGE_VARIANT: Record = { + 'read-only': 'default', + 'deploy-only': 'secondary', + 'full-admin': 'destructive', +}; + +function formatDate(ts: number): string { + return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function formatRelative(ts: number): string { + const diff = Date.now() - ts; + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'Just now'; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return formatDate(ts); +} + +export function ApiTokensSection() { + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [showForm, setShowForm] = useState(false); + const [newToken, setNewToken] = useState<{ id: number; token: string } | null>(null); + + const [formName, setFormName] = useState(''); + const [formScope, setFormScope] = useState('read-only'); + + const fetchTokens = async () => { + try { + const res = await apiFetch('/api-tokens', { localOnly: true }); + if (res.ok) { + const data: ApiTokenListItem[] = await res.json(); + setTokens(data.filter(t => !t.revoked_at)); + } + } catch { /* ignore */ } finally { setLoading(false); } + }; + + // eslint-disable-next-line react-hooks/set-state-in-effect + useEffect(() => { fetchTokens(); }, []); + + const handleCreate = async () => { + if (!formName.trim()) { + toast.error('Token name is required.'); + return; + } + setCreating(true); + try { + const res = await apiFetch('/api-tokens', { + method: 'POST', + localOnly: true, + body: JSON.stringify({ name: formName.trim(), scope: formScope }), + }); + if (res.ok) { + const data = await res.json(); + setNewToken({ id: data.id, token: data.token }); + setShowForm(false); + setFormName(''); + setFormScope('read-only'); + fetchTokens(); + toast.success('API token created.'); + } else { + const err = await res.json().catch(() => ({})); + toast.error(err?.error || err?.message || 'Failed to create token.'); + } + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Network error.'); + } finally { setCreating(false); } + }; + + const handleRevoke = async (id: number) => { + try { + const res = await apiFetch(`/api-tokens/${id}`, { method: 'DELETE', localOnly: true }); + if (res.ok) { + toast.success('API token revoked.'); + fetchTokens(); + } else { + const err = await res.json().catch(() => ({})); + toast.error(err?.error || err?.message || 'Failed to revoke token.'); + } + } catch { toast.error('Network error.'); } + }; + + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied to clipboard.`); + }; + + return ( + +
+
+
+

+ API Tokens +

+

+ Generate scoped tokens for CI/CD pipelines, scripts, and automation. +

+
+ +
+ + {/* Create form */} + {showForm && ( +
+
+ + setFormName(e.target.value)} + /> +
+
+ + +
+
+ + +
+
+ )} + + {/* Token reveal (shown once after creation) */} + {newToken && ( +
+
+ Token created — copy it now +
+

This token will not be shown again. Store it securely.

+
+ {newToken.token} + +
+ +
+ )} + + {/* Loading state */} + {loading && ( +
+ + +
+ )} + + {/* Empty state */} + {!loading && tokens.length === 0 && !showForm && ( +
+ +

No API tokens yet.

+

Create one to authenticate CI/CD pipelines and scripts.

+
+ )} + + {/* Token list */} + {!loading && tokens.map(token => ( +
+
+
+ + {token.name} + + {SCOPE_LABELS[token.scope] || token.scope} + +
+ + + + + + + Revoke API token? + + Revoking {token.name} will immediately invalidate it. Any pipelines or scripts using this token will stop working. + + + + Cancel + handleRevoke(token.id)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> + Revoke + + + + +
+
+ + + Created {formatDate(token.created_at)} + + + Last used: {token.last_used_at ? formatRelative(token.last_used_at) : 'Never'} + +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 5ffac0b3..99bdf1f9 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -28,6 +28,7 @@ import { useLicense } from '@/context/LicenseContext'; import { TierBadge } from './TierBadge'; import { ProGate } from './ProGate'; import { SSOSection } from './SSOSection'; +import { ApiTokensSection } from './ApiTokensSection'; interface Agent { type: 'discord' | 'slack' | 'webhook'; @@ -49,7 +50,7 @@ interface PatchableSettings { log_retention_days?: string; } -type SectionId = 'account' | 'license' | 'users' | 'sso' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'support' | 'about'; +type SectionId = 'account' | 'license' | 'users' | 'sso' | 'api-tokens' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'support' | 'about'; interface WebhookItem { id: number; @@ -657,7 +658,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { // When switching to a remote node, reset to a node-scoped section if on a global-only one useEffect(() => { - if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'sso' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) { + if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'sso' || activeSection === 'api-tokens' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) { setActiveSection('system'); } }, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps @@ -1001,6 +1002,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { {!isRemote && isAdmin && isPro && license?.variant === 'team' && ( } label="SSO" /> )} + {!isRemote && isAdmin && isPro && license?.variant === 'team' && ( + } label="API Tokens" /> + )} } @@ -1460,6 +1464,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { )} + {activeSection === 'api-tokens' && ( + + )} + {activeSection === 'developer' && (
diff --git a/frontend/src/components/TeamProGate.tsx b/frontend/src/components/TeamProGate.tsx index 7b4e09f6..85b016fd 100644 --- a/frontend/src/components/TeamProGate.tsx +++ b/frontend/src/components/TeamProGate.tsx @@ -46,7 +46,7 @@ export function TeamProGate({ children, featureName = 'This feature' }: TeamProG

{featureName} requires Sencho Team Pro

- Unlock team features like SSO authentication, audit logging, 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 Team Pro license.