fix(api-tokens): harden with security fixes, design compliance, and test coverage (#567)

Security: add JWT-level expiry ceiling (400d), per-user token count limit (25),
and token name uniqueness enforcement. Fix async clipboard copy.

Design: migrate Select to Combobox, apply card bevel styling, fix icon
strokeWidth, add tabular-nums to timestamps, fix destructive button pattern.

Tests: expand from ~20 to 47 test cases covering creation validation, token
limits, name uniqueness, last_used_at tracking, ownership constraints, delete
edge cases, and registry blocked endpoints.

Docs: update API Tokens docs with token limits, name uniqueness, registry
restrictions, and JWT expiry ceiling. Update OpenAPI spec with 409 response.
This commit is contained in:
Anso
2026-04-13 18:32:07 -04:00
committed by GitHub
parent 02c2d24004
commit e0d1ca9dc0
8 changed files with 328 additions and 45 deletions
+221 -10
View File
@@ -1,30 +1,34 @@
/**
* Tests for API token scope enforcement, blocked endpoints, expiration, and revocation.
* Tests for API token scope enforcement, blocked endpoints, expiration,
* revocation, creation validation, limits, and ownership.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let authCookie: string;
/** Create an API token in the DB and return its raw JWT string. */
/** Create an API token directly in the DB and return its raw JWT string. */
function createTestApiToken(
scope: 'read-only' | 'deploy-only' | 'full-admin',
expiresAt: number | null = null,
userId?: number,
): string {
const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' });
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const db = DatabaseService.getInstance();
const user = db.getUserByUsername('testadmin');
const resolvedUserId = userId ?? db.getUserByUsername('testadmin')!.id;
db.addApiToken({
token_hash: tokenHash,
name: `test-${scope}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
scope,
user_id: user!.id,
user_id: resolvedUserId,
created_at: Date.now(),
expires_at: expiresAt,
});
@@ -34,14 +38,22 @@ function createTestApiToken(
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
// Mock LicenseService to return paid/admiral for Admiral-gated routes
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
({ app } = await import('../index'));
authCookie = await loginAsTestAdmin(app);
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
// ─── Scope Enforcement Middleware ────────────────────────────────────────────
// --- Scope Enforcement Middleware ---
describe('enforceApiTokenScope', () => {
it('read-only token allows GET requests', async () => {
@@ -98,7 +110,7 @@ describe('enforceApiTokenScope', () => {
});
});
// ─── Blocked Endpoints (human-session-only) ─────────────────────────────────
// --- Blocked Endpoints (human-session-only) ---
describe('API token blocked endpoints', () => {
let fullAdminToken: string;
@@ -136,6 +148,12 @@ describe('API token blocked endpoints', () => {
{ method: 'get', path: '/api/api-tokens' },
{ method: 'post', path: '/api/api-tokens', body: { name: 'test', scope: 'read-only' } },
{ method: 'delete', path: '/api/api-tokens/1' },
// Registry management
{ method: 'get', path: '/api/registries' },
{ method: 'post', path: '/api/registries', body: { name: 'test', type: 'dockerhub', url: 'https://index.docker.io' } },
{ method: 'put', path: '/api/registries/1', body: { name: 'updated' } },
{ method: 'delete', path: '/api/registries/1' },
{ method: 'post', path: '/api/registries/1/test' },
];
for (const { method, path, body } of blockedEndpoints) {
@@ -149,7 +167,7 @@ describe('API token blocked endpoints', () => {
}
});
// ─── Token Expiration ───────────────────────────────────────────────────────
// --- Token Expiration ---
describe('API token expiration', () => {
it('expired token returns 401', async () => {
@@ -169,7 +187,7 @@ describe('API token expiration', () => {
});
});
// ─── Token Revocation ───────────────────────────────────────────────────────
// --- Token Revocation ---
describe('API token revocation', () => {
it('revoked token returns 401', async () => {
@@ -187,3 +205,196 @@ describe('API token revocation', () => {
expect(res.status).toBe(401);
});
});
// --- Creation Validation ---
describe('API token creation validation', () => {
it('rejects missing name', async () => {
const res = await request(app)
.post('/api/api-tokens')
.set('Cookie', authCookie)
.send({ scope: 'read-only' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/name/i);
});
it('rejects empty/whitespace name', async () => {
const res = await request(app)
.post('/api/api-tokens')
.set('Cookie', authCookie)
.send({ name: ' ', scope: 'read-only' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/name/i);
});
it('rejects name longer than 100 characters', async () => {
const res = await request(app)
.post('/api/api-tokens')
.set('Cookie', authCookie)
.send({ name: 'a'.repeat(101), scope: 'read-only' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/100/);
});
it('rejects invalid scope', async () => {
const res = await request(app)
.post('/api/api-tokens')
.set('Cookie', authCookie)
.send({ name: 'test-invalid-scope', scope: 'superadmin' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/scope/i);
});
it('rejects invalid expiry value', async () => {
const res = await request(app)
.post('/api/api-tokens')
.set('Cookie', authCookie)
.send({ name: 'test-invalid-expiry', scope: 'read-only', expires_in: 7 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/expires_in/i);
});
it('accepts null expiry (no expiration)', async () => {
const res = await request(app)
.post('/api/api-tokens')
.set('Cookie', authCookie)
.send({ name: `test-null-expiry-${Date.now()}`, scope: 'read-only', expires_in: null });
expect(res.status).toBe(201);
expect(res.body.token).toBeDefined();
});
it('accepts valid expiry values', async () => {
for (const days of [30, 60, 90, 365]) {
const res = await request(app)
.post('/api/api-tokens')
.set('Cookie', authCookie)
.send({ name: `test-expiry-${days}-${Date.now()}`, scope: 'read-only', expires_in: days });
expect(res.status).toBe(201);
}
});
});
// --- Token Count Limit ---
describe('API token count limit', () => {
it('rejects creation when user has 25 active tokens', async () => {
const db = DatabaseService.getInstance();
const user = db.getUserByUsername('testadmin')!;
// Seed up to 25 active tokens using the shared helper
const existing = db.getActiveApiTokenCountByUser(user.id);
const toCreate = 25 - existing;
for (let i = 0; i < toCreate; i++) {
createTestApiToken('read-only', null, user.id);
}
expect(db.getActiveApiTokenCountByUser(user.id)).toBeGreaterThanOrEqual(25);
const res = await request(app)
.post('/api/api-tokens')
.set('Cookie', authCookie)
.send({ name: `overflow-${Date.now()}`, scope: 'read-only' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/maximum/i);
});
});
// --- Token Name Uniqueness ---
describe('API token name uniqueness', () => {
it('rejects duplicate token name for same user', async () => {
// Revoke existing tokens to make room (limit test may have filled to 25)
const db = DatabaseService.getInstance();
const user = db.getUserByUsername('testadmin')!;
const existing = db.getApiTokensByUser(user.id);
for (const t of existing) {
if (!t.revoked_at) db.revokeApiToken(t.id);
}
const uniqueName = `dup-test-${Date.now()}`;
// Create the first token
const res1 = await request(app)
.post('/api/api-tokens')
.set('Cookie', authCookie)
.send({ name: uniqueName, scope: 'read-only' });
expect(res1.status).toBe(201);
// Attempt to create another with the same name
const res2 = await request(app)
.post('/api/api-tokens')
.set('Cookie', authCookie)
.send({ name: uniqueName, scope: 'read-only' });
expect(res2.status).toBe(409);
expect(res2.body.error).toMatch(/already exists/i);
});
});
// --- last_used_at Tracking ---
describe('API token last_used_at tracking', () => {
it('updates last_used_at on API token usage', async () => {
const rawToken = createTestApiToken('read-only');
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const db = DatabaseService.getInstance();
// Verify last_used_at is null initially
const before = db.getApiTokenByHash(tokenHash)!;
expect(before.last_used_at).toBeNull();
// Make a request with the token
await request(app)
.get('/api/stacks')
.set('Authorization', `Bearer ${rawToken}`);
// Verify last_used_at is now set
const after = db.getApiTokenByHash(tokenHash)!;
expect(after.last_used_at).toBeTypeOf('number');
expect(after.last_used_at!).toBeGreaterThan(0);
});
});
// --- Ownership Constraint ---
describe('API token ownership', () => {
it('returns 403 when deleting another user\'s token', async () => {
const db = DatabaseService.getInstance();
// Create a second user
const otherHash = await bcrypt.hash('otherpass123', 1);
db.addUser({ username: 'otheruser', password_hash: otherHash, role: 'admin' });
const otherUser = db.getUserByUsername('otheruser')!;
// Create a token for the other user using the shared helper
const rawToken = createTestApiToken('read-only', null, otherUser.id);
const hash = crypto.createHash('sha256').update(rawToken).digest('hex');
const apiToken = db.getApiTokenByHash(hash)!;
const tokenId = apiToken.id;
// Try to delete it as testadmin
const res = await request(app)
.delete(`/api/api-tokens/${tokenId}`)
.set('Cookie', authCookie);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/your own/i);
});
});
// --- Delete Edge Cases ---
describe('API token delete edge cases', () => {
it('returns 404 for nonexistent token ID', async () => {
const res = await request(app)
.delete('/api/api-tokens/99999')
.set('Cookie', authCookie);
expect(res.status).toBe(404);
});
it('returns 400 for non-numeric token ID', async () => {
const res = await request(app)
.delete('/api/api-tokens/abc')
.set('Cookie', authCookie);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid/i);
});
});
@@ -40,6 +40,19 @@ export async function setupTestDb(): Promise<string> {
return tmpDir;
}
/**
* Log in as the seeded test admin and return the session cookie string.
* Requires `app` to be the Express instance from `index.ts`.
*/
export async function loginAsTestAdmin(app: import('express').Express): Promise<string> {
const supertest = (await import('supertest')).default;
const res = await supertest(app)
.post('/api/auth/login')
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
const cookies = res.headers['set-cookie'] as string | string[];
return Array.isArray(cookies) ? cookies[0] : cookies;
}
export function cleanupTestDb(tmpDir: string): void {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
+26 -3
View File
@@ -441,10 +441,12 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const apiToken = DatabaseService.getInstance().getApiTokenByHash(tokenHash);
if (!apiToken || apiToken.revoked_at) {
if (isDebugEnabled()) console.log('[Auth:diag] API token rejected: not found or revoked');
res.status(401).json({ error: 'API token not found or revoked' });
return;
}
if (apiToken.expires_at && apiToken.expires_at < Date.now()) {
if (isDebugEnabled()) console.log('[Auth:diag] API token rejected: expired');
res.status(401).json({ error: 'API token has expired' });
return;
}
@@ -457,6 +459,7 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
};
req.user = { username: creator?.username || `api-token:${apiToken.name}`, role: roleMap[apiToken.scope] || 'viewer', userId: apiToken.user_id };
req.apiTokenScope = apiToken.scope as 'read-only' | 'deploy-only' | 'full-admin';
if (isDebugEnabled()) console.log('[Auth:diag] API token authenticated:', { scope: apiToken.scope, user: creator?.username, tokenName: apiToken.name });
next();
return;
}
@@ -1123,10 +1126,12 @@ const DEPLOY_ALLOWED_PATTERNS: RegExp[] = [
const enforceApiTokenScope = (req: Request, res: Response, next: NextFunction): void => {
const scope = req.apiTokenScope;
if (!scope) { next(); return; } // Not an API token request
if (isDebugEnabled()) console.log('[ApiTokenScope:diag]', req.method, req.path, 'scope:', scope);
if (scope === 'full-admin') { next(); return; }
if (scope === 'read-only') {
if (req.method !== 'GET') {
if (isDebugEnabled()) console.log('[ApiTokenScope:diag] Denied:', req.method, req.path, 'scope:', scope);
res.status(403).json({ error: 'API token scope "read-only" only allows GET requests.', code: 'SCOPE_DENIED' });
return;
}
@@ -1141,10 +1146,12 @@ const enforceApiTokenScope = (req: Request, res: Response, next: NextFunction):
next();
return;
}
if (isDebugEnabled()) console.log('[ApiTokenScope:diag] Denied:', req.method, req.path, 'scope:', scope);
res.status(403).json({ error: 'API token scope "deploy-only" does not allow this action.', code: 'SCOPE_DENIED' });
return;
}
if (isDebugEnabled()) console.log('[ApiTokenScope:diag] Denied: unknown scope', req.method, req.path, 'scope:', scope);
res.status(403).json({ error: 'Unknown API token scope.', code: 'SCOPE_DENIED' });
};
@@ -4848,16 +4855,30 @@ app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response):
return;
}
const user = DatabaseService.getInstance().getUserByUsername(req.user!.username);
const db = DatabaseService.getInstance();
const user = db.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 activeCount = db.getActiveApiTokenCountByUser(user.id);
if (activeCount >= 25) {
res.status(400).json({ error: 'Maximum of 25 active API tokens per user.' });
return;
}
if (db.getActiveApiTokenByNameAndUser(name.trim(), user.id)) {
res.status(409).json({ error: 'An active token with this name already exists.' });
return;
}
// JWT ceiling exceeds the longest user-selectable expiry (365d) so the DB check is always tighter
const API_TOKEN_JWT_CEILING = '400d';
const rawToken = jwt.sign({ scope: 'api_token', sub: user.username, jti: crypto.randomUUID() }, jwtSecret, { expiresIn: API_TOKEN_JWT_CEILING });
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const id = DatabaseService.getInstance().addApiToken({
const id = db.addApiToken({
token_hash: tokenHash,
name: name.trim(),
scope: scope as 'read-only' | 'deploy-only' | 'full-admin',
@@ -4866,6 +4887,7 @@ app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response):
expires_at: expiresAt,
});
if (isDebugEnabled()) console.log('[ApiTokens:diag] Token created:', { name: name.trim(), scope, expires_in, user: req.user!.username });
res.status(201).json({ id, token: rawToken });
} catch (error) {
console.error('[ApiTokens] Create error:', error);
@@ -4914,6 +4936,7 @@ app.delete('/api/api-tokens/:id', authMiddleware, async (req: Request, res: Resp
}
DatabaseService.getInstance().revokeApiToken(id);
if (isDebugEnabled()) console.log('[ApiTokens:diag] Token revoked:', { id, name: apiToken.name, user: req.user!.username });
res.json({ success: true });
} catch (error) {
console.error('[ApiTokens] Revoke error:', error);
+13
View File
@@ -1407,6 +1407,19 @@ export class DatabaseService {
this.db.prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(Date.now(), id);
}
public getActiveApiTokenCountByUser(userId: number): number {
const row = this.db.prepare(
'SELECT COUNT(*) AS cnt FROM api_tokens WHERE user_id = ? AND revoked_at IS NULL'
).get(userId) as { cnt: number };
return row.cnt;
}
public getActiveApiTokenByNameAndUser(name: string, userId: number): ApiToken | undefined {
return this.db.prepare(
'SELECT * FROM api_tokens WHERE name = ? AND user_id = ? AND revoked_at IS NULL LIMIT 1'
).get(name, userId) as ApiToken | undefined;
}
// --- Registries ---
public getRegistries(): Registry[] {
+7
View File
@@ -33,6 +33,7 @@ Regardless of scope, **all** API tokens are blocked from:
| **Node management** | Adding, updating, or deleting remote nodes |
| **License management** | Activating or deactivating license keys |
| **Token management** | Creating, listing, or revoking API tokens |
| **Registry management** | Viewing, creating, updating, deleting, or testing registry credentials |
| **Console access** | Generating console session tokens for interactive terminals |
These restrictions ensure that API tokens cannot escalate privileges or modify the identity and infrastructure configuration of your Sencho instance. These operations require a human user session (browser login).
@@ -47,6 +48,10 @@ These restrictions ensure that API tokens cannot escalate privileges or modify t
- **Expiration**: Choose 30 days, 60 days, 90 days, 1 year, or no expiration. Tokens without an expiration must be revoked manually.
4. Click **Create**. A green banner appears with the raw token value. Copy it immediately using the copy button.
<Note>
Each user can have up to **25 active API tokens**. Token names must be unique among your active tokens. If you need to reuse a name, revoke the existing token first.
</Note>
<Frame>
<img src="/images/api-tokens/api-tokens-overview.png" alt="API Tokens management view in Settings Hub showing the token list and create form" />
</Frame>
@@ -101,7 +106,9 @@ Click the trash icon next to any token in the API Tokens settings tab. A confirm
## Security model
- **Hashed storage**: Only a SHA-256 hash of the token is stored in the database. The raw token is never persisted.
- **JWT-level expiry ceiling**: Every token includes a built-in expiry at the JWT level as defense-in-depth, independent of the user-configured expiration. The database-level expiry is always the tighter constraint.
- **Audit trail**: All actions performed via API tokens are recorded in the [Audit Log](/features/audit-log) under the creating user's username.
- **Optional expiry**: Tokens can be created with an expiration period (30 days, 60 days, 90 days, or 1 year). Tokens without an expiry must be revoked manually when no longer needed.
- **Per-user limits**: Each user can create up to 25 active tokens. Token names must be unique among active tokens for the same user.
- **Usage tracking**: Each token tracks when it was last used, visible in the token list.
- **Scope enforcement**: Permission checks happen at the middleware level before any route handler executes, ensuring consistent enforcement across all endpoints.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 38 KiB

+9 -3
View File
@@ -1303,7 +1303,7 @@ paths:
**Note:** API tokens cannot create other API tokens.
responses:
"201":
description: Token created. The `token` field contains the full JWT — save it now, it won't be shown again.
description: Token created. The `token` field contains the full JWT. Save it now; it will not be shown again.
content:
application/json:
schema:
@@ -1316,13 +1316,19 @@ paths:
type: string
description: Full JWT token. Store securely — this is the only time it's returned.
"400":
description: Validation error.
description: Validation error (missing/invalid fields, or maximum of 25 active tokens reached).
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"403":
$ref: "#/components/responses/Forbidden"
"409":
description: An active token with this name already exists.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"500":
$ref: "#/components/responses/InternalError"
requestBody:
@@ -1335,7 +1341,7 @@ paths:
properties:
name:
type: string
description: Human-readable token name.
description: Human-readable token name. Must be unique among the user's active tokens.
maxLength: 100
example: CI/CD Deploy Token
scope:
+39 -29
View File
@@ -4,7 +4,7 @@ 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 { Combobox } from '@/components/ui/combobox';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
@@ -101,7 +101,8 @@ export function ApiTokensSection() {
toast.error(err?.error || err?.message || 'Failed to create token.');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Network error.');
const message = e instanceof Error ? e.message : 'Network error.';
toast.error(message);
} finally { setCreating(false); }
};
@@ -118,9 +119,13 @@ export function ApiTokensSection() {
} catch { toast.error('Network error.'); }
};
const copyToClipboard = (text: string, label: string) => {
navigator.clipboard.writeText(text);
toast.success(`${label} copied to clipboard.`);
const copyToClipboard = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(`${label} copied to clipboard.`);
} catch {
toast.error('Failed to copy to clipboard.');
}
};
return (
@@ -137,7 +142,7 @@ export function ApiTokensSection() {
</p>
</div>
<Button size="sm" onClick={() => setShowForm(!showForm)}>
<Plus className="w-4 h-4 mr-1.5" /> Create Token
<Plus className="w-4 h-4 mr-1.5" strokeWidth={1.5} /> Create Token
</Button>
</div>
@@ -150,31 +155,36 @@ export function ApiTokensSection() {
placeholder="CI deploy pipeline"
value={formName}
onChange={e => setFormName(e.target.value)}
maxLength={100}
/>
</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>
<Combobox
options={[
{ value: 'read-only', label: 'Read Only - GET requests only' },
{ value: 'deploy-only', label: 'Deploy Only - read + deploy actions' },
{ value: 'full-admin', label: 'Full Admin - unrestricted access' },
]}
value={formScope}
onValueChange={setFormScope}
placeholder="Select scope..."
/>
</div>
<div className="space-y-2">
<Label>Expiration</Label>
<Select value={formExpiry === null ? 'never' : String(formExpiry)} onValueChange={v => setFormExpiry(v === 'never' ? null : Number(v))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="30">30 days</SelectItem>
<SelectItem value="60">60 days</SelectItem>
<SelectItem value="90">90 days</SelectItem>
<SelectItem value="365">1 year</SelectItem>
<SelectItem value="never">No expiration</SelectItem>
</SelectContent>
</Select>
<Combobox
options={[
{ value: '30', label: '30 days' },
{ value: '60', label: '60 days' },
{ value: '90', label: '90 days' },
{ value: '365', label: '1 year' },
{ value: 'never', label: 'No expiration' },
]}
value={formExpiry === null ? 'never' : String(formExpiry)}
onValueChange={v => setFormExpiry(v === 'never' ? null : Number(v))}
placeholder="Select expiration..."
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={() => setShowForm(false)}>Cancel</Button>
@@ -195,7 +205,7 @@ export function ApiTokensSection() {
<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" />
<Copy className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
<Button variant="outline" size="sm" onClick={() => setNewToken(null)}>Dismiss</Button>
@@ -221,7 +231,7 @@ export function ApiTokensSection() {
{/* Token list */}
{!loading && tokens.map(token => (
<div key={token.id} className="border border-border rounded-xl p-4 space-y-3">
<div key={token.id} className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors hover:border-t-card-border-hover 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" />
@@ -232,8 +242,8 @@ export function ApiTokensSection() {
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive shrink-0">
<Trash2 className="w-4 h-4" />
<Button variant="ghost" size="sm" className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground shrink-0">
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
@@ -252,7 +262,7 @@ export function ApiTokensSection() {
</AlertDialogContent>
</AlertDialog>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-4 text-xs text-muted-foreground tabular-nums">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
Created {formatDate(token.created_at)}