mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<string, 'admin' | 'viewer'> = {
|
||||
'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<string, string> = {
|
||||
'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<void> =>
|
||||
}
|
||||
});
|
||||
|
||||
// --- API Token Routes (Team Pro, admin-only, local-only) ---
|
||||
|
||||
app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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) => {
|
||||
|
||||
@@ -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<ApiToken, 'id' | 'last_used_at' | 'revoked_at'>): 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"features/atomic-deployments",
|
||||
"features/fleet-backups",
|
||||
"features/audit-log",
|
||||
"features/api-tokens",
|
||||
"features/sso",
|
||||
"features/licensing"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: API Tokens
|
||||
description: Generate scoped API tokens for CI/CD pipelines, scripts, and automation workflows with granular permission control.
|
||||
---
|
||||
|
||||
<Note>
|
||||
API Tokens require a Sencho **Team Pro** license. Personal Pro and Community Edition do not include this feature.
|
||||
</Note>
|
||||
|
||||
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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/api-tokens/api-tokens-overview.png" alt="API Tokens management view in Settings Hub" />
|
||||
</Frame>
|
||||
|
||||
<Warning>
|
||||
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.
|
||||
</Warning>
|
||||
|
||||
## Using a token
|
||||
|
||||
Pass the token as a Bearer token in the `Authorization` header:
|
||||
|
||||
<CodeGroup>
|
||||
```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"}'
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### 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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,252 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
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 { TierBadge } from './TierBadge';
|
||||
import { Zap, Plus, Copy, Trash2, CheckCircle, RefreshCw, Clock } from 'lucide-react';
|
||||
|
||||
interface ApiTokenListItem {
|
||||
id: number;
|
||||
name: string;
|
||||
scope: string;
|
||||
created_at: number;
|
||||
last_used_at: number | null;
|
||||
expires_at: number | null;
|
||||
revoked_at: number | null;
|
||||
}
|
||||
|
||||
const SCOPE_LABELS: Record<string, string> = {
|
||||
'read-only': 'Read Only',
|
||||
'deploy-only': 'Deploy Only',
|
||||
'full-admin': 'Full Admin',
|
||||
};
|
||||
|
||||
const SCOPE_BADGE_VARIANT: Record<string, 'default' | 'secondary' | 'destructive'> = {
|
||||
'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<ApiTokenListItem[]>([]);
|
||||
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 (
|
||||
<TeamProGate featureName="API Tokens">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between pr-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight flex items-center gap-2">
|
||||
API Tokens <TierBadge />
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generate scoped tokens for CI/CD pipelines, scripts, and automation.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setShowForm(!showForm)}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> Create Token
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showForm && (
|
||||
<div className="space-y-4 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
placeholder="CI deploy pipeline"
|
||||
value={formName}
|
||||
onChange={e => setFormName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Permission Scope</Label>
|
||||
<Select value={formScope} onValueChange={setFormScope}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="read-only">Read Only — GET requests only</SelectItem>
|
||||
<SelectItem value="deploy-only">Deploy Only — read + deploy actions</SelectItem>
|
||||
<SelectItem value="full-admin">Full Admin — unrestricted access</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowForm(false)}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleCreate} disabled={creating}>
|
||||
{creating ? <><RefreshCw className="w-4 h-4 mr-1.5 animate-spin" />Creating...</> : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token reveal (shown once after creation) */}
|
||||
{newToken && (
|
||||
<div className="bg-emerald-500/10 border border-emerald-500/30 rounded-xl p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle className="w-4 h-4" /> Token created — copy it now
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">This token will not be shown again. Store it securely.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs font-mono bg-muted px-3 py-2 rounded-lg break-all select-all">{newToken.token}</code>
|
||||
<Button variant="outline" size="sm" onClick={() => copyToClipboard(newToken.token, 'Token')}>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setNewToken(null)}>Dismiss</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full rounded-xl" />
|
||||
<Skeleton className="h-20 w-full rounded-xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && tokens.length === 0 && !showForm && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Zap className="w-10 h-10 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No API tokens yet.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Create one to authenticate CI/CD pipelines and scripts.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token list */}
|
||||
{!loading && tokens.map(token => (
|
||||
<div key={token.id} className="border border-border rounded-xl p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Zap className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="font-medium text-sm truncate">{token.name}</span>
|
||||
<Badge variant={SCOPE_BADGE_VARIANT[token.scope] || 'default'} className="text-[10px] shrink-0">
|
||||
{SCOPE_LABELS[token.scope] || token.scope}
|
||||
</Badge>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive shrink-0">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revoke API token?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Revoking <strong>{token.name}</strong> will immediately invalidate it. Any pipelines or scripts using this token will stop working.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleRevoke(token.id)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Revoke
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Created {formatDate(token.created_at)}
|
||||
</span>
|
||||
<span>
|
||||
Last used: {token.last_used_at ? formatRelative(token.last_used_at) : 'Never'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TeamProGate>
|
||||
);
|
||||
}
|
||||
@@ -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' && (
|
||||
<NavButton section="sso" icon={<Shield className="w-4 h-4 mr-2" />} label="SSO" />
|
||||
)}
|
||||
{!isRemote && isAdmin && isPro && license?.variant === 'team' && (
|
||||
<NavButton section="api-tokens" icon={<Zap className="w-4 h-4 mr-2" />} label="API Tokens" />
|
||||
)}
|
||||
<NavButton
|
||||
section="system"
|
||||
icon={<Activity className="w-4 h-4 mr-2" />}
|
||||
@@ -1460,6 +1464,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
<SSOSection />
|
||||
)}
|
||||
|
||||
{activeSection === 'api-tokens' && (
|
||||
<ApiTokensSection />
|
||||
)}
|
||||
|
||||
{activeSection === 'developer' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between pr-8">
|
||||
|
||||
@@ -46,7 +46,7 @@ export function TeamProGate({ children, featureName = 'This feature' }: TeamProG
|
||||
<div className="text-center max-w-md">
|
||||
<h3 className="text-lg font-semibold mb-2">{featureName} requires Sencho Team Pro</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
|
||||
Reference in New Issue
Block a user